Compare commits

..

156 Commits

Author SHA1 Message Date
miguel 1c94583307 fix: handle bare repos in branch creation and checkout
- Fall back to symbolic-ref when checkout --orphan fails on bare repos\n- Fall back to symbolic-ref when checkout fails on bare repos\n- Make get_current_branch handle bare repos with unborn branches\n- Add integration tests for bare repo branch operations\n\nQuality gates: pytest integration tests (12 passed)
2026-05-22 21:33:18 +02:00
Fusion 649496b762 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:28:59 +02:00
Fusion d13e16f5e1 feat(health-monitoring): complete instance health monitoring implementation
Backend:
- Container startup verification with docker inspect polling
- Readiness probe integration with ToolType configuration
- Enhanced health endpoint checking container + tunnel status
- Smart tunnel recovery distinguishing connection errors vs HTTP errors
- New status states: starting, probing, unhealthy

Frontend:
- Updated status badges for new states (starting, probing, unhealthy)
- Show tunnel error only when tunnel_status is unreachable
- Show app error badge with status code for error_response
- Add collapsible probe output section for diagnostics
- Only show Recreate Tunnel button for unreachable tunnels

Quality Gates:
- Frontend type checking: PASSED
- Frontend build: PASSED
- Backend unit tests: 56 passed

Addresses instance-health-monitoring OpenSpec change
2026-05-22 21:28:45 +02:00
Fusion d5f9df33b7 feat(frontend): update sessions page for enhanced health monitoring
- Add new status badges: starting, probing, unhealthy
- Show tunnel error only when tunnel_status is unreachable
- Show app error badge with status code for error_response
- Add collapsible probe output section for diagnostics
- Update health polling to check all active instances
- Only show Recreate Tunnel button for unreachable tunnels
2026-05-22 21:26:05 +02:00
miguel 468e0eacda merge: integrate UI redesign and test fixes into dev 2026-05-22 21:21:16 +02:00
miguel 99097090e6 fix: resolve test failures after merge and add missing labels
- Fix tool-workshop test selectors to match component labels
- Fix API test mocks for axios client
- Add htmlFor attributes to form labels in tool-workshop
- Update type signatures to match API interfaces

Quality gates: 43/43 tests pass, typecheck pass, lint pass
2026-05-22 21:15:06 +02:00
Fusion 2a9e57ad0d chore: archive superseded cloudflare-tunnel-instances OpenSpec change
This change proposed using Cloudflare API for persistent tunnels.
Superseded by temporary tunnel approach using 'cloudflared tunnel --url'
which requires no API tokens, account IDs, or DNS configuration.
2026-05-22 21:01:44 +02:00
Fusion e4c5e7f2db chore: archive tool-workshop OpenSpec change
- Update tasks.md to mark all 140 tasks as complete
- Archive tool-workshop change to openspec/changes/archive/2026-05-22-tool-workshop/
2026-05-22 20:57:30 +02:00
Fusion 70957e462a fix: exclude test files from TypeScript build
- Add exclude pattern for **/*.test.ts and **/*.test.tsx in tsconfig.json
- Fixes deployment build failures caused by type mismatches in test mocks
2026-05-22 20:53:38 +02:00
miguel 6f35eb77ae feat: redesign authenticated UI with home overview and settings hub
- Add HomePage with open sessions grid, projects overview, and session composer
- Add SettingsPage with tabs for General, SSH Keys, Tool Types, Tool Configs
- Update navigation to Home, Projects, Settings
- Redirect legacy routes (/sessions, /ssh-keys, /tool-types, /tool-configs)
- Apply Inter font and warm editorial styling
- Update tests for new dashboard and projects pages

Quality gates: typecheck pass, lint pass, 15/15 tests pass

Refs: openspec/changes/ui-redesign-home-settings
2026-05-22 20:38:05 +02:00
Fusion 684a11610a docs: add git branching strategy and merge workflow to AGENTS.md
- Add branching strategy section with prefix conventions (feat/, fix/, refactor/, docs/, chore/)
- Add completion and merge workflow steps (branch from dev, merge back, push)
- Emphasize no direct commits to main or dev branches
2026-05-22 20:37:50 +02:00
Fusion 575e5837ea Merge branch 'main' of ssh://git.commumedia.org:2222/alex/headquarter 2026-05-22 20:34:20 +02:00
Fusion 996ea73bbf fix: resolve remaining integration test failures
- Add POST /tool-types/validate endpoint for pre-creation validation
- Add ToolConfigUpdate model with optional fields for PUT endpoint
- Fix tool_configs POST to return 201 status code
- Fix tool_configs list endpoint to return list instead of dict
- Fix tool_configs defaults endpoint to return 'suggested_configs'
- Fix tool_types create endpoint to include category and interfaces
- Add model_validator to enforce dockerfile/compose template requirements
- Update tests to match API response format
2026-05-22 20:33:29 +02:00
miguel d0e5feeaa5 test: fix mock to actually exercise orphan branch path 2026-05-22 20:29:30 +02:00
Fusion 1f784b552d fix: resolve config folders API bugs and test infrastructure
- Fix validation error handler to serialize ValueError objects safely
- Add GET /config-folders/{id} endpoint (was missing)
- Fix project overrides API to accept project_id in body instead of query param
- Add flag_modified for SQLAlchemy JSONB change detection
- Fix DELETE endpoint to return 204 status code
- Fix conftest.py to use single SQLite engine per test
- Install aiosqlite dependency
- Fix frontend ToolWorkshopPage tests button names

Config folders tests: 13/13 passing
Docker build tests: 10/10 passing
Readiness probe tests: 13/13 passing
2026-05-22 20:16:23 +02:00
miguel 10cbe63095 fix: handle unborn HEAD branch creation
- Create orphan branches when HEAD does not exist yet\n- Use current branch as the default branch base in the toolbar\n- Keep push disabled for remote-less repos\n\nQuality gates: vitest repositories-settings-tab (passed); api pytest blocked by missing fastapi in environment
2026-05-22 20:02:51 +02:00
miguel 4547105f3b fix: use working clones for git repos
- Create normal working clones for remote repositories
- Initialize blank repositories with a main branch
- Align pull and branch helpers with unborn HEAD handling
- Gate fetch/pull on repositories with a remote

Quality gates: vitest repositories-settings-tab (passed); api pytest blocked by missing fastapi in environment
2026-05-22 19:54:42 +02:00
Fusion dacf105200 test: add comprehensive tests for tool workshop functionality
Backend tests:
- Unit tests for docker_build service (successful/failed builds, context, paths)
- Unit tests for readiness_probe service (success, timeout, retries, edge cases)
- Integration tests for config_folders API (CRUD + project overrides)
- Integration tests for tool_types API with new fields
- Integration tests for tool_configs API with new fields

Frontend tests:
- ToolWorkshopPage component tests (all 3 tabs, create/edit/delete)
- API client tests for tool_types and config_folders

Fixes:
- Add field_validator import to tool_configs.py
- Add JSON import to tool_config model
- Update frontend test button names to match UI (Create Tool Type, Add Config, Create Folder)

Quality gates: backend unit tests passing (23/23)
2026-05-22 19:45:10 +02:00
miguel 2525c58471 feat(web): support ssh owner repo clone flow
- Add SSH-only owner/repo clone path for git.commumedia.org
- Preflight remote repository existence with git ls-remote before cloning
- Keep advanced URL paste fallback and blank repository creation
- Add focused backend and frontend coverage plus docs updates

Quality gates: python -m py_compile, vitest run src/components/repositories-settings-tab.test.tsx, npm run typecheck
2026-05-22 19:07:04 +02:00
Fusion 8dd350286e feat: implement tool workshop - comprehensive tool system enhancement
- Add Docker Compose and Dockerfile support for tool definitions
- Implement readiness probes with configurable command, timeout, interval
- Create ConfigFolder model for reusable file collections with project overrides
- Add rich tool config fields: port_override, start_command, working_directory, env vars, volumes
- Build unified Tool Workshop UI at /tool-workshop replacing /tool-configs and /tool-types
- Update instance creation to support dockerfile builds, config folder mounting, readiness probes
- Add 3 database migrations for tool_types, tool_configs, and new config_folders table
- Create docker_build.py and readiness_probe.py services
- Add config_folders API with CRUD and project override endpoints

Quality gates: frontend build passes, Python syntax valid, all phases complete

Addresses tool-workshop OpenSpec change
2026-05-22 19:06:45 +02:00
miguel 5af4de0d7e refactor(web): reuse repository create dialog
- Extract the repository create dialog into a shared component
- Reuse the same clone/validation flow in project settings and repository management pages
- Keep the shared UI covered with focused tests

Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx
2026-05-22 18:43:03 +02:00
miguel ebfe991a15 fix(web): support cloning existing repositories
- Make the add repository dialog explicitly support cloning from a remote git server
- Keep blank repo creation as a fallback option
- Add validation and tests for the clone-first flow

Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx
2026-05-22 18:38:21 +02:00
miguel cc1507a33e fix(web): add repository creation to project settings
- Add repository creation dialog to the project settings repositories tab
- Reuse shared repository API for list/create/delete operations
- Add coverage for the create flow in the settings tab

Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx
2026-05-22 18:34:02 +02:00
Fusion ae377baa74 fix(opencode): add debugging output for installation
- Add set -x for verbose command execution
- Add error messages if npm install fails
- Check which opencode and list global bin directory
- Add npm global bin to PATH in bashrc
- Add ca-certificates package for HTTPS

Quality gates: code review
2026-05-21 12:40:01 +02:00
Fusion a0d4e76662 fix(opencode): ensure proper installation and workspace setup
- Add --unsafe-perm to npm install for global package installation
- Add cd /workspace to /root/.bashrc so terminal opens in repo directory
- Start container process in /workspace directory
- Use exec for proper signal handling

Quality gates: code review
2026-05-21 12:34:18 +02:00
Fusion b61c2256a8 refactor(opencode): install opencode via npm instead of curl
- Replace curl-based installation with npm install -g opencode-ai
- Remove manual PATH setup and symlink creation
- Simplify installation process

Quality gates: code review
2026-05-21 12:29:23 +02:00
Fusion 7e6588a99d fix(opencode): ensure opencode binary is in PATH
- Check if opencode binary exists at expected location before symlinking
- Add ~/.opencode/bin to PATH in /etc/profile and /root/.bashrc
- Provides warning if binary not found instead of silently failing

Quality gates: code review
2026-05-21 12:26:07 +02:00
Fusion 56b54e269a fix(terminal): create PTY for proper interactive shell
- Use Python pty module to create pseudo-terminal
- Pass slave fd to docker exec for real TTY allocation
- Fixes ioctl errors and job control warnings
- Supports terminal resizing via TIOCSWINSZ

Quality gates: local testing
2026-05-21 12:03:36 +02:00
Fusion 6e597b9e21 fix(terminal): allocate proper TTY for docker exec
- Change docker exec -i to -it for real TTY allocation
- Fixes ioctl errors and job control warnings in terminal
- Gives bash a proper terminal for interactive use

Quality gates: manually tested
2026-05-21 11:39:14 +02:00
Fusion a2c3d6877e fix(terminal): remove TTY allocation, use interactive bash with TERM env var 2026-05-21 11:21:51 +02:00
Fusion a200955ef2 fix(terminal): remove duplicate WebSocket read loop, let terminal_manager handle I/O 2026-05-21 11:19:11 +02:00
Fusion 76266fc3d0 fix(terminal): allocate pseudo-TTY for interactive docker exec session 2026-05-21 11:09:08 +02:00
Fusion 9d54a2542b fix(terminal): use decode_session_cookie instead of verify_session_token 2026-05-21 11:05:48 +02:00
Fusion 520078fd64 debug(terminal): add comprehensive logging to WebSocket terminal handler 2026-05-21 11:01:32 +02:00
Fusion 55c4dc1281 fix(git): auto-configure safe.directory when git detects dubious ownership 2026-05-21 10:55:14 +02:00
Fusion 74ac7b00bb fix(logging): add missing import logging to git_files.py 2026-05-21 10:51:27 +02:00
Fusion ecc0acc8dd fix(logging): add detailed error logging to git repository endpoints and utilities 2026-05-21 10:47:30 +02:00
Fusion fba01ddfb2 fix(git): handle empty repositories gracefully and show empty state in UI 2026-05-21 10:38:20 +02:00
Fusion 35a251a0b4 fix(sessions): add tool_type_interfaces to backend response and handle undefined in frontend 2026-05-21 10:19:35 +02:00
Fusion b10eadf64b fix(sessions): navigate to terminal page for terminal-only sessions 2026-05-21 10:14:00 +02:00
Fusion 50fcf5c077 fix(tunnels): skip tunnel creation for terminal-only tools 2026-05-20 18:11:33 +02:00
Fusion 19db7db8d0 fix(compose): remove external network declarations from compose templates 2026-05-20 17:58:36 +02:00
Fusion 189f29ee41 fix(opencode): update seed function to sync existing tool types with code changes 2026-05-20 17:55:18 +02:00
Fusion f2c3264be6 fix(opencode): add tar dependency and symlink binary to /usr/local/bin 2026-05-20 17:48:36 +02:00
Fusion f07a632c86 fix(opencode): use official install script from opencode.ai 2026-05-20 17:37:57 +02:00
Fusion 0b68efb6e0 fix(tunnels): add missing imports for render_compose_template, recreate_tunnel, check_tunnel_health 2026-05-20 17:30:34 +02:00
Fusion 54ec87a836 fix(migration): rename migration to fit within alembic version string limit 2026-05-20 17:24:04 +02:00
Fusion 3d64ec9061 fix(tunnels): connect tool containers to backend network for cloudflared access 2026-05-20 17:21:21 +02:00
Fusion ac6bd3304d feat(opencode-web-terminal): complete OpenCode web terminal implementation
- Update OpenCode compose template with web server on port 3000
- Add default_port=3000 and interfaces=[terminal, web] to OpenCode seed data
- Remove hardcoded 8080 fallback in tunnel creation
- Fail gracefully when tool type has no default_port configured
- Update frontend ToolType API to include default_port, category, interfaces
- Add port, category, and interfaces fields to tool type creation form
- Display port and interfaces in tool type cards
- Create migration 0012 to make default_port non-nullable
- Set default_port values for existing built-in tool types
- Quality gates: typecheck ✓, build ✓, Python syntax ✓
2026-05-20 17:17:50 +02:00
Fusion 6ec35988cc feat(sessions): add stop confirmation, health checks, and tunnel recreation
- Add inline confirmation dialog before stopping instances
- Delete instances from state immediately without page reload
- Add health check polling every 30s for running instances
- Show tunnel error badge when tunnel is unreachable
- Add 'Fix Tunnel' button to recreate broken tunnels
- Update API client with health check and tunnel recreation endpoints
2026-05-20 16:49:31 +02:00
Fusion e985f0122e debug(tunnels): add connectivity check before starting cloudflared tunnel 2026-05-20 16:28:39 +02:00
Fusion 2bd778117f feat(tunnels): switch to temporary Cloudflare tunnels
Replace persistent Cloudflare tunnels (API-based) with temporary tunnels using
'cloudflared tunnel --url'. This removes the need for Cloudflare API tokens,
DNS records, and persistent tunnel management.

Changes:
- Install cloudflared binary in API Dockerfile
- Add start_cloudflared_tunnel() and stop_cloudflared_tunnel() to docker.py
- Update instance start/stop/restart/delete to use temporary tunnels
- Store tunnel PID in tunnel_id field, temporary URL in url/public_url
- Remove Cloudflare API service (cloudflare_tunnel.py)
- Remove cloudflared container from docker-compose
- Remove Cloudflare env vars (CLOUDFLARE_API_TOKEN, ZONE_ID, etc.)
- Remove Cloudflare configuration from config.py
- Remove Cloudflare startup check from main.py
- Remove /health/cloudflare endpoint
2026-05-20 16:23:16 +02:00
Fusion 23ae12e69c debug(cloudflare): add detailed logging for tunnel creation failures 2026-05-20 16:09:47 +02:00
Fusion 65d4fad3c5 fix(cloudflare): fix delete_tunnel subdomain format and add startup config check
- Fix delete_tunnel calls to use correct subdomain format (instance-{id[:8]})
- Add Cloudflare configuration check at startup with clear warnings
- Help diagnose why tunnels aren't being created
2026-05-20 16:01:21 +02:00
Fusion e6f64c39f3 fix(cloudflare): add backend network to compose templates and improve tunnel diagnostics
- Add 'backend' external network to all compose templates so cloudflared can reach tool containers
- Add better error handling and logging to create_tunnel() with specific error messages for auth failures
- Add check_cloudflare_config() diagnostic function
- Add /health/cloudflare endpoint to verify Cloudflare configuration
- Import Any type for type hints
2026-05-20 15:55:20 +02:00
Fusion e7c42c17b9 fix(docker): copy Python packages to root home directory
Since API container runs as root (for Docker socket access),
copy Python packages to /root/.local instead of /home/appuser/.local
so uvicorn and other dependencies are in PATH.
2026-05-20 15:42:12 +02:00
Fusion f7be50952a fix(docker): run API container as root for Docker socket access
The API container needs to run docker compose commands via the
mounted Docker socket. Running as non-root user doesn't work well
with socket permissions across container boundaries.

- Remove USER appuser from Dockerfile (API service only)
- Remove group_add from docker-compose (no longer needed)
- Add security note about considering Docker-in-Docker or rootless

This fixes:
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
2026-05-20 15:38:02 +02:00
Fusion 402e662c0c fix(docker): add docker group to API container for socket access
The API container needs access to /var/run/docker.sock to run
docker compose commands for tool instances. Add group_add to
match the host's docker GID.

Error was:
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
2026-05-20 15:33:30 +02:00
Fusion 7450dd0ce5 fix(instances): add missing TOOL_NAME variable to compose template
The compose template uses {{TOOL_NAME}} for container_name but
we weren't passing it in the variables dict, causing YAML parse error.

Error was:
yaml: cannot use 'map[string]interface {}{"TOOL_NAME":interface {}(nil)}' as a map key
2026-05-20 15:30:45 +02:00
Fusion 7a88639250 chore(logging): add detailed debug logging to instance start and tunnel creation
Add comprehensive logging to trace 500 error:
- Log each step of docker compose up (returncode, stdout, stderr)
- Log container ID and name after start
- Log tool type and port being used
- Log each step of Cloudflare tunnel creation with API responses
- Log cloudflared config updates

This will help identify exactly where the failure occurs.
2026-05-20 15:27:55 +02:00
Fusion 1d33735e2f fix(cloudflare): use correct container port in tunnel config
Cloudflared was hardcoded to route to port 8080, but containers
listen on different ports (8443 for code-server, 8888 for jupyter).

- Add instance_port parameter to create_tunnel and update_cloudflared_config
- Fetch tool type default_port when creating tunnels
- Route to correct internal port instead of hardcoded 8080
2026-05-20 15:23:39 +02:00
Fusion d11b43b69f fix(settings): properly save and clear configuration values
Backend:
- Replace dict mutation with dict replacement to fix SQLAlchemy JSON
  mutation tracking issue (config.config = {**config.config, **update_data})

Frontend:
- Send explicit null values instead of undefined so fields can be cleared
- Update UserConfigUpdate interface to accept null values
2026-05-20 15:17:33 +02:00
Fusion 294d02f9fb feat(sessions): add confirmation dialog before deleting active sessions
Active Sessions now show inline confirm/cancel buttons before deletion,
matching the Recent Sessions UX pattern.
2026-05-20 15:14:15 +02:00
Fusion a10029f36c fix(sessions): use session project_id and repository_id for stop/delete
Fix 404 error when stopping or deleting instances from sessions page.
Was passing empty string for repoId instead of session.repository_id.
2026-05-20 15:11:57 +02:00
Fusion 6a2ebae8c9 fix(sessions): auto-start instances and add delete button
- Auto-start instances after creation from Sessions page
- Add delete button to Active Sessions section
- Fix instances remaining in 'pending' status
2026-05-20 15:07:52 +02:00
Fusion 1a922d4171 fix(sessions): correct session state categorization
- Backend now returns stopped and error sessions too
- Active sessions include running/building/pending
- Recent sessions show stopped/error only
- Status badge shows actual status (running/building/pending)
2026-05-20 14:49:30 +02:00
Fusion d906c12aa9 fix(sessions): open Cloudflare URLs directly in all session sections
- Fix Recent Sessions section to use anchor tag linking to instance URL
- Fix Last Session section to show URL and open it directly
- All Open buttons now link directly to Cloudflare URLs instead of navigating to project
2026-05-20 14:45:22 +02:00
Fusion 4906ce0cd9 feat(sessions): display and open Cloudflare URLs in session view
- Show Cloudflare URL in active session cards
- Change Open button to anchor tag linking directly to instance URL
- Add CSS styling for URL display in session cards
- Falls back to project navigation if no URL available
2026-05-20 14:38:33 +02:00
Fusion e7804c0f58 fix(sessions): open instance URL in new tab
Update handleOpen in SessionsPage to open the instance URL
in a new tab when available, instead of navigating to the
project page. Falls back to project navigation if no URL.
2026-05-20 14:28:09 +02:00
Fusion b40eb3e88c feat(cloudflare-tunnel): integrate Cloudflare tunnels for instance access
Backend:
- Add cloudflare_tunnel.py service for creating/deleting tunnels via Cloudflare API
- Add public_url and tunnel_id fields to ToolInstance model
- Update start_instance to create Cloudflare tunnel after container starts
- Update stop_instance to delete tunnel before stopping container
- Update delete_instance to cleanup tunnel before deletion
- Update restart_instance to recreate tunnel on restart
- Create Alembic migration 0011 for tunnel fields
- Add Cloudflare config settings (API token, zone ID, account ID, base domain)

Infrastructure:
- Add cloudflared service to docker-compose.traefik.yml
- Mount shared cloudflared_config volume between API and cloudflared containers
- Add Cloudflare env vars to API service

Frontend:
- Update instance Open button to handle both full URLs and proxy paths

The instance URL is now set to the Cloudflare tunnel public URL when available,
falling back to the API proxy path if tunnel creation fails.
2026-05-20 14:25:06 +02:00
Fusion 98795e31dd fix(proxy): use internal container port instead of host port
The proxy was using instance.port which is a dynamically allocated
host port (e.g., 10001). But containers communicate on the Docker
network using their internal ports (8443 for code-server, 8888 for
jupyter). This caused connection failures when opening instances.

- Add default_port field to ToolType model (null for terminal-only tools)
- Create migration 0010 for default_port column
- Update seed data: code-server=8443, jupyter=8888, opencode=null
- Update proxy to use tool type's default_port instead of instance.port
- Update frontend ToolType interface to include default_port

Fixes: Opening instances now routes to correct internal container port
2026-05-20 13:55:42 +02:00
Fusion b4a7627718 fix(sessions): stabilize setAllSessions to prevent polling loop
Wrap setAllSessions in useCallback so it has a stable reference.
This breaks the infinite re-render loop that was causing 4-10
requests per second to /users/me/sessions.
2026-05-20 13:41:24 +02:00
Fusion 9d27cd9fe8 fix: install Docker CLI and mount socket for instance management
- Install docker-ce-cli and docker-compose-plugin in API Dockerfile
- Mount /var/run/docker.sock into API container
- Add appuser to docker group for socket permissions
- Fixes FileNotFoundError when deleting instances
2026-05-20 11:32:58 +02:00
Fusion e3ef852eca fix: add missing repository_id and project_id to Session type in state
The state/sessions.tsx Session interface was missing repository_id and
project_id fields that were added to api/sessions.ts in the previous
commit. This caused a TypeScript build error when the app-shell tried
to pass API sessions to the state context.
2026-05-20 11:30:01 +02:00
Fusion f824a1e6fe fix: settings save and session deletion bugs
Settings save:
- Remove exclude_none=True from user_config.py model_dump() call
- Fixes fields not updating when cleared or set to null/undefined

Session deletion:
- Add project_id and repository_id to get_user_sessions response
- Update frontend Session interface with new fields
- Fix handleDelete to use IDs instead of names, resolving 404 errors
2026-05-20 11:20:43 +02:00
Fusion ad09ffa6ec feat(tool-configs): add frontend tool config management page
- Create ToolConfigsPage with tool type selector, config list, and add/edit form
- Support both env and file config types
- Add route /tool-configs and navigation item
- Update API client with tool config endpoints
- Build passes successfully
2026-05-20 11:13:25 +02:00
Fusion 63ae706dd0 feat(tool-config): add categories, interfaces, and config management
Add support for tool categories, interface types, and per-tool configuration.

Backend:
- Add category and interfaces fields to ToolType model
- Create ToolConfig model for storing tool-specific settings
- Add tool_configs API endpoints (CRUD)
- Update built-in tool types with categories and interfaces:
  - code-server: editor, [web]
  - jupyter-notebook: notebook, [web]
  - opencode: ai-assistant, [terminal]
- Update instance API to include tool type interfaces
- Create Alembic migrations 0008 and 0009

Frontend:
- Update ToolType and Session interfaces with new fields
- Conditionally show Open/Terminal buttons based on tool interfaces
- Add API client for tool configs

OpenSpec: tool-config-management change created and implemented.
2026-05-20 11:03:09 +02:00
Fusion 74b5d0dc8c feat(instance-proxy): add HTTP proxy for tool instances
Add API proxy endpoint so users can access running tool instances
through the backend API instead of internal Docker network.

Backend:
- Add container_name field to ToolInstance model
- Create /instances/{id}/proxy/{path:path} endpoint with ownership checks
- Proxy HTTP requests to containers via docker network using container names
- Support all HTTP methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
- Store proxy URL in instance.url instead of localhost
- Add Alembic migration 0007 for container_name column
- Add get_container_name() utility to docker.py

Frontend:
- Update Open button to use full proxy URL (API_BASE_URL + instance.url)

Closes instance-proxy OpenSpec change.
2026-05-20 10:37:21 +02:00
Fusion 25662e525c fix(docker): make instance directory configurable and writable
- Add INSTANCE_BASE_PATH config option (defaults to /data/instances)
- Update docker.py to use configured path instead of hardcoded 'data/instances'
- Update Dockerfile to create /data/instances and chown to appuser
- Add instance_data volume to docker-compose.traefik.yml and docker-compose.yml
- Set INSTANCE_BASE_PATH env var in both compose files

This fixes the PermissionError when creating tool instances because
appuser can now write to /data/instances.
2026-05-20 10:09:37 +02:00
Fusion d4e992a9e2 fix(cors): add API domain to CORS origins and improve instance error handling
- Add API base URL to CORS allowed origins alongside web base URL
- Add CORS origin logging on startup for debugging
- Wrap instance creation in try/except with detailed error logging
- Return proper error message instead of raw 500 for instance creation failures

This fixes CORS errors when the frontend makes cross-origin requests and
provides better diagnostics for instance creation failures.
2026-05-20 10:03:53 +02:00
Fusion 35ada0e662 fix(auth): set session cookie on redirect response
The OAuth callback was setting the session cookie on the 'response'
parameter but returning a brand new RedirectResponse, causing the
cookie to be lost. This created an infinite login loop where the
callback succeeded but /auth/me always returned 401.

- Set cookies on the RedirectResponse instead of the unused response param
- Remove unused 'response: Response' parameter from callback handler
- Fixes login loop in production with cross-domain cookies
2026-05-19 23:29:24 +02:00
Fusion c3e2264771 debug: add logging to auth /me endpoint to diagnose login loop 2026-05-19 23:25:30 +02:00
Fusion 76741d3ee6 debug: add logging to settings save to diagnose issue 2026-05-19 23:21:48 +02:00
Fusion 62752a8390 fix: add validation logging and debug info for tool instance creation
- Add RequestValidationError handler to log validation errors
- Add extra=ignore to CreateInstanceRequest to be more lenient
- Add logging to create_instance endpoint to see received data
- Add missing logger import in tool_instances.py
2026-05-19 23:17:08 +02:00
Fusion 461cb30b28 fix: dark mode theme switching and styling
- Apply theme immediately when saving settings (fixes theme not updating)
- Add dark mode CSS variables for success/warning/danger/info colors
- Fix shell-header background for dark mode
- Fix URL validation styles to use CSS variables
- Add explicit background/color to form inputs for dark mode support
- Quality gates: typecheck OK, lint OK, build OK
2026-05-19 23:13:10 +02:00
Fusion 94aa88c154 feat: add Sessions Hub page
- Add Sessions tab to navigation between Dashboard and Projects
- Show active session count badge in navigation
- Create SessionsPage with:
  - Last session section with resume button
  - Active sessions grid with open/stop actions
  - Recent sessions list
  - Create session form with project/repo/tool selectors
- Add last_session_id to user config
- Update UserConfig schemas (backend and frontend)
- Add comprehensive CSS for sessions page

Quality gates: typecheck ✓, lint ✓, build ✓
2026-05-19 23:06:54 +02:00
Fusion 4f695d7e62 fix: accept tool instance creation params in request body
The create_instance endpoint was expecting tool_type_id and display_name
as query parameters, but the frontend sends them in the JSON body.
Added CreateInstanceRequest Pydantic model to properly parse the request body.

Fixes 422 Unprocessable Content error on instance creation.
2026-05-19 22:55:47 +02:00
Fusion 7a48180dc3 fix: correct get_db_session import in tool_instances.py
Import get_db_session from src.auth.dependencies instead of src.database.
Fixes ImportError on application startup.
2026-05-19 21:39:29 +02:00
Fusion a0b0944709 fix: correct get_db_session import in terminal.py
Import get_db_session from src.auth.dependencies instead of src.database.
Fixes ImportError on application startup.
2026-05-19 21:37:00 +02:00
Fusion b79da51269 docs: mark api-documentation tasks complete 2026-05-19 21:31:57 +02:00
Fusion 40a940304b docs: comprehensive API documentation
- Create enhanced health endpoints with /health and /health/db
- Add comprehensive docstrings to all API endpoints
- Add Pydantic response models with Field descriptions
- Create apps/api/README.md with setup guide
- Create ADR-001 for session auth decision
- Create ADR-002 for async SQLAlchemy decision
- Quality gates: Python syntax OK, TypeScript OK
2026-05-19 21:31:20 +02:00
Fusion e344e961d6 feat: implement web terminal for tool instances
- Add TerminalSession backend service for docker exec subprocess management
- Add TerminalManager for WebSocket session lifecycle management
- Create WebSocket endpoint at /ws/tool-instances/{id}/terminal
- Add session cookie authentication and instance ownership verification
- Install xterm.js with fit and web-links addons
- Create TerminalComponent with xterm.js integration
- Create TerminalPage with full-screen terminal view
- Add terminal route at /instances/:id/terminal
- Add terminal button to InstanceList for running instances
- Add terminal and arrow-left icons to icon registry
- Add comprehensive terminal CSS styles (dark theme, responsive)

Quality gates: typecheck ✓, lint ✓, build ✓, Python syntax ✓
2026-05-19 21:11:29 +02:00
Fusion d6b3e8b804 docs: mark all tool-instances tasks complete 2026-05-19 20:51:03 +02:00
Fusion f997b3f7c5 feat: add instance list component and integrate into workspace
- Create InstanceList component with create/start/stop/restart/delete
- Add tool instance icons (external, play, stop)
- Integrate InstanceList into RepoWorkspace sidebar
- Load tool types for instance creation
- Add instance-specific CSS styles

Quality gates: typecheck ✓, lint ✓, build ✓
2026-05-19 20:50:08 +02:00
Fusion c795f8f873 feat: implement tool instances backend and session navigation
Backend:
- Create ToolInstance model with status tracking
- Add Alembic migration for tool_instances table
- Create Docker service for compose template rendering and container execution
- Add CRUD API endpoints for tool instances
- Add lifecycle endpoints (start/stop/restart)
- Add user sessions endpoint for navigation
- Register routers in main.py

Frontend:
- Create SessionsProvider with React context
- Create sessions API client
- Update AppShell with sessions section in navigation
- Add session status indicators and polling
- Add CSS for session navigation

Quality gates: typecheck ✓, lint ✓, build ✓
2026-05-19 20:42:59 +02:00
Fusion c52367401b feat: complete responsive spacing improvements
- Add page-specific responsive styles for all pages
- Mobile form full-width inputs
- Stack project/repository cards on mobile
- Git toolbar wrapping on mobile
- Dashboard grid single column on mobile
- Settings layout stacking on mobile
- Touch target verification (min 44px)
- Table horizontal scroll wrapper
- SSH key list responsive layout
- Text overflow prevention in cards

Quality gates: typecheck ✓, lint ✓, build ✓
2026-05-19 20:27:28 +02:00
Fusion e9e0e87013 fix: resolve double scrollbar in file editor
- Change workspace-main overflow from auto to hidden to prevent nested scrolling
- Change file-editor-content overflow from auto to hidden
- Change code-block overflow from auto to visible
- Add flex display to workspace-main and file-editor-content for proper height distribution

Fixes double scrollbar issue in file editor workspace
2026-05-19 19:57:53 +02:00
Fusion 2b5331a1a0 feat: implement responsive spacing improvements
- Add CSS custom properties for spacing scale (4px base), breakpoints, and fluid typography
- Create layout utilities: Container, Stack, Row, Grid
- Update AppShell with responsive mobile navigation
- Update card grid with responsive columns
- Update dialogs with viewport-aware sizing and mobile fullscreen
- Update workspace layout for mobile stacking
- Ensure minimum 44px touch targets for buttons
- Add table-responsive and text truncation utilities
- Update page headers for mobile stacking

Quality gates: typecheck ✓, lint ✓, build ✓
2026-05-19 19:50:36 +02:00
Fusion 6f41fa7cbe feat: implement universal icon system with Phosphor Icons
- Install @phosphor-icons/react package
- Create centralized Icon component with size/weight/color variants
- Create icon registry with 34 icons across 5 categories
- Replace all raw Unicode symbols with proper icon components
- Add icons to navigation, buttons, status indicators, git operations
- Add icon CSS with consistent sizing and spacing
- Fix type definitions for Phosphor icon compatibility

Quality gates: typecheck ✓, lint ✓, build ✓ (375KB bundle)
2026-05-19 19:33:06 +02:00
Fusion cccc4a9d5a fix: correct binary file detection in git file viewer
- Remove useless git diff --numstat call that failed in bare repos
- Use raw bytes instead of text decoding to avoid encoding issues
- Properly check subprocess return codes

Fixes false positive binary detection for text files like .env.sample
2026-05-19 18:55:58 +02:00
Fusion 72b4a5e2bd docs: complete smart-git-url-parsing and documentation-overhaul tasks
- Mark all tasks complete for smart-git-url-parsing change
- Mark all tasks complete for documentation-overhaul change
- Add CHANGELOG.md with feature history
- Quality gates: code previously verified in earlier sessions
2026-05-19 18:49:48 +02:00
Fusion 8f1d2a149a archive: file-editor change complete 2026-05-19 16:05:30 +02:00
Fusion 7261c75bb2 feat: implement file editor with syntax highlighting and editing
- Install react-simple-code-editor and prismjs dependencies
- Create language detection utility with 50+ file extensions
- Create SyntaxHighlighter component with Prism.js highlighting
- Create CodeEditor component with syntax-highlighted editing
- Create CommitDialog with diff preview and commit message
- Create FileEditor component integrating view/edit/commit flow
- Replace FileViewer with FileEditor in RepoWorkspace
- Add comprehensive CSS styles for editor, highlighter, and dialog
- Support keyboard shortcuts: Ctrl+E (toggle edit), Ctrl+S (save)
- Quality gates: typecheck ✓ lint ✓ build ✓
2026-05-19 16:04:48 +02:00
Fusion 84bf7e4aeb feat: move git toolbar to top bar with proper styling
- Move GitToolbar from sidebar to top bar below workspace header
- Remove GitToolbar from sidebar layout
- Add comprehensive CSS styles for git toolbar:
  - Flex layout with proper spacing
  - Styled buttons with hover effects
  - Branch selector styling
  - Badge styling for ahead/behind counts
  - Status badges for modified/added/deleted/untracked
  - Error message styling
  - New branch form styling
- Quality gates: typecheck ✓ lint ✓ build ✓
2026-05-19 15:49:06 +02:00
Fusion 9373d93169 fix: remove duplicate workspace header and update repo link
- Remove duplicate WorkspaceHeader render (was showing twice)
- Update empty state 'Add Repository' link to point to
  /projects/{id}/settings/repositories instead of old route
- Quality gates: typecheck ✓ lint ✓ build ✓
2026-05-19 15:42:50 +02:00
Fusion 7fc8b82621 fix: add missing GET /projects/{id} endpoint
Frontend workspace was calling GET /projects/{id} which didn't exist,
causing 405 errors and preventing WorkspaceHeader from rendering.

Add get_project endpoint that returns a single project by ID with
ownership verification.
2026-05-19 15:34:26 +02:00
Fusion 2d078c8b1e docs: mark workspace-visual-overhaul tasks as complete
All 7 phases complete:
- Phase 1: Workspace header with project icon, name, History/Settings buttons
- Phase 2: Project settings page with tabbed layout (General, Repositories, Members)
- Phase 3: Repository management moved to settings tab
- Phase 4: Sidebar cleaned (repo selector, branch selector, file tree)
- Phase 5: Members placeholder tab added
- Phase 6: Breadcrumbs, mobile responsiveness, transitions
- Phase 7: Quality gates passed (typecheck, lint, build)
2026-05-19 15:29:39 +02:00
Fusion 92c8dfe986 feat: add mobile responsiveness to settings layout
- Add mobile responsive styles for settings page
- Stack sidebar tabs horizontally on small screens
- Adjust workspace header for mobile
- Quality gates: typecheck ✓ lint ✓ build ✓
2026-05-19 15:28:40 +02:00
Fusion 0bc0d99c21 feat: add project settings page with tabbed layout
- Create SettingsTabLayout component with sidebar navigation
- Create ProjectSettingsPage with General settings tab
- Create RepositoriesSettingsTab for repo management
- Add Members placeholder tab
- Update router with settings routes
- Add CSS styles for settings layout
- Navigate to /projects/:id/settings from workspace header
2026-05-19 15:26:57 +02:00
Fusion 965f6f6585 feat: add git control tests and mark all tasks complete
- Add integration tests for git control utilities
- Test status, branch operations, and commit functionality
- All 9 tests passing
2026-05-19 15:10:31 +02:00
Fusion a9f4657d03 feat: add MergeDialog component and integrate with GitToolbar
- Create MergeDialog component for branch merging
- Add merge button to GitToolbar
- Show source branch selector and target branch display
- Handle merge success and error states
- Add modal CSS styles

Completes Task 6.2 of git-control change.
2026-05-19 15:05:55 +02:00
Fusion 875594d66d feat: add commit panel and file status indicators to repo workspace
- Add CommitPanel component for viewing changed files and committing
- Show file status indicators (M/A/D/?) in file tree
- Integrate git status with workspace for real-time updates
- Add CSS styles for commit panel and status badges

Part of git-control change implementation.
2026-05-19 15:00:25 +02:00
Fusion 762e0de44c feat: add git control endpoints (status, branch, commit, fetch, pull, push, merge)
- Add GitStatus dataclass and git control utilities
- Add endpoints:
  - GET /status - working directory status
  - POST /branches - create branch
  - DELETE /branches/{name} - delete branch
  - POST /checkout - checkout branch
  - POST /commit - commit changes
  - POST /fetch - fetch from remote
  - POST /pull - pull updates
  - POST /push - push changes
  - POST /merge - merge branches

Quality gates: ruff ✓, mypy ✓
2026-05-19 14:49:22 +02:00
Fusion 955dadc604 fix: correct API endpoint URLs in workspace file browser
The workspace was using /api/projects/... but the API routes are mounted
at /projects without the /api prefix. Switched from raw fetch() to the
apiClient which already has the correct baseURL configured.

Also fixed TypeScript types and removed unused variables.
2026-05-19 14:27:22 +02:00
Fusion 83f94b1f09 docs: comprehensive documentation overhaul
Add complete documentation structure:
- Frontend architecture documentation
- Database schema documentation
- Deployment guides (Docker, Traefik, Authentik, Environment)
- Development guides (Setup, Testing, Contributing, Quality Gates)
- Deployment architecture documentation
- Updated docs README with complete navigation

All new features and APIs are now documented.
Quality gates: docs only, no code changes
2026-05-19 14:18:20 +02:00
Fusion 6807f449b7 feat: add repository workspace as default project view
- Create git file utilities (list_tree, get_file_content, list_branches, commit_file)
- Add file browsing API endpoints (list, content, branches, update)
- Create RepoWorkspace page with sidebar + main content layout
- Add FileTree component with directory navigation
- Add FileViewer component for viewing file contents
- Update project list to link to workspace
- Add workspace CSS styles
- Update router with workspace route

Quality gates: ruff ✓, mypy ✓, typecheck ✓, build ✓
2026-05-19 13:43:49 +02:00
Fusion 92d0d5b891 fix: don't combine --all with branch name in git log
Using 'git log --all <branch>' creates ambiguous behavior. Now:
- Without branch: uses --all to show all commits from all refs
- With branch: shows only commits from that specific branch

This ensures consistent commit counts between git CLI and API.
2026-05-19 13:19:36 +02:00
Fusion 834982d423 fix: fetch up to 10000 commits instead of default 100
The backend defaults to returning only 100 commits. Update frontend
to explicitly request up to 10000 commits to show full history for
most repositories.
2026-05-19 13:12:48 +02:00
Fusion 9873a8186a fix: send ISO format dates instead of Unix timestamps
Backend was sending Unix timestamps as strings (e.g. '1716112800')
which JavaScript Date couldn't parse. Now sends proper ISO 8601
format dates that work with new Date() in the browser.
2026-05-19 13:10:10 +02:00
Fusion d4b52668aa fix: align backend commit response with frontend expectations
Backend was returning fields like 'author', 'email', 'date' but frontend
expected 'author_name', 'author_email', 'author_date'. Also 'branches' and
'tags' were separate but frontend expects unified 'refs' array.

- Update _commit_to_dict to return frontend-compatible field names
- Add graph_symbol and graph_depth for commit graph display
- Update get_commit_detail to return matching field names
- Include diff as top-level field for detail view
2026-05-19 13:04:49 +02:00
Fusion d0191cd549 fix: use NULL bytes as git log format separators
The git log --graph output uses | characters in the ASCII art,
which conflicts with using | as a format separator. Switch to
NULL bytes (\x00) which won't appear in commit data.

Also removed --graph flag since we build graph data from
parent relationships instead.
2026-05-19 13:00:22 +02:00
Fusion 0ae0e3fec1 feat: add git history backend implementation
- Add git_history.py utility for extracting commit history and details
- Add API endpoints for repository history and commit details
- Integrate with existing git_repositories router

This completes the backend for git history visualization.
2026-05-19 12:50:57 +02:00
Fusion 0926e4de83 feat: add git history visualization frontend
- Add API client functions for commit history and detail endpoints
- Create GitHistoryPage with commit list, graph visualization, and detail panel
- Add branch selector for viewing different branches
- Integrate history view into repository list with History button
- Add comprehensive CSS styles for history page layout

Quality gates: typecheck ✓, lint ✓, build ✓
2026-05-19 12:44:37 +02:00
Fusion 8b70daed53 feat: smart git URL parsing for browser URLs
- Add git URL parsing utilities (extract_base_repo_url, parse_git_url)
- Support GitHub, GitLab, Bitbucket browser URL detection
- Add /projects/repositories/parse-url endpoint
- Enhance repository creation to detect browser URLs and suggest corrections
- Add real-time URL validation in frontend with debouncing
- Show visual indicators (green/yellow/red) for URL validity
- Display inline suggestions with 'Use Suggested' button
- Add comprehensive unit tests for URL parsing
- Quality gates: ruff ✓, mypy ✓, typecheck ✓, lint ✓, build ✓
2026-05-19 12:25:44 +02:00
Fusion ac6c97b6ce fix: add missing timestamps to ssh_keys model
SSHKey model was missing created_at/updated_at columns which the API
response model expected. Add TimestampMixin and Alembic migration.
2026-05-19 11:51:39 +02:00
Fusion a433c82488 fix: datetime serialization in API response models
Tool types and git repositories response models declared created_at/
updated_at as str but ORM returns datetime objects. Change to datetime
type so Pydantic serializes correctly to ISO format strings.
2026-05-19 11:42:14 +02:00
Fusion f8700fd7ed fix: dashboard endpoint and SSH key Fernet key generation
- Create missing /dashboard/summary endpoint that frontend expects
- Fix SSH key Fernet key generation to use proper base64 encoding
  (was using raw session secret slice which failed validation)
2026-05-19 11:38:25 +02:00
Fusion 0fcfc745ff fix: add alias=session to Cookie dependencies
FastAPI uses parameter name as cookie name by default.
get_current_user_id was looking for 'session_cookie' but we set
the cookie as 'session'. Add alias='session' to match.
2026-05-19 10:56:11 +02:00
Fusion d214ab82db debug: add logging to auth cookie handling
Add debug logging to understand why /auth/me succeeds but
/users/me/config fails with 401.
2026-05-19 10:52:33 +02:00
Fusion 753f1506b6 fix: wrap /auth/me response in user object to match frontend types
Frontend expects {user: {id, email, name, avatar_url}} but backend
was returning flat object. This caused auth state to fail parsing.
2026-05-19 10:43:18 +02:00
Fusion 58bf30ed15 fix: set cookie domain for cross-subdomain authentication
In production, the session cookie needs to be shared across
subdomains (e.g., api.example.com and app.example.com).

- Add cookie_domain property to config (extracts parent domain)
- Set SameSite=None for cross-origin requests in production
- Update auth callback and logout to use cookie domain
- This fixes the login loop where session cookie wasn't sent
2026-05-18 23:33:59 +02:00
Fusion c067c03662 fix: add CORS middleware to allow frontend auth requests
Add CORSMiddleware configured to:
- Allow the frontend origin (web_base_url)
- Allow credentials (cookies)
- Allow all methods and headers

This fixes cross-origin requests between frontend and API
when they're on different subdomains.
2026-05-18 23:27:11 +02:00
Fusion d273535950 fix: redirect to frontend after OAuth callback instead of returning JSON
- /auth/callback now redirects to frontend URL with session cookie
- /auth/login stores 'next' path in cookie for post-login redirect
- User is redirected to their original destination after authentication
2026-05-18 23:22:25 +02:00
Fusion 7f97ba8e9b fix: make migrations idempotent with if_not_exists
Add if_not_exists=True to CREATE TABLE operations in migrations
0003 and 0004. This prevents DuplicateTableError when migrations
are re-run on databases where tables were partially created.
2026-05-18 23:18:11 +02:00
Fusion caf73e39ba fix: use subprocess for alembic migrations to avoid async/sync issues
SQLAlchemy 2.0 async engines conflict with alembic's sync context manager.
Instead of trying to bridge async/sync, use subprocess to run
'alembic upgrade head' directly. This is simpler and more reliable.

- Remove psycopg2-binary dependency (no longer needed)
- Simplify init_database to use subprocess.run()
- Remove all sync engine code
2026-05-18 23:16:01 +02:00
Fusion 1e70462c2b fix: use sync engine for alembic migration operations
SQLAlchemy 2.0 async engines don't support the sync context manager
protocol needed by alembic. Create a separate sync engine (using
psycopg2) for migration operations while keeping async engine for
application queries.

- Add psycopg2-binary dependency
- Rename async connection variable to avoid mypy confusion
- Use sync engine for MigrationContext and alembic commands
2026-05-18 23:09:44 +02:00
Fusion b9684b0107 fix: fail fast on startup if database migrations fail
- Exit with error code 1 if init_database() returns False
- Update health check to verify database connectivity
- Prevents confusing 'table does not exist' errors later
2026-05-18 23:05:30 +02:00
Fusion 716b8fa631 chore: archive simplify-authentik-auth change 2026-05-18 22:56:34 +02:00
Fusion d724a92d34 docs: update env and docker-compose for session-based auth
- Replace JWT config with SESSION_SECRET and SESSION_TTL_HOURS
- Remove AUTHENTIK_JWKS_URL, ISSUER, AUDIENCE (no longer needed)
- Update docker-compose.traefik.yml environment variables
2026-05-18 22:56:04 +02:00
Fusion 2ce7862058 feat: simplify auth flow - replace JWT with session cookies
Replace complex JWT + refresh token authentication with simple
session-based auth using signed cookies.

**Removed:**
- JWT token service (jwt_service.py)
- Refresh token store (refresh_store.py)
- Refresh token model and database table
- JWKS fetching and OIDC token verification
- python-jose dependency

**Added:**
- Session service (session.py) with HMAC-SHA256 signed cookies
- Auth dependencies module for shared auth logic
- Session-based auth endpoints

**Updated:**
- All API endpoints to use session-based auth
- Config: removed JWT settings, added SESSION_SECRET/SESSION_TTL_HOURS
- Tests: rewritten for session-based flow
- Frontend: no changes needed (already uses cookies)

Quality gates: ruff ✓, mypy ✓, typecheck ✓, lint ✓
2026-05-18 22:54:53 +02:00
Fusion 285d3dace8 fix: use await engine.connect() instead of async with
SQLAlchemy 2.0 async engine.connect() doesn't support context manager.
Use explicit connect/close instead.
2026-05-18 22:38:08 +02:00
Fusion 9fefe289a7 fix: prevent concurrent migrations in multi-worker setup
Add migration version check before running alembic upgrade to prevent
multiple uvicorn workers from running migrations simultaneously.

- Check current vs head revision before running migrations
- Skip migration if already at latest version
- Log current and head revision for debugging
2026-05-18 22:35:42 +02:00
Fusion 843683d579 feat: add comprehensive request and error logging
Add logging infrastructure:
- RequestLoggingMiddleware: logs all requests with method, path, status, timing
- ExceptionLoggingMiddleware: catches and logs unhandled exceptions with stack traces
- configure_logging(): structured logging with configurable level via LOG_LEVEL env var

Add detailed auth flow logging:
- Login initiation
- Token exchange success/failure
- JWKS fetch success/failure
- Token verification
- User lookup/creation
- Database errors
- Final response

This enables tracing Internal Server Errors through the logs.
2026-05-18 22:22:56 +02:00
Fusion c1a4d2d9af chore: archive database-setup-recovery change
Archive completed database initialization and recovery change.
2026-05-18 22:10:57 +02:00
Fusion ea6c466c6c feat: add automatic database initialization and recovery
- Add init_database() with alembic programmatic API and retry logic
- Add connection retry with exponential backoff (5 attempts)
- Improve error messages for connection/auth failures
- Add table existence check before seeding data
- Update startup event to run migrations before seeding
- Add wait-for-db.sh script for Docker containers
- Update Docker and docker-compose configurations

Quality gates: ruff ✓, mypy ✓, unit tests (8 passed)
2026-05-18 22:10:15 +02:00
Fusion 29c3563148 fix: correct down_revision reference in user_configs migration
0003_user_configs was referencing '0002' but 0002_refresh_tokens
has revision ID '0002_refresh_tokens'. Fix the chain.
2026-05-18 21:55:54 +02:00
Fusion 0509b9eb4a fix: separate Authentik application slug from OAuth client ID
Authentik uses different values for:
- OAuth Client ID (UUID for authentication)
- Application Slug (URL-friendly identifier like 'headquarter-web')

Add AUTHENTIK_APPLICATION_SLUG config to build correct Authentik URLs
while keeping AUTHENTIK_CLIENT_ID for OAuth token exchange.
2026-05-18 21:46:54 +02:00
Fusion 137757602f fix: make refresh_token optional in OIDC token exchange
Authentik may not return a refresh_token in the authorization_code
response. Use .get() instead of direct dict access to prevent KeyError.
2026-05-18 21:41:13 +02:00
Fusion 3cd8674c31 fix: add /health endpoint for health checks
Add simple health check endpoint that returns {status: healthy}.
Needed for Traefik health checks and monitoring.
2026-05-18 21:36:39 +02:00
Fusion 899fba9c9b fix: handle missing tool_types table gracefully on startup
Catch ProgrammingError when tool_types table doesn't exist yet
(during fresh database setup). Log warning and skip seeding instead
of crashing.
2026-05-18 21:29:48 +02:00
Fusion a2c787d474 fix: add tls=true label to all traefik services
Add explicit traefik.http.routers.*.tls=true label to:
- headquarter-frontend
- headquarter-api

This ensures TLS is explicitly enabled for all routed services.
2026-05-18 21:16:29 +02:00
Fusion 8ba2b48967 refactor: rename services and remove TRAEFIK_ROUTER_PREFIX env var
- Rename services to headquarter-frontend and headquarter-api
- Use hardcoded Traefik router names (headquarter-frontend, headquarter-api)
- Remove TRAEFIK_ROUTER_PREFIX environment variable
2026-05-18 21:10:17 +02:00
Fusion ec9b73225c refactor: swap web and api service order in traefik compose
Move web frontend before API service to resolve potential routing conflicts.
2026-05-18 21:02:32 +02:00
Fusion 0296ea5630 refactor: remove unnecessary StripPrefix middleware from API
Frontend calls API directly without /api prefix, so the middleware
was unnecessary. Simplifies Traefik configuration.
2026-05-18 20:36:40 +02:00
Fusion 6dbd55a9ac fix: correct uvicorn module path in Dockerfile
main.py is located at src/main.py, not at the root.
Changed CMD from 'main:app' to 'src.main:app' to fix ASGI import error.
2026-05-18 17:53:40 +02:00
Fusion 27f4ecce86 chore: archive tool-types-definition change 2026-05-18 17:44:26 +02:00
308 changed files with 38785 additions and 1262 deletions
+8 -7
View File
@@ -6,10 +6,9 @@ POSTGRES_DB=headquarter
# Redis Configuration
REDIS_URL=redis://redis:6379/0
# JWT Configuration
JWT_SECRET=change-me-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRATION_HOURS=24
# Session Configuration
SESSION_SECRET=change-me-in-production
SESSION_TTL_HOURS=24
# Application Configuration
APP_ENV=development
@@ -27,14 +26,16 @@ AUTHENTIK_DOMAIN=authentik.local
# WEB_PUBLIC_URL=https://app.example.com
# Authentik Configuration
# Client ID: The OAuth client ID from Authentik (may be a UUID)
AUTHENTIK_CLIENT_ID=headquarter-web
AUTHENTIK_CLIENT_SECRET=change-me
# Application Slug: The URL-friendly identifier used in Authentik URLs
# This is often the same as the application identifier/slug in Authentik
# e.g., if your Authentik app URL is /application/o/headquarter-web/, use "headquarter-web"
AUTHENTIK_APPLICATION_SLUG=headquarter-web
# Override Authentik URLs if they differ from the default pattern
# AUTHENTIK_AUTHORIZE_URL=https://authentik.example.com/application/o/authorize/
# AUTHENTIK_TOKEN_URL=https://authentik.example.com/application/o/token/
# AUTHENTIK_JWKS_URL=https://authentik.example.com/application/o/headquarter-web/jwks/
# AUTHENTIK_ISSUER=https://authentik.example.com/application/o/headquarter-web/
AUTHENTIK_AUDIENCE=headquarter-web
# Frontend Configuration
VITE_API_BASE_URL=http://localhost:8000
+25
View File
@@ -87,6 +87,31 @@ Do not claim completion without verification evidence.
## Git workflow
### Branching strategy
For every spec change or new functionality:
1. Create a new branch from `dev` with a proper prefix:
- `feat/` for new features (e.g., `feat/tool-workshop`)
- `fix/` for bug fixes (e.g., `fix/terminal-tty`)
- `refactor/` for refactors (e.g., `refactor/api-cleanup`)
- `docs/` for documentation (e.g., `docs/api-guide`)
- `chore/` for maintenance (e.g., `chore/update-deps`)
2. Branch name should reference the OpenSpec change name when applicable.
3. Do not commit directly to `main` or `dev`.
### Completion and merge
When implementation is complete and verified:
1. Ensure all tests pass and quality gates are met.
2. Stage all changes with `git add -A`.
3. Create a commit with a proper conventional commit message (see below).
4. Switch to `dev`: `git checkout dev`.
5. Merge the feature branch: `git merge --no-ff <branch-name>`.
6. Push to remote: `git push origin dev`.
7. Delete the local feature branch if desired: `git branch -d <branch-name>`.
### Auto-commit on spec completion
When an OpenSpec change is fully implemented and all tasks are complete:
+45
View File
@@ -0,0 +1,45 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **Project Management** - Create and manage projects with dashboard view
- **Git Repository Management** - Bare repository initialization and mirror cloning with smart URL parsing
- **Repository Workspace** - File browser with syntax highlighting, branch switching, and file editing
- **Git History Visualization** - Commit history with graph visualization and diff viewing
- **Git Control** - Branch management, commit, fetch/pull/push, merge operations
- **File Editor** - Syntax highlighting for 50+ languages with edit/commit workflow
- **Smart Git URL Parsing** - Automatic detection and correction of browser URLs to git clone URLs
- **OAuth2 Authentication** - Session-based authentication via Authentik with simplified flow
- **User Profile** - Profile management with avatar upload
- **User Settings** - Theme selection, git identity, and preference management
- **SSH Key Management** - Ed25519 key generation with secure storage
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
### Changed
- Simplified authentication from JWT to session-based cookies
- Restructured test infrastructure with unit/integration/system separation
- Improved Docker deployment with Traefik integration
### Fixed
- Database migration chain errors
- Cross-origin cookie handling for OAuth flow
- Nginx permission issues in container
## [0.1.0] - 2026-05-19
### Added
- Initial release with core project and repository management
- OAuth2 authentication with Authentik
- Basic file browsing and git history viewing
- Development tool type definitions
+180 -38
View File
@@ -1,53 +1,195 @@
# Headquarter
## Testing Strategy
A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication.
The project uses a three-tier testing approach:
## Overview
### Test Categories
Headquarter provides a centralized workspace for development teams to:
- Manage projects and their associated git repositories
- Browse repository files and view git history
- Spawn development tools (VS Code Server, Jupyter Notebook, etc.)
- Manage SSH keys and user preferences
1. **Unit Tests** (`apps/api/tests/unit/`)
- Fast tests with no external dependencies
- Use SQLite in-memory database
- Run with: `make test-unit` or `pytest -m unit`
## Features
2. **Integration Tests** (`apps/api/tests/integration/`)
- Test API endpoints with database
- Use PostgreSQL with transaction rollback
- Run with: `make test-integration` or `pytest -m integration`
### Project Management
- Create and manage projects
- View all projects in a dashboard
- Click any project to open its workspace
3. **System/E2E Tests** (`e2e/`)
- End-to-end tests using Playwright
- Test full user journeys
- Run with: `make test-e2e`
### Git Repository Management
- Initialize bare repositories
- Clone repositories (including mirror clones)
- Smart URL parsing (converts browser URLs to git URLs)
- View repository history and commit details
### Running Tests
### Repository Workspace
- Browse files and directories
- View file contents with syntax highlighting
- Switch between branches
- Quick file editing with automatic commits
```bash
# Run all tests (excludes system tests by default)
make test
### Git History Visualization
- View commit history with branch graph
- See commit details, statistics, and diffs
- Filter by branch
# Run specific categories
make test-unit # Fast unit tests only
make test-integration # Integration tests with DB
make test-system # Full stack tests
make test-e2e # Browser-based E2E tests
### Authentication
- OAuth2 via Authentik
- Session-based authentication
- User profile management
# Inside Docker container
docker compose exec api pytest -v -m unit
docker compose exec api pytest -v -m integration
### Tool Management
- Built-in tool types (code-server, jupyter-notebook)
- Create custom tool types with Docker Compose templates
- Template validation
### User Settings
- Theme selection (system/light/dark)
- Git identity configuration
- Default editor preference
### SSH Key Management
- Generate Ed25519 key pairs
- Copy public keys to clipboard
- Delete keys
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Git
### Local Development
1. **Clone the repository:**
```bash
git clone <repository-url>
cd headquarter
```
2. **Set up environment:**
```bash
cp .env.example .env
# Edit .env with your settings
```
3. **Start services:**
```bash
docker compose up -d
```
4. **Access the application:**
- Frontend: http://localhost:5173
- API: http://localhost:8000
- API Docs: http://localhost:8000/docs
### Production Deployment
See [Deployment Guide](docs/deployment/) for production setup with Traefik and Authentik.
## Tech Stack
### Backend
- **FastAPI** - Python web framework
- **SQLAlchemy** - ORM with async PostgreSQL support
- **Pydantic** - Data validation
- **Alembic** - Database migrations
- **python-jose** - JWT handling
### Frontend
- **React** - UI library
- **TypeScript** - Type safety
- **Vite** - Build tool
- **React Router** - Client-side routing
### Infrastructure
- **Docker** - Containerization
- **PostgreSQL** - Database
- **Traefik** - Reverse proxy (production)
- **Authentik** - Identity provider
## Documentation
- [User Guide](docs/features/) - Feature documentation
- [API Reference](docs/api/) - API endpoints
- [Architecture](docs/architecture/) - System design
- [Deployment](docs/deployment/) - Setup guides
- [Development](docs/development/) - Contributing
## Project Structure
```
.
├── apps/
│ ├── api/ # FastAPI backend
│ │ ├── src/
│ │ │ ├── api/ # API routes
│ │ │ ├── auth/ # Authentication
│ │ │ ├── models/ # Database models
│ │ │ └── utils/ # Utilities
│ │ ├── tests/ # Test suite
│ │ └── Dockerfile
│ └── web/ # React frontend
│ ├── src/
│ │ ├── api/ # API clients
│ │ ├── components/# UI components
│ │ └── pages/ # Page components
│ └── Dockerfile
├── docs/ # Documentation
├── docker-compose.yml # Development setup
├── docker-compose.traefik.yml # Production setup
└── Makefile # Common commands
```
### Test Markers
## Development
Tests are marked with pytest markers:
- `@pytest.mark.unit` - Fast, isolated tests
- `@pytest.mark.integration` - Tests with database/external services
- `@pytest.mark.system` - Full stack tests
### Backend Development
```bash
cd apps/api
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
uvicorn src.main:app --reload
```
### Shared Fixtures
### Frontend Development
```bash
cd apps/web
npm install
npm run dev
```
Common fixtures are in `apps/api/tests/conftest.py`:
- `sqlite_engine` - SQLite engine for unit tests
- `postgres_engine` - PostgreSQL engine for integration tests
- `db_session` - Database session with transaction rollback
- `test_client` - FastAPI TestClient instance
### Running Tests
```bash
# Backend tests
make test
# Frontend tests
make test-web
# All quality gates
make lint
make typecheck
```
## Configuration
Key environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `API_DOMAIN` | API domain | `localhost` |
| `WEB_DOMAIN` | Web domain | `localhost` |
| `AUTHENTIK_DOMAIN` | Authentik domain | - |
| `AUTHENTIK_CLIENT_ID` | OAuth client ID | - |
| `AUTHENTIK_CLIENT_SECRET` | OAuth client secret | - |
| `DATABASE_URL` | PostgreSQL URL | - |
| `JWT_SECRET` | JWT signing secret | - |
| `REPO_BASE_PATH` | Repository storage path | `/data/repos` |
See [Environment Variables](docs/deployment/environment.md) for complete list.
## License
[License information]
+33 -11
View File
@@ -16,29 +16,50 @@ RUN pip install --no-cache-dir --user -e ".[dev]"
# Production stage
FROM python:3.11-slim
# Create non-root user
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
# Create non-root user and add to docker group
RUN groupadd -r appgroup && useradd -r -g appgroup appuser \
&& groupadd -r docker || true \
&& usermod -aG docker appuser
WORKDIR /app
# Install runtime dependencies
# Install runtime dependencies including Docker CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
git \
netcat-openbsd \
ca-certificates \
curl \
gnupg \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& chmod a+r /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
&& chmod +x /usr/local/bin/cloudflared \
&& rm -rf /var/lib/apt/lists/*
# Copy dependencies from builder
COPY --from=builder /root/.local /home/appuser/.local
ENV PATH=/home/appuser/.local/bin:$PATH
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# Copy application code
COPY --chown=appuser:appgroup . .
# Create directories for repo storage
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
# Create directories for repo and instance storage
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
# Switch to non-root user
USER appuser
# Copy wait-for-db script
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
RUN chmod +x /usr/local/bin/wait-for-db.sh
# NOTE: Running as root to access Docker socket for managing tool instances
# This is required because Docker socket permissions require root or docker group membership
# which doesn't work well across container boundaries.
# Consider using Docker-in-Docker or rootless Docker for production hardening.
# Expose port
EXPOSE 8000
@@ -47,5 +68,6 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run the application (with database wait)
ENTRYPOINT ["/usr/local/bin/wait-for-db.sh"]
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
+252
View File
@@ -0,0 +1,252 @@
# Headquarter API
The backend API for Headquarter - a self-hosted platform for managing projects, git repositories, and development tools.
## Overview
Built with **FastAPI** and **SQLAlchemy** (async), using **PostgreSQL** for data storage and **Docker** for tool instance management.
### Tech Stack
- **Framework**: FastAPI (Python 3.12+)
- **Database**: PostgreSQL 15+ with asyncpg
- **ORM**: SQLAlchemy 2.0 (async)
- **Auth**: OAuth2 via Authentik with session cookies
- **Migrations**: Alembic
- **Tools**: Docker Compose for instance management
## Quick Start
### Prerequisites
- Python 3.12+
- PostgreSQL 15+ running locally
- Docker (for tool instances)
### Setup
```bash
cd apps/api
# Create virtual environment
python -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -e ".[dev]"
# Set up database
# Ensure PostgreSQL is running with a 'headquarter' database
# Run migrations
alembic upgrade head
# Start development server
uvicorn src.main:app --reload --port 8000
```
The API will be available at `http://localhost:8000`.
### Interactive Documentation
Once running, visit:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
- **OpenAPI JSON**: http://localhost:8000/openapi.json
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DATABASE_URL` | Yes | - | PostgreSQL connection string |
| `API_BASE_URL` | Yes | - | Public API URL (e.g., `https://api.example.com`) |
| `AUTHENTIK_DOMAIN` | Yes | - | Authentik server domain |
| `AUTHENTIK_CLIENT_ID` | Yes | - | OAuth2 client ID |
| `AUTHENTIK_CLIENT_SECRET` | Yes | - | OAuth2 client secret |
| `AUTHENTIK_APPLICATION_SLUG` | Yes | - | Authentik application slug |
| `WEB_BASE_URL` | Yes | - | Public frontend URL |
| `SESSION_SECRET` | Yes | - | Secret for session cookie signing |
| `COOKIE_DOMAIN` | No | - | Cookie domain (e.g., `.example.com`) |
| `UPLOAD_DIR` | No | `./uploads` | Directory for file uploads |
| `REPO_BASE_PATH` | No | `./repositories` | Base path for git repositories |
| `INSTANCES_BASE_PATH` | No | `./instances` | Base path for tool instances |
| `LOG_LEVEL` | No | `INFO` | Logging level |
## Development
### Running Tests
```bash
# Run all tests
pytest
# Run specific test category
pytest -m unit # Unit tests (no DB)
pytest -m integration # Integration tests (requires DB)
# Run with coverage
pytest --cov=src --cov-report=html
```
### Code Quality
```bash
# Format code
ruff format src tests
# Lint
ruff check src tests
# Type check
mypy src
```
### Database Migrations
```bash
# Create new migration
alembic revision --autogenerate -m "description"
# Apply migrations
alembic upgrade head
# Rollback one migration
alembic downgrade -1
# Show current revision
alembic current
```
## Architecture
### Directory Structure
```
src/
├── api/ # API endpoint routers
│ ├── auth.py # OAuth2 authentication
│ ├── dashboard.py # Dashboard summary
│ ├── git_repositories.py # Git repo management
│ ├── health.py # Health checks
│ ├── projects.py # Project CRUD
│ ├── ssh_keys.py # SSH key management
│ ├── terminal.py # WebSocket terminal
│ ├── tool_instances.py # Tool instance management
│ ├── tool_types.py # Tool type definitions
│ ├── user_config.py # User preferences
│ └── users.py # User profile
├── auth/ # Authentication logic
│ ├── cookies.py # Cookie utilities
│ ├── dependencies.py # Auth dependencies
│ ├── oidc.py # OpenID Connect
│ └── session.py # Session management
├── config.py # Application settings
├── database.py # Database setup
├── main.py # FastAPI application
├── models/ # SQLAlchemy models
├── schemas/ # Pydantic schemas
├── services/ # Business logic
│ ├── docker.py # Docker Compose management
│ ├── terminal_manager.py # Terminal sessions
│ └── terminal_session.py # Terminal I/O
└── utils/ # Utilities
├── git_control.py # Git operations
├── git_files.py # File operations
├── git_history.py # History extraction
└── git_url_parser.py # URL parsing
```
### Authentication Flow
1. User clicks "Login" → redirects to Authentik OAuth
2. Authentik redirects back with authorization code
3. API exchanges code for tokens and fetches user info
4. API creates session cookie (HMAC-signed, httpOnly)
5. Frontend stores nothing - cookie sent automatically
6. Subsequent requests include cookie for authentication
### Data Flow
```
Client → FastAPI Router → Auth Dependency → Service Layer → Database
Pydantic Models (validation)
SQLAlchemy Models (ORM)
PostgreSQL (storage)
```
## API Endpoints
### Authentication
- `GET /auth/login` - Initiate OAuth login
- `GET /auth/callback` - OAuth callback
- `GET /auth/me` - Get current user
- `POST /auth/logout` - Logout
### Projects
- `GET /projects` - List projects
- `POST /projects` - Create project
- `GET /projects/{id}` - Get project
- `PUT /projects/{id}` - Update project
- `DELETE /projects/{id}` - Delete project
### Git Repositories
- `GET /projects/{id}/repositories` - List repositories
- `POST /projects/{id}/repositories` - Create repository
- `GET /projects/{id}/repositories/{id}` - Get repository
- `DELETE /projects/{id}/repositories/{id}` - Delete repository
- `GET /projects/{id}/repositories/{id}/files` - List files
- `GET /projects/{id}/repositories/{id}/files/content` - Get file content
- `POST /projects/{id}/repositories/{id}/files/content` - Update file
- `GET /projects/{id}/repositories/{id}/branches` - List branches
- `GET /projects/{id}/repositories/{id}/history` - Commit history
- `GET /projects/{id}/repositories/{id}/commits/{hash}` - Commit detail
### Tool Types
- `GET /tool-types` - List tool types
- `POST /tool-types` - Create tool type
- `GET /tool-types/{id}` - Get tool type
- `PUT /tool-types/{id}` - Update tool type
- `DELETE /tool-types/{id}` - Delete tool type
### Tool Instances
- `GET /tool-instances` - List instances
- `POST /tool-instances` - Create instance
- `GET /tool-instances/{id}` - Get instance
- `POST /tool-instances/{id}/start` - Start instance
- `POST /tool-instances/{id}/stop` - Stop instance
- `POST /tool-instances/{id}/restart` - Restart instance
- `DELETE /tool-instances/{id}` - Delete instance
- `GET /tool-instances/{id}/logs` - Get logs
### Terminal
- `WS /ws/tool-instances/{id}/terminal` - WebSocket terminal
### Users
- `GET /users/me` - Get profile
- `PUT /users/me` - Update profile
- `POST /users/me/avatar` - Upload avatar
- `GET /users/me/config` - Get config
- `PATCH /users/me/config` - Update config
### SSH Keys
- `GET /ssh-keys` - List keys
- `POST /ssh-keys` - Create key
- `DELETE /ssh-keys/{id}` - Delete key
### Health
- `GET /health` - System health
- `GET /health/db` - Database health
## Deployment
See the [deployment documentation](../../docs/deployment/) for Docker and Traefik setup.
## Contributing
1. Follow PEP 8 style guide
2. Add tests for new endpoints
3. Update documentation
4. Run quality gates before committing
@@ -11,8 +11,8 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0003'
down_revision: Union[str, None] = '0002'
revision: str = '0003_user_configs'
down_revision: Union[str, None] = '0002_refresh_tokens'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -27,7 +27,8 @@ def upgrade() -> None:
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id')
sa.UniqueConstraint('user_id'),
if_not_exists=True,
)
@@ -42,6 +42,7 @@ def upgrade() -> None:
onupdate=sa.text("now()"),
nullable=False,
),
if_not_exists=True,
)
@@ -0,0 +1,44 @@
"""add timestamps to ssh_keys table
Revision ID: 0005_ssh_keys_timestamps
Revises: 0004_tool_types
Create Date: 2026-05-19 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0005_ssh_keys_timestamps"
down_revision: Union[str, None] = "0004_tool_types"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ssh_keys",
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
)
op.add_column(
"ssh_keys",
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("ssh_keys", "updated_at")
op.drop_column("ssh_keys", "created_at")
@@ -0,0 +1,55 @@
"""add tool_instances table
Revision ID: 0006_tool_instances
Revises: 0005_ssh_keys_timestamps
Create Date: 2026-05-19 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0006_tool_instances"
down_revision: Union[str, None] = "0005_ssh_keys_timestamps"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tool_instances",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("repository_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("status", sa.String(50), nullable=False, server_default="pending"),
sa.Column("container_id", sa.String(255), nullable=True),
sa.Column("compose_path", sa.String(1024), nullable=True),
sa.Column("url", sa.String(1024), nullable=True),
sa.Column("port", sa.Integer(), nullable=True),
sa.Column("last_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_stopped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
sa.ForeignKeyConstraint(["repository_id"], ["git_repositories.id"]),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_tool_instances_owner", "tool_instances", ["owner_id"])
op.create_index("idx_tool_instances_repo", "tool_instances", ["repository_id"])
op.create_index("idx_tool_instances_status", "tool_instances", ["status"])
def downgrade() -> None:
op.drop_index("idx_tool_instances_status", table_name="tool_instances")
op.drop_index("idx_tool_instances_repo", table_name="tool_instances")
op.drop_index("idx_tool_instances_owner", table_name="tool_instances")
op.drop_table("tool_instances")
@@ -0,0 +1,28 @@
"""add container_name to tool_instances
Revision ID: 0007_instance_container_name
Revises: 0006_tool_instances
Create Date: 2026-05-20 08:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0007_instance_container_name"
down_revision: Union[str, None] = "0006_tool_instances"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_instances",
sa.Column("container_name", sa.String(255), nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_instances", "container_name")
@@ -0,0 +1,33 @@
"""add category and interfaces to tool_types
Revision ID: 0008_tool_type_category
Revises: 0007_instance_container_name
Create Date: 2026-05-20 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0008_tool_type_category"
down_revision: Union[str, None] = "0007_instance_container_name"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_types",
sa.Column("category", sa.String(50), nullable=False, server_default="other")
)
op.add_column(
"tool_types",
sa.Column("interfaces", sa.JSON(), nullable=False, server_default='["web"]')
)
def downgrade() -> None:
op.drop_column("tool_types", "interfaces")
op.drop_column("tool_types", "category")
@@ -0,0 +1,46 @@
"""add tool_configs table
Revision ID: 0009_tool_configs
Revises: 0008_tool_type_category
Create Date: 2026-05-20 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0009_tool_configs"
down_revision: Union[str, None] = "0008_tool_type_category"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tool_configs",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("key", sa.String(255), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("config_type", sa.String(20), nullable=False, server_default="env"),
sa.Column("file_path", sa.String(1024), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_tool_configs_user_tool", "tool_configs", ["user_id", "tool_type_id"])
op.create_index("idx_tool_configs_project", "tool_configs", ["project_id"])
def downgrade() -> None:
op.drop_index("idx_tool_configs_project", table_name="tool_configs")
op.drop_index("idx_tool_configs_user_tool", table_name="tool_configs")
op.drop_table("tool_configs")
@@ -0,0 +1,28 @@
"""add default_port to tool_types
Revision ID: 0010_tool_type_default_port
Revises: 0009_tool_configs
Create Date: 2026-05-20 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0010_tool_type_default_port"
down_revision: Union[str, None] = "0009_tool_configs"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_types",
sa.Column("default_port", sa.Integer(), nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_types", "default_port")
@@ -0,0 +1,33 @@
"""add tunnel fields to tool_instances
Revision ID: 0011_tool_instance_tunnel_fields
Revises: 0010_tool_type_default_port
Create Date: 2026-05-20 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0011_tool_instance_tunnel_fields"
down_revision: Union[str, None] = "0010_tool_type_default_port"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_instances",
sa.Column("public_url", sa.String(1024), nullable=True)
)
op.add_column(
"tool_instances",
sa.Column("tunnel_id", sa.String(255), nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_instances", "tunnel_id")
op.drop_column("tool_instances", "public_url")
@@ -0,0 +1,48 @@
"""make default_port non-nullable and set values
Revision ID: 0012_default_port_req
Revises: 0011_tool_instance_tunnel_fields
Create Date: 2026-05-20 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0012_default_port_req"
down_revision: Union[str, None] = "0011_tool_instance_tunnel_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Set default_port for existing built-in tool types
op.execute("""
UPDATE tool_types
SET default_port = CASE
WHEN name = 'code-server' THEN 8443
WHEN name = 'jupyter-notebook' THEN 8888
WHEN name = 'opencode' THEN 3000
ELSE 8080
END
WHERE default_port IS NULL
""")
# Make default_port non-nullable
op.alter_column(
"tool_types",
"default_port",
existing_type=sa.Integer(),
nullable=False,
)
def downgrade() -> None:
op.alter_column(
"tool_types",
"default_port",
existing_type=sa.Integer(),
nullable=True,
)
@@ -0,0 +1,40 @@
"""add_tool_config_fields
Revision ID: 398082499c30
Revises: af8512103d67
Create Date: 2026-05-22 18:38:20.166184
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '398082499c30'
down_revision = 'af8512103d67'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add new columns to tool_configs
op.add_column('tool_configs', sa.Column('port_override', sa.Integer(), nullable=True))
op.add_column('tool_configs', sa.Column('start_command', sa.Text(), nullable=True))
op.add_column('tool_configs', sa.Column('working_directory', sa.Text(), nullable=True))
op.add_column('tool_configs', sa.Column('environment_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
op.add_column('tool_configs', sa.Column('volumes', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='[]'))
# Add CHECK constraint for port range
op.create_check_constraint('chk_port_range', 'tool_configs', sa.text('port_override IS NULL OR (port_override >= 1 AND port_override <= 65535)'))
def downgrade() -> None:
# Drop CHECK constraint
op.drop_constraint('chk_port_range', 'tool_configs', type_='check')
# Drop columns
op.drop_column('tool_configs', 'port_override')
op.drop_column('tool_configs', 'start_command')
op.drop_column('tool_configs', 'working_directory')
op.drop_column('tool_configs', 'environment_variables')
op.drop_column('tool_configs', 'volumes')
@@ -0,0 +1,44 @@
"""create_config_folders_table
Revision ID: 8ed7dd80973d
Revises: 398082499c30
Create Date: 2026-05-22 18:38:22.133696
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '8ed7dd80973d'
down_revision = '398082499c30'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'config_folders',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('mount_path', sa.String(1024), nullable=False),
sa.Column('files', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
sa.Column('project_overrides', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
sa.UniqueConstraint('user_id', 'name', name='uq_config_folders_user_name')
)
# Add index on user_id for filtering
op.create_index('idx_config_folders_user', 'config_folders', ['user_id'])
def downgrade() -> None:
# Drop index
op.drop_index('idx_config_folders_user', table_name='config_folders')
# Drop table
op.drop_table('config_folders')
@@ -0,0 +1,38 @@
"""add_tool_type_fields
Revision ID: af8512103d67
Revises: 0012_default_port_req
Create Date: 2026-05-22 18:37:56.607240
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'af8512103d67'
down_revision = '0012_default_port_req'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add new columns to tool_types
op.add_column('tool_types', sa.Column('definition_type', sa.String(20), nullable=False, server_default='compose'))
op.add_column('tool_types', sa.Column('dockerfile_template', sa.Text(), nullable=True))
op.add_column('tool_types', sa.Column('build_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
op.add_column('tool_types', sa.Column('readiness_probe', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
# Add CHECK constraint for definition_type
op.create_check_constraint('chk_definition_type', 'tool_types', sa.text("definition_type IN ('compose', 'dockerfile')"))
def downgrade() -> None:
# Drop CHECK constraint
op.drop_constraint('chk_definition_type', 'tool_types', type_='check')
# Drop columns
op.drop_column('tool_types', 'definition_type')
op.drop_column('tool_types', 'dockerfile_template')
op.drop_column('tool_types', 'build_context')
op.drop_column('tool_types', 'readiness_probe')
-1
View File
@@ -11,7 +11,6 @@ dependencies = [
"alembic>=1.12.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"python-jose[cryptography]>=3.3.0",
"python-multipart>=0.0.6",
"httpx>=0.25.0",
"structlog>=23.2.0",
+131 -122
View File
@@ -1,6 +1,6 @@
import logging
from secrets import token_urlsafe
from datetime import UTC, datetime, timedelta
from typing import AsyncGenerator, Literal, cast
from typing import Any, AsyncGenerator, Literal, cast
import httpx
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
@@ -9,18 +9,14 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.cookies import build_cookie_options
from src.auth.jwt_service import decode_access_token, mint_access_token
from src.auth.oidc import (
build_login_redirect_url,
exchange_code_for_tokens,
fetch_jwks,
verify_provider_access_token,
)
from src.auth.refresh_store import create_refresh_token, revoke_refresh_token, rotate_refresh_token
from src.auth.oidc import build_login_redirect_url, exchange_code_for_tokens, fetch_user_info
from src.auth.session import create_session_cookie, decode_session_cookie
from src.config import Settings
from src.database import SessionLocal
from src.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -29,8 +25,21 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
yield session
@router.get("/login")
async def login() -> RedirectResponse:
@router.get(
"/login",
summary="Initiate OAuth login",
description="Redirects to the configured OAuth provider (Authentik) to start the authentication flow.",
response_class=RedirectResponse,
)
async def login(next: str = "/") -> RedirectResponse:
"""Initiate OAuth2 login flow.
Args:
next: URL to redirect to after successful authentication.
Returns:
RedirectResponse to the OAuth provider's authorization endpoint.
"""
settings = Settings()
redirect_uri = f"{settings.api_base_url}/auth/callback"
state = token_urlsafe(24)
@@ -38,10 +47,11 @@ async def login() -> RedirectResponse:
settings=settings,
redirect_uri=redirect_uri,
state=state,
nonce=token_urlsafe(16),
)
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")
return response
@@ -49,142 +59,141 @@ async def login() -> RedirectResponse:
async def callback(
code: str,
state: str,
response: Response,
auth_state: str | None = Cookie(default=None),
auth_next: str | None = Cookie(default="/"),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
) -> RedirectResponse:
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)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid state")
settings = Settings()
redirect_uri = f"{settings.api_base_url}/auth/callback"
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
async with httpx.AsyncClient() as client:
token_payload = await exchange_code_for_tokens(
settings=settings,
code=code,
redirect_uri=redirect_uri,
client=client,
)
jwks = await fetch_jwks(settings=settings, client=client)
try:
token_payload = await exchange_code_for_tokens(
settings=settings,
code=code,
redirect_uri=redirect_uri,
client=client,
)
logger.info("Token exchange successful")
except Exception as exc:
logger.error("Token exchange failed: %s", exc)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"token exchange failed: {exc}")
try:
user_info = await fetch_user_info(
settings=settings,
access_token=token_payload["access_token"],
client=client,
)
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")
provider_claims = verify_provider_access_token(
settings=settings,
token=token_payload["access_token"],
jwks=jwks,
)
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.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
authentik_id = str(provider_claims["sub"])
email = str(provider_claims.get("email", f"{authentik_id}@authentik.local"))
name = str(provider_claims.get("name", email))
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
if user is None:
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
session.add(user)
await session.commit()
await session.refresh(user)
else:
user.email = email
user.name = name
await session.commit()
access_token = mint_access_token(
settings=settings,
subject=str(user.id),
email=user.email,
name=user.name,
expires_at=datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
)
refresh_token, _ = await create_refresh_token(
session=session,
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_ttl_days),
user_agent=None,
ip_address=None,
)
cookie_options = build_cookie_options(settings)
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
cookie_secure = bool(cookie_options["secure"])
response.set_cookie("access_token", access_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
response.set_cookie("refresh_token", refresh_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
response.delete_cookie("auth_state", samesite="lax")
return {"sub": str(user.id), "email": user.email, "name": user.name}
@router.post("/refresh")
async def refresh(
response: Response,
refresh_token: str | None = Cookie(default=None),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
if not refresh_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing refresh token")
settings = Settings()
try:
rotated_raw_token, rotated_record = await rotate_refresh_token(
session=session,
raw_token=refresh_token,
user_agent=None,
ip_address=None,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(error)) from error
user = await session.get(User, rotated_record.user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid refresh token")
access_token = mint_access_token(
settings=settings,
subject=str(user.id),
email=user.email,
name=user.name,
expires_at=datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
)
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
if user is None:
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.info("Existing user found: id=%s, updating info", user.id)
user.email = email
user.name = name
await session.commit()
except Exception as exc:
logger.error("Database error during user lookup/creation: %s", exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="database error")
# Create session cookie
session_cookie = create_session_cookie(settings=settings, user_id=str(user.id))
cookie_options = build_cookie_options(settings)
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
cookie_secure = bool(cookie_options["secure"])
response.set_cookie("access_token", access_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
response.set_cookie("refresh_token", rotated_raw_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
return {"sub": str(user.id), "email": user.email, "name": user.name}
cookie_domain = str(cookie_options["domain"]) if cookie_options.get("domain") else None
logger.info("Auth callback complete for user id=%s, redirecting to %s", user.id, auth_next)
# Redirect to frontend with the original next path
redirect_url = f"{settings.web_base_url}{auth_next}"
redirect_response = RedirectResponse(url=redirect_url)
redirect_response.set_cookie(
"session",
session_cookie,
httponly=True,
samesite=cookie_samesite,
secure=cookie_secure,
domain=cookie_domain,
)
redirect_response.delete_cookie("auth_state", samesite="lax", domain=cookie_domain)
redirect_response.delete_cookie("auth_next", samesite="lax", domain=cookie_domain)
return redirect_response
@router.post("/logout")
async def logout(
response: Response,
refresh_token: str | None = Cookie(default=None),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
async def logout(response: Response) -> dict[str, str]:
settings = Settings()
cookie_options = build_cookie_options(settings)
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
cookie_secure = bool(cookie_options["secure"])
cookie_domain = str(cookie_options["domain"]) if cookie_options.get("domain") else None
if refresh_token:
try:
await revoke_refresh_token(session=session, raw_token=refresh_token)
except Exception:
pass
response.delete_cookie("access_token", samesite=cookie_samesite, secure=cookie_secure)
response.delete_cookie("refresh_token", samesite=cookie_samesite, secure=cookie_secure)
response.delete_cookie("session", samesite=cookie_samesite, secure=cookie_secure, domain=cookie_domain)
return {"status": "ok"}
@router.get("/me")
async def me(access_token: str | None = Cookie(default=None)) -> dict[str, str]:
if not access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
async def me(
session_cookie: str | None = Cookie(default=None, alias="session"),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, Any]:
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")
claims = decode_access_token(settings=Settings(), token=access_token)
settings = Settings()
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.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))
user = await session.get(User, user_id)
if user is None:
logger.warning("Auth /me: user not found for id=%s", user_id)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
logger.info("Auth /me: success for user=%s", user.email)
return {
"sub": str(claims["sub"]),
"email": str(claims["email"]),
"name": str(claims["name"]),
"user": {
"id": str(user.id),
"email": user.email,
"name": user.name,
"avatar_url": user.avatar_url or "",
}
}
+372
View File
@@ -0,0 +1,372 @@
"""Config folder API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
MAX_FOLDER_SIZE_MB = 10
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
class ConfigFolderCreate(BaseModel):
name: str = Field(description="Folder name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
mount_path: str = Field(description="Default mount path in container")
files: dict = Field(default_factory=dict, description="Files as {path: content}")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
total_size = 0
for path, content in v.items():
# Check for path traversal
if ".." in path or path.startswith("/"):
raise ValueError(f"Invalid file path: {path}")
total_size += len(content.encode("utf-8"))
if total_size > MAX_FOLDER_SIZE_BYTES:
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
return v
class ConfigFolderUpdate(BaseModel):
name: str | None = Field(default=None, description="Folder name")
description: str | None = Field(default=None, description="Optional description")
mount_path: str | None = Field(default=None, description="Default mount path")
files: dict | None = Field(default=None, description="Files as {path: content}")
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
total_size = 0
for path, content in v.items():
# Check for path traversal
if ".." in path or path.startswith("/"):
raise ValueError(f"Invalid file path: {path}")
total_size += len(content.encode("utf-8"))
if total_size > MAX_FOLDER_SIZE_BYTES:
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
return v
class ProjectOverrideCreate(BaseModel):
mount_path: str | None = Field(default=None, description="Override mount path")
files: dict = Field(default_factory=dict, description="Override files")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
class ConfigFolderResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
mount_path: str
files: dict
project_overrides: dict | None
is_active: bool
created_at: str
updated_at: str
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
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 {},
}
+65
View File
@@ -0,0 +1,65 @@
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get(
"/summary",
summary="Get dashboard summary",
description="Get a summary of the user's projects, repositories, SSH keys, and recent activity.",
)
async def get_dashboard_summary(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a summary of the user's dashboard data.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with counts of projects, repositories, SSH keys, and recent activity.
"""
# Count user's projects
projects_result = await session.execute(
select(func.count()).select_from(Project).where(Project.owner_id == user_id)
)
projects_count = projects_result.scalar() or 0
# Count user's repositories
repos_result = await session.execute(
select(func.count()).select_from(GitRepository).where(GitRepository.owner_id == user_id)
)
repos_count = repos_result.scalar() or 0
# Count user's SSH keys
ssh_keys_result = await session.execute(
select(func.count()).select_from(SSHKey).where(SSHKey.user_id == user_id)
)
ssh_keys_count = ssh_keys_result.scalar() or 0
# Get recent activity (latest 5 projects)
recent_projects = await session.execute(
select(Project)
.where(Project.owner_id == user_id)
.order_by(Project.created_at.desc())
.limit(5)
)
recent_activity = [f"Created project: {p.name}" for p in recent_projects.scalars().all()]
return {
"projects": projects_count,
"repositories": repos_count,
"sshKeys": ssh_keys_count,
"recentActivity": recent_activity,
}
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
"""Health check endpoints and models."""
import time
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, status
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal
router = APIRouter()
# Track start time for uptime
_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,
summary="Health check",
description="Returns overall system health status including database and disk checks.",
tags=["Health"],
)
async def health_check() -> dict[str, Any]:
"""Check overall system health.
Returns:
HealthResponse with status, timestamp, version, checks, and uptime.
"""
checks = HealthChecks()
overall_status = "healthy"
# Database check
try:
import time as time_module
start = time_module.perf_counter()
async with SessionLocal() as session:
await session.execute(text("SELECT 1"))
db_time = (time_module.perf_counter() - start) * 1000
checks.database = DatabaseHealth(
status="healthy",
response_time_ms=round(db_time, 2),
)
except Exception:
checks.database = DatabaseHealth(
status="unhealthy",
response_time_ms=0.0,
)
overall_status = "degraded"
# Disk check
try:
import shutil
disk = shutil.disk_usage("/")
free_gb = disk.free / (1024**3)
total_gb = disk.total / (1024**3)
disk_status = "healthy" if free_gb > 1.0 else "degraded"
if disk_status == "degraded":
overall_status = "degraded"
checks.disk = DiskHealth(
status=disk_status,
free_gb=round(free_gb, 2),
total_gb=round(total_gb, 2),
)
except Exception:
checks.disk = None
return HealthResponse(
status=overall_status,
timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
version="0.1.0",
checks=checks,
uptime_seconds=round(time.time() - _start_time, 2),
).model_dump()
@router.get(
"/health/db",
response_model=DatabaseHealthResponse,
summary="Database health check",
description="Returns database-specific health status with response time.",
tags=["Health"],
)
async def health_check_db() -> dict[str, Any]:
"""Check database health.
Returns:
DatabaseHealthResponse with status and response time.
"""
import time as time_module
try:
start = time_module.perf_counter()
async with SessionLocal() as session:
await session.execute(text("SELECT 1"))
db_time = (time_module.perf_counter() - start) * 1000
return DatabaseHealthResponse(
status="healthy",
response_time_ms=round(db_time, 2),
).model_dump()
except Exception:
return DatabaseHealthResponse(
status="unhealthy",
response_time_ms=0.0,
).model_dump()
+125
View File
@@ -0,0 +1,125 @@
"""Instance proxy router for forwarding HTTP requests to running containers."""
import logging
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/instances", tags=["instance-proxy"])
async def _proxy_request(
request: Request,
instance_id: uuid.UUID,
path: str,
user_id: uuid.UUID,
session: AsyncSession,
) -> Response:
"""Proxy an HTTP request to a running instance."""
instance = await session.get(ToolInstance, instance_id)
if instance is None:
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",
)
if instance.status != "running" or not instance.container_name:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="instance is not running",
)
# Get the tool type to find the internal port
tool_type = await session.get(ToolType, instance.tool_type_id)
internal_port = tool_type.default_port if tool_type and tool_type.default_port else instance.port
# Build target URL using internal port
target_url = f"http://{instance.container_name}:{internal_port}"
if path:
target_url += f"/{path}"
# Get query string
query_string = str(request.query_params)
if query_string:
target_url += f"?{query_string}"
# Forward headers (excluding host and cookies)
headers: dict[str, str] = {}
for key, value in request.headers.items():
if key.lower() not in ("host", "cookie", "content-length"):
headers[key] = value
# 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,
)
except Exception as exc:
logger.error("Proxy error to %s: %s", target_url, 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,
)
@router.get("/{instance_id}/proxy/{path:path}")
@router.post("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.put("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.delete("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.patch("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.head("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.options("/{instance_id}/proxy/{path:path}", include_in_schema=False)
async def proxy_to_instance(
request: Request,
instance_id: uuid.UUID,
path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Proxy requests to a running tool instance.
Args:
request: The incoming HTTP request.
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.
"""
return await _proxy_request(request, instance_id, path, user_id, session)
+123 -28
View File
@@ -1,16 +1,13 @@
import os
import shutil
import uuid
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
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.jwt_service import decode_access_token
from src.config import Settings
from src.database import SessionLocal
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
@@ -19,25 +16,8 @@ from src.models.user import User
router = APIRouter(prefix="/projects", tags=["projects"])
async def get_db_session():
async with SessionLocal() as session:
yield session
async def get_current_user_id(
access_token: Annotated[str | None, Cookie()] = None,
) -> uuid.UUID:
if not access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
try:
claims = decode_access_token(settings=Settings(), token=access_token)
return uuid.UUID(str(claims["sub"]))
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
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")
@@ -68,12 +48,28 @@ class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=ProjectResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new project",
description="Create a new project for the authenticated user.",
)
async def create_project(
data: ProjectCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Create a new project.
Args:
data: Project creation data including name and optional description.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created project.
"""
user = await _get_user(session, user_id)
project = Project(
name=data.name,
@@ -87,21 +83,73 @@ async def create_project(
return project
@router.get("", response_model=list[ProjectResponse])
@router.get(
"",
response_model=list[ProjectResponse],
summary="List all projects",
description="Retrieve all projects owned by the authenticated user.",
)
async def list_projects(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[Project]:
"""List all projects for the authenticated user.
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))
return list(result.scalars().all())
@router.get(
"/{project_id}",
response_model=ProjectResponse,
summary="Get a project",
description="Retrieve a specific project by ID.",
)
async def get_project(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Get a specific project by ID.
Args:
project_id: UUID of the project to retrieve.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The requested project.
"""
await _get_user(session, user_id)
return await _get_owned_project(project_id, user_id, session)
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
@@ -110,13 +158,29 @@ async def _get_owned_project(
return project
@router.patch("/{project_id}", response_model=ProjectResponse)
@router.patch(
"/{project_id}",
response_model=ProjectResponse,
summary="Update a project",
description="Update a project's name or description.",
)
async def update_project(
project_id: uuid.UUID,
data: ProjectUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Update a project.
Args:
project_id: UUID of the project to update.
data: Project update data with optional name and description.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated project.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
@@ -130,12 +194,27 @@ async def update_project(
return project
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{project_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a project",
description="Delete a project and all its associated repositories.",
)
async def delete_project(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Delete a project and all its repositories.
Args:
project_id: UUID of the project to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Empty response with 204 status code.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
@@ -152,13 +231,29 @@ async def delete_project(
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.patch("/{project_id}/default-ssh-key", response_model=ProjectResponse)
@router.patch(
"/{project_id}/default-ssh-key",
response_model=ProjectResponse,
summary="Set default SSH key",
description="Set the default SSH key for a project.",
)
async def set_default_ssh_key(
project_id: uuid.UUID,
data: SetDefaultSSHKeyRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Set the default SSH key for a project.
Args:
project_id: UUID of the project.
data: Request containing the SSH key ID to set as default.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated project.
"""
user = await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
+65 -27
View File
@@ -1,43 +1,24 @@
import uuid
from datetime import datetime
from typing import Annotated
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
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.jwt_service import decode_access_token
from src.auth.dependencies import get_current_user_id, get_db_session
from src.config import Settings
from src.database import SessionLocal
from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
async def get_db_session():
async with SessionLocal() as session:
yield session
async def get_current_user_id(
access_token: Annotated[str | None, Cookie()] = None,
) -> uuid.UUID:
if not access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
try:
claims = decode_access_token(settings=Settings(), token=access_token)
return uuid.UUID(str(claims["sub"]))
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
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")
@@ -45,12 +26,24 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
import base64
import hashlib
settings = Settings()
key = settings.jwt_secret[:32].ljust(32, "=")
return Fernet(key.encode())
# Derive a 32-byte key from the session secret using SHA256
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
# Base64 encode it for Fernet (must be 32 url-safe base64-encoded bytes)
key = base64.urlsafe_b64encode(key_bytes)
return Fernet(key)
def generate_ssh_key_pair() -> tuple[str, str]:
"""Generate a new Ed25519 SSH key pair.
Returns:
Tuple of (private_key, public_key) as strings.
"""
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
@@ -81,12 +74,28 @@ class SSHKeyResponse(BaseModel):
created_at: datetime
@router.post("", response_model=SSHKeyResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=SSHKeyResponse,
status_code=status.HTTP_201_CREATED,
summary="Create SSH key",
description="Generate a new Ed25519 SSH key pair for the authenticated user.",
)
async def create_ssh_key(
data: SSHKeyCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> SSHKey:
"""Create a new SSH key pair.
Args:
data: SSH key creation data including the key name.
user_id: ID of the authenticated user.
session: Database session.
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()
@@ -105,22 +114,51 @@ async def create_ssh_key(
return ssh_key
@router.get("", response_model=list[SSHKeyResponse])
@router.get(
"",
response_model=list[SSHKeyResponse],
summary="List SSH keys",
description="List all SSH keys for the authenticated user.",
)
async def list_ssh_keys(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[SSHKey]:
"""List all SSH keys for the authenticated user.
Args:
user_id: ID of the authenticated user.
session: Database session.
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())
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{key_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete SSH key",
description="Delete an SSH key by ID.",
)
async def delete_ssh_key(
key_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete an SSH key.
Args:
key_id: UUID of the SSH key 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)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
+124
View File
@@ -0,0 +1,124 @@
"""WebSocket terminal endpoint for tool instances."""
import asyncio
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session
from src.models.tool_instance import ToolInstance
from src.services.terminal_manager import terminal_manager
router = APIRouter()
logger = logging.getLogger(__name__)
@router.websocket(
"/ws/tool-instances/{instance_id}/terminal",
)
async def terminal_websocket(
websocket: WebSocket,
instance_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
"""WebSocket endpoint for terminal access to a tool instance.
Provides an interactive terminal session inside a running tool instance container.
Args:
websocket: The WebSocket connection.
instance_id: UUID string of the tool instance.
db_session: Database session.
Returns:
None. Communicates via WebSocket messages.
"""
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
await websocket.accept()
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)
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)
await websocket.close(code=4004, reason="Instance not found")
return
if instance.owner_id != user_id:
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
await websocket.close(code=4003, reason="Forbidden")
return
if instance.status != "running" or not instance.container_id:
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
await websocket.close(code=4004, reason="Instance not running")
return
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
# Create terminal session
try:
session = await terminal_manager.create_session(
instance_uuid,
instance.container_id,
websocket,
)
logger.info("Terminal session created successfully for instance %s", instance_id)
# Send connected status
await websocket.send_json({"type": "status", "status": "connected"})
# Keep connection alive until session ends
# The terminal_manager handles I/O loops, we just wait here
while session.is_alive() and not session._closed:
await asyncio.sleep(0.5)
except Exception as exc:
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
await websocket.close(code=4000, reason=f"Error: {exc}")
finally:
# Cleanup will be handled by the session manager
pass
async def _get_user_from_websocket(
websocket: WebSocket,
db_session: AsyncSession,
) -> uuid.UUID | None:
"""Extract and validate user ID from session cookie in WebSocket.
Args:
websocket: The WebSocket connection.
db_session: Database session.
Returns:
The user's UUID if authenticated, None otherwise.
"""
from src.auth.session import decode_session_cookie
from src.config import Settings
session_cookie = websocket.cookies.get("session")
if not session_cookie:
return None
settings = Settings()
try:
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
return uuid.UUID(str(payload["user_id"]))
except (ValueError, KeyError):
return None
+322
View File
@@ -0,0 +1,322 @@
"""Tool configuration API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
class ToolConfigCreate(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type")
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
key: str = Field(description="Config key name")
value: str = Field(description="Config value")
config_type: str = Field(default="env", description="Type: env or file")
file_path: str | None = Field(default=None, description="File path for file-type configs")
port_override: int | None = Field(default=None, description="Port override (1-65535)")
start_command: str | None = Field(default=None, description="Override container start command")
working_directory: str | None = Field(default=None, description="Working directory inside container")
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
@field_validator("port_override")
@classmethod
def validate_port(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 1 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
if v is None:
return v
if not isinstance(v, list):
raise ValueError("volumes must be a JSON array")
for i, vol in enumerate(v):
if not isinstance(vol, dict):
raise ValueError(f"Volume at index {i} must be an object")
if "source" not in vol:
raise ValueError(f"Volume at index {i} must have 'source' field")
if "target" not in vol:
raise ValueError(f"Volume at index {i} must have 'target' field")
return v
class ToolConfigUpdate(BaseModel):
key: str | None = Field(default=None, description="Config key name")
value: str | None = Field(default=None, description="Config value")
config_type: str | None = Field(default=None, description="Type: env or file")
file_path: str | None = Field(default=None, description="File path for file-type configs")
port_override: int | None = Field(default=None, description="Port override (1-65535)")
start_command: str | None = Field(default=None, description="Override container start command")
working_directory: str | None = Field(default=None, description="Working directory inside container")
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
@field_validator("port_override")
@classmethod
def validate_port(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 1 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
if v is None:
return v
if not isinstance(v, list):
raise ValueError("volumes must be a JSON array")
for i, vol in enumerate(v):
if not isinstance(vol, dict):
raise ValueError(f"Volume at index {i} must be an object")
if "source" not in vol:
raise ValueError(f"Volume at index {i} must have 'source' field")
if "target" not in vol:
raise ValueError(f"Volume at index {i} must have 'target' field")
return v
class ToolConfigResponse(BaseModel):
id: str
tool_type_id: str
project_id: str | None
key: str
value: str
config_type: str
file_path: str | None
port_override: int | None
start_command: str | None
working_directory: str | None
environment_variables: dict | None
volumes: list[dict] | None
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
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()
File diff suppressed because it is too large Load Diff
+417 -58
View File
@@ -1,40 +1,21 @@
import uuid
from typing import Annotated
from datetime import datetime
import yaml
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator
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.auth.jwt_service import decode_access_token
from src.config import Settings
from src.database import SessionLocal
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_type import ToolType
from src.models.user import User
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
async def get_db_session():
async with SessionLocal() as session:
yield session
async def get_current_user_id(
access_token: Annotated[str | None, Cookie()] = None,
) -> uuid.UUID:
if not access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
try:
claims = decode_access_token(settings=Settings(), token=access_token)
return uuid.UUID(str(claims["sub"]))
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
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")
@@ -42,6 +23,11 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
async def _require_admin(user: User) -> None:
"""Check if user has admin privileges.
For now, all authenticated users can manage tool types.
In production, this should check user.role or similar.
"""
# For now, all authenticated users can manage tool types
# In production, check user.role or similar
pass
@@ -51,12 +37,33 @@ class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
compose_template: str
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) -> str:
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:
@@ -73,18 +80,79 @@ class ToolTypeCreate(BaseModel):
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")
# Get compose_template from the model data
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
# Check if the port is exposed in any service
port_str = str(v)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_name, service_config in parsed["services"].items():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
# Format: "8443:8443" or "8443"
if 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
# Get compose_template from the model data
data = info.data
if "compose_template" not in data:
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
template = data["compose_template"]
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
@@ -92,19 +160,48 @@ class ToolTypeCreate(BaseModel):
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) -> str | None:
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:
@@ -121,6 +218,22 @@ class ToolTypeUpdate(BaseModel):
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)
@@ -129,20 +242,43 @@ class ToolTypeResponse(BaseModel):
name: str
display_name: str
description: str | None
compose_template: str
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: str
updated_at: str
created_at: datetime
updated_at: datetime
@router.post("", response_model=ToolTypeResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=ToolTypeResponse,
status_code=status.HTTP_201_CREATED,
summary="Create tool type",
description="Create a new custom tool type with a Docker Compose template.",
)
async def create_tool_type(
data: ToolTypeCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Create a new tool type.
Args:
data: Tool type creation data including name, display name, and compose template.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
@@ -155,8 +291,15 @@ async def create_tool_type(
name=data.name,
display_name=data.display_name,
description=data.description,
default_port=data.default_port,
definition_type=data.definition_type,
compose_template=data.compose_template,
dockerfile_template=data.dockerfile_template,
build_context=data.build_context,
readiness_probe=data.readiness_probe,
required_variables=data.required_variables,
category=data.category,
interfaces=data.interfaces,
is_builtin=False,
created_by_id=user.id,
)
@@ -166,22 +309,51 @@ async def create_tool_type(
return tool_type
@router.get("", response_model=list[ToolTypeResponse])
@router.get(
"",
response_model=list[ToolTypeResponse],
summary="List tool types",
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),
session: AsyncSession = Depends(get_db_session),
) -> list[ToolType]:
"""List all tool types.
Args:
user_id: ID of the authenticated user.
session: Database session.
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())
@router.get("/{tool_type_id}", response_model=ToolTypeResponse)
@router.get(
"/{tool_type_id}",
response_model=ToolTypeResponse,
summary="Get tool type",
description="Get a specific tool type by ID.",
)
async def get_tool_type(
tool_type_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Get a specific tool type by ID.
Args:
tool_type_id: UUID of the tool type to retrieve.
user_id: ID of the authenticated user.
session: Database session.
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:
@@ -189,13 +361,29 @@ async def get_tool_type(
return tool_type
@router.put("/{tool_type_id}", response_model=ToolTypeResponse)
@router.put(
"/{tool_type_id}",
response_model=ToolTypeResponse,
summary="Update tool type",
description="Update a custom tool type. Built-in tool types cannot be modified.",
)
async def update_tool_type(
tool_type_id: uuid.UUID,
data: ToolTypeUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Update a tool type.
Args:
tool_type_id: UUID of the tool type to update.
data: Tool type update data with optional fields.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
@@ -208,26 +396,68 @@ async def update_tool_type(
update_data = data.model_dump(exclude_unset=True)
# Validate required variables if both are being updated
if "required_variables" in update_data and "compose_template" in update_data:
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:
# Only updating variables, check against existing template
template = tool_type.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"
)
# Validate port if being updated
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"
)
# 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 = 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"
)
# 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:
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:
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)
@@ -237,12 +467,141 @@ async def update_tool_type(
return tool_type
@router.delete("/{tool_type_id}", status_code=status.HTTP_204_NO_CONTENT)
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
@router.post(
"/validate",
summary="Validate tool type template",
description="Validate a compose template or dockerfile syntax before creating a tool type.",
)
async def validate_tool_type_template(
data: ToolTypeValidateRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Validate a tool type template syntax.
Args:
data: Validation request with definition type and template.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Validation result with success status and any errors.
"""
await _get_user(session, user_id)
errors = []
if data.definition_type == "compose":
if not data.compose_template:
errors.append("Compose template is required")
else:
try:
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:
errors.append("Dockerfile template is required")
elif not data.dockerfile_template.strip().startswith("FROM"):
errors.append("Dockerfile must start with a FROM instruction")
else:
errors.append("definition_type must be 'compose' or 'dockerfile'")
return {
"valid": len(errors) == 0,
"errors": errors,
}
@router.get(
"/{tool_type_id}/validate",
summary="Validate tool type",
description="Validate the compose template or dockerfile syntax of a tool type.",
)
async def validate_tool_type(
tool_type_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Validate a tool type's template syntax.
Args:
tool_type_id: UUID of the tool type to validate.
user_id: ID of the authenticated user.
session: Database session.
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")
errors = []
if tool_type.definition_type == "compose":
if not tool_type.compose_template:
errors.append("Compose template is empty")
else:
try:
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")
return {
"valid": len(errors) == 0,
"errors": errors,
}
@router.delete(
"/{tool_type_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete tool type",
description="Delete a custom tool type. Built-in tool types cannot be deleted.",
)
async def delete_tool_type(
tool_type_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool type.
Args:
tool_type_id: UUID of the tool type 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)
await _require_admin(user)
+53 -28
View File
@@ -1,40 +1,22 @@
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any
from src.auth.jwt_service import decode_access_token
from src.config import Settings
from src.database import SessionLocal
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.user import User
from src.models.user_config import UserConfig
router = APIRouter(prefix="/users/me", tags=["user-config"])
async def get_db_session():
async with SessionLocal() as session:
yield session
async def get_current_user_id(
access_token: Annotated[str | None, Cookie()] = None,
) -> uuid.UUID:
if not access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
try:
claims = decode_access_token(settings=Settings(), token=access_token)
return uuid.UUID(str(claims["sub"]))
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
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")
@@ -42,6 +24,15 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
"""Get or create user config record.
Args:
session: Database session.
user_id: UUID of the user.
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))
config = result.scalar_one_or_none()
if config is None:
@@ -59,6 +50,7 @@ class UserConfigResponse(BaseModel):
theme: str = "system"
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
class UserConfigUpdate(BaseModel):
@@ -66,31 +58,64 @@ class UserConfigUpdate(BaseModel):
theme: str | None = None
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
@router.get("/config", response_model=UserConfigResponse)
@router.get(
"/config",
response_model=UserConfigResponse,
summary="Get user config",
description="Get the current user's configuration settings.",
)
async def get_user_config(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Get the current user's configuration.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
The user's configuration settings.
"""
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
return UserConfigResponse.model_validate(config.config)
@router.patch("/config", response_model=UserConfigResponse)
@router.patch(
"/config",
response_model=UserConfigResponse,
summary="Update user config",
description="Update the current user's configuration settings.",
)
async def update_user_config(
data: UserConfigUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Update the current user's configuration.
Args:
data: Configuration update data with optional fields.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user configuration.
"""
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
# Merge updates
update_data = data.model_dump(exclude_unset=True, exclude_none=True)
config.config.update(update_data)
update_data = data.model_dump(exclude_unset=True)
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.info("Updated config: %s", config.config)
return UserConfigResponse.model_validate(config.config)
+50 -26
View File
@@ -1,14 +1,11 @@
import uuid
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.jwt_service import decode_access_token
from src.config import Settings
from src.database import SessionLocal
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.user import User
router = APIRouter(prefix="/users", tags=["users"])
@@ -19,25 +16,8 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
async def get_db_session():
async with SessionLocal() as session:
yield session
async def get_current_user_id(
access_token: Annotated[str | None, Cookie()] = None,
) -> uuid.UUID:
if not access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
try:
claims = decode_access_token(settings=Settings(), token=access_token)
return uuid.UUID(str(claims["sub"]))
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
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")
@@ -58,20 +38,49 @@ class UserProfileUpdate(BaseModel):
email: str | None = None
@router.get("/me", response_model=UserProfileResponse)
@router.get(
"/me",
response_model=UserProfileResponse,
summary="Get current user profile",
description="Retrieve the profile of the currently authenticated user.",
)
async def get_profile(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Get the current user's profile.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
The user's profile information.
"""
return await _get_user(session, user_id)
@router.put("/me", response_model=UserProfileResponse)
@router.put(
"/me",
response_model=UserProfileResponse,
summary="Update user profile",
description="Update the current user's profile information.",
)
async def update_profile(
data: UserProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Update the current user's profile.
Args:
data: Profile update data with optional name and email.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user profile.
"""
user = await _get_user(session, user_id)
if data.name is not None:
@@ -89,12 +98,27 @@ async def update_profile(
return user
@router.post("/me/avatar", response_model=UserProfileResponse)
@router.post(
"/me/avatar",
response_model=UserProfileResponse,
summary="Upload avatar",
description="Upload a profile avatar image (PNG or JPG, max 2MB).",
)
async def upload_avatar(
file: UploadFile,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Upload a profile avatar image.
Args:
file: The image file to upload (PNG or JPG, max 2MB).
user_id: ID of the authenticated user.
session: Database session.
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:
+3 -5
View File
@@ -1,12 +1,10 @@
from src.auth.cookies import build_cookie_options
from src.auth.jwt_service import decode_access_token, mint_access_token
from src.auth.oidc import build_login_redirect_url
from src.auth.refresh_store import hash_refresh_token
from src.auth.session import create_session_cookie, decode_session_cookie
__all__ = [
"build_cookie_options",
"build_login_redirect_url",
"decode_access_token",
"hash_refresh_token",
"mint_access_token",
"create_session_cookie",
"decode_session_cookie",
]
+2 -1
View File
@@ -1,9 +1,10 @@
from src.config import Settings
def build_cookie_options(settings: Settings) -> dict[str, str | bool]:
def build_cookie_options(settings: Settings) -> dict[str, str | bool | None]:
return {
"httponly": True,
"secure": settings.cookie_secure,
"samesite": settings.cookie_samesite,
"domain": settings.cookie_domain,
}
+49
View File
@@ -0,0 +1,49 @@
import uuid
from typing import Annotated
from fastapi import Cookie, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.session import decode_session_cookie
from src.config import Settings
from src.database import SessionLocal
from src.models.user import User
async def get_db_session():
async with SessionLocal() as session:
yield session
async def get_current_user_id(
session_cookie: Annotated[str | None, Cookie(alias="session")] = None,
) -> uuid.UUID:
if not session_cookie:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
settings = Settings()
try:
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
return uuid.UUID(str(payload["user_id"]))
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid session")
async def get_current_user(
session_cookie: Annotated[str | None, Cookie(alias="session")] = None,
db_session: AsyncSession = Depends(get_db_session),
) -> User:
if not session_cookie:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
settings = Settings()
try:
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
user_id = uuid.UUID(str(payload["user_id"]))
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid session")
user = await db_session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
-27
View File
@@ -1,27 +0,0 @@
from datetime import datetime
from jose import jwt # type: ignore[import-untyped]
from src.config import Settings
def mint_access_token(
*,
settings: Settings,
subject: str,
email: str,
name: str,
expires_at: datetime,
) -> str:
payload = {
"sub": subject,
"email": email,
"name": name,
"exp": expires_at,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(*, settings: Settings, token: str) -> dict[str, str | int]:
claims = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return dict(claims)
+12 -25
View File
@@ -1,7 +1,7 @@
from typing import Any
from urllib.parse import urlencode
import httpx
from jose import jwt # type: ignore[import-untyped]
from src.config import Settings
@@ -11,7 +11,6 @@ def build_login_redirect_url(
settings: Settings,
redirect_uri: str,
state: str,
nonce: str,
) -> str:
query = urlencode(
{
@@ -20,7 +19,6 @@ def build_login_redirect_url(
"redirect_uri": redirect_uri,
"scope": "openid profile email",
"state": state,
"nonce": nonce,
}
)
return f"{settings.resolved_authentik_authorize_url}?{query}"
@@ -47,31 +45,20 @@ async def exchange_code_for_tokens(
payload = response.json()
return {
"access_token": payload["access_token"],
"refresh_token": payload["refresh_token"],
"refresh_token": payload.get("refresh_token"),
}
async def fetch_jwks(*, settings: Settings, client: httpx.AsyncClient) -> dict[str, list[dict[str, str]]]:
response = await client.get(settings.resolved_authentik_jwks_url)
response.raise_for_status()
payload = response.json()
return {"keys": payload["keys"]}
def verify_provider_access_token(
async def fetch_user_info(
*,
settings: Settings,
token: str,
jwks: dict[str, list[dict[str, str]]],
) -> dict[str, str | int]:
unverified_header = jwt.get_unverified_header(token)
key_id = unverified_header["kid"]
jwk_key = next(key for key in jwks["keys"] if key.get("kid") == key_id)
claims = jwt.decode(
token,
jwk_key,
algorithms=[jwk_key.get("alg", "HS256")],
audience=settings.authentik_audience,
issuer=settings.resolved_authentik_issuer,
access_token: str,
client: httpx.AsyncClient,
) -> dict[str, Any]:
"""Fetch user info from Authentik userinfo endpoint."""
response = await client.get(
f"{settings.authentik_base_url}/application/o/userinfo/",
headers={"Authorization": f"Bearer {access_token}"},
)
return dict(claims)
response.raise_for_status()
return response.json()
-79
View File
@@ -1,79 +0,0 @@
from datetime import UTC, datetime
from hashlib import sha256
from secrets import token_urlsafe
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.refresh_token import RefreshToken
def hash_refresh_token(raw_token: str) -> str:
return sha256(raw_token.encode("utf-8")).hexdigest()
async def create_refresh_token(
*,
session: AsyncSession,
user_id: object,
expires_at: datetime,
user_agent: str | None,
ip_address: str | None,
) -> tuple[str, RefreshToken]:
raw_token = token_urlsafe(48)
record = RefreshToken(
user_id=user_id,
token_hash=hash_refresh_token(raw_token),
expires_at=expires_at,
created_at=datetime.now(UTC),
user_agent=user_agent,
ip_address=ip_address,
)
session.add(record)
await session.commit()
await session.refresh(record)
return raw_token, record
async def rotate_refresh_token(
*,
session: AsyncSession,
raw_token: str,
user_agent: str | None,
ip_address: str | None,
) -> tuple[str, RefreshToken]:
existing_hash = hash_refresh_token(raw_token)
existing = await session.scalar(
select(RefreshToken).where(
RefreshToken.token_hash == existing_hash,
RefreshToken.revoked_at.is_(None),
)
)
if existing is None:
raise ValueError("refresh token not found")
if existing.expires_at <= datetime.now(UTC):
raise ValueError("refresh token expired")
existing.revoked_at = datetime.now(UTC)
await session.flush()
return await create_refresh_token(
session=session,
user_id=existing.user_id,
expires_at=existing.expires_at,
user_agent=user_agent,
ip_address=ip_address,
)
async def revoke_refresh_token(*, session: AsyncSession, raw_token: str) -> bool:
token_hash = hash_refresh_token(raw_token)
existing = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if existing is None:
return False
if existing.revoked_at is not None:
return True
existing.revoked_at = datetime.now(UTC)
await session.commit()
return True
+71
View File
@@ -0,0 +1,71 @@
import hmac
import hashlib
import json
import base64
from datetime import UTC, datetime, timedelta
from typing import Any
from src.config import Settings
def _base64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _base64url_decode(data: str) -> bytes:
padding = 4 - len(data) % 4
if padding != 4:
data += "=" * padding
return base64.urlsafe_b64decode(data)
def create_session_cookie(*, settings: Settings, user_id: str) -> str:
"""Create a signed session cookie value."""
payload = {
"user_id": user_id,
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
}
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
payload_encoded = _base64url_encode(json.dumps(payload).encode())
message = f"{header}.{payload_encoded}"
signature = hmac.new(
settings.session_secret.encode(),
message.encode(),
hashlib.sha256,
).digest()
signature_encoded = _base64url_encode(signature)
return f"{message}.{signature_encoded}"
def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str, Any]:
"""Decode and verify a session cookie. Returns payload or raises ValueError."""
parts = cookie_value.split(".")
if len(parts) != 3:
raise ValueError("invalid session format")
header, payload_encoded, signature_encoded = parts
message = f"{header}.{payload_encoded}"
# Verify signature
expected_signature = hmac.new(
settings.session_secret.encode(),
message.encode(),
hashlib.sha256,
).digest()
expected_signature_encoded = _base64url_encode(expected_signature)
if not hmac.compare_digest(signature_encoded, expected_signature_encoded):
raise ValueError("invalid session signature")
# Decode payload
payload_bytes = _base64url_decode(payload_encoded)
payload = json.loads(payload_bytes)
# Check expiry
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
raise ValueError("session expired")
return payload
+30 -7
View File
@@ -34,19 +34,26 @@ class Settings(BaseSettings):
# Authentik configuration - no hardcoded URLs
authentik_client_id: str = "headquarter-web"
authentik_client_secret: str = "change-me"
# Authentik application slug used in URLs (e.g., "headquarter-web")
# This is different from the OAuth client_id which may be a UUID
authentik_application_slug: str = "headquarter-web"
authentik_authorize_url: str | None = None
authentik_token_url: str | None = None
authentik_jwks_url: str | None = None
authentik_issuer: str | None = None
authentik_audience: str = "headquarter-web"
jwt_secret: str = "change-me-jwt-secret"
jwt_algorithm: str = "HS256"
access_token_ttl_minutes: int = 15
refresh_token_ttl_days: int = 7
# Session configuration
session_secret: str = "change-me-session-secret"
session_ttl_hours: int = 24
# Repository storage
repo_base_path: str = "/data/repos"
# Tool instance storage
instance_base_path: str = "/data/instances"
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
@@ -100,13 +107,13 @@ class Settings(BaseSettings):
def resolved_authentik_jwks_url(self) -> str:
if self.authentik_jwks_url:
return self.authentik_jwks_url
return f"{self.authentik_base_url}/application/o/{self.authentik_client_id}/jwks/"
return f"{self.authentik_base_url}/application/o/{self.authentik_application_slug}/jwks/"
@property
def resolved_authentik_issuer(self) -> str:
if self.authentik_issuer:
return self.authentik_issuer
return f"{self.authentik_base_url}/application/o/{self.authentik_client_id}/"
return f"{self.authentik_base_url}/application/o/{self.authentik_application_slug}/"
@property
def cookie_secure(self) -> bool:
@@ -115,6 +122,22 @@ class Settings(BaseSettings):
@property
def cookie_samesite(self) -> str:
if self.app_env == "production":
return "strict"
return "none"
return "lax"
@property
def cookie_domain(self) -> str | None:
"""Return the parent domain for cross-subdomain cookies.
E.g., api.example.com and app.example.com both share .example.com
"""
if self.app_env != "production":
return None
# Extract parent domain from api_domain
# e.g., "api.headquarter.commumedia.org" -> ".headquarter.commumedia.org"
parts = self.api_domain.split(".")
if len(parts) >= 3:
return "." + ".".join(parts[1:])
return None
+94 -1
View File
@@ -1,8 +1,13 @@
import asyncio
import logging
import subprocess
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from src.config import Settings, build_database_url
logger = logging.getLogger(__name__)
settings = Settings()
database_url = settings.database_url
@@ -20,4 +25,92 @@ engine = create_async_engine(
)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
__all__ = ["SessionLocal", "build_database_url", "engine", "settings"]
async def init_database(
max_retries: int = 5,
retry_delay: float = 2.0,
) -> bool:
"""Initialize the database by running pending migrations.
Uses subprocess to run 'alembic upgrade head' to avoid
async/sync context manager issues with SQLAlchemy 2.0.
Returns True if migrations succeeded, False otherwise.
"""
for attempt in range(1, max_retries + 1):
try:
# Test basic connectivity
from sqlalchemy import text
test_conn = await engine.connect()
try:
await test_conn.execute(text("SELECT 1"))
finally:
await test_conn.close()
logger.info("Database connection established.")
# Run migrations via subprocess
logger.info("Running database migrations...")
result = await asyncio.get_event_loop().run_in_executor(
None,
lambda: subprocess.run(
["alembic", "upgrade", "head"],
capture_output=True,
text=True,
cwd="/app",
),
)
if result.returncode == 0:
logger.info("Database migrations completed successfully.")
logger.debug("Alembic output: %s", result.stdout)
return True
else:
logger.error("Migration failed: %s", result.stderr)
if attempt < max_retries:
wait = retry_delay * (2 ** (attempt - 1))
logger.info("Retrying in %.1f seconds...", wait)
await asyncio.sleep(wait)
else:
return False
except Exception as exc:
error_msg = str(exc).lower()
if "connection" in error_msg or "could not connect" in error_msg:
logger.warning(
"Database connection failed (attempt %d/%d): %s",
attempt,
max_retries,
exc,
)
elif "authentication" in error_msg or "password" in error_msg:
logger.error(
"Database authentication failed: %s. "
"Check POSTGRES_USER and POSTGRES_PASSWORD environment variables.",
exc,
)
return False
else:
logger.error(
"Database initialization error (attempt %d/%d): %s",
attempt,
max_retries,
exc,
)
if attempt < max_retries:
wait = retry_delay * (2 ** (attempt - 1))
logger.info("Retrying in %.1f seconds...", wait)
await asyncio.sleep(wait)
else:
logger.error(
"Failed to initialize database after %d attempts. "
"Ensure the database is running and accessible.",
max_retries,
)
return False
return False
__all__ = ["SessionLocal", "build_database_url", "engine", "settings", "init_database"]
+92
View File
@@ -0,0 +1,92 @@
import logging
import sys
import time
import traceback
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Log all HTTP requests with timing and status codes."""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
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,
request.url.path,
client_host,
)
try:
response = await call_next(request)
duration = time.time() - start_time
# Log the response
logger.info(
"← Response: %s %s%d (%dms)",
request.method,
request.url.path,
response.status_code,
int(duration * 1000),
)
return response
except Exception as exc:
duration = time.time() - start_time
logger.error(
"✗ Error: %s %s%s (%dms)\n%s",
request.method,
request.url.path,
type(exc).__name__,
int(duration * 1000),
traceback.format_exc(),
)
raise
class ExceptionLoggingMiddleware(BaseHTTPMiddleware):
"""Catch and log all unhandled exceptions."""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
try:
return await call_next(request)
except Exception:
logger.critical(
"Unhandled exception in %s %s:\n%s",
request.method,
request.url.path,
traceback.format_exc(),
)
raise
def configure_logging(level: int = logging.INFO) -> None:
"""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)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(level)
root_logger.handlers = [console_handler]
# Set levels for specific loggers
logging.getLogger("uvicorn").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logger.info("Logging configured at level %s", logging.getLevelName(level))
+201 -4
View File
@@ -1,27 +1,143 @@
from fastapi import FastAPI
import json
import logging
import os
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select, text
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_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.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.user_config import router as user_config_router
from src.api.users import router as users_router
from src.database import SessionLocal
from src.config import Settings
from src.database import SessionLocal, init_database
from src.logging_config import (
ExceptionLoggingMiddleware,
RequestLoggingMiddleware,
configure_logging,
)
from src.models.tool_type import ToolType
from sqlalchemy import select
# Configure logging early
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
configure_logging(level=getattr(logging, log_level, logging.INFO))
logger = logging.getLogger(__name__)
settings = Settings()
app = FastAPI(title="Headquarter API")
# Configure CORS - must be before other middleware
# Build allowed origins list including web and api domains
cors_origins = [settings.web_base_url]
if settings.api_base_url != settings.web_base_url:
cors_origins.append(settings.api_base_url)
logger.info("CORS configured with origins: %s", cors_origins)
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(ExceptionLoggingMiddleware)
def _sanitize_validation_errors(errors):
"""Convert validation errors to JSON-safe format."""
sanitized = []
for error in errors:
safe_error = {
"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,
}
# Convert ctx to safe format
ctx = error.get("ctx")
if ctx:
safe_ctx = {}
for key, value in ctx.items():
if isinstance(value, Exception):
safe_ctx[key] = str(value)
elif isinstance(value, (str, int, float, bool, type(None))):
safe_ctx[key] = value
else:
safe_ctx[key] = str(value)
safe_error["ctx"] = safe_ctx
sanitized.append(safe_error)
return sanitized
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Log validation errors and return detailed response."""
errors = exc.errors()
logger.warning(
"Validation error for %s %s: %s",
request.method,
request.url.path,
errors,
)
safe_errors = _sanitize_validation_errors(errors)
return JSONResponse(
status_code=422,
content={"detail": safe_errors},
)
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
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:
@@ -36,12 +152,16 @@ services:
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:
@@ -56,6 +176,47 @@ services:
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:
@@ -65,24 +226,60 @@ services:
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.")
@app.on_event("startup")
async def on_startup():
await seed_builtin_tool_types()
logger.info("Starting up Headquarter API...")
# Initialize database (run migrations)
db_ready = await init_database()
if not db_ready:
logger.error("Database initialization failed. Shutting down.")
import sys
sys.exit(1)
# Seed built-in data
await seed_builtin_tool_types()
logger.info("Startup complete.")
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(dashboard_router)
app.include_router(projects_router)
app.include_router(users_router)
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(config_folders_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.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+3 -2
View File
@@ -1,10 +1,11 @@
from src.models.base import Base
from src.models.config_folder import ConfigFolder
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.refresh_token import RefreshToken
from src.models.ssh_key import SSHKey
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.user import User
from src.models.user_config import UserConfig
__all__ = ["Base", "GitRepository", "Project", "RefreshToken", "SSHKey", "ToolType", "User", "UserConfig"]
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
+31
View File
@@ -0,0 +1,31 @@
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": {...}}}
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
user: Mapped["User"] = relationship()
-24
View File
@@ -1,24 +0,0 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.user import User
class RefreshToken(UUIDPrimaryKeyMixin, Base):
__tablename__ = "refresh_tokens"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
user: Mapped["User"] = relationship(back_populates="refresh_tokens")
+2 -2
View File
@@ -5,14 +5,14 @@ from sqlalchemy import ForeignKey, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, UUIDPrimaryKeyMixin
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.user import User
class SSHKey(UUIDPrimaryKeyMixin, Base):
class SSHKey(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "ssh_keys"
name: Mapped[str] = mapped_column(String(255))
+48
View File
@@ -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()
+72
View File
@@ -0,0 +1,72 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, 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.git_repository import GitRepository
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "tool_instances"
name: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
tool_type_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("tool_types.id"), nullable=False
)
repository_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("git_repositories.id"), nullable=False
)
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
)
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
)
probe_result: Mapped[dict | None] = mapped_column(
JSON, nullable=True
)
tool_type: Mapped["ToolType"] = relationship()
repository: Mapped["GitRepository"] = relationship()
project: Mapped["Project"] = relationship()
owner: Mapped["User"] = relationship()
+12 -1
View File
@@ -17,7 +17,18 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
compose_template: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
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(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)
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(
-2
View File
@@ -7,7 +7,6 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.refresh_token import RefreshToken
from src.models.ssh_key import SSHKey
from src.models.user_config import UserConfig
@@ -21,6 +20,5 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
projects: Mapped[list["Project"]] = relationship(back_populates="owner")
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user")
ssh_keys: Mapped[list["SSHKey"]] = relationship(back_populates="user")
user_config: Mapped["UserConfig | None"] = relationship(back_populates="user", uselist=False)
+563
View File
@@ -0,0 +1,563 @@
"""Docker service for managing tool instances."""
import os
import subprocess
from pathlib import Path
from typing import Any
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
Args:
template: The compose template string
variables: Dictionary of variable names to values
Returns:
Rendered compose file content
"""
result = template
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
return result
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
"""Create and return the instance directory path.
Args:
instance_id: Unique instance identifier
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
Returns:
Absolute path to instance directory
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
return str(instance_dir.absolute())
def write_compose_file(instance_dir: str, content: str) -> str:
"""Write the rendered compose file to the instance directory.
Args:
instance_dir: Path to instance directory
content: Rendered compose content
Returns:
Path to the compose file
"""
compose_path = Path(instance_dir) / "docker-compose.yml"
compose_path.write_text(content)
return str(compose_path)
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
"""Write environment variables to a .env file.
Args:
instance_dir: Path to instance directory
env_vars: Dictionary of env var names to values
Returns:
Path to the env file
"""
env_path = Path(instance_dir) / ".env"
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
env_path.write_text("\n".join(lines) + "\n")
return str(env_path)
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
"""Write config files to the instance directory.
Args:
instance_dir: Path to instance directory
files: Dictionary of file paths (relative to instance dir) to content
"""
instance_path = Path(instance_dir)
for file_path, content in files.items():
# Ensure the path is within the instance directory (security)
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
"""Write config folder files to the instance directory and return volume mounts.
Args:
instance_dir: Path to instance directory
folders: List of ConfigFolder objects
project_id: Optional project ID for applying overrides
Returns:
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
"""
instance_path = Path(instance_dir)
volume_mounts = []
for folder in folders:
# Determine mount path (with project override if applicable)
mount_path = folder.mount_path
files = folder.files.copy()
if project_id and folder.project_overrides:
override = folder.project_overrides.get(str(project_id))
if override:
if override.get("mount_path"):
mount_path = override["mount_path"]
if override.get("files"):
files.update(override["files"])
# Write files to instance directory
folder_dir = instance_path / "volumes" / folder.name
folder_dir.mkdir(parents=True, exist_ok=True)
for file_path, content in files.items():
# Security: ensure path doesn't escape folder_dir
full_path = folder_dir / file_path
try:
full_path.resolve().relative_to(folder_dir.resolve())
except ValueError:
logger.warning("Config folder file path escapes directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Add volume mount
volume_mounts.append({
"source": str(folder_dir),
"target": mount_path,
"type": "bind",
})
return volume_mounts
def execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
"""Execute a docker compose command.
Args:
compose_path: Path to docker-compose.yml
action: The compose action (up, down, start, stop, restart)
timeout: Command timeout in seconds
env_file: Optional path to .env file for environment variables
Returns:
Tuple of (returncode, stdout, stderr)
"""
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
cmd.append(action)
else:
raise ValueError(f"Unknown compose action: {action}")
result = subprocess.run(
cmd,
cwd=str(instance_dir),
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service.
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) -> dict[str, Any]:
"""Get the status of a Docker container.
Args:
container_id: Docker container ID
Returns:
Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
"""
result = subprocess.run(
[
"docker", "inspect", "-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
return {"status": "not_found", "exit_code": None, "health": None}
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
def wait_for_container_running(
container_id: str, timeout: int = 30, interval: float = 2.0
) -> dict[str, Any]:
"""Wait for a container to reach the running state.
Polls docker inspect until the container status is "running" or timeout.
Args:
container_id: Docker container ID
timeout: Maximum seconds to wait
interval: Seconds between polls
Returns:
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float)
"""
import time
start_time = time.time()
while time.time() - start_time < timeout:
info = get_container_status(container_id)
if info["status"] == "running":
return {
"success": True,
"status": "running",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
if info["status"] == "exited":
return {
"success": False,
"status": "exited",
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if info["status"] == "not_found":
return {
"success": False,
"status": "not_found",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
time.sleep(interval)
# Timeout reached
info = get_container_status(container_id)
return {
"success": False,
"status": info["status"],
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
def get_container_logs(container_id: str, tail: int = 100) -> str:
"""Get the logs of a Docker container.
Args:
container_id: Docker container ID
tail: Number of lines to return
Returns:
Container logs
"""
result = subprocess.run(
["docker", "logs", "--tail", str(tail), container_id],
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout
return f"Failed to get logs: {result.stderr}"
def find_free_port(start: int = 10000, end: int = 20000) -> int:
"""Find a free TCP port in the given range.
Args:
start: Start of port range
end: End of port range
Returns:
Free port number
"""
import socket
for port in range(start, end):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("localhost", port)) != 0:
return port
raise RuntimeError(f"No free port found in range {start}-{end}")
import subprocess
import time
import re
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for a container.
Uses 'cloudflared tunnel --url' to create a temporary tunnel
with a random trycloudflare.com URL.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
timeout: Maximum seconds to wait for tunnel URL
Returns:
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import subprocess
import time
import re
import logging
logger = logging.getLogger(__name__)
# First verify the container is accessible
logger.info("Checking connectivity to %s:%d...", container_name, port)
for attempt in range(10):
check = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
f"http://{container_name}:{port}"],
capture_output=True,
text=True,
timeout=5,
)
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
if check.returncode == 0:
break
time.sleep(1)
else:
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# Wait for the URL to appear in output
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
start_time = time.time()
url = None
while time.time() - start_time < timeout:
# Read available output
import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
if line:
match = url_pattern.search(line)
if match:
url = match.group(0)
break
if not url:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError(
f"Failed to get tunnel URL within {timeout}s. "
f"cloudflared output may contain errors."
)
return {"url": url, "pid": str(proc.pid)}
def stop_cloudflared_tunnel(pid: str) -> None:
"""Stop a cloudflared tunnel process.
Args:
pid: Process ID of the cloudflared tunnel
"""
import os
import signal
try:
os.kill(int(pid), signal.SIGTERM)
except ProcessLookupError:
pass # Already stopped
def recreate_tunnel(
container_name: str, port: int, old_pid: str | None = None
) -> dict[str, str]:
"""Recreate a temporary Cloudflare tunnel.
Stops the old tunnel (if pid provided) and starts a new one.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
old_pid: Optional PID of the old tunnel process to stop
Returns:
Dict with 'url' and 'pid' for the new tunnel
"""
if old_pid:
stop_cloudflared_tunnel(old_pid)
return start_cloudflared_tunnel(container_name, port)
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy with smart error classification.
Args:
url: The tunnel URL to check
timeout: Request timeout in seconds
Returns:
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
"""
import subprocess
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", str(timeout), url],
capture_output=True,
text=True,
timeout=timeout + 5,
)
status_code = int(result.stdout.strip())
if 200 <= status_code < 400:
return {
"tunnel_status": "healthy",
"status_code": status_code,
"healthy": True,
"error": None,
}
elif status_code in (502, 503, 504):
# Application error, not tunnel error
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"Application returned HTTP {status_code}",
}
else:
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"HTTP {status_code}",
}
except subprocess.TimeoutExpired:
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": "Tunnel request timed out",
}
except (ValueError, Exception) as e:
error_str = str(e).lower()
# Classify connection errors
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": f"Tunnel unreachable: {e}",
}
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": str(e),
}
+69
View File
@@ -0,0 +1,69 @@
"""Docker build service for building images from Dockerfiles."""
import logging
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]:
"""Build a Docker image from a Dockerfile.
Args:
instance_dir: Directory containing the Dockerfile
dockerfile: Dockerfile content
tag: Image tag to apply
build_context: Optional build context files {path: content}
Returns:
Tuple of (returncode, stdout, stderr)
"""
import os
from pathlib import Path
# Write Dockerfile
dockerfile_path = Path(instance_dir) / "Dockerfile"
dockerfile_path.write_text(dockerfile)
logger.info("Wrote Dockerfile to %s", dockerfile_path)
# Write build context files
if build_context:
for file_path, content in build_context.items():
full_path = Path(instance_dir) / file_path
# Security: ensure path doesn't escape instance_dir
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")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
logger.info("Wrote build context file: %s", full_path)
# Build image
logger.info("Building Docker image with tag: %s", tag)
cmd = [
"docker", "build",
"-t", tag,
"-f", str(dockerfile_path),
instance_dir,
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300, # 5 minute timeout for builds
)
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
except subprocess.TimeoutExpired:
logger.error("Docker build timed out after 300 seconds")
return 1, "", "Build timed out after 300 seconds"
except Exception as exc:
logger.exception("Docker build failed: %s", exc)
return 1, "", str(exc)
+66
View File
@@ -0,0 +1,66 @@
"""Readiness probe service for checking if containers are ready."""
import asyncio
import logging
import subprocess
logger = logging.getLogger(__name__)
async def execute_probe(
container_id: str,
command: str,
timeout: int = 30,
interval: int = 2,
) -> tuple[bool, list[str]]:
"""Execute a readiness probe command inside a container.
Args:
container_id: Docker container ID or name
command: Command to execute inside the container
timeout: Maximum total time to wait (seconds)
interval: Time between retries (seconds)
Returns:
Tuple of (success, logs)
"""
logs = []
start_time = asyncio.get_event_loop().time()
attempt = 0
while True:
attempt += 1
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed >= timeout:
logs.append(f"Probe timed out after {timeout}s ({attempt} attempts)")
return False, logs
try:
logger.debug("Probe attempt %d: %s", attempt, command)
# Execute command inside container
result = subprocess.run(
["docker", "exec", container_id, "sh", "-c", command],
capture_output=True,
text=True,
timeout=interval, # Each attempt has its own timeout
)
if result.returncode == 0:
logs.append(f"Attempt {attempt}: Success")
if result.stdout:
logs.append(f"Output: {result.stdout.strip()}")
return True, logs
else:
logs.append(f"Attempt {attempt}: Failed (exit code {result.returncode})")
if result.stderr:
logs.append(f"Stderr: {result.stderr.strip()[:200]}")
except subprocess.TimeoutExpired:
logs.append(f"Attempt {attempt}: Command timed out")
except Exception as exc:
logs.append(f"Attempt {attempt}: Error - {exc}")
# Wait before next attempt
await asyncio.sleep(interval)
+96
View File
@@ -0,0 +1,96 @@
"""Terminal session manager for WebSocket connections."""
import asyncio
import uuid
from typing import Any
from fastapi import WebSocket
from src.services.terminal_session import TerminalSession
class TerminalManager:
"""Manages active terminal sessions."""
def __init__(self) -> None:
self._sessions: dict[str, TerminalSession] = {}
async def create_session(
self,
instance_id: uuid.UUID,
container_id: str,
websocket: WebSocket,
) -> TerminalSession:
"""Create a new terminal session."""
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
self._sessions[session_id] = session
# Start background tasks for I/O streaming
asyncio.create_task(self._read_loop(session, websocket))
asyncio.create_task(self._write_loop(session, websocket))
return session
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while session.is_alive() and not session._closed:
data = await session.read_output()
if data:
await websocket.send_bytes(data)
else:
await asyncio.sleep(0.01)
except Exception:
pass
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()
if message["type"] == "websocket.receive":
if "bytes" in message:
await session.write_input(message["bytes"])
elif "text" in message:
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
if ctrl.get("type") == "resize":
await session.resize(
ctrl.get("cols", 80),
ctrl.get("rows", 24),
)
except json.JSONDecodeError:
pass
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
break
except Exception:
pass
finally:
await self._cleanup_session(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]
await session.close()
async def close_all(self) -> None:
"""Close all active sessions."""
sessions = list(self._sessions.values())
self._sessions.clear()
for session in sessions:
await session.close()
# Global terminal manager instance
terminal_manager = TerminalManager()
+117
View File
@@ -0,0 +1,117 @@
"""Terminal session management for tool instances."""
import asyncio
import os
import pty
import select
import struct
import fcntl
import uuid
from typing import Any
class TerminalSession:
"""Manages a single terminal session connected to a docker container."""
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
self.process: asyncio.subprocess.Process | None = None
self._closed = False
self._master_fd: int | None = None
self._slave_fd: int | 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, self._slave_fd = pty.openpty()
# Set the terminal size initially
self._set_terminal_size(80, 24)
# Start docker exec with the slave fd as stdin/stdout/stderr
# Using -it because the slave fd IS a TTY
self.process = await asyncio.create_subprocess_exec(
"docker",
"exec",
"-it",
"-e",
"TERM=xterm",
self.container_id,
"bash",
"-il",
stdin=self._slave_fd,
stdout=self._slave_fd,
stderr=self._slave_fd,
)
# Close slave fd in parent process
os.close(self._slave_fd)
self._slave_fd = None
def _set_terminal_size(self, cols: int, rows: int) -> None:
"""Set the terminal size using TIOCSWINSZ."""
if self._master_fd is None:
return
# TIOCSWINSZ = 0x5414 on Linux
TIOCSWINSZ = 0x5414
size = struct.pack('HHHH', rows, cols, 0, 0)
try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
except (OSError, IOError):
pass
async def read_output(self) -> bytes:
"""Read output from the PTY master."""
if self._master_fd is None or self._closed:
return b""
try:
# Use select to check if data is available
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
if readable:
return os.read(self._master_fd, 4096)
return b""
except (OSError, IOError, 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:
os.write(self._master_fd, data)
except (OSError, IOError):
pass
async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal."""
if self._closed:
return
self._set_terminal_size(cols, rows)
async def close(self) -> None:
"""Close the session and cleanup."""
if self._closed:
return
self._closed = True
if self._master_fd is not None:
try:
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):
pass
def is_alive(self) -> bool:
"""Check if the session process is still running."""
if self.process is None:
return False
return self.process.returncode is None
+314
View File
@@ -0,0 +1,314 @@
"""Git control utilities for repository operations."""
import subprocess
from dataclasses import dataclass, field
from typing import Any
def _run_git_command(repo_path: str, *args: str) -> str:
"""Run a git command in the repository directory."""
result = subprocess.run(
["git", *args],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Git command failed: {result.stderr}")
return result.stdout
@dataclass
class GitStatus:
"""Represents the working directory status."""
branch: str
modified: list[str] = field(default_factory=list)
added: list[str] = field(default_factory=list)
deleted: list[str] = field(default_factory=list)
untracked: list[str] = field(default_factory=list)
renamed: list[str] = field(default_factory=list)
ahead: int = 0
behind: int = 0
def get_status(repo_path: str) -> GitStatus:
"""Get the working directory status.
Args:
repo_path: Path to the git repository
Returns:
GitStatus with changes
"""
# Get current branch
try:
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
except RuntimeError:
try:
branch = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
except RuntimeError:
branch = "HEAD"
status = GitStatus(branch=branch)
# Get status with porcelain format
try:
output = _run_git_command(repo_path, "status", "--porcelain", "--branch")
except RuntimeError:
return status
for line in output.strip().split("\n"):
if not line:
continue
# Branch info line starts with ##
if line.startswith("## "):
branch_info = line[3:]
# Parse ahead/behind info
if "[ahead " in branch_info:
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
status.ahead = int(ahead_str.split(",")[0])
if "[behind " in branch_info:
behind_str = branch_info.split("[behind ")[1].split("]")[0]
status.behind = int(behind_str.split(",")[0])
continue
# Parse status code
if len(line) < 3:
continue
index_status = line[0]
worktree_status = line[1]
filename = line[3:]
# Untracked files
if index_status == "?" and worktree_status == "?":
status.untracked.append(filename)
continue
# Added files
if index_status == "A" or worktree_status == "A":
status.added.append(filename)
continue
# Deleted files
if index_status == "D" or worktree_status == "D":
status.deleted.append(filename)
continue
# Renamed files
if index_status == "R" or worktree_status == "R":
status.renamed.append(filename)
continue
# Modified files
if index_status == "M" or worktree_status == "M":
status.modified.append(filename)
continue
return status
def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
"""Create a new branch.
Args:
repo_path: Path to the git repository
name: Branch name
base_branch: Base branch to create from (default: HEAD)
Raises:
RuntimeError: If branch creation fails
"""
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
return
_run_git_command(repo_path, "branch", name, base_branch)
def delete_branch(repo_path: str, name: str, force: bool = False) -> None:
"""Delete a branch.
Args:
repo_path: Path to the git repository
name: Branch name
force: Force delete even if not merged
Raises:
RuntimeError: If branch deletion fails
"""
flag = "-D" if force else "-d"
_run_git_command(repo_path, "branch", flag, name)
def checkout_branch(repo_path: str, name: str) -> None:
"""Checkout a branch.
Args:
repo_path: Path to the git repository
name: Branch name
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
def commit_changes(
repo_path: str,
message: str,
author_name: str,
author_email: str,
files: list[str] | None = None,
) -> str:
"""Commit changes to the repository.
Args:
repo_path: Path to the git repository
message: Commit message
author_name: Author name
author_email: Author email
files: Specific files to commit (None = all staged)
Returns:
Commit hash
Raises:
RuntimeError: If commit fails
"""
# Stage files if specified
if files:
for file in files:
_run_git_command(repo_path, "add", file)
else:
_run_git_command(repo_path, "add", "-A")
# Commit
_run_git_command(
repo_path,
"commit",
"-m",
message,
f"--author={author_name} <{author_email}>",
)
# Return commit hash
return _run_git_command(repo_path, "rev-parse", "HEAD").strip()
def fetch(repo_path: str) -> None:
"""Fetch from remote.
Args:
repo_path: Path to the git repository
Raises:
RuntimeError: If fetch fails
"""
_run_git_command(repo_path, "fetch", "--all")
def pull(repo_path: str, branch: str | None = None) -> None:
"""Pull updates from remote.
Args:
repo_path: Path to the git repository
branch: Branch to pull (default: current branch)
Raises:
RuntimeError: If pull fails
"""
args = ["pull"]
if branch:
args.append("origin")
args.append(branch)
_run_git_command(repo_path, *args)
def push(repo_path: str, branch: str | None = None) -> None:
"""Push changes to remote.
Args:
repo_path: Path to the git repository
branch: Branch to push (default: current branch)
Raises:
RuntimeError: If push fails
"""
args = ["push"]
if branch:
args.extend(["origin", branch])
_run_git_command(repo_path, *args)
def merge(
repo_path: str,
source_branch: str,
target_branch: str | None = None,
message: str | None = None,
) -> str:
"""Merge a branch into the current branch.
Args:
repo_path: Path to the git repository
source_branch: Branch to merge from
target_branch: Branch to merge into (default: current branch)
message: Merge commit message
Returns:
Merge commit hash
Raises:
RuntimeError: If merge fails (including conflicts)
"""
# Checkout target branch if specified
if target_branch:
checkout_branch(repo_path, target_branch)
# Merge
args = ["merge", source_branch]
if message:
args.extend(["-m", message])
_run_git_command(repo_path, *args)
# Return merge commit hash
return _run_git_command(repo_path, "rev-parse", "HEAD").strip()
def get_current_branch(repo_path: str) -> str:
"""Get the current branch name.
Args:
repo_path: Path to the git repository
Returns:
Current branch name
"""
try:
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
if branch != "HEAD":
return branch
except RuntimeError:
pass
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
+439
View File
@@ -0,0 +1,439 @@
"""Git file utilities for browsing repository contents."""
import logging
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class FileTreeEntry:
"""Represents a file or directory in the repository."""
name: str
type: str # "file" or "directory"
path: str
size: int | None = None
mode: str | None = None
last_commit: dict[str, Any] | None = None
@dataclass
class BranchInfo:
"""Represents a git branch."""
name: str
is_default: bool
last_commit: dict[str, Any] | None = None
@dataclass
class FileContent:
"""Represents file content and metadata."""
path: str
branch: str
content: str
size: int
encoding: str
language: str | None
is_binary: bool
last_commit: dict[str, Any] | None = None
def _run_git_command(repo_path: str, *args: str) -> str:
"""Run a git command in the repository directory."""
result = subprocess.run(
["git", *args],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode != 0:
stderr = result.stderr
# Handle "dubious ownership" security error
if "dubious ownership" in stderr.lower():
logger.warning("Git ownership mismatch for %s, adding to safe.directory", repo_path)
# Add this directory to git's safe.directory list
subprocess.run(
["git", "config", "--global", "--add", "safe.directory", repo_path],
capture_output=True,
)
# Retry the command
result = subprocess.run(
["git", *args],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout
stderr = result.stderr
raise RuntimeError(f"Git command failed: {stderr}")
return result.stdout
logger = logging.getLogger(__name__)
def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[FileTreeEntry]:
"""List files and directories in a repository path.
Args:
repo_path: Path to the git repository
branch: Branch name to list from
path: Directory path within the repository (empty for root)
Returns:
List of FileTreeEntry objects
"""
tree_path = f"{branch}:{path}" if path else branch
try:
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
except RuntimeError as e:
logger.warning("git ls-tree failed for %s with branch '%s': %s", repo_path, tree_path, str(e))
# Try with HEAD if branch doesn't exist
tree_path = f"HEAD:{path}" if path else "HEAD"
try:
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
except RuntimeError as e:
logger.error("git ls-tree failed for %s with HEAD: %s", repo_path, str(e))
# Check if this is an empty repository (no commits yet)
error_msg = str(e).lower()
if "not a valid object name" in error_msg or "does not exist" in error_msg:
# Empty repository - return empty list
return []
raise
entries = []
for line in output.strip().split("\n"):
if not line:
continue
# Format: <mode> <type> <hash> <size>\t<name>
parts = line.split("\t", 1)
if len(parts) != 2:
continue
meta, name = parts
meta_parts = meta.split()
if len(meta_parts) < 4:
continue
mode = meta_parts[0]
obj_type = meta_parts[1]
_ = meta_parts[2] # object hash, not used
size = int(meta_parts[3]) if obj_type == "blob" else None
entry_path = f"{path}/{name}" if path else name
# Get last commit info for this entry
last_commit = _get_last_commit_for_path(repo_path, branch, entry_path)
entries.append(
FileTreeEntry(
name=name,
type="directory" if obj_type == "tree" else "file",
path=entry_path,
size=size,
mode=mode,
last_commit=last_commit,
)
)
return entries
def _get_last_commit_for_path(repo_path: str, branch: str, path: str) -> dict[str, Any] | None:
"""Get the last commit that modified a path."""
try:
output = _run_git_command(
repo_path,
"log",
"-1",
"--format=%H|%s|%an|%aI",
branch,
"--",
path,
)
if not output.strip():
return None
parts = output.strip().split("|", 3)
if len(parts) != 4:
return None
return {
"hash": parts[0],
"message": parts[1],
"author": parts[2],
"date": parts[3],
}
except RuntimeError:
return None
def get_file_content(repo_path: str, branch: str, path: str) -> FileContent:
"""Get the content of a file.
Args:
repo_path: Path to the git repository
branch: Branch name
path: File path within the repository
Returns:
FileContent with content and metadata
"""
# Check if file exists
try:
_run_git_command(repo_path, "cat-file", "-e", f"{branch}:{path}")
except RuntimeError:
raise FileNotFoundError(f"File '{path}' not found in branch '{branch}'")
# Get file size
size_output = _run_git_command(repo_path, "cat-file", "-s", f"{branch}:{path}")
size = int(size_output.strip())
# Check if binary
is_binary = _is_binary_file(repo_path, branch, path)
# Get content (only for text files)
content = ""
if not is_binary:
content = _run_git_command(repo_path, "show", f"{branch}:{path}")
# Detect language from extension
language = _detect_language(path)
# Get last commit
last_commit = _get_last_commit_for_path(repo_path, branch, path)
return FileContent(
path=path,
branch=branch,
content=content,
size=size,
encoding="utf-8",
language=language,
is_binary=is_binary,
last_commit=last_commit,
)
def _is_binary_file(repo_path: str, branch: str, path: str) -> bool:
"""Check if a file is binary using raw bytes to avoid encoding issues."""
try:
result = subprocess.run(
["git", "show", f"{branch}:{path}"],
cwd=repo_path,
capture_output=True,
)
if result.returncode != 0:
raise RuntimeError(f"Git command failed: {result.stderr.decode()}")
# A file is binary if it contains null bytes
return b"\x00" in result.stdout
except RuntimeError:
return True
def _detect_language(path: str) -> str | None:
"""Detect programming language from file extension."""
ext = Path(path).suffix.lower()
language_map = {
".py": "python",
".js": "javascript",
".ts": "typescript",
".jsx": "jsx",
".tsx": "tsx",
".html": "html",
".css": "css",
".scss": "scss",
".json": "json",
".md": "markdown",
".yaml": "yaml",
".yml": "yaml",
".sh": "bash",
".rs": "rust",
".go": "go",
".java": "java",
".c": "c",
".cpp": "cpp",
".h": "c",
".php": "php",
".rb": "ruby",
".sql": "sql",
".dockerfile": "dockerfile",
".vue": "vue",
".svelte": "svelte",
}
return language_map.get(ext)
def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
"""List all branches and identify the default branch.
Args:
repo_path: Path to the git repository
Returns:
Tuple of (list of BranchInfo, default branch name)
"""
# Get all branches
try:
output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)")
except RuntimeError as e:
logger.error("Failed to list branches for %s: %s", repo_path, str(e))
raise
branches: list[BranchInfo] = []
default_branch = "main"
for line in output.strip().split("\n"):
if not line:
continue
branch_name = line.strip()
# 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):
continue
# Check if this is the default branch (HEAD points to it)
try:
head_output = _run_git_command(
repo_path,
"symbolic-ref",
"HEAD",
)
if head_output.strip() == f"refs/heads/{branch_name}":
default_branch = branch_name
except RuntimeError:
pass
# Get last commit for branch
last_commit = _get_last_commit_for_path(repo_path, branch_name, ".")
branches.append(
BranchInfo(
name=branch_name,
is_default=(branch_name == default_branch),
last_commit=last_commit,
)
)
# If no branches found, try to get HEAD
if not branches:
try:
output = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD")
branch_name = output.strip()
if branch_name and branch_name != "HEAD":
last_commit = _get_last_commit_for_path(repo_path, branch_name, ".")
branches.append(
BranchInfo(
name=branch_name,
is_default=True,
last_commit=last_commit,
)
)
default_branch = branch_name
except RuntimeError:
try:
output = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD")
branch_name = output.strip()
if branch_name:
branches.append(
BranchInfo(
name=branch_name,
is_default=True,
last_commit=None,
)
)
default_branch = branch_name
except RuntimeError:
pass
return branches, default_branch
def commit_file(
repo_path: str,
branch: str,
path: str,
content: str,
commit_message: str,
author_name: str,
author_email: str,
) -> str:
"""Commit a file change.
Args:
repo_path: Path to the git repository
branch: Branch to commit to
path: File path within the repository
content: New file content
commit_message: Commit message
author_name: Author name
author_email: Author email
Returns:
Commit hash
"""
# For bare repositories, we need to use git commands differently
# We'll create a temporary worktree, make changes, and commit
import tempfile
import os
# Create a temporary worktree
with tempfile.TemporaryDirectory() as worktree_path:
# Add worktree
_run_git_command(
repo_path,
"worktree",
"add",
"--detach",
worktree_path,
branch,
)
try:
# Write file content
file_path = os.path.join(worktree_path, path)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
# Configure git author
_run_git_command(worktree_path, "config", "user.name", author_name)
_run_git_command(worktree_path, "config", "user.email", author_email)
# Stage and commit
_run_git_command(worktree_path, "add", path)
_run_git_command(
worktree_path,
"commit",
"-m",
commit_message,
)
# Get commit hash
commit_hash = _run_git_command(
worktree_path,
"rev-parse",
"HEAD",
).strip()
return commit_hash
finally:
# Remove worktree
_run_git_command(repo_path, "worktree", "remove", worktree_path)
+382
View File
@@ -0,0 +1,382 @@
"""Git history extraction utilities for bare/mirror repositories."""
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
@dataclass
class Commit:
"""Represents a single git commit."""
hash: str
short_hash: str
parents: list[str]
author: str
email: str
date: str
timestamp: int
message: str
branches: list[str]
tags: list[str]
@dataclass
class FileChange:
"""Represents a changed file in a commit."""
path: str
change_type: str
insertions: int
deletions: int
diff: str
@dataclass
class CommitDetail(Commit):
"""Extended commit info with diff."""
body: str
stats: dict[str, int]
files: list[FileChange]
def _run_git_command(repo_path: str, args: list[str]) -> str:
"""Execute a git command in the repository directory."""
result = subprocess.run(
["git", "-C", repo_path, *args],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"Git command failed: {result.stderr}")
return result.stdout
def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 100, offset: int = 0) -> dict[str, Any]:
"""Extract commit history from a git repository.
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()]
# Build git log command - use NULL bytes as separators to avoid parsing issues
log_args = [
"log",
"--format=%H%x00%P%x00%an%x00%ae%x00%at%x00%s",
f"--max-count={limit}",
f"--skip={offset}",
]
if branch:
log_args.append(branch)
else:
log_args.append("--all")
log_output = _run_git_command(repo_path, log_args)
# Get branch info for each commit
branch_map = _get_branch_map(repo_path)
tag_map = _get_tag_map(repo_path)
commits = []
log_lines = log_output.strip().split("\n") if log_output.strip() else []
for line in log_lines:
line = line.strip()
if not line:
continue
parts = line.split("\x00")
if len(parts) < 6:
continue
commit_hash = parts[0]
parents = parts[1].split() if parts[1] else []
commits.append(
Commit(
hash=commit_hash,
short_hash=commit_hash[:7],
parents=parents,
author=parts[2],
email=parts[3],
date=parts[4],
timestamp=int(parts[4]),
message=parts[5],
branches=branch_map.get(commit_hash, []),
tags=tag_map.get(commit_hash, []),
)
)
# 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
# Build graph data and generate graph symbols
graph_data = _build_graph_data(commits)
# Generate simple graph symbols based on parent count
commit_dicts = []
for i, commit in enumerate(commits):
if len(commit.parents) == 0:
graph_symbol = "" # Initial commit
elif len(commit.parents) > 1:
graph_symbol = "" # Merge commit
else:
graph_symbol = "" # Regular commit
# Simple depth calculation based on merge status
graph_depth = min(len(commit.parents), 3)
commit_dicts.append(_commit_to_dict(commit, graph_symbol, graph_depth))
return {
"commits": commit_dicts,
"branches": branches,
"total_commits": total_commits,
"graph_data": graph_data,
}
def get_commit_detail(repo_path: str, commit_hash: str) -> dict[str, Any]:
"""Get detailed information about a specific commit."""
# Get commit metadata
format_str = "%H|%P|%an|%ae|%at|%s|%b"
log_output = _run_git_command(
repo_path, ["log", "-1", f"--format={format_str}", commit_hash]
)
parts = log_output.strip().split("|", 6)
if len(parts) < 6:
raise ValueError(f"Invalid commit: {commit_hash}")
commit_hash = parts[0]
parents = parts[1].split() if parts[1] else []
author = parts[2]
email = parts[3]
timestamp = int(parts[4])
message = parts[5]
body = parts[6] if len(parts) > 6 else ""
# Get stats
stat_output = _run_git_command(
repo_path, ["show", "--stat", "--format=", commit_hash]
)
stats = _parse_stats(stat_output)
# Get diff
diff_output = _run_git_command(
repo_path, ["show", "--format=", commit_hash]
)
files = _parse_diff(diff_output)
# Get branch/tag info
branch_map = _get_branch_map(repo_path)
tag_map = _get_tag_map(repo_path)
return {
"hash": commit_hash,
"short_hash": commit_hash[:7],
"parents": parents,
"author_name": author,
"author_email": email,
"author_date": datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat(),
"committer_name": author, # TODO: extract committer separately
"committer_email": email, # TODO: extract committer separately
"committer_date": datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat(),
"message": message,
"body": body,
"branches": branch_map.get(commit_hash, []),
"tags": tag_map.get(commit_hash, []),
"stats": stats,
"diff": diff_output,
"files": [_file_change_to_dict(f) for f in files],
}
def _get_branch_map(repo_path: str) -> dict[str, list[str]]:
"""Build a mapping of commit hash to branch names."""
result = {}
branch_output = _run_git_command(
repo_path, ["for-each-ref", "--format=%(objectname) %(refname:short)", "refs/heads/"]
)
for line in branch_output.strip().split("\n"):
if " " in line:
commit_hash, branch_name = line.split(" ", 1)
if commit_hash not in result:
result[commit_hash] = []
result[commit_hash].append(branch_name)
return result
def _get_tag_map(repo_path: str) -> dict[str, list[str]]:
"""Build a mapping of commit hash to tag names."""
result = {}
tag_output = _run_git_command(
repo_path, ["for-each-ref", "--format=%(objectname) %(refname:short)", "refs/tags/"]
)
for line in tag_output.strip().split("\n"):
if " " in line:
commit_hash, tag_name = line.split(" ", 1)
if commit_hash not in result:
result[commit_hash] = []
result[commit_hash].append(tag_name)
return result
def _build_graph_data(commits: list[Commit]) -> dict[str, Any]:
"""Build graph visualization data from commits."""
if not commits:
return {"nodes": [], "edges": []}
# Create hash to index mapping
hash_to_idx = {c.hash: i for i, c in enumerate(commits)}
nodes = []
edges = []
for i, commit in enumerate(commits):
# Calculate column based on branch
column = 0
if commit.branches:
# Use first branch as column indicator
column = hash(commit.branches[0]) % 5
nodes.append(
{
"hash": commit.hash,
"x": column * 60 + 30,
"y": i * 50 + 25,
"column": column,
}
)
# Create edges to parents
for parent_hash in commit.parents:
if parent_hash in hash_to_idx:
edges.append(
{
"from_hash": commit.hash,
"to_hash": parent_hash,
"type": "parent",
}
)
return {"nodes": nodes, "edges": edges}
def _parse_stats(stat_output: str) -> dict[str, int]:
"""Parse git show --stat output."""
lines = stat_output.strip().split("\n")
stats = {"files_changed": 0, "insertions": 0, "deletions": 0}
for line in lines:
line = line.strip()
if "files changed" in line or "file changed" in line:
# Parse summary line like "3 files changed, 45 insertions(+), 12 deletions(-)"
parts = line.split(",")
for part in parts:
part = part.strip()
if "file" in part:
try:
stats["files_changed"] = int(part.split()[0])
except (ValueError, IndexError):
pass
elif "insertion" in part:
try:
stats["insertions"] = int(part.split()[0])
except (ValueError, IndexError):
pass
elif "deletion" in part:
try:
stats["deletions"] = int(part.split()[0])
except (ValueError, IndexError):
pass
return stats
def _parse_diff(diff_output: str) -> list[FileChange]:
"""Parse git diff output into file changes."""
files = []
current_file = None
current_diff = []
for line in diff_output.split("\n"):
if line.startswith("diff --git"):
# Save previous file
if current_file:
current_file.diff = "\n".join(current_diff)
files.append(current_file)
# Start new file
current_diff = [line]
current_file = FileChange(
path="",
change_type="modified",
insertions=0,
deletions=0,
diff="",
)
elif line.startswith("--- ") or line.startswith("+++ "):
current_diff.append(line)
if line.startswith("+++ ") and not line.startswith("+++ /dev/null"):
current_file.path = line[6:]
elif line.startswith("@@ "):
current_diff.append(line)
elif line.startswith("+") and not line.startswith("+++"):
current_diff.append(line)
current_file.insertions += 1
elif line.startswith("-") and not line.startswith("---"):
current_diff.append(line)
current_file.deletions += 1
elif current_file:
current_diff.append(line)
# Save last file
if current_file:
current_file.diff = "\n".join(current_diff)
files.append(current_file)
return files
def _commit_to_dict(commit: Commit, graph_symbol: str = "", graph_depth: int = 0) -> dict[str, Any]:
"""Convert Commit dataclass to dictionary."""
refs = []
if commit.branches:
refs.extend(commit.branches)
if commit.tags:
refs.extend(commit.tags)
return {
"hash": commit.hash,
"short_hash": commit.short_hash,
"parents": commit.parents,
"author_name": commit.author,
"author_email": commit.email,
"author_date": datetime.fromtimestamp(commit.timestamp, tz=timezone.utc).isoformat(),
"message": commit.message,
"refs": refs,
"graph_symbol": graph_symbol,
"graph_depth": graph_depth,
}
def _file_change_to_dict(file_change: FileChange) -> dict[str, Any]:
"""Convert FileChange dataclass to dictionary."""
return {
"path": file_change.path,
"change_type": file_change.change_type,
"insertions": file_change.insertions,
"deletions": file_change.deletions,
"diff": file_change.diff,
}
+228
View File
@@ -0,0 +1,228 @@
"""Git URL parsing utilities to extract base repository URLs from browser URLs."""
from urllib.parse import urlparse
def extract_base_repo_url(url: str) -> str | None:
"""Extract base repository URL from a browser/git URL.
Examples:
https://github.com/user/repo/tree/main → https://github.com/user/repo.git
https://github.com/user/repo.git → https://github.com/user/repo.git
git@github.com:user/repo.git → git@github.com:user/repo.git
https://gitlab.com/user/repo/-/blob/main/README.md → https://gitlab.com/user/repo.git
Returns None if URL doesn't match known patterns.
"""
# Handle SSH URLs (pass through unchanged)
if url.startswith("git@"):
return url if url.endswith(".git") else f"{url}.git"
try:
parsed = urlparse(url)
except Exception:
return None
# Remove query parameters
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
# Extract host
host = parsed.netloc.lower()
# Split path
path_parts = [p for p in parsed.path.split("/") if p]
if not path_parts:
return None
# Determine host type and extract base
if "github.com" in host:
return _extract_github_url(url, path_parts)
elif "gitlab.com" in host:
return _extract_gitlab_url(url, path_parts)
elif "bitbucket.org" in host:
return _extract_bitbucket_url(url, path_parts)
else:
# Generic host - try basic extraction
return _extract_generic_url(url, path_parts)
def _extract_github_url(url: str, path_parts: list[str]) -> str | None:
"""Extract base repo URL from GitHub URL."""
# Need at least owner/repo
if len(path_parts) < 2:
return None
# Find the repo name (second path part)
# Remove trailing .git if present
repo_name = path_parts[1]
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
# Reconstruct base URL
base = f"https://github.com/{path_parts[0]}/{repo_name}"
# Add .git suffix
return f"{base}.git"
def _extract_gitlab_url(url: str, path_parts: list[str]) -> str | None:
"""Extract base repo URL from GitLab URL."""
# Need at least owner/repo
if len(path_parts) < 2:
return None
# Find the repo name (second path part)
repo_name = path_parts[1]
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
# Reconstruct base URL
base = f"https://gitlab.com/{path_parts[0]}/{repo_name}"
return f"{base}.git"
def _extract_bitbucket_url(url: str, path_parts: list[str]) -> str | None:
"""Extract base repo URL from Bitbucket URL."""
# Need at least owner/repo
if len(path_parts) < 2:
return None
# Find the repo name (second path part)
repo_name = path_parts[1]
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
# Reconstruct base URL
base = f"https://bitbucket.org/{path_parts[0]}/{repo_name}"
return f"{base}.git"
def _extract_generic_url(url: str, path_parts: list[str]) -> str | None:
"""Extract base repo URL from generic git host URL."""
# Need at least owner/repo
if len(path_parts) < 2:
return None
# Find the repo name (second path part)
repo_name = path_parts[1]
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
# Reconstruct base URL
parsed = urlparse(url)
base = f"{parsed.scheme}://{parsed.netloc}/{path_parts[0]}/{repo_name}"
return f"{base}.git"
def is_valid_clone_url(url: str) -> bool:
"""Check if URL is already a valid git clone URL.
A valid clone URL:
- Is an SSH URL (git@host:path)
- Ends with .git
- Has no browser-specific path segments
"""
# SSH URLs are always valid
if url.startswith("git@"):
return True
try:
parsed = urlparse(url)
except Exception:
return False
path = parsed.path
# Must end with .git for HTTPS
if not path.endswith(".git"):
return False
# Check for browser-specific paths
browser_paths = ["/tree/", "/blob/", "/pull/", "/issues/", "/actions/",
"-/tree/", "-/blob/", "-/merge_requests/",
"/src/"]
for bp in browser_paths:
if bp in path:
return False
return True
def parse_git_url(url: str) -> dict:
"""Parse a git URL and return detailed information.
Returns:
{
"original_url": str,
"base_url": str | None,
"is_valid_clone_url": bool,
"needs_parsing": bool,
"host": str | None,
"message": str,
"error_code": str | None,
}
"""
result = {
"original_url": url,
"base_url": None,
"is_valid_clone_url": False,
"needs_parsing": False,
"host": None,
"message": "",
"error_code": None,
}
# Check if empty
if not url or not url.strip():
result["message"] = "Please enter a URL"
result["error_code"] = "INVALID_URL"
return result
url = url.strip()
# Try to parse
try:
parsed = urlparse(url)
except Exception:
result["message"] = "Please enter a valid URL"
result["error_code"] = "INVALID_URL"
return result
# Extract host
if parsed.netloc:
result["host"] = parsed.netloc.lower()
elif url.startswith("git@"):
# SSH URL: git@host:path
parts = url.split(":", 1)
if len(parts) == 2:
result["host"] = parts[0].replace("git@", "")
else:
result["message"] = "Please enter a valid URL"
result["error_code"] = "INVALID_URL"
return result
# Check if already valid
if is_valid_clone_url(url):
result["base_url"] = url
result["is_valid_clone_url"] = True
result["needs_parsing"] = False
result["message"] = "Valid git repository URL"
return result
# Try to extract base URL
base = extract_base_repo_url(url)
if base:
result["base_url"] = base
result["needs_parsing"] = True
result["message"] = f"This looks like a browser URL. Did you mean: {base}?"
result["error_code"] = "URL_NEEDS_PARSING"
else:
result["message"] = "Could not parse this URL. Please enter a valid git repository URL."
result["error_code"] = "INVALID_URL"
return result
+131 -70
View File
@@ -3,6 +3,7 @@
import asyncio
import os
from typing import AsyncGenerator, Generator
from unittest.mock import patch
import pytest
import pytest_asyncio
@@ -11,82 +12,142 @@ 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, build_database_url
from src.models.base import Base
from src.main import app
# Unit test fixtures (SQLite in-memory)
@pytest.fixture(scope="session")
def sqlite_engine():
"""Create a SQLite in-memory engine for unit tests."""
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def sqlite_session(sqlite_engine) -> Generator:
"""Provide a SQLite session for unit tests."""
connection = sqlite_engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
yield session
session.close()
transaction.rollback()
connection.close()
# Integration test fixtures (PostgreSQL)
TEST_DATABASE_URL = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
@pytest_asyncio.fixture(scope="session")
async def postgres_engine():
"""Create a PostgreSQL engine for integration tests."""
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(postgres_engine) -> AsyncGenerator[AsyncSession, None]:
"""Provide a database session with transaction rollback."""
async with postgres_engine.connect() as connection:
transaction = await connection.begin_nested()
session_factory = async_sessionmaker(
connection, expire_on_commit=False, class_=AsyncSession
)
session = session_factory()
yield session
await session.close()
await transaction.rollback()
from src.auth.dependencies import get_db_session
@pytest.fixture
def test_client() -> Generator[TestClient, None, None]:
"""Provide a FastAPI test client."""
with TestClient(app) as client:
yield client
"""Provide a FastAPI test client with SQLite database."""
# Create a single engine for this test
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
)
# Create tables
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(init_db())
async def override_get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
yield session
# Override the dependency
app.dependency_overrides[get_db_session] = override_get_db_session
# Patch startup events to prevent PostgreSQL connection attempts
with patch("src.main.init_database") as mock_init, \
patch("src.main.seed_builtin_tool_types") as mock_seed:
mock_init.return_value = True
mock_seed.return_value = None
try:
with TestClient(app) as client:
yield client
finally:
# Clean up overrides
app.dependency_overrides.pop(get_db_session, None)
asyncio.run(engine.dispose())
@pytest.fixture(autouse=True)
def configure_test_env(monkeypatch):
"""Configure environment for testing."""
monkeypatch.setenv("DATABASE_URL", TEST_DATABASE_URL)
monkeypatch.setenv("APP_ENV", "testing")
@pytest.fixture
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with a test user."""
import uuid
from src.auth.session import create_session_cookie
from src.models.user import User
user_id = str(uuid.uuid4())
settings = Settings()
# Create user in database using the same engine as test_client
# We need to access the engine from the test_client fixture
# Since we can't easily do that, we'll create the user via API call
# But we need the user to exist before any API calls
# So we need to create the user using the overridden dependency
async def create_test_user():
# Get the override function
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
user = User(
id=uuid.UUID(user_id),
email="test@headquarter.local",
name="Test User",
authentik_id=f"authentik-{user_id}",
avatar_url=None,
)
session.add(user)
await session.commit()
finally:
await gen.aclose()
asyncio.run(create_test_user())
# Create session cookie
session_cookie = create_session_cookie(
settings=settings,
user_id=user_id,
)
# Set cookie on client
test_client.cookies.set("session", session_cookie)
yield test_client
@pytest.fixture
def admin_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with an admin user."""
import uuid
from src.auth.session import create_session_cookie
from src.models.user import User
user_id = str(uuid.uuid4())
settings = Settings()
async def create_admin_user():
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
user = User(
id=uuid.UUID(user_id),
email="admin@headquarter.local",
name="Admin User",
authentik_id=f"authentik-admin-{user_id}",
avatar_url=None,
is_admin=True,
)
session.add(user)
await session.commit()
finally:
await gen.aclose()
asyncio.run(create_admin_user())
# Create session cookie
session_cookie = create_session_cookie(
settings=settings,
user_id=user_id,
)
# Set cookie on client
test_client.cookies.set("session", session_cookie)
yield test_client
+19 -103
View File
@@ -1,5 +1,4 @@
import uuid
from datetime import UTC, datetime, timedelta
import asyncio
import importlib
@@ -8,7 +7,7 @@ import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.user import User
@@ -27,7 +26,7 @@ def _prepare_auth_test_db() -> None:
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(text("TRUNCATE TABLE refresh_tokens, users RESTART IDENTITY CASCADE"))
await connection.execute(text("TRUNCATE TABLE users RESTART IDENTITY CASCADE"))
await engine.dispose()
asyncio.run(_run())
@@ -44,7 +43,7 @@ def _load_app():
return main_module.app
def _insert_user_for_refresh(user_id: str) -> None:
def _insert_test_user(user_id: str) -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
@@ -63,9 +62,9 @@ def _insert_user_for_refresh(user_id: str) -> None:
async with session_factory() as session:
user = User(
id=uuid.UUID(user_id),
email="refresh@headquarter.local",
name="Refresh User",
authentik_id="refresh-user",
email="test@headquarter.local",
name="Test User",
authentik_id="test-user",
avatar_url=None,
)
await session.merge(user)
@@ -88,7 +87,7 @@ def test_login_redirects_to_authentik_authorize_endpoint() -> None:
@pytest.mark.integration
def test_me_returns_401_without_access_cookie() -> None:
def test_me_returns_401_without_session_cookie() -> None:
_prepare_auth_test_db()
app = _load_app()
@@ -99,116 +98,33 @@ def test_me_returns_401_without_access_cookie() -> None:
@pytest.mark.integration
def test_me_returns_user_payload_with_valid_access_cookie() -> None:
def test_me_returns_user_with_valid_session() -> None:
user_id = "11111111-1111-1111-1111-111111111111"
_prepare_auth_test_db()
_insert_test_user(user_id)
app = _load_app()
settings = Settings()
token = mint_access_token(
settings=settings,
subject=str(uuid.uuid4()),
email="dev@headquarter.local",
name="Dev User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
)
session_cookie = create_session_cookie(settings=settings, user_id=user_id)
client = TestClient(app)
client.cookies.set("access_token", token)
response = client.get("/auth/me")
response = client.get("/auth/me", cookies={"session": session_cookie})
assert response.status_code == 200
assert response.json()["email"] == "dev@headquarter.local"
data = response.json()
assert data["email"] == "test@headquarter.local"
assert data["name"] == "Test User"
@pytest.mark.integration
def test_logout_clears_auth_cookies() -> None:
def test_logout_clears_session_cookie() -> None:
_prepare_auth_test_db()
app = _load_app()
client = TestClient(app)
client.cookies.set("refresh_token", "opaque-token")
response = client.post("/auth/logout")
assert response.status_code == 200
assert "access_token=" in response.headers.get("set-cookie", "")
@pytest.mark.integration
def test_callback_rejects_mismatched_state() -> None:
_prepare_auth_test_db()
app = _load_app()
client = TestClient(app)
client.cookies.set("auth_state", "expected")
response = client.get("/auth/callback?code=test-code&state=wrong")
assert response.status_code == 401
@pytest.mark.integration
def test_callback_sets_auth_cookies_after_success(monkeypatch) -> None:
_prepare_auth_test_db()
app = _load_app()
async def fake_exchange_code_for_tokens(*, settings, code, redirect_uri, client):
return {"access_token": "provider-access", "refresh_token": "provider-refresh"}
def fake_verify_provider_access_token(*, settings, token, jwks):
return {"sub": "auth-sub-1", "email": "callback@headquarter.local", "name": "Callback User"}
async def fake_fetch_jwks(*, settings, client):
return {"keys": []}
monkeypatch.setattr("src.api.auth.exchange_code_for_tokens", fake_exchange_code_for_tokens)
monkeypatch.setattr("src.api.auth.verify_provider_access_token", fake_verify_provider_access_token)
monkeypatch.setattr("src.api.auth.fetch_jwks", fake_fetch_jwks)
client = TestClient(app)
client.cookies.set("auth_state", "good-state")
response = client.get("/auth/callback?code=valid-code&state=good-state")
assert response.status_code == 200
assert response.json()["email"] == "callback@headquarter.local"
set_cookie_header = response.headers.get("set-cookie", "")
assert "access_token=" in set_cookie_header
assert "refresh_token=" in set_cookie_header
@pytest.mark.integration
def test_refresh_rotates_cookie_and_returns_user_payload(monkeypatch) -> None:
_prepare_auth_test_db()
_insert_user_for_refresh("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb")
app = _load_app()
async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address):
class StoredToken:
user_id = uuid.UUID("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb")
return "new-refresh-token", StoredToken()
monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token)
client = TestClient(app)
client.cookies.set("refresh_token", "old-refresh-token")
response = client.post("/auth/refresh")
assert response.status_code == 200
assert response.json()["sub"] == "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
assert "refresh_token=" in response.headers.get("set-cookie", "")
@pytest.mark.integration
def test_refresh_returns_401_for_invalid_refresh_token(monkeypatch) -> None:
_prepare_auth_test_db()
app = _load_app()
async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address):
raise ValueError("refresh token not found")
monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token)
client = TestClient(app)
client.cookies.set("refresh_token", "invalid")
response = client.post("/auth/refresh")
assert response.status_code == 401
# Check that session cookie is deleted
set_cookie = response.headers.get("set-cookie", "")
assert "session=" in set_cookie or "session=\"\"" in set_cookie
+20 -136
View File
@@ -1,16 +1,9 @@
from datetime import UTC, datetime, timedelta
import base64
import httpx
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.cookies import build_cookie_options
from src.auth.jwt_service import decode_access_token, mint_access_token
from src.auth.oidc import build_login_redirect_url, exchange_code_for_tokens, verify_provider_access_token
from src.auth.refresh_store import create_refresh_token, hash_refresh_token, revoke_refresh_token, rotate_refresh_token
from src.auth.oidc import build_login_redirect_url
from src.auth.session import create_session_cookie, decode_session_cookie
from src.config import Settings
from src.models.user import User
@pytest.mark.integration
@@ -38,153 +31,44 @@ def test_login_redirect_url_contains_required_oidc_params() -> None:
settings=settings,
redirect_uri="http://localhost:8000/auth/callback",
state="state-123",
nonce="nonce-123",
)
assert "response_type=code" in url
assert "client_id=headquarter-web" in url
assert "scope=openid+profile+email" in url
assert "state=state-123" in url
assert "nonce=nonce-123" in url
@pytest.mark.integration
def test_mint_and_decode_internal_access_token_round_trip() -> None:
def test_create_and_decode_session_cookie_round_trip() -> None:
settings = Settings()
expires_at = datetime.now(UTC) + timedelta(minutes=15)
user_id = "test-user-123"
token = mint_access_token(
settings=settings,
subject="user-123",
email="dev@headquarter.local",
name="Dev User",
expires_at=expires_at,
)
cookie = create_session_cookie(settings=settings, user_id=user_id)
payload = decode_session_cookie(settings=settings, cookie_value=cookie)
claims = decode_access_token(settings=settings, token=token)
assert claims["sub"] == "user-123"
assert claims["email"] == "dev@headquarter.local"
assert claims["name"] == "Dev User"
assert "exp" in claims
assert payload["user_id"] == user_id
assert "exp" in payload
@pytest.mark.integration
def test_refresh_token_hash_is_deterministic_and_non_reversible() -> None:
raw_token = "refresh-token-abc"
first_hash = hash_refresh_token(raw_token)
second_hash = hash_refresh_token(raw_token)
assert first_hash == second_hash
assert first_hash != raw_token
assert len(first_hash) == 64
@pytest.mark.integration
def test_decode_access_token_rejects_invalid_signature() -> None:
def test_decode_session_rejects_invalid_signature() -> None:
settings = Settings()
other_settings = Settings(jwt_secret="different-secret")
expires_at = datetime.now(UTC) + timedelta(minutes=15)
other_settings = Settings(session_secret="different-secret")
user_id = "test-user-123"
token = mint_access_token(
settings=other_settings,
subject="user-123",
email="dev@headquarter.local",
name="Dev User",
expires_at=expires_at,
)
cookie = create_session_cookie(settings=other_settings, user_id=user_id)
with pytest.raises(Exception):
decode_access_token(settings=settings, token=token)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_exchange_code_for_tokens_posts_expected_payload() -> None:
settings = Settings()
def handler(request: httpx.Request) -> httpx.Response:
assert request.url == httpx.URL(settings.resolved_authentik_token_url)
payload = dict(httpx.QueryParams(request.content.decode("utf-8")))
assert payload["grant_type"] == "authorization_code"
assert payload["code"] == "auth-code"
assert payload["redirect_uri"] == "http://localhost:8000/auth/callback"
return httpx.Response(200, json={"access_token": "provider-token", "refresh_token": "provider-refresh"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
token_payload = await exchange_code_for_tokens(
settings=settings,
code="auth-code",
redirect_uri="http://localhost:8000/auth/callback",
client=client,
)
assert token_payload["access_token"] == "provider-token"
with pytest.raises(ValueError, match="invalid session signature"):
decode_session_cookie(settings=settings, cookie_value=cookie)
@pytest.mark.integration
def test_verify_provider_access_token_with_jwks_oct_key() -> None:
settings = Settings(authentik_audience="headquarter-web", authentik_issuer="https://authentik.local/")
shared_secret = b"shared-secret-123"
jwks = {
"keys": [
{
"kty": "oct",
"alg": "HS256",
"k": base64.urlsafe_b64encode(shared_secret).decode("utf-8").rstrip("="),
"kid": "kid-1",
}
]
}
def test_decode_session_rejects_expired_cookie(monkeypatch) -> None:
settings = Settings(session_ttl_hours=-1) # Already expired
user_id = "test-user-123"
from jose import jwt # type: ignore[import-untyped]
cookie = create_session_cookie(settings=settings, user_id=user_id)
token = jwt.encode(
{
"sub": "authentik-user",
"iss": settings.authentik_issuer,
"aud": settings.authentik_audience,
"exp": int((datetime.now(UTC) + timedelta(minutes=5)).timestamp()),
},
shared_secret,
algorithm="HS256",
headers={"kid": "kid-1"},
)
claims = verify_provider_access_token(settings=settings, token=token, jwks=jwks)
assert claims["sub"] == "authentik-user"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_refresh_store_create_rotate_and_revoke(db_session: AsyncSession) -> None:
user = User(email="dev-auth@headquarter.local", name="Dev Auth", authentik_id="auth-dev", avatar_url=None)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
raw_refresh_token, stored_token = await create_refresh_token(
session=db_session,
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=7),
user_agent="pytest",
ip_address="127.0.0.1",
)
assert raw_refresh_token
assert stored_token.revoked_at is None
rotated_raw, rotated_stored = await rotate_refresh_token(
session=db_session,
raw_token=raw_refresh_token,
user_agent="pytest-rotated",
ip_address="127.0.0.2",
)
assert rotated_raw != raw_refresh_token
assert rotated_stored.revoked_at is None
assert stored_token.revoked_at is not None
revoked = await revoke_refresh_token(session=db_session, raw_token=rotated_raw)
assert revoked is True
with pytest.raises(ValueError, match="session expired"):
decode_session_cookie(settings=settings, cookie_value=cookie)
@@ -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"]
@@ -0,0 +1,164 @@
"""Tests for git control utilities."""
import os
import tempfile
import pytest
from src.utils.git_control import (
GitStatus,
checkout_branch,
commit_changes,
create_branch,
delete_branch,
get_current_branch,
get_status,
)
@pytest.fixture
def temp_repo():
"""Create a temporary git repository."""
with tempfile.TemporaryDirectory() as tmpdir:
# Initialize git repo
os.system(f"cd {tmpdir} && git init && git config user.email 'test@test.com' && git config user.name 'Test User'")
# Create initial commit
with open(os.path.join(tmpdir, "README.md"), "w") as f:
f.write("# Test Repo\n")
os.system(f"cd {tmpdir} && git add README.md && git commit -m 'Initial commit'")
yield tmpdir
class TestGitStatus:
"""Tests for get_status function."""
def test_clean_repo(self, temp_repo):
"""Test status of a clean repository."""
status = get_status(temp_repo)
assert isinstance(status, GitStatus)
assert status.branch in ["main", "master"]
assert len(status.modified) == 0
assert len(status.added) == 0
assert len(status.deleted) == 0
assert len(status.untracked) == 0
def test_modified_file(self, temp_repo):
"""Test detecting modified files."""
# Modify a file
with open(os.path.join(temp_repo, "README.md"), "w") as f:
f.write("# Modified\n")
status = get_status(temp_repo)
assert "README.md" in status.modified
def test_untracked_file(self, temp_repo):
"""Test detecting untracked files."""
# Create new file
with open(os.path.join(temp_repo, "new.py"), "w") as f:
f.write("print('hello')\n")
status = get_status(temp_repo)
assert "new.py" in status.untracked
def test_get_current_branch_handles_unborn_main() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
os.system(f"git init -b main {tmpdir} >/dev/null 2>&1")
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."""
def test_create_branch(self, temp_repo):
"""Test creating a new branch."""
# Get the actual default branch name
default_branch = get_current_branch(temp_repo)
create_branch(temp_repo, "feature/test", default_branch)
# Check branch exists
branches = get_status(temp_repo)
# Branch should still be on default
assert branches.branch == default_branch
def test_checkout_branch(self, temp_repo):
"""Test checking out a branch."""
default_branch = get_current_branch(temp_repo)
create_branch(temp_repo, "feature/test", default_branch)
checkout_branch(temp_repo, "feature/test")
current = get_current_branch(temp_repo)
assert current == "feature/test"
def test_delete_branch(self, temp_repo):
"""Test deleting a branch."""
default_branch = get_current_branch(temp_repo)
create_branch(temp_repo, "feature/delete", default_branch)
delete_branch(temp_repo, "feature/delete")
# Should be back on default
current = get_current_branch(temp_repo)
assert current == default_branch
def test_get_current_branch(self, temp_repo):
"""Test getting current branch."""
branch = get_current_branch(temp_repo)
assert branch in ["main", "master"]
class TestCommit:
"""Tests for commit function."""
def test_commit_changes(self, temp_repo):
"""Test committing changes."""
# Modify file
with open(os.path.join(temp_repo, "README.md"), "w") as f:
f.write("# Updated\n")
# Commit
commit_changes(
temp_repo,
"Update README",
"Test User",
"test@test.com",
["README.md"]
)
# Check status is clean
status = get_status(temp_repo)
assert "README.md" not in status.modified
def test_commit_all_changes(self, temp_repo):
"""Test committing all changes."""
# Modify file
with open(os.path.join(temp_repo, "README.md"), "w") as f:
f.write("# All updated\n")
# Commit all
commit_changes(
temp_repo,
"Update all",
"Test User",
"test@test.com"
)
# Check status is clean
status = get_status(temp_repo)
assert len(status.modified) == 0
@@ -5,7 +5,6 @@ from src.models import Base
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.refresh_token import RefreshToken
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.models.user_config import UserConfig
@@ -6,7 +6,7 @@ import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.project import Project
@@ -53,7 +53,7 @@ def _load_app():
def _mint_token(user_id: str) -> str:
settings = Settings()
return mint_access_token(
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
@@ -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
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.tool_type import ToolType
@@ -54,7 +54,7 @@ def _load_app():
def _mint_token(user_id: str) -> str:
settings = Settings()
return mint_access_token(
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
@@ -0,0 +1,188 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestToolTypesAPIExtended:
"""Integration tests for tool types API with new fields."""
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",
json={
"name": "dockerfile-tool",
"display_name": "Dockerfile Tool",
"category": "utility",
"interfaces": ["terminal"],
"default_port": 8080,
"definition_type": "dockerfile",
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
"required_variables": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "dockerfile-tool"
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:
"""Test creating a tool type with readiness probe."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "probed-tool",
"display_name": "Probed Tool",
"category": "utility",
"interfaces": ["web"],
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"readiness_probe": {
"command": "curl -f http://localhost:8080",
"timeout": 30,
"interval": 2,
},
"required_variables": [],
},
)
assert response.status_code == 201
data = response.json()
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:
"""Test that invalid definition types are rejected."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "invalid-tool",
"display_name": "Invalid Tool",
"default_port": 8080,
"definition_type": "invalid",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
assert response.status_code == 422
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",
json={
"name": "no-dockerfile",
"display_name": "No Dockerfile",
"default_port": 8080,
"definition_type": "dockerfile",
"required_variables": [],
},
)
assert response.status_code == 422
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(
"/tool-types",
json={
"name": "update-test-tool",
"display_name": "Update Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = create_response.json()["id"]
# Update it
response = authenticated_client.put(
f"/tool-types/{tool_id}",
json={
"display_name": "Updated Name",
"readiness_probe": {
"command": "curl -f http://localhost:8080/health",
"timeout": 60,
"interval": 5,
},
},
)
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"
def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
"""Test validating compose template."""
response = authenticated_client.post(
"/tool-types/validate",
json={
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None:
"""Test validating invalid compose template."""
response = authenticated_client.post(
"/tool-types/validate",
json={
"definition_type": "compose",
"compose_template": "invalid: yaml: [",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is False
assert "errors" in data
def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None:
"""Test validating dockerfile template."""
response = authenticated_client.post(
"/tool-types/validate",
json={
"definition_type": "dockerfile",
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
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(
"/tool-types",
json={
"name": "full-tool",
"display_name": "Full Tool",
"category": "editor",
"interfaces": ["web", "terminal"],
"default_port": 8443,
"definition_type": "compose",
"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,
"interval": 2,
},
"required_variables": ["REPO_PATH"],
},
)
tool_id = create_response.json()["id"]
# Get it
response = authenticated_client.get(f"/tool-types/{tool_id}")
assert response.status_code == 200
data = response.json()
assert data["definition_type"] == "compose"
assert data["category"] == "editor"
assert data["interfaces"] == ["web", "terminal"]
assert "readiness_probe" in data
+2 -2
View File
@@ -8,7 +8,7 @@ import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.user import User
@@ -78,7 +78,7 @@ def _insert_test_user(user_id: str) -> None:
def _create_auth_cookie(user_id: str) -> str:
settings = Settings()
return mint_access_token(
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
+151
View File
@@ -0,0 +1,151 @@
"""Unit tests for docker build service."""
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src.services.docker_build import build_image
class TestBuildImage:
"""Tests for build_image function."""
@patch("subprocess.run")
def test_builds_image_successfully(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="Successfully built abc123",
stderr="",
)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test-image:latest")
assert result[0] == 0
assert "Successfully built" in result[1]
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "test-image:latest" in call_args[0][0]
assert "build" in call_args[0][0]
@patch("subprocess.run")
def test_build_fails(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Error: failed to build",
)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM invalid:image", "test-image:latest")
assert result[0] == 1
assert "failed to build" in result[2]
@patch("subprocess.run")
def test_build_with_tag(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="",
stderr="",
)
with tempfile.TemporaryDirectory() as tmpdir:
build_image(tmpdir, "FROM python:3.11", "my-registry/tool:v1.0")
call_args = mock_run.call_args[0][0]
assert "my-registry/tool:v1.0" in call_args
@patch("subprocess.run")
def test_build_command_structure(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_image(tmpdir, "FROM python:3.11", "test:latest")
cmd = mock_run.call_args[0][0]
assert cmd[0] == "docker"
assert cmd[1] == "build"
assert "-t" in cmd
assert "test:latest" in cmd
assert tmpdir in cmd
@patch("subprocess.run")
def test_build_writes_dockerfile(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
dockerfile_content = "FROM python:3.11\\nRUN pip install flask"
build_image(tmpdir, dockerfile_content, "test:latest")
dockerfile_path = Path(tmpdir) / "Dockerfile"
assert dockerfile_path.exists()
assert dockerfile_path.read_text() == dockerfile_content
@patch("subprocess.run")
def test_build_writes_context_files(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"requirements.txt": "flask==2.0\\nnumpy==1.21",
"app.py": "from flask import Flask\\napp = Flask(__name__)",
}
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
req_path = Path(tmpdir) / "requirements.txt"
app_path = Path(tmpdir) / "app.py"
assert req_path.exists()
assert req_path.read_text() == "flask==2.0\\nnumpy==1.21"
assert app_path.exists()
assert app_path.read_text() == "from flask import Flask\\napp = Flask(__name__)"
@patch("subprocess.run")
def test_build_creates_nested_directories(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"src/app.py": "print('hello')",
}
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
app_path = Path(tmpdir) / "src" / "app.py"
assert app_path.exists()
@patch("subprocess.run")
def test_build_prevents_path_traversal(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"../../../etc/passwd": "root:x:0:0",
}
with pytest.raises(ValueError, match="escapes instance directory"):
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
mock_run.assert_not_called()
@patch("subprocess.run")
def test_build_timeout(self, mock_run) -> None:
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker", "build"], timeout=300)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
assert result[0] == 1
assert "timed out" in result[2].lower()
@patch("subprocess.run")
def test_build_exception(self, mock_run) -> None:
mock_run.side_effect = OSError("Docker not available")
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
assert result[0] == 1
assert "Docker not available" in result[2]
@@ -0,0 +1,28 @@
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from src.api.git_repositories import _build_provider_clone_url, _preflight_remote_repository
def test_build_provider_clone_url_uses_fixed_host() -> None:
assert _build_provider_clone_url("alice", "demo") == "git@git.commumedia.org:alice/demo.git"
def test_preflight_remote_repository_allows_accessible_repo() -> None:
completed = Mock(returncode=0)
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
_preflight_remote_repository("git@git.commumedia.org:alice/demo.git")
run_mock.assert_called_once()
def test_preflight_remote_repository_rejects_missing_repo() -> None:
completed = Mock(returncode=128)
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
with pytest.raises(HTTPException) as exc_info:
_preflight_remote_repository("git@git.commumedia.org:alice/missing.git")
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "repository not found or inaccessible"
@@ -0,0 +1,64 @@
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from src.api.git_repositories import _clone_working_repository, _init_working_repository
from src.utils.git_control import create_branch
def test_clone_working_repository_uses_normal_clone() -> None:
completed = Mock(returncode=0, stderr="")
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
_clone_working_repository("git@git.commumedia.org:alice/demo.git", "/tmp/demo.git")
run_mock.assert_called_once()
assert run_mock.call_args.args[0] == ["git", "clone", "git@git.commumedia.org:alice/demo.git", "/tmp/demo.git"]
def test_clone_working_repository_raises_on_failure() -> None:
completed = Mock(returncode=128, stderr="fatal: repository not found")
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
with pytest.raises(HTTPException) as exc_info:
_clone_working_repository("git@git.commumedia.org:alice/missing.git", "/tmp/missing.git")
assert exc_info.value.status_code == 400
assert "failed to clone repository" in exc_info.value.detail
def test_init_working_repository_prefers_init_b() -> None:
init_b = Mock(returncode=0, stderr="")
with patch("src.api.git_repositories.subprocess.run", return_value=init_b) as run_mock:
_init_working_repository("/tmp/new-repo")
assert run_mock.call_args.args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
def test_init_working_repository_falls_back_to_symbolic_ref() -> None:
init_b = Mock(returncode=1, stderr="unknown switch `b'")
init_ok = Mock(returncode=0, stderr="")
symbolic_ref = Mock(returncode=0, stderr="")
with patch("src.api.git_repositories.subprocess.run", side_effect=[init_b, init_ok, symbolic_ref]) as run_mock:
_init_working_repository("/tmp/new-repo")
assert run_mock.call_args_list[0].args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
assert run_mock.call_args_list[1].args[0] == ["git", "init", "/tmp/new-repo"]
assert run_mock.call_args_list[2].args[0] == ["git", "-C", "/tmp/new-repo", "symbolic-ref", "HEAD", "refs/heads/main"]
def test_create_branch_uses_orphan_checkout_when_head_is_unborn() -> None:
call_count = 0
def mock_run(repo_path: str, *args: str) -> str:
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("fatal: Needed a single revision")
return ""
with patch("src.utils.git_control._run_git_command", side_effect=mock_run) as run_mock:
create_branch("/tmp/new-repo", "feature/test")
assert run_mock.call_args_list[0].args[1:] == ("rev-parse", "--verify", "HEAD^{commit}")
assert run_mock.call_args_list[1].args[1:] == ("checkout", "--orphan", "feature/test")
+147
View File
@@ -0,0 +1,147 @@
"""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
class TestExtractBaseRepoUrl:
"""Tests for extract_base_repo_url function."""
def test_github_tree_url(self):
url = "https://github.com/owner/repo/tree/main"
result = extract_base_repo_url(url)
assert result == "https://github.com/owner/repo.git"
def test_github_blob_url(self):
url = "https://github.com/owner/repo/blob/main/README.md"
result = extract_base_repo_url(url)
assert result == "https://github.com/owner/repo.git"
def test_github_pull_url(self):
url = "https://github.com/owner/repo/pull/123"
result = extract_base_repo_url(url)
assert result == "https://github.com/owner/repo.git"
def test_github_issues_url(self):
url = "https://github.com/owner/repo/issues/456"
result = extract_base_repo_url(url)
assert result == "https://github.com/owner/repo.git"
def test_github_valid_url(self):
url = "https://github.com/owner/repo.git"
result = extract_base_repo_url(url)
assert result == "https://github.com/owner/repo.git"
def test_github_url_with_query_params(self):
url = "https://github.com/owner/repo?tab=readme-ov-file"
result = extract_base_repo_url(url)
assert result == "https://github.com/owner/repo.git"
def test_gitlab_tree_url(self):
url = "https://gitlab.com/owner/repo/-/tree/main"
result = extract_base_repo_url(url)
assert result == "https://gitlab.com/owner/repo.git"
def test_gitlab_blob_url(self):
url = "https://gitlab.com/owner/repo/-/blob/main/README.md"
result = extract_base_repo_url(url)
assert result == "https://gitlab.com/owner/repo.git"
def test_gitlab_merge_request_url(self):
url = "https://gitlab.com/owner/repo/-/merge_requests/123"
result = extract_base_repo_url(url)
assert result == "https://gitlab.com/owner/repo.git"
def test_gitlab_valid_url(self):
url = "https://gitlab.com/owner/repo.git"
result = extract_base_repo_url(url)
assert result == "https://gitlab.com/owner/repo.git"
def test_bitbucket_src_url(self):
url = "https://bitbucket.org/owner/repo/src/main/"
result = extract_base_repo_url(url)
assert result == "https://bitbucket.org/owner/repo.git"
def test_bitbucket_valid_url(self):
url = "https://bitbucket.org/owner/repo.git"
result = extract_base_repo_url(url)
assert result == "https://bitbucket.org/owner/repo.git"
def test_ssh_url(self):
url = "git@github.com:owner/repo.git"
result = extract_base_repo_url(url)
assert result == "git@github.com:owner/repo.git"
def test_ssh_url_without_git_suffix(self):
url = "git@github.com:owner/repo"
result = extract_base_repo_url(url)
assert result == "git@github.com:owner/repo.git"
def test_invalid_url(self):
url = "not-a-url"
result = extract_base_repo_url(url)
assert result is None
def test_empty_url(self):
url = ""
result = extract_base_repo_url(url)
assert result is None
class TestIsValidCloneUrl:
"""Tests for is_valid_clone_url function."""
def test_valid_ssh_url(self):
assert is_valid_clone_url("git@github.com:owner/repo.git") is True
def test_valid_https_url(self):
assert is_valid_clone_url("https://github.com/owner/repo.git") is True
def test_browser_url(self):
assert is_valid_clone_url("https://github.com/owner/repo/tree/main") is False
def test_url_without_git_suffix(self):
assert is_valid_clone_url("https://github.com/owner/repo") is False
def test_invalid_url(self):
assert is_valid_clone_url("not-a-url") is False
class TestParseGitUrl:
"""Tests for parse_git_url function."""
def test_valid_git_url(self):
result = parse_git_url("https://github.com/owner/repo.git")
assert result["is_valid_clone_url"] is True
assert result["needs_parsing"] is False
assert result["base_url"] == "https://github.com/owner/repo.git"
assert result["host"] == "github.com"
assert "Valid" in result["message"]
def test_browser_url(self):
result = parse_git_url("https://github.com/owner/repo/tree/main")
assert result["is_valid_clone_url"] is False
assert result["needs_parsing"] is True
assert result["base_url"] == "https://github.com/owner/repo.git"
assert result["host"] == "github.com"
assert result["error_code"] == "URL_NEEDS_PARSING"
assert "browser URL" in result["message"]
def test_invalid_url(self):
result = parse_git_url("not-a-url")
assert result["is_valid_clone_url"] is False
assert result["base_url"] is None
assert result["error_code"] == "INVALID_URL"
def test_empty_url(self):
result = parse_git_url("")
assert result["is_valid_clone_url"] is False
assert result["base_url"] is None
assert result["error_code"] == "INVALID_URL"
def test_ssh_url(self):
result = parse_git_url("git@github.com:owner/repo.git")
assert result["is_valid_clone_url"] is True
assert result["needs_parsing"] is False
assert result["host"] == "github.com"
+217
View File
@@ -0,0 +1,217 @@
"""Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe
class TestExecuteProbe:
"""Tests for execute_probe function."""
@patch("subprocess.run")
async def test_probe_succeeds_first_attempt(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="healthy",
stderr="",
)
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080")
assert result is True
assert any("Success" in log for log in logs)
mock_run.assert_called_once_with(
["docker", "exec", "container-123", "sh", "-c", "curl -f http://localhost:8080"],
capture_output=True,
text=True,
timeout=2,
)
@patch("subprocess.run")
async def test_probe_fails_then_succeeds(self, mock_run) -> None:
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
MagicMock(returncode=0, stdout="healthy", stderr=""),
]
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=10, interval=0.1)
assert result is True
assert mock_run.call_count == 3
assert any("Attempt 1: Failed" in log for log in logs)
assert any("Attempt 3: Success" in log for log in logs)
@patch("subprocess.run")
async def test_probe_times_out(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Connection refused",
)
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=0.5, interval=0.2)
assert result is False
assert any("timed out" in log.lower() for log in logs)
@patch("subprocess.run")
async def test_probe_command_not_found(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=127,
stdout="",
stderr="command not found",
)
result, logs = await execute_probe("container-123", "nonexistent-command", timeout=1, interval=0.3)
assert result is False
assert any("exit code 127" in log for log in logs)
@patch("subprocess.run")
async def test_probe_exception(self, mock_run) -> None:
mock_run.side_effect = OSError("Docker not available")
result, logs = await execute_probe("container-123", "curl http://localhost", timeout=1, interval=0.3)
assert result is False
assert any("Error" in log for log in logs)
@patch("subprocess.run")
async def test_probe_with_special_characters(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="",
stderr="",
)
cmd = "bash -c 'echo \"hello world\" && exit 0'"
await execute_probe("container-123", cmd)
call_args = mock_run.call_args
assert cmd in call_args[0][0]
@patch("subprocess.run")
async def test_probe_captures_stdout(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="Server is ready\\nVersion: 1.0",
stderr="",
)
result, logs = await execute_probe("container-123", "cat /app/status")
assert result is True
assert any("Server is ready" in log for log in logs)
class TestIntegrationScenarios:
"""Integration-style tests with realistic scenarios."""
@patch("subprocess.run")
async def test_web_server_probe(self, mock_run) -> None:
"""Test typical web server health check."""
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=0, stdout="OK", stderr=""),
]
result, logs = await execute_probe(
"web-container",
"curl -f http://localhost:8080/health",
timeout=10,
interval=0.2,
)
assert result is True
assert mock_run.call_count == 4
@patch("subprocess.run")
async def test_command_probe(self, mock_run) -> None:
"""Test command availability check."""
mock_run.return_value = MagicMock(
returncode=0,
stdout="opencode 1.0.0",
stderr="",
)
result, logs = await execute_probe(
"tool-container",
"which opencode && opencode --version",
timeout=30,
interval=2,
)
assert result is True
assert any("opencode 1.0.0" in log for log in logs)
@patch("subprocess.run")
async def test_database_probe(self, mock_run) -> None:
"""Test database readiness check."""
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=0, stdout="/var/run/postgresql:5432 - accepting connections", stderr=""),
]
result, logs = await execute_probe(
"db-container",
"pg_isready -U postgres",
timeout=10,
interval=0.3,
)
assert result is True
assert mock_run.call_count == 3
@patch("subprocess.run")
async def test_file_probe(self, mock_run) -> None:
"""Test file existence check."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result, logs = await execute_probe(
"app-container",
"[ -f /app/ready ]",
timeout=10,
interval=1,
)
assert result is True
@patch("subprocess.run")
async def test_slow_starting_service(self, mock_run) -> None:
"""Test service that takes time to start."""
# Simulate 5 failures before success
side_effects = [MagicMock(returncode=1, stdout="", stderr="")] * 5
side_effects.append(MagicMock(returncode=0, stdout="Ready", stderr=""))
mock_run.side_effect = side_effects
result, logs = await execute_probe(
"slow-container",
"curl -f http://localhost:8080",
timeout=10,
interval=0.2,
)
assert result is True
assert mock_run.call_count == 6
assert any("Attempt 6: Success" in log for log in logs)
@patch("subprocess.run")
async def test_zero_timeout_immediate_return(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
result, logs = await execute_probe(
"container",
"test",
timeout=0,
interval=1,
)
assert result is False
assert any("timed out" in log.lower() for log in logs)
+1371
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# wait-for-db.sh - Wait for PostgreSQL to be ready
set -e
host="${POSTGRES_HOST:-postgres}"
port="${POSTGRES_PORT:-5432}"
max_attempts="${DB_MAX_ATTEMPTS:-30}"
wait_seconds="${DB_WAIT_SECONDS:-2}"
echo "Waiting for database at ${host}:${port}..."
attempt=1
while ! nc -z "${host}" "${port}"; do
if [ "${attempt}" -ge "${max_attempts}" ]; then
echo "ERROR: Database not available after ${max_attempts} attempts. Exiting."
exit 1
fi
echo " Attempt ${attempt}/${max_attempts}: Database not ready yet, waiting ${wait_seconds}s..."
sleep "${wait_seconds}"
attempt=$((attempt + 1))
done
echo "Database is ready!"
exec "$@"
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Headquarter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+73 -1
View File
@@ -8,11 +8,18 @@
"name": "headquarter-web",
"version": "0.1.0",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@types/prismjs": "^1.26.6",
"axios": "^1.6.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"tailwindcss": "^3.3.0"
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -1268,6 +1275,19 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">= 16.8",
"react-dom": ">= 16.8"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -2127,6 +2147,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -5091,6 +5117,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -5205,6 +5240,16 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-simple-code-editor": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz",
"integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -6310,6 +6355,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
"license": "MIT",
"peerDependencies": {
"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.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+8 -1
View File
@@ -11,11 +11,18 @@
"test": "vitest run"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@types/prismjs": "^1.26.6",
"axios": "^1.6.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"tailwindcss": "^3.3.0"
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
+796
View File
@@ -0,0 +1,796 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Headquarter - UI Preview</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f4f1ea;
--panel: #fffef9;
--ink: #1d1d1b;
--muted: #5f5b55;
--brand: #275d4b;
--brand-strong: #154236;
--border: #d8d0c5;
--primary: #275d4b;
--primary-fg: #fffef9;
--color-primary: #275d4b;
--success: #2f8f62;
--success-light: rgba(47, 143, 98, 0.14);
--warning: #c08a1e;
--warning-light: rgba(192, 138, 30, 0.14);
--danger: #b94a3c;
--danger-light: rgba(185, 74, 60, 0.14);
--info: #4f7fb8;
--info-light: rgba(79, 127, 184, 0.14);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
}
/* App Shell */
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.shell-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px);
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
color: var(--ink);
text-decoration: none;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-chip {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-size: 0.9rem;
color: var(--ink);
text-decoration: none;
}
.ghost-button {
border: 1px solid var(--border);
background: transparent;
border-radius: 10px;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
color: var(--muted);
}
.shell-body {
display: grid;
grid-template-columns: 230px 1fr;
min-height: calc(100vh - 57px);
}
.shell-nav {
border-right: 1px solid var(--border);
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent);
}
.nav-item {
padding: 0.65rem 0.75rem;
border-radius: 10px;
color: var(--muted);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
}
.nav-item:hover {
background: #ece7df;
color: var(--ink);
}
.nav-item-active {
background: var(--brand);
color: #f7fff7;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--primary);
color: var(--primary-fg);
border-radius: 9px;
font-size: 11px;
font-weight: 600;
margin-left: auto;
}
.nav-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.nav-section-title {
margin-top: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.session-item {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
}
.session-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
display: inline-block;
margin-right: 0.25rem;
}
.session-status.running {
background: var(--success);
}
.shell-content {
padding: 1.25rem;
overflow-x: hidden;
}
/* Common Components */
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.stack-sm {
gap: 0.5rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem;
}
.muted {
color: var(--muted);
}
.eyebrow {
margin: 0;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.primary-button {
background: var(--brand);
color: white;
border-radius: 10px;
border: 1px solid transparent;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
}
.primary-button:hover {
background: var(--brand-strong);
}
.secondary-button {
border-color: var(--border);
background: var(--panel);
border-radius: 10px;
border: 1px solid var(--border);
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
}
/* Home Page */
.home-page {
max-width: 1240px;
}
.home-hero {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: flex-start;
}
.home-hero-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.home-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-4);
}
.home-summary-card .card-label {
margin: 0;
color: var(--muted);
font-size: 0.875rem;
}
.home-summary-card .card-value {
margin: 0.45rem 0 0;
font-size: 1.6rem;
font-weight: 700;
}
.home-section h2,
.home-section h3 {
margin: 0;
}
.home-session-grid,
.home-project-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.session-card {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
.session-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.running {
background: var(--success-light);
color: var(--success);
}
.status-badge.building {
background: var(--warning-light);
color: var(--warning);
}
.status-badge.pending {
background: var(--info-light);
color: var(--info);
}
.recent-sessions-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.recent-session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
.recent-session-name {
font-weight: 500;
}
.create-session-form .form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4);
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-field input,
.form-field select,
.form-field textarea {
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: 10px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
/* Settings Page */
.settings-page {
max-width: 1240px;
}
.settings-header {
padding: 1.5rem;
}
.settings-tabs {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.settings-tab {
padding: 0.6rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--panel);
text-decoration: none;
cursor: pointer;
}
.settings-tab.active {
background: var(--brand);
color: white;
border-color: transparent;
}
.settings-panel {
padding: 1.5rem;
}
.settings-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.success-text {
color: var(--success);
}
.error-text {
color: var(--danger);
}
/* Preview Switcher */
.preview-switcher {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.preview-switcher button {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font: inherit;
color: var(--muted);
}
.preview-switcher button.active {
background: var(--brand);
color: white;
}
.page-preview {
display: none;
}
.page-preview.active {
display: block;
}
/* Responsive */
@media (max-width: 767px) {
.shell-body {
grid-template-columns: 1fr;
}
.shell-nav {
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
}
.home-hero {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="shell">
<header class="shell-header">
<a href="#" class="brand">Headquarter</a>
<div class="header-actions">
<a href="#" class="user-chip">User</a>
<button class="ghost-button">Logout</button>
</div>
</header>
<div class="shell-body">
<aside class="shell-nav" aria-label="Primary navigation">
<a href="#" class="nav-item nav-item-active">
<span>🏠</span> Home
<span class="nav-badge">3</span>
</a>
<a href="#" class="nav-item">
<span>📁</span> Projects
</a>
<a href="#" class="nav-item">
<span>⚙️</span> Settings
</a>
<div class="nav-divider"></div>
<div class="nav-section-title">Live sessions</div>
<a href="#" class="nav-item session-item">
<span class="session-status running"></span>
<span>Dev Environment</span>
</a>
<a href="#" class="nav-item session-item">
<span class="session-status running"></span>
<span>Jupyter Lab</span>
</a>
<a href="#" class="nav-item session-item">
<span class="session-status"></span>
<span>Code Server</span>
</a>
</aside>
<main class="shell-content">
<!-- HOME PAGE PREVIEW -->
<div id="home-preview" class="page-preview active">
<section class="stack home-page">
<header class="home-hero card">
<div class="stack-sm">
<p class="eyebrow">Workspace overview</p>
<h1>Home</h1>
<p class="muted">Open sessions, available projects, and the fastest path back into work.</p>
</div>
<div class="home-hero-actions">
<button class="primary-button">New Project</button>
<button class="secondary-button">Settings</button>
</div>
</header>
<div class="home-summary-grid">
<article class="card home-summary-card">
<p class="card-label">Open sessions</p>
<p class="card-value">3</p>
</article>
<article class="card home-summary-card">
<p class="card-label">Projects</p>
<p class="card-value">5</p>
</article>
<article class="card home-summary-card">
<p class="card-label">Repositories</p>
<p class="card-value">12</p>
</article>
</div>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Open sessions</p>
<h2>3</h2>
</div>
</div>
<div class="home-session-grid">
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Dev Environment</h3>
<span class="status-badge running">running</span>
</div>
<p class="muted">Acme Corp · main</p>
<p class="muted">VS Code Server</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Jupyter Lab</h3>
<span class="status-badge running">running</span>
</div>
<p class="muted">Data Science · experiments</p>
<p class="muted">Jupyter Notebook</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Database Console</h3>
<span class="status-badge building">building</span>
</div>
<p class="muted">Backend API · staging</p>
<p class="muted">PostgreSQL Client</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
</div>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Available projects</p>
<h2>5</h2>
</div>
<button class="secondary-button">View all</button>
</div>
<div class="home-project-grid">
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Acme Corp</h3>
<p class="muted">Main product development</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Data Science</h3>
<p class="muted">ML experiments and notebooks</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Backend API</h3>
<p class="muted">REST API services</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
</div>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Quick create</p>
<h2>Start a session</h2>
</div>
</div>
<form class="stack create-session-form">
<div class="form-row">
<label class="form-field">
Project
<select>
<option>Select project...</option>
<option>Acme Corp</option>
<option>Data Science</option>
</select>
</label>
<label class="form-field">
Repository
<select disabled>
<option>Select repository...</option>
</select>
</label>
<label class="form-field">
Tool type
<select>
<option>Select tool...</option>
<option>VS Code Server</option>
<option>Jupyter Lab</option>
</select>
</label>
</div>
<label class="form-field">
Display name
<input type="text" placeholder="My Development Environment">
</label>
<div class="form-actions">
<button class="primary-button" type="submit">Create Session</button>
</div>
</form>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Recent sessions</p>
<h2>2</h2>
</div>
</div>
<div class="recent-sessions-list">
<article class="recent-session-item">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span class="recent-session-name">Old Dev Box</span>
<span class="muted">Acme Corp · VS Code Server</span>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
</article>
<article class="recent-session-item">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span class="recent-session-name">ML Training</span>
<span class="muted">Data Science · Jupyter Lab</span>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
</article>
</div>
</section>
</section>
</div>
<!-- SETTINGS PAGE PREVIEW -->
<div id="settings-preview" class="page-preview">
<section class="stack settings-page">
<header class="settings-header card stack-sm">
<div>
<p class="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p class="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
</header>
<nav class="settings-tabs" aria-label="Settings sections">
<a href="#" class="settings-tab active">General</a>
<a href="#" class="settings-tab">SSH Keys</a>
<a href="#" class="settings-tab">Tool Types</a>
<a href="#" class="settings-tab">Tool Configs</a>
</nav>
<div class="settings-panel card">
<div class="stack">
<h2>General</h2>
<label class="form-field">
Theme
<select>
<option>System</option>
<option>Light</option>
<option>Dark</option>
</select>
</label>
<label class="form-field">
Git user name
<input type="text" placeholder="Your git commit name" value="John Doe">
</label>
<label class="form-field">
Git user email
<input type="email" placeholder="your.email@example.com" value="john@example.com">
</label>
<label class="form-field">
Default editor
<input type="text" placeholder="e.g., vscode, vim, cursor" value="vscode">
</label>
<div class="settings-actions">
<button class="primary-button">Save Settings</button>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</div>
<div class="preview-switcher">
<button class="active" onclick="showPage('home')">Home</button>
<button onclick="showPage('settings')">Settings</button>
</div>
<script>
function showPage(page) {
document.querySelectorAll('.page-preview').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.preview-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(page + '-preview').classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>
+131
View File
@@ -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");
});
});
});
+95
View File
@@ -0,0 +1,95 @@
import { apiClient } from "./client";
export interface ConfigFolder {
id: string;
user_id: string;
name: string;
description: string | null;
mount_path: string;
files: Record<string, string>;
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateConfigFolderRequest {
name: string;
description?: string;
mount_path: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface UpdateConfigFolderRequest {
name?: string;
description?: string;
mount_path?: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface ProjectOverrideRequest {
mount_path?: string;
files?: Record<string, string>;
}
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
return response.data;
};
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
return response.data;
};
export const createConfigFolder = async (
data: CreateConfigFolderRequest
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
return response.data;
};
export const updateConfigFolder = async (
id: string,
data: UpdateConfigFolderRequest
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(`/config-folders/${id}`, data);
return response.data;
};
export const deleteConfigFolder = async (id: string): Promise<void> => {
await apiClient.delete(`/config-folders/${id}`);
};
export const addProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
);
return response.data;
};
export const updateProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
);
return response.data;
};
export const deleteProjectOverride = async (
id: string,
projectId: string
): Promise<void> => {
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
};
+210
View File
@@ -15,6 +15,22 @@ export interface GitRepository {
export interface GitRepositoryCreate {
name: string;
remote_url?: string;
force_original_url?: boolean;
}
export interface URLParseResult {
original_url: string;
base_url: string | null;
is_valid_clone_url: boolean;
needs_parsing: boolean;
host: string | null;
message: string;
error_code: string | null;
}
export async function parseGitUrl(url: string): Promise<URLParseResult> {
const response = await apiClient.post("/projects/repositories/parse-url", { url });
return response.data;
}
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
@@ -33,3 +49,197 @@ export async function createRepository(
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
}
export interface CommitHistoryEntry {
hash: string;
short_hash: string;
message: string;
author_name: string;
author_email: string;
author_date: string;
refs: string[];
graph_symbol: string;
graph_depth: number;
}
export interface CommitHistoryResponse {
commits: CommitHistoryEntry[];
branches: string[];
tags: string[];
}
export async function getRepositoryHistory(
projectId: string,
repoId: string,
branch?: string,
limit?: number
): Promise<CommitHistoryResponse> {
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 interface CommitDetail {
hash: string;
short_hash: string;
message: string;
author_name: string;
author_email: string;
author_date: string;
committer_name: string;
committer_email: string;
committer_date: string;
stats: {
additions: number;
deletions: number;
files_changed: number;
};
diff: string;
parents: string[];
}
export async function getCommitDetail(
projectId: string,
repoId: string,
commitHash: string
): Promise<CommitDetail> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`
);
return response.data;
}
// Git Control API
export interface GitStatus {
branch: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
renamed: string[];
ahead: number;
behind: number;
}
export async function getRepositoryStatus(
projectId: string,
repoId: string
): Promise<GitStatus> {
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 interface CommitResponse {
commit_hash: string;
message: string;
}
export async function commitChanges(
projectId: string,
repoId: string,
message: string,
files?: string[]
): Promise<CommitResponse> {
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 interface MergeResponse {
commit_hash: string;
message: string;
}
export async function mergeBranches(
projectId: string,
repoId: string,
sourceBranch: string,
targetBranch?: string,
message?: string
): Promise<MergeResponse> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/merge`,
{ source_branch: sourceBranch, target_branch: targetBranch, message }
);
return response.data;
}
+138
View File
@@ -0,0 +1,138 @@
import { apiClient } from "./client";
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;
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;
}
export async function listInstances(
projectId: string,
repoId: string
): Promise<ToolInstance[]> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances`
);
return response.data.instances;
}
export async function createInstance(
projectId: string,
repoId: string,
toolTypeId: string,
displayName?: string
): Promise<ToolInstance> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`,
{
tool_type_id: toolTypeId,
display_name: displayName,
}
);
return response.data;
}
export async function startInstance(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
);
return response.data;
}
export async function stopInstance(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ status: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
);
return response.data;
}
export async function restartInstance(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ status: string; url?: string }> {
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
): Promise<void> {
await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
);
}
export async function getUserSessions(): Promise<Session[]> {
const response = await apiClient.get("/users/me/sessions");
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<InstanceHealth> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
);
return response.data;
}
export async function recreateInstanceTunnel(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
);
return response.data;
}
+6 -4
View File
@@ -5,13 +5,15 @@ export interface UserConfig {
theme: string;
git_user_name: string | null;
git_user_email: string | null;
last_session_id: string | null;
}
export interface UserConfigUpdate {
default_editor?: string;
theme?: string;
git_user_name?: string;
git_user_email?: 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<UserConfig> => {
+75
View File
@@ -0,0 +1,75 @@
import { apiClient } from "./client";
export interface ToolConfig {
id: string;
tool_type_id: string;
project_id: string | null;
key: string;
value: string;
config_type: string;
file_path: string | null;
port_override: number | null;
start_command: string | null;
working_directory: string | null;
environment_variables: Record<string, string> | null;
volumes: Array<{ source: string; target: string; type?: string }> | null;
}
export interface CreateToolConfigRequest {
tool_type_id: string;
project_id?: string;
key: string;
value: string;
config_type?: string;
file_path?: string;
port_override?: number;
start_command?: string;
working_directory?: string;
environment_variables?: Record<string, string>;
volumes?: Array<{ source: string; target: string; type?: string }>;
}
export const listToolConfigs = async (
tool_type_id?: string,
project_id?: string
): Promise<ToolConfig[]> => {
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<ToolConfig> => {
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data);
return response.data.configs[0];
};
export const updateToolConfig = async (
id: string,
data: CreateToolConfigRequest
): Promise<ToolConfig> => {
const response = await apiClient.put<{ configs: ToolConfig[] }>(
`/tool-configs/${id}`,
data
);
return response.data.configs[0];
};
export const deleteToolConfig = async (id: string): Promise<void> => {
await apiClient.delete(`/tool-configs/${id}`);
};
export const getToolConfigDefaults = async (
toolTypeId: string
): Promise<ToolConfig> => {
const response = await apiClient.get<ToolConfig>(
`/tool-configs/defaults/${toolTypeId}`
);
return response.data;
};
+227
View File
@@ -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");
});
});
});
+34 -2
View File
@@ -1,11 +1,24 @@
import { apiClient } from "./client";
export interface ReadinessProbe {
command: string;
timeout: number;
interval: number;
}
export interface ToolType {
id: string;
name: string;
display_name: string;
description: string | null;
compose_template: string;
category: string;
interfaces: string[];
default_port: number | null;
definition_type: 'compose' | 'dockerfile';
compose_template: string | null;
dockerfile_template: string | null;
build_context: Record<string, string> | null;
readiness_probe: ReadinessProbe | null;
required_variables: string[];
is_builtin: boolean;
created_by_id: string | null;
@@ -17,14 +30,28 @@ export interface CreateToolTypeRequest {
name: string;
display_name: string;
description?: string;
compose_template: string;
category?: string;
interfaces?: string[];
default_port: number;
definition_type?: 'compose' | 'dockerfile';
compose_template?: string;
dockerfile_template?: string;
build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe;
required_variables: string[];
}
export interface UpdateToolTypeRequest {
display_name?: string;
description?: string;
category?: string;
interfaces?: string[];
default_port?: number;
definition_type?: 'compose' | 'dockerfile';
compose_template?: string;
dockerfile_template?: string;
build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe;
required_variables?: string[];
}
@@ -51,3 +78,8 @@ export const updateToolType = async (id: string, data: UpdateToolTypeRequest): P
export const deleteToolType = async (id: string): Promise<void> => {
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;
};
+77 -16
View File
@@ -1,19 +1,61 @@
import { useCallback, useEffect } from "react";
import { Link, NavLink, Outlet } from "react-router-dom";
import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions";
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard" },
{ to: "/projects", label: "Projects" },
{ to: "/ssh-keys", label: "SSH Keys" },
{ to: "/tool-types", label: "Tool Types" },
{ to: "/settings", label: "Settings" }
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/settings", label: "Settings", icon: "settings" }
];
const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running";
return (
<a
href={session.url ?? `/projects/${session.project_id}`}
target={session.url ? "_blank" : undefined}
rel={session.url ? "noopener noreferrer" : undefined}
className="nav-item session-item"
title={`${session.display_name} (${session.status})`}
>
<span className={`session-status ${isRunning ? "running" : ""}`} />
<Icon name={session.tool_icon as IconName} size="sm" />
<span className="session-name">{session.display_name}</span>
</a>
);
};
export const AppShell = () => {
useTheme();
const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions();
const loadSessions = useCallback(async () => {
try {
const data = await getUserSessions();
setAllSessions(data);
} catch {
// Silently fail - sessions are optional
}
}, [setAllSessions]);
useEffect(() => {
void loadSessions();
// Poll every 10 seconds
const interval = setInterval(() => {
void loadSessions();
}, 10000);
return () => clearInterval(interval);
}, [loadSessions]);
return (
<div className="shell">
@@ -32,6 +74,7 @@ export const AppShell = () => {
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
</div>
@@ -39,16 +82,34 @@ export const AppShell = () => {
<div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
{item.label}
</NavLink>
))}
{NAV_ITEMS.map((item) => {
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{isHome && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
<main className="shell-content">
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect } from "react";
import Editor from "react-simple-code-editor";
import { highlightCode, loadLanguage } from "../utils/language";
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
language: string;
readOnly?: boolean;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({
value,
onChange,
language,
readOnly = false,
}) => {
useEffect(() => {
const highlight = async () => {
await loadLanguage(language);
};
void highlight();
}, [language]);
const hightlightWithLineNumbers = (input: string) =>
input
.split("\n")
.map(
(line, i) =>
`<div class="editor-line"><span class="editor-line-number">${
i + 1
}</span><span class="editor-line-content">${highlightCode(
line || " ",
language
)}</span></div>`
)
.join("");
return (
<div className="code-editor">
<Editor
value={value}
onValueChange={onChange}
highlight={hightlightWithLineNumbers}
padding={0}
className="editor-textarea"
textareaClassName="editor-textarea-input"
readOnly={readOnly}
style={{
fontFamily: '"Fira Code", "Monaco", "Courier New", monospace',
fontSize: 14,
}}
/>
</div>
);
};
+159
View File
@@ -0,0 +1,159 @@
import React, { useState } from "react";
import { Icon } from "./icon";
interface CommitDialogProps {
isOpen: boolean;
filePath: string;
originalContent: string;
newContent: string;
onCommit: (message: string) => Promise<void>;
onCancel: () => void;
}
export const CommitDialog: React.FC<CommitDialogProps> = ({
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 (
<div className="dialog-overlay">
<div className="commit-dialog">
<div className="dialog-header">
<h3>Commit Changes</h3>
<button className="dialog-close" onClick={onCancel} type="button">
×
</button>
</div>
<div className="dialog-body">
<p className="file-info">
Editing: <strong>{filePath}</strong>
</p>
{!hasChanges && (
<div className="warning-message">No changes to commit</div>
)}
{hasChanges && (
<div className="diff-preview">
<h4>Changes</h4>
<div className="diff-content">
{diff.map((line, i) => (
<div
key={i}
className={`diff-line diff-${line.type}`}
>
<span className="diff-line-number">{line.lineNum}</span>
<span className="diff-marker">
{line.type === "added" && "+"}
{line.type === "removed" && "-"}
{line.type === "same" && " "}
</span>
<span className="diff-line-content">{line.line}</span>
</div>
))}
</div>
</div>
)}
<div className="form-group">
<label>Commit Message *</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your changes..."
rows={3}
className="form-textarea"
/>
</div>
{error && <div className="error-message">{error}</div>}
</div>
<div className="dialog-footer">
<button
className="btn-secondary"
onClick={onCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button
className="btn-primary"
onClick={handleCommit}
disabled={loading || !hasChanges || !message.trim()}
type="button"
>
{loading ? (
<>
<Icon name="loading" size="sm" />
Committing...
</>
) : (
<>
<Icon name="commit" size="sm" />
Commit Changes
</>
)}
</button>
</div>
</div>
</div>
);
};
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "react";
import { commitChanges } from "../api/git_repositories";
interface CommitPanelProps {
projectId: string;
repoId: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
onCommit: () => void;
}
export const CommitPanel = ({
projectId,
repoId,
modified,
added,
deleted,
untracked,
onCommit,
}: CommitPanelProps) => {
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const allFiles = [...modified, ...added, ...deleted, ...untracked];
const hasChanges = allFiles.length > 0;
const handleCommit = async () => {
if (!message.trim()) {
setError("Please enter a commit message");
return;
}
setLoading(true);
setError(null);
try {
await commitChanges(projectId, repoId, message);
setMessage("");
onCommit();
} catch {
setError("Commit failed. Please try again.");
} finally {
setLoading(false);
}
};
if (!hasChanges) return null;
return (
<div className="commit-panel">
<h4>Changes</h4>
<div className="file-list">
{modified.map((file) => (
<div key={file} className="file-item modified">
<span className="file-status">M</span>
<span className="file-name">{file}</span>
</div>
))}
{added.map((file) => (
<div key={file} className="file-item added">
<span className="file-status">A</span>
<span className="file-name">{file}</span>
</div>
))}
{deleted.map((file) => (
<div key={file} className="file-item deleted">
<span className="file-status">D</span>
<span className="file-name">{file}</span>
</div>
))}
{untracked.map((file) => (
<div key={file} className="file-item untracked">
<span className="file-status">?</span>
<span className="file-name">{file}</span>
</div>
))}
</div>
<div className="commit-form">
<textarea
placeholder="Commit message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
className="commit-message-input"
/>
{error && <div className="commit-error">{error}</div>}
<button
onClick={handleCommit}
disabled={loading || !message.trim()}
className="commit-button"
type="button"
>
{loading ? "Committing..." : "Commit"}
</button>
</div>
</div>
);
};
+241
View File
@@ -0,0 +1,241 @@
import React, { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiClient } from "../api/client";
import { useAuth } from "../state/auth";
import { CodeEditor } from "../components/code-editor";
import { CommitDialog } from "../components/commit-dialog";
import { Icon } from "../components/icon";
import { SyntaxHighlighter } from "../components/syntax-highlighter";
import { detectLanguage } from "../utils/language";
interface FileEditorProps {
projectId: string;
repoId: string;
}
export const FileEditor: React.FC<FileEditorProps> = ({
projectId,
repoId,
}) => {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const [mode, setMode] = useState<"view" | "edit">("view");
const [content, setContent] = useState(">");
const [originalContent, setOriginalContent] = useState(">");
const [language, setLanguage] = useState("plaintext");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCommitDialog, setShowCommitDialog] = useState(false);
const [isBinary, setIsBinary] = useState(false);
const [saving, setSaving] = useState(false);
const branch = searchParams.get("branch") || "main";
const filePath = searchParams.get("file");
const loadFile = useCallback(async () => {
if (!filePath) {
setContent("");
setOriginalContent("");
return;
}
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
params: {
branch,
path: filePath,
},
}
);
const data = response.data;
if (data.is_binary) {
setIsBinary(true);
setContent("Binary file - cannot display");
setOriginalContent("");
} else {
setIsBinary(false);
setContent(data.content);
setOriginalContent(data.content);
setLanguage(detectLanguage(filePath));
}
} catch {
setError("Failed to load file");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, filePath]);
useEffect(() => {
void loadFile();
}, [loadFile]);
const handleEdit = () => {
if (isBinary) return;
setMode("edit");
};
const handleCancel = () => {
setContent(originalContent);
setMode("view");
setShowCommitDialog(false);
};
const handleSave = () => {
if (content === originalContent) {
setMode("view");
return;
}
setShowCommitDialog(true);
};
const handleCommit = async (message: string) => {
if (!filePath || !user) return;
setSaving(true);
try {
await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
path: filePath,
branch,
content,
commit_message: message,
author_name: user.name || "User",
author_email: user.email || "user@example.com",
}
);
setOriginalContent(content);
setMode("view");
setShowCommitDialog(false);
} catch {
setError("Failed to save changes");
} finally {
setSaving(false);
}
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
e.preventDefault();
if (mode === "view" && !isBinary) {
handleEdit();
} else if (mode === "edit") {
handleCancel();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (mode === "edit") {
handleSave();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [mode, isBinary, content, originalContent]);
if (!filePath) {
return (
<div className="file-viewer-empty">
<p className="muted">Select a file to view its contents</p>
</div>
);
}
if (loading) return <p className="muted">Loading file...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-editor">
<div className="file-editor-toolbar">
<div className="file-breadcrumbs">
{filePath.split("/").map((part, i, arr) => (
<span key={i}>
{part}
{i < arr.length - 1 && (
<span className="breadcrumb-sep">/</span>
)}
</span>
))}
</div>
<div className="file-actions">
{mode === "view" && !isBinary && (
<button
className="btn-primary"
onClick={handleEdit}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
)}
{mode === "edit" && (
<>
<button
className="btn-primary"
onClick={handleSave}
disabled={content === originalContent || saving}
type="button"
>
{saving ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
<button
className="btn-secondary"
onClick={handleCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</>
)}
</div>
</div>
<div className="file-editor-content">
{mode === "view" && (
<SyntaxHighlighter
code={content}
language={language}
showLineNumbers={!isBinary}
/>
)}
{mode === "edit" && (
<CodeEditor
value={content}
onChange={setContent}
language={language}
/>
)}
</div>
<CommitDialog
isOpen={showCommitDialog}
filePath={filePath}
originalContent={originalContent}
newContent={content}
onCommit={handleCommit}
onCancel={() => setShowCommitDialog(false)}
/>
</div>
);
};
+268
View File
@@ -0,0 +1,268 @@
import { useCallback, useEffect, useState } from "react";
import {
checkoutBranch,
createBranch,
fetchRepository,
getRepositoryStatus,
pullRepository,
pushRepository,
type GitStatus,
} from "../api/git_repositories";
import { Icon } from "./icon";
import { MergeDialog } from "./merge-dialog";
interface GitToolbarProps {
projectId: string;
repoId: string;
currentBranch: string;
branches: string[];
hasRemote: boolean;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
export const GitToolbar = ({
projectId,
repoId,
currentBranch,
branches,
hasRemote,
onBranchChange,
onRefresh,
}: GitToolbarProps) => {
const [status, setStatus] = useState<GitStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showNewBranch, setShowNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [newBranchBase, setNewBranchBase] = useState("");
const [showMergeDialog, setShowMergeDialog] = useState(false);
const loadStatus = useCallback(async () => {
try {
const data = await getRepositoryStatus(projectId, repoId);
setStatus(data);
setError(null);
} catch {
setError("Failed to load status");
}
}, [projectId, repoId]);
useEffect(() => {
void loadStatus();
// Poll status every 5 seconds
const interval = setInterval(() => void loadStatus(), 5000);
return () => clearInterval(interval);
}, [loadStatus]);
const handleFetch = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await fetchRepository(projectId, repoId);
await loadStatus();
} catch {
setError("Fetch failed");
} finally {
setLoading(false);
}
};
const handlePull = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await pullRepository(projectId, repoId, currentBranch || undefined);
await loadStatus();
onRefresh();
} catch {
setError("Pull failed");
} finally {
setLoading(false);
}
};
const handlePush = async () => {
setLoading(true);
try {
await pushRepository(projectId, repoId, currentBranch);
await loadStatus();
} catch {
setError("Push failed");
} finally {
setLoading(false);
}
};
const handleCheckout = async (branch: string) => {
setLoading(true);
try {
await checkoutBranch(projectId, repoId, branch);
onBranchChange(branch);
onRefresh();
} catch {
setError("Checkout failed");
} finally {
setLoading(false);
}
};
const handleCreateBranch = async () => {
if (!newBranchName.trim()) return;
setLoading(true);
try {
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
setShowNewBranch(false);
setNewBranchName("");
setNewBranchBase("");
onRefresh();
} catch {
setError("Failed to create branch");
} finally {
setLoading(false);
}
};
const hasChanges = status && (
status.modified.length > 0 ||
status.added.length > 0 ||
status.deleted.length > 0 ||
status.untracked.length > 0
);
const canSync = hasRemote;
return (
<div className="git-toolbar">
{error && <div className="toolbar-error">{error}</div>}
<div className="toolbar-row">
<div className="toolbar-group">
<select
value={currentBranch}
onChange={(e) => handleCheckout(e.target.value)}
disabled={loading}
className="branch-select"
>
{branches.map((b) => (
<option key={b} value={b}>
{b === currentBranch ? (
<>
<Icon name="branch" size="sm" /> {b}
</>
) : (
b
)}
</option>
))}
</select>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(!showNewBranch)}
disabled={loading}
type="button"
>
<Icon name="add" size="sm" /> New
</button>
</div>
<div className="toolbar-group">
<button
className="toolbar-button"
onClick={handleFetch}
disabled={loading || !canSync}
type="button"
>
<Icon name="fetch" size="sm" /> Fetch
</button>
<button
className="toolbar-button"
onClick={handlePull}
disabled={loading || !canSync}
type="button"
>
<Icon name="pull" size="sm" /> Pull
{status?.behind ? <span className="badge">{status.behind}</span> : null}
</button>
<button
className="toolbar-button"
onClick={handlePush}
disabled={loading || !canSync || !status?.ahead}
type="button"
>
<Icon name="push" size="sm" /> Push
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
</button>
<button
className="toolbar-button"
onClick={() => setShowMergeDialog(true)}
disabled={loading}
type="button"
>
<Icon name="merge" size="sm" /> Merge
</button>
</div>
</div>
{showNewBranch && (
<div className="toolbar-row new-branch-form">
<input
type="text"
placeholder="Branch name"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
className="toolbar-input"
/>
<select
value={newBranchBase}
onChange={(e) => setNewBranchBase(e.target.value)}
className="toolbar-input"
>
<option value="">Base: HEAD</option>
{branches.map((b) => (
<option key={b} value={b}>{ b}</option>
))}
</select>
<button
className="toolbar-button primary"
onClick={handleCreateBranch}
disabled={loading || !newBranchName.trim()}
type="button"
>
<Icon name="add" size="sm" /> Create
</button>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(false)}
type="button"
>
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
)}
{hasChanges && status && (
<div className="toolbar-row status-summary">
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
</div>
)}
<MergeDialog
projectId={projectId}
repoId={repoId}
branches={branches}
currentBranch={currentBranch}
isOpen={showMergeDialog}
onClose={() => setShowMergeDialog(false)}
onMerge={() => {
void loadStatus();
onRefresh();
}}
/>
</div>
);
};
+165
View File
@@ -0,0 +1,165 @@
import React from "react";
import {
House,
Folder,
GitBranch,
Gear,
User,
SignOut,
Plus,
PencilSimple,
Trash,
FloppyDisk,
X,
ArrowsClockwise,
Copy,
MagnifyingGlass,
List,
Check,
Warning,
Info,
Spinner,
GitCommit,
GitMerge,
ClockCounterClockwise,
ArrowDown,
ArrowUp,
File,
FileText,
Image,
Binary,
Code,
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
} from "@phosphor-icons/react";
export type IconName =
| "dashboard"
| "projects"
| "repositories"
| "settings"
| "profile"
| "logout"
| "add"
| "edit"
| "delete"
| "save"
| "cancel"
| "refresh"
| "copy"
| "search"
| "menu"
| "close"
| "success"
| "error"
| "warning"
| "info"
| "loading"
| "branch"
| "commit"
| "merge"
| "history"
| "pull"
| "push"
| "fetch"
| "file"
| "folder"
| "code"
| "document"
| "image"
| "binary"
| "external"
| "play"
| "stop"
| "terminal"
| "arrow-left";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
projects: Folder,
repositories: GitBranch,
settings: Gear,
profile: User,
logout: SignOut,
add: Plus,
edit: PencilSimple,
delete: Trash,
save: FloppyDisk,
cancel: X,
refresh: ArrowsClockwise,
copy: Copy,
search: MagnifyingGlass,
menu: List,
close: X,
success: Check,
error: X,
warning: Warning,
info: Info,
loading: Spinner,
branch: GitBranch,
commit: GitCommit,
merge: GitMerge,
history: ClockCounterClockwise,
pull: ArrowDown,
push: ArrowUp,
fetch: ArrowsClockwise,
file: File,
folder: Folder,
code: Code,
document: FileText,
image: Image,
binary: Binary,
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
};
export interface IconProps {
name: IconName;
size?: "sm" | "md" | "lg" | "xl";
color?: string;
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
className?: string;
ariaLabel?: string;
}
const sizeMap: Record<NonNullable<IconProps["size"]>, number> = {
sm: 16,
md: 20,
lg: 24,
xl: 32,
};
export const Icon: React.FC<IconProps> = ({
name,
size = "md",
color,
weight = "regular",
className,
ariaLabel,
}) => {
const IconComponent = iconMap[name];
const sizeValue = sizeMap[size];
if (!IconComponent) {
console.warn(`Icon "${name}" not found`);
return null;
}
return (
<span
className={`icon icon-${size}${className ? ` ${className}` : ""}`}
style={{ color }}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
role="img"
>
<IconComponent size={sizeValue} weight={weight} />
</span>
);
};
+359
View File
@@ -0,0 +1,359 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions";
import {
checkInstanceHealth,
createInstance,
deleteInstance,
listInstances,
recreateInstanceTunnel,
restartInstance,
startInstance,
stopInstance,
} from "../api/sessions";
import type { ToolType } from "../api/tool_types";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps {
projectId: string;
repoId: string;
toolTypes: ToolType[];
}
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState<string | null>(null);
// Stop confirmation
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
// Health check state
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
const loadInstances = useCallback(async () => {
setLoading(true);
try {
const data = await listInstances(projectId, repoId);
setInstances(data);
} catch {
setError("Failed to load instances");
} finally {
setLoading(false);
}
}, [projectId, repoId]);
useEffect(() => {
void loadInstances();
}, [loadInstances]);
// Health check polling
useEffect(() => {
const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http"));
if (runningInstances.length === 0) return;
const checkHealth = async () => {
for (const instance of runningInstances) {
try {
const health = await checkInstanceHealth(projectId, repoId, instance.id);
setHealthStatus(prev => ({
...prev,
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() }
}));
} catch {
setHealthStatus(prev => ({
...prev,
[instance.id]: { healthy: false, lastCheck: Date.now() }
}));
}
}
};
// Check immediately
void checkHealth();
// Then every 30 seconds
const interval = setInterval(() => void checkHealth(), 30000);
return () => clearInterval(interval);
}, [instances, projectId, repoId]);
const handleCreate = async () => {
if (!selectedToolType) return;
setError(null);
try {
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
setShowCreate(false);
setSelectedToolType("");
setDisplayName("");
await loadInstances();
} catch {
setError("Failed to create instance");
}
};
const handleStart = async (instanceId: string) => {
try {
await startInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to start instance");
}
};
const handleStop = async (instanceId: string) => {
try {
await stopInstance(projectId, repoId, instanceId);
setStopConfirmId(null);
await loadInstances();
} catch {
setError("Failed to stop instance");
}
};
const handleRestart = async (instanceId: string) => {
try {
await restartInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to restart instance");
}
};
const handleDelete = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return;
try {
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances(prev => prev.filter(i => i.id !== instanceId));
} catch {
setError("Failed to delete instance");
}
};
const handleRecreateTunnel = async (instanceId: string) => {
try {
await recreateInstanceTunnel(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to recreate tunnel");
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "running":
return "var(--success)";
case "error":
return "var(--danger)";
case "pending":
case "building":
return "var(--warning)";
default:
return "var(--muted)";
}
};
const isTunnelUnhealthy = (instance: ToolInstance) => {
if (instance.status !== "running") return false;
if (!instance.url?.startsWith("http")) return false;
const health = healthStatus[instance.id];
if (!health) return false;
return !health.healthy;
};
return (
<div className="instance-list">
<div className="instance-list-header">
<h3>Tool Instances</h3>
<button
className="secondary-button small"
onClick={() => setShowCreate(true)}
type="button"
>
<Icon name="add" size="sm" />
Launch Tool
</button>
</div>
{error && (
<div className="error-message">{error}</div>
)}
{loading ? (
<p className="muted">Loading instances...</p>
) : instances.length === 0 ? (
<p className="muted">No instances yet. Launch a tool to get started.</p>
) : (
<div className="instance-grid">
{instances.map((instance) => (
<div key={instance.id} className="instance-card">
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
<span
className="status-dot"
style={{ backgroundColor: getStatusColor(instance.status) }}
/>
{instance.status}
{isTunnelUnhealthy(instance) && (
<span className="error-badge" title="Tunnel unreachable">
<Icon name="warning" size="sm" />
tunnel error
</span>
)}
</div>
</div>
<div className="instance-actions">
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
<>
<a
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
{isTunnelUnhealthy(instance) && (
<button
className="secondary-button small warning"
onClick={() => void handleRecreateTunnel(instance.id)}
type="button"
title="Recreate tunnel"
>
<Icon name="refresh" size="sm" />
Fix Tunnel
</button>
)}
</>
)}
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
type="button"
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<button
className="secondary-button small"
onClick={() => void handleStart(instance.id)}
type="button"
>
<Icon name="play" size="sm" />
Start
</button>
)}
{instance.status === "running" && (
<>
{stopConfirmId === instance.id ? (
<div className="inline-confirm">
<span>Stop?</span>
<button
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
>
Yes
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
>
No
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
>
<Icon name="stop" size="sm" />
</button>
)}
<button
className="ghost-button small"
onClick={() => void handleRestart(instance.id)}
type="button"
>
<Icon name="refresh" size="sm" />
</button>
</>
)}
<button
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Launch Tool</h2>
<div className="stack">
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select a tool...</option>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
</label>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="dialog-actions">
<button
className="secondary-button"
onClick={() => setShowCreate(false)}
type="button"
>
Cancel
</button>
<button
className="primary-button"
onClick={() => void handleCreate()}
disabled={!selectedToolType}
type="button"
>
Launch
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};

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