Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c94583307 | |||
| 649496b762 | |||
| d13e16f5e1 | |||
| d5f9df33b7 | |||
| 468e0eacda | |||
| 99097090e6 | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 70957e462a | |||
| 6f35eb77ae | |||
| 684a11610a | |||
| 575e5837ea | |||
| 996ea73bbf | |||
| d0e5feeaa5 | |||
| 1f784b552d | |||
| 10cbe63095 | |||
| 4547105f3b | |||
| dacf105200 | |||
| 2525c58471 | |||
| 8dd350286e | |||
| 5af4de0d7e | |||
| ebfe991a15 | |||
| cc1507a33e | |||
| ae377baa74 | |||
| a0d4e76662 | |||
| b61c2256a8 | |||
| 7e6588a99d | |||
| 56b54e269a | |||
| 6e597b9e21 | |||
| a2c3d6877e | |||
| a200955ef2 | |||
| 76266fc3d0 | |||
| 9d54a2542b | |||
| 520078fd64 | |||
| 55c4dc1281 | |||
| 74ac7b00bb | |||
| ecc0acc8dd | |||
| fba01ddfb2 | |||
| 35a251a0b4 | |||
| b10eadf64b | |||
| 50fcf5c077 | |||
| 19db7db8d0 | |||
| 189f29ee41 | |||
| f2c3264be6 | |||
| f07a632c86 | |||
| 0b68efb6e0 | |||
| 54ec87a836 | |||
| 3d64ec9061 | |||
| ac6bd3304d | |||
| 6ec35988cc | |||
| e985f0122e | |||
| 2bd778117f | |||
| 23ae12e69c | |||
| 65d4fad3c5 | |||
| e6f64c39f3 | |||
| e7c42c17b9 | |||
| f7be50952a | |||
| 402e662c0c | |||
| 7450dd0ce5 | |||
| 7a88639250 | |||
| 1d33735e2f | |||
| d11b43b69f | |||
| 294d02f9fb | |||
| a10029f36c | |||
| 6a2ebae8c9 | |||
| 1a922d4171 | |||
| d906c12aa9 | |||
| 4906ce0cd9 | |||
| e7804c0f58 | |||
| b40eb3e88c | |||
| 98795e31dd | |||
| b4a7627718 | |||
| 9d27cd9fe8 | |||
| e3ef852eca | |||
| f824a1e6fe | |||
| ad09ffa6ec | |||
| 63ae706dd0 | |||
| 74b5d0dc8c | |||
| 25662e525c | |||
| d4e992a9e2 | |||
| 35ada0e662 | |||
| c3e2264771 | |||
| 76741d3ee6 | |||
| 62752a8390 | |||
| 461cb30b28 | |||
| 94aa88c154 | |||
| 4f695d7e62 | |||
| 7a48180dc3 | |||
| a0b0944709 | |||
| b79da51269 | |||
| 40a940304b | |||
| e344e961d6 | |||
| d6b3e8b804 | |||
| f997b3f7c5 | |||
| c795f8f873 | |||
| c52367401b | |||
| e9e0e87013 | |||
| 2b5331a1a0 | |||
| 6f41fa7cbe | |||
| cccc4a9d5a | |||
| 72b4a5e2bd | |||
| 8f1d2a149a | |||
| 7261c75bb2 | |||
| 84bf7e4aeb | |||
| 9373d93169 | |||
| 7fc8b82621 | |||
| 2d078c8b1e | |||
| 92c8dfe986 | |||
| 0bc0d99c21 | |||
| 965f6f6585 | |||
| a9f4657d03 | |||
| 875594d66d | |||
| 762e0de44c | |||
| 955dadc604 | |||
| 83f94b1f09 | |||
| 6807f449b7 | |||
| 92d0d5b891 | |||
| 834982d423 | |||
| 9873a8186a | |||
| d4b52668aa | |||
| d0191cd549 | |||
| 0ae0e3fec1 | |||
| 0926e4de83 | |||
| 8b70daed53 | |||
| ac6c97b6ce | |||
| a433c82488 | |||
| f8700fd7ed | |||
| 0fcfc745ff | |||
| d214ab82db | |||
| 753f1506b6 | |||
| 58bf30ed15 | |||
| c067c03662 | |||
| d273535950 | |||
| 7f97ba8e9b | |||
| caf73e39ba | |||
| 1e70462c2b | |||
| b9684b0107 | |||
| 716b8fa631 | |||
| d724a92d34 | |||
| 2ce7862058 | |||
| 285d3dace8 | |||
| 9fefe289a7 | |||
| 843683d579 | |||
| c1a4d2d9af | |||
| ea6c466c6c | |||
| 29c3563148 | |||
| 0509b9eb4a | |||
| 137757602f | |||
| 3cd8674c31 | |||
| 899fba9c9b | |||
| a2c787d474 | |||
| 8ba2b48967 | |||
| ec9b73225c | |||
| 0296ea5630 | |||
| 6dbd55a9ac | |||
| 27f4ecce86 | |||
| 7b72ccdc3c | |||
| 6b302b3279 |
+8
-7
@@ -6,10 +6,9 @@ POSTGRES_DB=headquarter
|
|||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
# JWT Configuration
|
# Session Configuration
|
||||||
JWT_SECRET=change-me-in-production
|
SESSION_SECRET=change-me-in-production
|
||||||
JWT_ALGORITHM=HS256
|
SESSION_TTL_HOURS=24
|
||||||
JWT_EXPIRATION_HOURS=24
|
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
@@ -27,14 +26,16 @@ AUTHENTIK_DOMAIN=authentik.local
|
|||||||
# WEB_PUBLIC_URL=https://app.example.com
|
# WEB_PUBLIC_URL=https://app.example.com
|
||||||
|
|
||||||
# Authentik Configuration
|
# Authentik Configuration
|
||||||
|
# Client ID: The OAuth client ID from Authentik (may be a UUID)
|
||||||
AUTHENTIK_CLIENT_ID=headquarter-web
|
AUTHENTIK_CLIENT_ID=headquarter-web
|
||||||
AUTHENTIK_CLIENT_SECRET=change-me
|
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
|
# Override Authentik URLs if they differ from the default pattern
|
||||||
# AUTHENTIK_AUTHORIZE_URL=https://authentik.example.com/application/o/authorize/
|
# AUTHENTIK_AUTHORIZE_URL=https://authentik.example.com/application/o/authorize/
|
||||||
# AUTHENTIK_TOKEN_URL=https://authentik.example.com/application/o/token/
|
# 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
|
# Frontend Configuration
|
||||||
VITE_API_BASE_URL=http://localhost:8000
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
|
|||||||
@@ -87,6 +87,31 @@ Do not claim completion without verification evidence.
|
|||||||
|
|
||||||
## Git workflow
|
## Git workflow
|
||||||
|
|
||||||
|
### Branching strategy
|
||||||
|
|
||||||
|
For every spec change or new functionality:
|
||||||
|
|
||||||
|
1. Create a new branch from `dev` with a proper prefix:
|
||||||
|
- `feat/` for new features (e.g., `feat/tool-workshop`)
|
||||||
|
- `fix/` for bug fixes (e.g., `fix/terminal-tty`)
|
||||||
|
- `refactor/` for refactors (e.g., `refactor/api-cleanup`)
|
||||||
|
- `docs/` for documentation (e.g., `docs/api-guide`)
|
||||||
|
- `chore/` for maintenance (e.g., `chore/update-deps`)
|
||||||
|
2. Branch name should reference the OpenSpec change name when applicable.
|
||||||
|
3. Do not commit directly to `main` or `dev`.
|
||||||
|
|
||||||
|
### Completion and merge
|
||||||
|
|
||||||
|
When implementation is complete and verified:
|
||||||
|
|
||||||
|
1. Ensure all tests pass and quality gates are met.
|
||||||
|
2. Stage all changes with `git add -A`.
|
||||||
|
3. Create a commit with a proper conventional commit message (see below).
|
||||||
|
4. Switch to `dev`: `git checkout dev`.
|
||||||
|
5. Merge the feature branch: `git merge --no-ff <branch-name>`.
|
||||||
|
6. Push to remote: `git push origin dev`.
|
||||||
|
7. Delete the local feature branch if desired: `git branch -d <branch-name>`.
|
||||||
|
|
||||||
### Auto-commit on spec completion
|
### Auto-commit on spec completion
|
||||||
|
|
||||||
When an OpenSpec change is fully implemented and all tasks are complete:
|
When an OpenSpec change is fully implemented and all tasks are complete:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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/`)
|
## Features
|
||||||
- Fast tests with no external dependencies
|
|
||||||
- Use SQLite in-memory database
|
|
||||||
- Run with: `make test-unit` or `pytest -m unit`
|
|
||||||
|
|
||||||
2. **Integration Tests** (`apps/api/tests/integration/`)
|
### Project Management
|
||||||
- Test API endpoints with database
|
- Create and manage projects
|
||||||
- Use PostgreSQL with transaction rollback
|
- View all projects in a dashboard
|
||||||
- Run with: `make test-integration` or `pytest -m integration`
|
- Click any project to open its workspace
|
||||||
|
|
||||||
3. **System/E2E Tests** (`e2e/`)
|
### Git Repository Management
|
||||||
- End-to-end tests using Playwright
|
- Initialize bare repositories
|
||||||
- Test full user journeys
|
- Clone repositories (including mirror clones)
|
||||||
- Run with: `make test-e2e`
|
- 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
|
### Git History Visualization
|
||||||
# Run all tests (excludes system tests by default)
|
- View commit history with branch graph
|
||||||
make test
|
- See commit details, statistics, and diffs
|
||||||
|
- Filter by branch
|
||||||
|
|
||||||
# Run specific categories
|
### Authentication
|
||||||
make test-unit # Fast unit tests only
|
- OAuth2 via Authentik
|
||||||
make test-integration # Integration tests with DB
|
- Session-based authentication
|
||||||
make test-system # Full stack tests
|
- User profile management
|
||||||
make test-e2e # Browser-based E2E tests
|
|
||||||
|
|
||||||
# Inside Docker container
|
### Tool Management
|
||||||
docker compose exec api pytest -v -m unit
|
- Built-in tool types (code-server, jupyter-notebook)
|
||||||
docker compose exec api pytest -v -m integration
|
- 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:
|
### Backend Development
|
||||||
- `@pytest.mark.unit` - Fast, isolated tests
|
```bash
|
||||||
- `@pytest.mark.integration` - Tests with database/external services
|
cd apps/api
|
||||||
- `@pytest.mark.system` - Full stack tests
|
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`:
|
### Running Tests
|
||||||
- `sqlite_engine` - SQLite engine for unit tests
|
```bash
|
||||||
- `postgres_engine` - PostgreSQL engine for integration tests
|
# Backend tests
|
||||||
- `db_session` - Database session with transaction rollback
|
make test
|
||||||
- `test_client` - FastAPI TestClient instance
|
|
||||||
|
# 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
@@ -16,29 +16,50 @@ RUN pip install --no-cache-dir --user -e ".[dev]"
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user and add to docker group
|
||||||
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
|
RUN groupadd -r appgroup && useradd -r -g appgroup appuser \
|
||||||
|
&& groupadd -r docker || true \
|
||||||
|
&& usermod -aG docker appuser
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies including Docker CLI
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
git \
|
git \
|
||||||
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy dependencies from builder
|
# Copy dependencies from builder
|
||||||
COPY --from=builder /root/.local /home/appuser/.local
|
COPY --from=builder /root/.local /root/.local
|
||||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
ENV PATH=/root/.local/bin:$PATH
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
# Create directories for repo storage
|
# Create directories for repo and instance storage
|
||||||
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
|
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
||||||
|
|
||||||
# Switch to non-root user
|
# Copy wait-for-db script
|
||||||
USER appuser
|
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 port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -47,5 +68,6 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
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
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application (with database wait)
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
ENTRYPOINT ["/usr/local/bin/wait-for-db.sh"]
|
||||||
|
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -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
|
import sqlalchemy as sa
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '0003'
|
revision: str = '0003_user_configs'
|
||||||
down_revision: Union[str, None] = '0002'
|
down_revision: Union[str, None] = '0002_refresh_tokens'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: 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.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
sa.UniqueConstraint('user_id')
|
sa.UniqueConstraint('user_id'),
|
||||||
|
if_not_exists=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""add tool_types table
|
||||||
|
|
||||||
|
Revision ID: 0004_tool_types
|
||||||
|
Revises: 0003_user_configs
|
||||||
|
Create Date: 2026-05-18 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0004_tool_types"
|
||||||
|
down_revision: Union[str, None] = "0003_user_configs"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False, unique=True),
|
||||||
|
sa.Column("display_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("compose_template", sa.Text(), nullable=False),
|
||||||
|
sa.Column("required_variables", sa.JSON(), nullable=False, default=list),
|
||||||
|
sa.Column("is_builtin", sa.Boolean(), nullable=False, default=False),
|
||||||
|
sa.Column("created_by_id", sa.Uuid(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
onupdate=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
if_not_exists=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("tool_types")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""add timestamps to ssh_keys table
|
||||||
|
|
||||||
|
Revision ID: 0005_ssh_keys_timestamps
|
||||||
|
Revises: 0004_tool_types
|
||||||
|
Create Date: 2026-05-19 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0005_ssh_keys_timestamps"
|
||||||
|
down_revision: Union[str, None] = "0004_tool_types"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ssh_keys",
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ssh_keys",
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ssh_keys", "updated_at")
|
||||||
|
op.drop_column("ssh_keys", "created_at")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""add tool_instances table
|
||||||
|
|
||||||
|
Revision ID: 0006_tool_instances
|
||||||
|
Revises: 0005_ssh_keys_timestamps
|
||||||
|
Create Date: 2026-05-19 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0006_tool_instances"
|
||||||
|
down_revision: Union[str, None] = "0005_ssh_keys_timestamps"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("repository_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("status", sa.String(50), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("container_id", sa.String(255), nullable=True),
|
||||||
|
sa.Column("compose_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("url", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("port", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("last_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_stopped_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["repository_id"], ["git_repositories.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_owner", "tool_instances", ["owner_id"])
|
||||||
|
op.create_index("idx_tool_instances_repo", "tool_instances", ["repository_id"])
|
||||||
|
op.create_index("idx_tool_instances_status", "tool_instances", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_tool_instances_status", table_name="tool_instances")
|
||||||
|
op.drop_index("idx_tool_instances_repo", table_name="tool_instances")
|
||||||
|
op.drop_index("idx_tool_instances_owner", table_name="tool_instances")
|
||||||
|
op.drop_table("tool_instances")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add container_name to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0007_instance_container_name
|
||||||
|
Revises: 0006_tool_instances
|
||||||
|
Create Date: 2026-05-20 08:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0007_instance_container_name"
|
||||||
|
down_revision: Union[str, None] = "0006_tool_instances"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "container_name")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add category and interfaces to tool_types
|
||||||
|
|
||||||
|
Revision ID: 0008_tool_type_category
|
||||||
|
Revises: 0007_instance_container_name
|
||||||
|
Create Date: 2026-05-20 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0008_tool_type_category"
|
||||||
|
down_revision: Union[str, None] = "0007_instance_container_name"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column("category", sa.String(50), nullable=False, server_default="other")
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column("interfaces", sa.JSON(), nullable=False, server_default='["web"]')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "interfaces")
|
||||||
|
op.drop_column("tool_types", "category")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""add tool_configs table
|
||||||
|
|
||||||
|
Revision ID: 0009_tool_configs
|
||||||
|
Revises: 0008_tool_type_category
|
||||||
|
Create Date: 2026-05-20 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0009_tool_configs"
|
||||||
|
down_revision: Union[str, None] = "0008_tool_type_category"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tool_configs",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("key", sa.String(255), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("config_type", sa.String(20), nullable=False, server_default="env"),
|
||||||
|
sa.Column("file_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_configs_user_tool", "tool_configs", ["user_id", "tool_type_id"])
|
||||||
|
op.create_index("idx_tool_configs_project", "tool_configs", ["project_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_tool_configs_project", table_name="tool_configs")
|
||||||
|
op.drop_index("idx_tool_configs_user_tool", table_name="tool_configs")
|
||||||
|
op.drop_table("tool_configs")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add default_port to tool_types
|
||||||
|
|
||||||
|
Revision ID: 0010_tool_type_default_port
|
||||||
|
Revises: 0009_tool_configs
|
||||||
|
Create Date: 2026-05-20 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0010_tool_type_default_port"
|
||||||
|
down_revision: Union[str, None] = "0009_tool_configs"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column("default_port", sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "default_port")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add tunnel fields to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0011_tool_instance_tunnel_fields
|
||||||
|
Revises: 0010_tool_type_default_port
|
||||||
|
Create Date: 2026-05-20 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0011_tool_instance_tunnel_fields"
|
||||||
|
down_revision: Union[str, None] = "0010_tool_type_default_port"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("public_url", sa.String(1024), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("tunnel_id", sa.String(255), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "tunnel_id")
|
||||||
|
op.drop_column("tool_instances", "public_url")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""make default_port non-nullable and set values
|
||||||
|
|
||||||
|
Revision ID: 0012_default_port_req
|
||||||
|
Revises: 0011_tool_instance_tunnel_fields
|
||||||
|
Create Date: 2026-05-20 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0012_default_port_req"
|
||||||
|
down_revision: Union[str, None] = "0011_tool_instance_tunnel_fields"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Set default_port for existing built-in tool types
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET default_port = CASE
|
||||||
|
WHEN name = 'code-server' THEN 8443
|
||||||
|
WHEN name = 'jupyter-notebook' THEN 8888
|
||||||
|
WHEN name = 'opencode' THEN 3000
|
||||||
|
ELSE 8080
|
||||||
|
END
|
||||||
|
WHERE default_port IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make default_port non-nullable
|
||||||
|
op.alter_column(
|
||||||
|
"tool_types",
|
||||||
|
"default_port",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"tool_types",
|
||||||
|
"default_port",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""add_tool_config_fields
|
||||||
|
|
||||||
|
Revision ID: 398082499c30
|
||||||
|
Revises: af8512103d67
|
||||||
|
Create Date: 2026-05-22 18:38:20.166184
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '398082499c30'
|
||||||
|
down_revision = 'af8512103d67'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add new columns to tool_configs
|
||||||
|
op.add_column('tool_configs', sa.Column('port_override', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('start_command', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('working_directory', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('environment_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||||
|
op.add_column('tool_configs', sa.Column('volumes', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='[]'))
|
||||||
|
|
||||||
|
# Add CHECK constraint for port range
|
||||||
|
op.create_check_constraint('chk_port_range', 'tool_configs', sa.text('port_override IS NULL OR (port_override >= 1 AND port_override <= 65535)'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_port_range', 'tool_configs', type_='check')
|
||||||
|
|
||||||
|
# Drop columns
|
||||||
|
op.drop_column('tool_configs', 'port_override')
|
||||||
|
op.drop_column('tool_configs', 'start_command')
|
||||||
|
op.drop_column('tool_configs', 'working_directory')
|
||||||
|
op.drop_column('tool_configs', 'environment_variables')
|
||||||
|
op.drop_column('tool_configs', 'volumes')
|
||||||
@@ -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')
|
||||||
@@ -11,7 +11,6 @@ dependencies = [
|
|||||||
"alembic>=1.12.0",
|
"alembic>=1.12.0",
|
||||||
"pydantic>=2.5.0",
|
"pydantic>=2.5.0",
|
||||||
"pydantic-settings>=2.1.0",
|
"pydantic-settings>=2.1.0",
|
||||||
"python-jose[cryptography]>=3.3.0",
|
|
||||||
"python-multipart>=0.0.6",
|
"python-multipart>=0.0.6",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"structlog>=23.2.0",
|
"structlog>=23.2.0",
|
||||||
@@ -28,6 +27,9 @@ dev = [
|
|||||||
"aiosqlite>=0.19.0",
|
"aiosqlite>=0.19.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|||||||
+127
-118
@@ -1,6 +1,6 @@
|
|||||||
|
import logging
|
||||||
from secrets import token_urlsafe
|
from secrets import token_urlsafe
|
||||||
from datetime import UTC, datetime, timedelta
|
from typing import Any, AsyncGenerator, Literal, cast
|
||||||
from typing import AsyncGenerator, Literal, cast
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||||
@@ -9,18 +9,14 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.cookies import build_cookie_options
|
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_user_info
|
||||||
from src.auth.oidc import (
|
from src.auth.session import create_session_cookie, decode_session_cookie
|
||||||
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.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@@ -29,8 +25,21 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get(
|
||||||
async def login() -> RedirectResponse:
|
"/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()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
state = token_urlsafe(24)
|
state = token_urlsafe(24)
|
||||||
@@ -38,10 +47,11 @@ async def login() -> RedirectResponse:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
state=state,
|
state=state,
|
||||||
nonce=token_urlsafe(16),
|
|
||||||
)
|
)
|
||||||
|
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||||
response = RedirectResponse(location)
|
response = RedirectResponse(location)
|
||||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||||
|
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -49,142 +59,141 @@ async def login() -> RedirectResponse:
|
|||||||
async def callback(
|
async def callback(
|
||||||
code: str,
|
code: str,
|
||||||
state: str,
|
state: str,
|
||||||
response: Response,
|
|
||||||
auth_state: str | None = Cookie(default=None),
|
auth_state: str | None = Cookie(default=None),
|
||||||
|
auth_next: str | None = Cookie(default="/"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
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:
|
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")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid state")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
|
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
token_payload = await exchange_code_for_tokens(
|
try:
|
||||||
settings=settings,
|
token_payload = await exchange_code_for_tokens(
|
||||||
code=code,
|
settings=settings,
|
||||||
redirect_uri=redirect_uri,
|
code=code,
|
||||||
client=client,
|
redirect_uri=redirect_uri,
|
||||||
)
|
client=client,
|
||||||
jwks = await fetch_jwks(settings=settings, 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}")
|
||||||
|
|
||||||
provider_claims = verify_provider_access_token(
|
try:
|
||||||
settings=settings,
|
user_info = await fetch_user_info(
|
||||||
token=token_payload["access_token"],
|
settings=settings,
|
||||||
jwks=jwks,
|
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")
|
||||||
|
|
||||||
authentik_id = str(provider_claims["sub"])
|
authentik_id = str(user_info.get("sub", ""))
|
||||||
email = str(provider_claims.get("email", f"{authentik_id}@authentik.local"))
|
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||||
name = str(provider_claims.get("name", email))
|
name = str(user_info.get("name", email))
|
||||||
|
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||||
|
|
||||||
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:
|
try:
|
||||||
rotated_raw_token, rotated_record = await rotate_refresh_token(
|
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||||
session=session,
|
if user is None:
|
||||||
raw_token=refresh_token,
|
logger.info("Creating new user: authentik_id=%s", authentik_id)
|
||||||
user_agent=None,
|
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||||
ip_address=None,
|
session.add(user)
|
||||||
)
|
await session.commit()
|
||||||
except ValueError as error:
|
await session.refresh(user)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(error)) from error
|
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")
|
||||||
|
|
||||||
user = await session.get(User, rotated_record.user_id)
|
# Create session cookie
|
||||||
if user is None:
|
session_cookie = create_session_cookie(settings=settings, user_id=str(user.id))
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
cookie_options = build_cookie_options(settings)
|
cookie_options = build_cookie_options(settings)
|
||||||
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
||||||
cookie_secure = bool(cookie_options["secure"])
|
cookie_secure = bool(cookie_options["secure"])
|
||||||
|
cookie_domain = str(cookie_options["domain"]) if cookie_options.get("domain") else None
|
||||||
|
|
||||||
response.set_cookie("access_token", access_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
logger.info("Auth callback complete for user id=%s, redirecting to %s", user.id, auth_next)
|
||||||
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}
|
# 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")
|
@router.post("/logout")
|
||||||
async def logout(
|
async def logout(response: Response) -> dict[str, str]:
|
||||||
response: Response,
|
|
||||||
refresh_token: str | None = Cookie(default=None),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict[str, str]:
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
cookie_options = build_cookie_options(settings)
|
cookie_options = build_cookie_options(settings)
|
||||||
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
||||||
cookie_secure = bool(cookie_options["secure"])
|
cookie_secure = bool(cookie_options["secure"])
|
||||||
|
cookie_domain = str(cookie_options["domain"]) if cookie_options.get("domain") else None
|
||||||
|
|
||||||
if refresh_token:
|
response.delete_cookie("session", samesite=cookie_samesite, secure=cookie_secure, domain=cookie_domain)
|
||||||
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)
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(access_token: str | None = Cookie(default=None)) -> dict[str, str]:
|
async def me(
|
||||||
if not access_token:
|
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||||
|
|
||||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
if not session_cookie:
|
||||||
|
logger.warning("Auth /me: missing session cookie")
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
logger.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 {
|
return {
|
||||||
"sub": str(claims["sub"]),
|
"user": {
|
||||||
"email": str(claims["email"]),
|
"id": str(user.id),
|
||||||
"name": str(claims["name"]),
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
"avatar_url": user.avatar_url or "",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {},
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -1,16 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
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 pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.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.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -19,25 +16,8 @@ from src.models.user import User
|
|||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
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:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -68,12 +48,28 @@ class SetDefaultSSHKeyRequest(BaseModel):
|
|||||||
ssh_key_id: uuid.UUID
|
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(
|
async def create_project(
|
||||||
data: ProjectCreate,
|
data: ProjectCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Create a new project.
|
||||||
|
|
||||||
|
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)
|
user = await _get_user(session, user_id)
|
||||||
project = Project(
|
project = Project(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -87,21 +83,73 @@ async def create_project(
|
|||||||
return 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(
|
async def list_projects(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[Project]:
|
) -> list[Project]:
|
||||||
|
"""List all projects for the authenticated user.
|
||||||
|
|
||||||
|
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)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
async def _get_owned_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Project:
|
) -> 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)
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
@@ -110,13 +158,29 @@ async def _get_owned_project(
|
|||||||
return 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(
|
async def update_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: ProjectUpdate,
|
data: ProjectUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> 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)
|
await _get_user(session, user_id)
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -130,12 +194,27 @@ async def update_project(
|
|||||||
return 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(
|
async def delete_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> 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)
|
await _get_user(session, user_id)
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
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)
|
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(
|
async def set_default_ssh_key(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: SetDefaultSSHKeyRequest,
|
data: SetDefaultSSHKeyRequest,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,24 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.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.config import Settings
|
||||||
from src.database import SessionLocal
|
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
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:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
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:
|
def _get_fernet() -> Fernet:
|
||||||
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
key = settings.jwt_secret[:32].ljust(32, "=")
|
# Derive a 32-byte key from the session secret using SHA256
|
||||||
return Fernet(key.encode())
|
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]:
|
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()
|
private_key = Ed25519PrivateKey.generate()
|
||||||
public_key = private_key.public_key()
|
public_key = private_key.public_key()
|
||||||
|
|
||||||
@@ -81,12 +74,28 @@ class SSHKeyResponse(BaseModel):
|
|||||||
created_at: datetime
|
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(
|
async def create_ssh_key(
|
||||||
data: SSHKeyCreate,
|
data: SSHKeyCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> SSHKey:
|
) -> SSHKey:
|
||||||
|
"""Create a new SSH key pair.
|
||||||
|
|
||||||
|
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)
|
user = await _get_user(session, user_id)
|
||||||
private_key, public_key = generate_ssh_key_pair()
|
private_key, public_key = generate_ssh_key_pair()
|
||||||
|
|
||||||
@@ -105,22 +114,51 @@ async def create_ssh_key(
|
|||||||
return 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(
|
async def list_ssh_keys(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SSHKey]:
|
) -> list[SSHKey]:
|
||||||
|
"""List all SSH keys for the authenticated user.
|
||||||
|
|
||||||
|
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)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@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(
|
async def delete_ssh_key(
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Delete an SSH key.
|
||||||
|
|
||||||
|
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)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -0,0 +1,616 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.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_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _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
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str | None = None
|
||||||
|
default_port: int
|
||||||
|
definition_type: str = "compose"
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
|
required_variables: list[str] = []
|
||||||
|
category: str = "other"
|
||||||
|
interfaces: list[str] = ["web"]
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str) -> str:
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("compose_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(v)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Compose template must be a YAML mapping")
|
||||||
|
|
||||||
|
if "services" not in parsed:
|
||||||
|
raise ValueError("Compose template must contain 'services' key")
|
||||||
|
|
||||||
|
if not parsed["services"]:
|
||||||
|
raise ValueError("Compose template must define at least one service")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("default_port")
|
||||||
|
@classmethod
|
||||||
|
def validate_default_port(cls, v: int, info) -> int:
|
||||||
|
if v <= 0 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
template = data.get("compose_template")
|
||||||
|
if not template:
|
||||||
|
return v
|
||||||
|
|
||||||
|
for var in v:
|
||||||
|
placeholder = f"{{{{{var}}}}}"
|
||||||
|
if placeholder not in template:
|
||||||
|
raise ValueError(f"Required variable '{var}' not found in compose template")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_templates(self) -> "ToolTypeCreate":
|
||||||
|
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
if self.definition_type == "compose" and self.compose_template is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeUpdate(BaseModel):
|
||||||
|
display_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
default_port: int | None = None
|
||||||
|
definition_type: str | None = None
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
|
required_variables: list[str] | None = None
|
||||||
|
category: str | None = None
|
||||||
|
interfaces: list[str] | None = None
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("compose_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
data = info.data
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(v)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Compose template must be a YAML mapping")
|
||||||
|
|
||||||
|
if "services" not in parsed:
|
||||||
|
raise ValueError("Compose template must contain 'services' key")
|
||||||
|
|
||||||
|
if not parsed["services"]:
|
||||||
|
raise ValueError("Compose template must define at least one service")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
data = info.data
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str | None
|
||||||
|
category: str
|
||||||
|
interfaces: list[str]
|
||||||
|
default_port: int
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None
|
||||||
|
dockerfile_template: str | None
|
||||||
|
build_context: dict | None
|
||||||
|
readiness_probe: dict | None
|
||||||
|
required_variables: list[str]
|
||||||
|
is_builtin: bool
|
||||||
|
created_by_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.scalar(select(ToolType).where(ToolType.name == data.name))
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="tool type with this name already exists")
|
||||||
|
|
||||||
|
tool_type = ToolType(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
session.add(tool_type)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(tool_type)
|
||||||
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
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:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
if tool_type.is_builtin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Validate port if being updated
|
||||||
|
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)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(tool_type)
|
||||||
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeValidateRequest(BaseModel):
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/validate",
|
||||||
|
summary="Validate tool type template",
|
||||||
|
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)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
if tool_type.is_builtin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
|
||||||
|
|
||||||
|
await session.delete(tool_type)
|
||||||
|
await session.commit()
|
||||||
@@ -1,40 +1,22 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
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 pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
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.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
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:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
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:
|
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))
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
@@ -59,6 +50,7 @@ class UserConfigResponse(BaseModel):
|
|||||||
theme: str = "system"
|
theme: str = "system"
|
||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
|
last_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserConfigUpdate(BaseModel):
|
class UserConfigUpdate(BaseModel):
|
||||||
@@ -66,31 +58,64 @@ class UserConfigUpdate(BaseModel):
|
|||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: 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(
|
async def get_user_config(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
|
"""Get the current user's configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's configuration settings.
|
||||||
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
_user = await _get_user(session, user_id)
|
||||||
config = await _get_or_create_config(session, user_id)
|
config = await _get_or_create_config(session, user_id)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
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(
|
async def update_user_config(
|
||||||
data: UserConfigUpdate,
|
data: UserConfigUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
|
"""Update the current user's configuration.
|
||||||
|
|
||||||
|
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)
|
_user = await _get_user(session, user_id)
|
||||||
config = await _get_or_create_config(session, user_id)
|
config = await _get_or_create_config(session, user_id)
|
||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True, exclude_none=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
config.config.update(update_data)
|
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
||||||
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
|
logger.info("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|||||||
+50
-26
@@ -1,14 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
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 pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
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
|
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:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -58,20 +38,49 @@ class UserProfileUpdate(BaseModel):
|
|||||||
email: str | None = None
|
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(
|
async def get_profile(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""Get the current user's profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's profile information.
|
||||||
|
"""
|
||||||
return await _get_user(session, user_id)
|
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(
|
async def update_profile(
|
||||||
data: UserProfileUpdate,
|
data: UserProfileUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""Update the current user's profile.
|
||||||
|
|
||||||
|
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)
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
@@ -89,12 +98,27 @@ async def update_profile(
|
|||||||
return user
|
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(
|
async def upload_avatar(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""Upload a profile avatar image.
|
||||||
|
|
||||||
|
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)
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
from src.auth.cookies import build_cookie_options
|
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.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__ = [
|
__all__ = [
|
||||||
"build_cookie_options",
|
"build_cookie_options",
|
||||||
"build_login_redirect_url",
|
"build_login_redirect_url",
|
||||||
"decode_access_token",
|
"create_session_cookie",
|
||||||
"hash_refresh_token",
|
"decode_session_cookie",
|
||||||
"mint_access_token",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from src.config import Settings
|
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 {
|
return {
|
||||||
"httponly": True,
|
"httponly": True,
|
||||||
"secure": settings.cookie_secure,
|
"secure": settings.cookie_secure,
|
||||||
"samesite": settings.cookie_samesite,
|
"samesite": settings.cookie_samesite,
|
||||||
|
"domain": settings.cookie_domain,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from jose import jwt # type: ignore[import-untyped]
|
|
||||||
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ def build_login_redirect_url(
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
redirect_uri: str,
|
redirect_uri: str,
|
||||||
state: str,
|
state: str,
|
||||||
nonce: str,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
query = urlencode(
|
query = urlencode(
|
||||||
{
|
{
|
||||||
@@ -20,7 +19,6 @@ def build_login_redirect_url(
|
|||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": redirect_uri,
|
||||||
"scope": "openid profile email",
|
"scope": "openid profile email",
|
||||||
"state": state,
|
"state": state,
|
||||||
"nonce": nonce,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return f"{settings.resolved_authentik_authorize_url}?{query}"
|
return f"{settings.resolved_authentik_authorize_url}?{query}"
|
||||||
@@ -47,31 +45,20 @@ async def exchange_code_for_tokens(
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
return {
|
return {
|
||||||
"access_token": payload["access_token"],
|
"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]]]:
|
async def fetch_user_info(
|
||||||
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(
|
|
||||||
*,
|
*,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
token: str,
|
access_token: str,
|
||||||
jwks: dict[str, list[dict[str, str]]],
|
client: httpx.AsyncClient,
|
||||||
) -> dict[str, str | int]:
|
) -> dict[str, Any]:
|
||||||
unverified_header = jwt.get_unverified_header(token)
|
"""Fetch user info from Authentik userinfo endpoint."""
|
||||||
key_id = unverified_header["kid"]
|
response = await client.get(
|
||||||
jwk_key = next(key for key in jwks["keys"] if key.get("kid") == key_id)
|
f"{settings.authentik_base_url}/application/o/userinfo/",
|
||||||
claims = jwt.decode(
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
token,
|
|
||||||
jwk_key,
|
|
||||||
algorithms=[jwk_key.get("alg", "HS256")],
|
|
||||||
audience=settings.authentik_audience,
|
|
||||||
issuer=settings.resolved_authentik_issuer,
|
|
||||||
)
|
)
|
||||||
return dict(claims)
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -34,20 +34,27 @@ class Settings(BaseSettings):
|
|||||||
# Authentik configuration - no hardcoded URLs
|
# Authentik configuration - no hardcoded URLs
|
||||||
authentik_client_id: str = "headquarter-web"
|
authentik_client_id: str = "headquarter-web"
|
||||||
authentik_client_secret: str = "change-me"
|
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_authorize_url: str | None = None
|
||||||
authentik_token_url: str | None = None
|
authentik_token_url: str | None = None
|
||||||
authentik_jwks_url: str | None = None
|
authentik_jwks_url: str | None = None
|
||||||
authentik_issuer: str | None = None
|
authentik_issuer: str | None = None
|
||||||
authentik_audience: str = "headquarter-web"
|
authentik_audience: str = "headquarter-web"
|
||||||
|
|
||||||
jwt_secret: str = "change-me-jwt-secret"
|
# Session configuration
|
||||||
jwt_algorithm: str = "HS256"
|
session_secret: str = "change-me-session-secret"
|
||||||
access_token_ttl_minutes: int = 15
|
session_ttl_hours: int = 24
|
||||||
refresh_token_ttl_days: int = 7
|
|
||||||
|
|
||||||
# Repository storage
|
# Repository storage
|
||||||
repo_base_path: str = "/data/repos"
|
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)
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -100,13 +107,13 @@ class Settings(BaseSettings):
|
|||||||
def resolved_authentik_jwks_url(self) -> str:
|
def resolved_authentik_jwks_url(self) -> str:
|
||||||
if self.authentik_jwks_url:
|
if self.authentik_jwks_url:
|
||||||
return 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
|
@property
|
||||||
def resolved_authentik_issuer(self) -> str:
|
def resolved_authentik_issuer(self) -> str:
|
||||||
if self.authentik_issuer:
|
if self.authentik_issuer:
|
||||||
return 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
|
@property
|
||||||
def cookie_secure(self) -> bool:
|
def cookie_secure(self) -> bool:
|
||||||
@@ -115,6 +122,22 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def cookie_samesite(self) -> str:
|
def cookie_samesite(self) -> str:
|
||||||
if self.app_env == "production":
|
if self.app_env == "production":
|
||||||
return "strict"
|
return "none"
|
||||||
|
|
||||||
return "lax"
|
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
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
database_url = settings.database_url
|
database_url = settings.database_url
|
||||||
@@ -20,4 +25,92 @@ engine = create_async_engine(
|
|||||||
)
|
)
|
||||||
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
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"]
|
||||||
|
|||||||
@@ -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))
|
||||||
+268
-1
@@ -1,18 +1,285 @@
|
|||||||
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 fastapi.staticfiles import StaticFiles
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
|
from src.api.dashboard import router as dashboard_router
|
||||||
from src.api.git_repositories import router as git_repositories_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.projects import router as projects_router
|
||||||
from src.api.ssh_keys import router as ssh_keys_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.user_config import router as user_config_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
|
from src.config import Settings
|
||||||
|
from src.database import SessionLocal, init_database
|
||||||
|
from src.logging_config import (
|
||||||
|
ExceptionLoggingMiddleware,
|
||||||
|
RequestLoggingMiddleware,
|
||||||
|
configure_logging,
|
||||||
|
)
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
|
||||||
|
# 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")
|
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:
|
||||||
|
image: lscr.io/linuxserver/code-server:latest
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
- TZ=Europe/London
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/config/workspace
|
||||||
|
ports:
|
||||||
|
- "8443:8443"
|
||||||
|
restart: unless-stopped""",
|
||||||
|
"default_port": 8443,
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "jupyter-notebook",
|
||||||
|
"display_name": "Jupyter Notebook",
|
||||||
|
"description": "Jupyter Lab for interactive development",
|
||||||
|
"category": "notebook",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"default_port": 8888,
|
||||||
|
"compose_template": """version: "3.8"
|
||||||
|
services:
|
||||||
|
jupyter:
|
||||||
|
image: jupyter/scipy-notebook:latest
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
environment:
|
||||||
|
- JUPYTER_ENABLE_LAB=yes
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/home/jovyan/work
|
||||||
|
ports:
|
||||||
|
- "8888:8888"
|
||||||
|
restart: unless-stopped""",
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "opencode",
|
||||||
|
"display_name": "OpenCode",
|
||||||
|
"description": "AI coding assistant - run opencode in terminal",
|
||||||
|
"category": "ai-assistant",
|
||||||
|
"interfaces": ["terminal"],
|
||||||
|
"default_port": 3000,
|
||||||
|
"compose_template": """version: "3.8"
|
||||||
|
services:
|
||||||
|
opencode:
|
||||||
|
image: node:20-slim
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
working_dir: /workspace
|
||||||
|
environment:
|
||||||
|
- HOME=/tmp
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/workspace
|
||||||
|
- opencode_home:/tmp
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
command: >
|
||||||
|
sh -c "set -x &&
|
||||||
|
apt-get update && apt-get install -y git ca-certificates &&
|
||||||
|
echo 'Installing opencode...' &&
|
||||||
|
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
||||||
|
which opencode || echo 'ERROR: opencode not in PATH' &&
|
||||||
|
npm bin -g &&
|
||||||
|
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
||||||
|
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
|
||||||
|
echo 'cd /workspace' >> /root/.bashrc &&
|
||||||
|
echo 'OpenCode installation complete' &&
|
||||||
|
cd /workspace &&
|
||||||
|
exec tail -f /dev/null"
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
opencode_home:""",
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for tool_data in builtin_types:
|
||||||
|
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
||||||
|
if not existing:
|
||||||
|
tool_type = ToolType(
|
||||||
|
name=tool_data["name"],
|
||||||
|
display_name=tool_data["display_name"],
|
||||||
|
description=tool_data["description"],
|
||||||
|
category=tool_data["category"],
|
||||||
|
interfaces=tool_data["interfaces"],
|
||||||
|
definition_type="compose",
|
||||||
|
compose_template=tool_data["compose_template"],
|
||||||
|
required_variables=tool_data["required_variables"],
|
||||||
|
default_port=tool_data.get("default_port"),
|
||||||
|
is_builtin=True,
|
||||||
|
)
|
||||||
|
session.add(tool_type)
|
||||||
|
logger.info("Created built-in tool type: %s", tool_data["name"])
|
||||||
|
else:
|
||||||
|
# Update existing built-in tool types to reflect code changes
|
||||||
|
existing.display_name = tool_data["display_name"]
|
||||||
|
existing.description = tool_data["description"]
|
||||||
|
existing.category = tool_data["category"]
|
||||||
|
existing.interfaces = tool_data["interfaces"]
|
||||||
|
existing.definition_type = "compose"
|
||||||
|
existing.compose_template = tool_data["compose_template"]
|
||||||
|
existing.required_variables = tool_data["required_variables"]
|
||||||
|
existing.default_port = tool_data.get("default_port")
|
||||||
|
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Built-in tool types seeded successfully.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def on_startup():
|
||||||
|
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(auth_router)
|
||||||
|
app.include_router(dashboard_router)
|
||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(ssh_keys_router)
|
app.include_router(ssh_keys_router)
|
||||||
app.include_router(git_repositories_router)
|
app.include_router(git_repositories_router)
|
||||||
app.include_router(user_config_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")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
|
from src.models.config_folder import ConfigFolder
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.refresh_token import RefreshToken
|
|
||||||
from src.models.ssh_key import SSHKey
|
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 import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = ["Base", "GitRepository", "Project", "RefreshToken", "SSHKey", "User", "UserConfig"]
|
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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")
|
|
||||||
@@ -5,14 +5,14 @@ from sqlalchemy import ForeignKey, String, Text
|
|||||||
from sqlalchemy import Uuid as UUID
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.models.base import Base, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
class SSHKey(UUIDPrimaryKeyMixin, Base):
|
class SSHKey(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
__tablename__ = "ssh_keys"
|
__tablename__ = "ssh_keys"
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "tool_types"
|
||||||
|
|
||||||
|
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)
|
||||||
|
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(
|
||||||
|
UUID(),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
created_by: Mapped["User | None"] = relationship()
|
||||||
@@ -7,7 +7,6 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.refresh_token import RefreshToken
|
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user_config import UserConfig
|
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)
|
avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
|
||||||
projects: Mapped[list["Project"]] = relationship(back_populates="owner")
|
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")
|
ssh_keys: Mapped[list["SSHKey"]] = relationship(back_populates="user")
|
||||||
user_config: Mapped["UserConfig | None"] = relationship(back_populates="user", uselist=False)
|
user_config: Mapped["UserConfig | None"] = relationship(back_populates="user", uselist=False)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from typing import AsyncGenerator, Generator
|
from typing import AsyncGenerator, Generator
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
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.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
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.config import Settings, build_database_url
|
||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.main import app
|
from src.main import app
|
||||||
|
from src.auth.dependencies import get_db_session
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_client() -> Generator[TestClient, None, None]:
|
def test_client() -> Generator[TestClient, None, None]:
|
||||||
"""Provide a FastAPI test client."""
|
"""Provide a FastAPI test client with SQLite database."""
|
||||||
with TestClient(app) as client:
|
# Create a single engine for this test
|
||||||
yield client
|
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)
|
@pytest.fixture
|
||||||
def configure_test_env(monkeypatch):
|
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Configure environment for testing."""
|
"""Provide an authenticated test client with a test user."""
|
||||||
monkeypatch.setenv("DATABASE_URL", TEST_DATABASE_URL)
|
import uuid
|
||||||
monkeypatch.setenv("APP_ENV", "testing")
|
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
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
@@ -8,7 +7,7 @@ import pytest
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
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.config import Settings, build_database_url
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -27,7 +26,7 @@ def _prepare_auth_test_db() -> None:
|
|||||||
)
|
)
|
||||||
async with engine.begin() as connection:
|
async with engine.begin() as connection:
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
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()
|
await engine.dispose()
|
||||||
|
|
||||||
asyncio.run(_run())
|
asyncio.run(_run())
|
||||||
@@ -44,7 +43,7 @@ def _load_app():
|
|||||||
return main_module.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:
|
async def _run() -> None:
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
build_database_url(
|
build_database_url(
|
||||||
@@ -63,9 +62,9 @@ def _insert_user_for_refresh(user_id: str) -> None:
|
|||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
user = User(
|
user = User(
|
||||||
id=uuid.UUID(user_id),
|
id=uuid.UUID(user_id),
|
||||||
email="refresh@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Refresh User",
|
name="Test User",
|
||||||
authentik_id="refresh-user",
|
authentik_id="test-user",
|
||||||
avatar_url=None,
|
avatar_url=None,
|
||||||
)
|
)
|
||||||
await session.merge(user)
|
await session.merge(user)
|
||||||
@@ -88,7 +87,7 @@ def test_login_redirects_to_authentik_authorize_endpoint() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@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()
|
_prepare_auth_test_db()
|
||||||
app = _load_app()
|
app = _load_app()
|
||||||
|
|
||||||
@@ -99,116 +98,33 @@ def test_me_returns_401_without_access_cookie() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@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()
|
_prepare_auth_test_db()
|
||||||
|
_insert_test_user(user_id)
|
||||||
app = _load_app()
|
app = _load_app()
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
token = mint_access_token(
|
session_cookie = create_session_cookie(settings=settings, user_id=user_id)
|
||||||
settings=settings,
|
|
||||||
subject=str(uuid.uuid4()),
|
|
||||||
email="dev@headquarter.local",
|
|
||||||
name="Dev User",
|
|
||||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
|
||||||
)
|
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
client.cookies.set("access_token", token)
|
response = client.get("/auth/me", cookies={"session": session_cookie})
|
||||||
response = client.get("/auth/me")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
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
|
@pytest.mark.integration
|
||||||
def test_logout_clears_auth_cookies() -> None:
|
def test_logout_clears_session_cookie() -> None:
|
||||||
_prepare_auth_test_db()
|
_prepare_auth_test_db()
|
||||||
app = _load_app()
|
app = _load_app()
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
client.cookies.set("refresh_token", "opaque-token")
|
|
||||||
response = client.post("/auth/logout")
|
response = client.post("/auth/logout")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "access_token=" in response.headers.get("set-cookie", "")
|
# Check that session cookie is deleted
|
||||||
|
set_cookie = response.headers.get("set-cookie", "")
|
||||||
|
assert "session=" in set_cookie or "session=\"\"" in 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
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
import base64
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.auth.cookies import build_cookie_options
|
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.oidc import build_login_redirect_url, exchange_code_for_tokens, verify_provider_access_token
|
from src.auth.session import create_session_cookie, decode_session_cookie
|
||||||
from src.auth.refresh_store import create_refresh_token, hash_refresh_token, revoke_refresh_token, rotate_refresh_token
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -38,153 +31,44 @@ def test_login_redirect_url_contains_required_oidc_params() -> None:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
redirect_uri="http://localhost:8000/auth/callback",
|
redirect_uri="http://localhost:8000/auth/callback",
|
||||||
state="state-123",
|
state="state-123",
|
||||||
nonce="nonce-123",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "response_type=code" in url
|
assert "response_type=code" in url
|
||||||
assert "client_id=headquarter-web" in url
|
assert "client_id=headquarter-web" in url
|
||||||
assert "scope=openid+profile+email" in url
|
assert "scope=openid+profile+email" in url
|
||||||
assert "state=state-123" in url
|
assert "state=state-123" in url
|
||||||
assert "nonce=nonce-123" in url
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@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()
|
settings = Settings()
|
||||||
expires_at = datetime.now(UTC) + timedelta(minutes=15)
|
user_id = "test-user-123"
|
||||||
|
|
||||||
token = mint_access_token(
|
cookie = create_session_cookie(settings=settings, user_id=user_id)
|
||||||
settings=settings,
|
payload = decode_session_cookie(settings=settings, cookie_value=cookie)
|
||||||
subject="user-123",
|
|
||||||
email="dev@headquarter.local",
|
|
||||||
name="Dev User",
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
claims = decode_access_token(settings=settings, token=token)
|
assert payload["user_id"] == user_id
|
||||||
|
assert "exp" in payload
|
||||||
assert claims["sub"] == "user-123"
|
|
||||||
assert claims["email"] == "dev@headquarter.local"
|
|
||||||
assert claims["name"] == "Dev User"
|
|
||||||
assert "exp" in claims
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
def test_refresh_token_hash_is_deterministic_and_non_reversible() -> None:
|
def test_decode_session_rejects_invalid_signature() -> 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:
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
other_settings = Settings(jwt_secret="different-secret")
|
other_settings = Settings(session_secret="different-secret")
|
||||||
expires_at = datetime.now(UTC) + timedelta(minutes=15)
|
user_id = "test-user-123"
|
||||||
|
|
||||||
token = mint_access_token(
|
cookie = create_session_cookie(settings=other_settings, user_id=user_id)
|
||||||
settings=other_settings,
|
|
||||||
subject="user-123",
|
|
||||||
email="dev@headquarter.local",
|
|
||||||
name="Dev User",
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(ValueError, match="invalid session signature"):
|
||||||
decode_access_token(settings=settings, token=token)
|
decode_session_cookie(settings=settings, cookie_value=cookie)
|
||||||
|
|
||||||
|
|
||||||
@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"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
def test_verify_provider_access_token_with_jwks_oct_key() -> None:
|
def test_decode_session_rejects_expired_cookie(monkeypatch) -> None:
|
||||||
settings = Settings(authentik_audience="headquarter-web", authentik_issuer="https://authentik.local/")
|
settings = Settings(session_ttl_hours=-1) # Already expired
|
||||||
shared_secret = b"shared-secret-123"
|
user_id = "test-user-123"
|
||||||
jwks = {
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"kty": "oct",
|
|
||||||
"alg": "HS256",
|
|
||||||
"k": base64.urlsafe_b64encode(shared_secret).decode("utf-8").rstrip("="),
|
|
||||||
"kid": "kid-1",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
from jose import jwt # type: ignore[import-untyped]
|
cookie = create_session_cookie(settings=settings, user_id=user_id)
|
||||||
|
|
||||||
token = jwt.encode(
|
with pytest.raises(ValueError, match="session expired"):
|
||||||
{
|
decode_session_cookie(settings=settings, cookie_value=cookie)
|
||||||
"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
|
|
||||||
|
|||||||
@@ -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.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.refresh_token import RefreshToken
|
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
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.config import Settings, build_database_url
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
@@ -53,7 +53,7 @@ def _load_app():
|
|||||||
|
|
||||||
def _mint_token(user_id: str) -> str:
|
def _mint_token(user_id: str) -> str:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
return mint_access_token(
|
return create_session_cookie(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
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
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
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
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_test_db() -> None:
|
||||||
|
async def _run() -> None:
|
||||||
|
engine = create_async_engine(
|
||||||
|
build_database_url(
|
||||||
|
user="headquarter",
|
||||||
|
password="headquarter",
|
||||||
|
host="localhost",
|
||||||
|
port=5432,
|
||||||
|
database="headquarter",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
await connection.execute(text("TRUNCATE TABLE tool_types, git_repositories, ssh_keys, projects, users RESTART IDENTITY CASCADE"))
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def _load_app():
|
||||||
|
import importlib
|
||||||
|
import src.database as database_module
|
||||||
|
import src.api.auth as auth_module
|
||||||
|
import src.api.tool_types as tool_types_module
|
||||||
|
import src.main as main_module
|
||||||
|
|
||||||
|
# Dispose old engine connections before reload to prevent pool exhaustion
|
||||||
|
if hasattr(database_module, 'engine'):
|
||||||
|
import asyncio
|
||||||
|
asyncio.run(database_module.engine.dispose())
|
||||||
|
|
||||||
|
importlib.reload(database_module)
|
||||||
|
importlib.reload(auth_module)
|
||||||
|
importlib.reload(tool_types_module)
|
||||||
|
importlib.reload(main_module)
|
||||||
|
return main_module.app
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_token(user_id: str) -> str:
|
||||||
|
settings = Settings()
|
||||||
|
return create_session_cookie(
|
||||||
|
settings=settings,
|
||||||
|
subject=user_id,
|
||||||
|
email="test@headquarter.local",
|
||||||
|
name="Test User",
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_user(user_id: str, email: str = "test@headquarter.local") -> None:
|
||||||
|
async def _run() -> None:
|
||||||
|
engine = create_async_engine(
|
||||||
|
build_database_url(
|
||||||
|
user="headquarter",
|
||||||
|
password="headquarter",
|
||||||
|
host="localhost",
|
||||||
|
port=5432,
|
||||||
|
database="headquarter",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
user = User(
|
||||||
|
id=uuid.UUID(user_id),
|
||||||
|
email=email,
|
||||||
|
name="Test User",
|
||||||
|
authentik_id=f"authentik-{user_id}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
await session.merge(user)
|
||||||
|
await session.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_tool_type(
|
||||||
|
tool_type_id: str,
|
||||||
|
name: str,
|
||||||
|
display_name: str,
|
||||||
|
compose_template: str,
|
||||||
|
is_builtin: bool = False,
|
||||||
|
created_by_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
async def _run() -> None:
|
||||||
|
engine = create_async_engine(
|
||||||
|
build_database_url(
|
||||||
|
user="headquarter",
|
||||||
|
password="headquarter",
|
||||||
|
host="localhost",
|
||||||
|
port=5432,
|
||||||
|
database="headquarter",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=uuid.UUID(tool_type_id),
|
||||||
|
name=name,
|
||||||
|
display_name=display_name,
|
||||||
|
description="A test tool type",
|
||||||
|
compose_template=compose_template,
|
||||||
|
required_variables=["REPO_PATH", "TOOL_NAME"],
|
||||||
|
is_builtin=is_builtin,
|
||||||
|
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
|
||||||
|
)
|
||||||
|
await session.merge(tool_type)
|
||||||
|
await session.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_tool_types_requires_authentication() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/tool-types")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_tool_types_returns_all_types() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
_insert_tool_type(
|
||||||
|
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
"custom-tool",
|
||||||
|
"Custom Tool",
|
||||||
|
"version: '3.8'\nservices:\n app:\n image: custom",
|
||||||
|
created_by_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
response = client.get("/tool-types")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
custom_tool = next((t for t in data if t["name"] == "custom-tool"), None)
|
||||||
|
assert custom_tool is not None
|
||||||
|
assert custom_tool["display_name"] == "Custom Tool"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_get_tool_type_by_id() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||||
|
_insert_user(user_id)
|
||||||
|
_insert_tool_type(
|
||||||
|
tool_type_id,
|
||||||
|
"custom-tool",
|
||||||
|
"Custom Tool",
|
||||||
|
"version: '3.8'\nservices:\n app:\n image: custom",
|
||||||
|
created_by_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
response = client.get(f"/tool-types/{tool_type_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == tool_type_id
|
||||||
|
assert data["name"] == "custom-tool"
|
||||||
|
assert data["display_name"] == "Custom Tool"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_get_tool_type_not_found() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
response = client.get("/tool-types/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_create_tool_type_successfully() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "my-custom-tool",
|
||||||
|
"display_name": "My Custom Tool",
|
||||||
|
"description": "A custom development tool",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: custom:latest",
|
||||||
|
"required_variables": ["REPO_PATH"],
|
||||||
|
}
|
||||||
|
response = client.post("/tool-types", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "my-custom-tool"
|
||||||
|
assert data["display_name"] == "My Custom Tool"
|
||||||
|
assert data["description"] == "A custom development tool"
|
||||||
|
assert data["is_builtin"] == False
|
||||||
|
assert data["created_by_id"] == user_id
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_create_tool_type_duplicate_name() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
_insert_tool_type(
|
||||||
|
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
"existing-tool",
|
||||||
|
"Existing Tool",
|
||||||
|
"version: '3.8'\nservices:\n app:\n image: existing",
|
||||||
|
created_by_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "existing-tool",
|
||||||
|
"display_name": "Existing Tool",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: custom",
|
||||||
|
"required_variables": [],
|
||||||
|
}
|
||||||
|
response = client.post("/tool-types", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_create_tool_type_invalid_yaml() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "bad-tool",
|
||||||
|
"display_name": "Bad Tool",
|
||||||
|
"compose_template": "this is not: valid: yaml: [",
|
||||||
|
"required_variables": [],
|
||||||
|
}
|
||||||
|
response = client.post("/tool-types", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_create_tool_type_missing_services() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "bad-tool",
|
||||||
|
"display_name": "Bad Tool",
|
||||||
|
"compose_template": "version: '3.8'\ninvalid_key: value",
|
||||||
|
"required_variables": [],
|
||||||
|
}
|
||||||
|
response = client.post("/tool-types", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_create_tool_type_missing_required_variable() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "bad-tool",
|
||||||
|
"display_name": "Bad Tool",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: custom",
|
||||||
|
"required_variables": ["MISSING_VAR"],
|
||||||
|
}
|
||||||
|
response = client.post("/tool-types", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_update_tool_type_successfully() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||||
|
_insert_user(user_id)
|
||||||
|
_insert_tool_type(
|
||||||
|
tool_type_id,
|
||||||
|
"custom-tool",
|
||||||
|
"Custom Tool",
|
||||||
|
"version: '3.8'\nservices:\n app:\n image: custom",
|
||||||
|
created_by_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"display_name": "Updated Custom Tool",
|
||||||
|
"description": "Updated description",
|
||||||
|
}
|
||||||
|
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["display_name"] == "Updated Custom Tool"
|
||||||
|
assert data["description"] == "Updated description"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_update_tool_type_not_found() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
payload = {"display_name": "Updated"}
|
||||||
|
response = client.put("/tool-types/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_update_builtin_tool_type_fails() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||||
|
_insert_user(user_id)
|
||||||
|
_insert_tool_type(
|
||||||
|
tool_type_id,
|
||||||
|
"builtin-tool",
|
||||||
|
"Built-in Tool",
|
||||||
|
"version: '3.8'\nservices:\n app:\n image: builtin",
|
||||||
|
is_builtin=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
payload = {"display_name": "Updated"}
|
||||||
|
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_delete_tool_type_successfully() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||||
|
_insert_user(user_id)
|
||||||
|
_insert_tool_type(
|
||||||
|
tool_type_id,
|
||||||
|
"deletable-tool",
|
||||||
|
"Deletable Tool",
|
||||||
|
"version: '3.8'\nservices:\n app:\n image: custom",
|
||||||
|
created_by_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
response = client.delete(f"/tool-types/{tool_type_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
get_response = client.get(f"/tool-types/{tool_type_id}")
|
||||||
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_delete_tool_type_not_found() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
response = client.delete("/tool-types/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_delete_builtin_tool_type_fails() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||||
|
_insert_user(user_id)
|
||||||
|
_insert_tool_type(
|
||||||
|
tool_type_id,
|
||||||
|
"builtin-tool",
|
||||||
|
"Built-in Tool",
|
||||||
|
"version: '3.8'\nservices:\n app:\n image: builtin",
|
||||||
|
is_builtin=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
response = client.delete(f"/tool-types/{tool_type_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_builtin_tool_types_seeded_on_startup() -> None:
|
||||||
|
_prepare_test_db()
|
||||||
|
user_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
_insert_user(user_id)
|
||||||
|
|
||||||
|
# Load app triggers startup event which seeds built-in types
|
||||||
|
app = _load_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.cookies.set("access_token", _mint_token(user_id))
|
||||||
|
|
||||||
|
response = client.get("/tool-types")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Check that built-in types exist
|
||||||
|
builtin_names = [t["name"] for t in data if t["is_builtin"]]
|
||||||
|
assert "code-server" in builtin_names
|
||||||
|
assert "jupyter-notebook" in builtin_names
|
||||||
|
|
||||||
|
# Verify built-in types have correct attributes
|
||||||
|
code_server = next((t for t in data if t["name"] == "code-server"), None)
|
||||||
|
assert code_server is not None
|
||||||
|
assert code_server["display_name"] == "VS Code Server"
|
||||||
|
assert "services" in code_server["compose_template"]
|
||||||
|
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
|
||||||
@@ -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
|
||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
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.config import Settings, build_database_url
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.user import User
|
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:
|
def _create_auth_cookie(user_id: str) -> str:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
return mint_access_token(
|
return create_session_cookie(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
Generated
+1371
File diff suppressed because it is too large
Load Diff
Executable
+25
@@ -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 "$@"
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Headquarter</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+73
-1
@@ -8,11 +8,18 @@
|
|||||||
"name": "headquarter-web",
|
"name": "headquarter-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
"@types/prismjs": "^1.26.6",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.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": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -1268,6 +1275,19 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"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": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
@@ -2127,6 +2147,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"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"
|
"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": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -5205,6 +5240,16 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -6310,6 +6355,33 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -11,11 +11,18 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
"@types/prismjs": "^1.26.6",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.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": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
};
|
||||||
@@ -15,6 +15,22 @@ export interface GitRepository {
|
|||||||
export interface GitRepositoryCreate {
|
export interface GitRepositoryCreate {
|
||||||
name: string;
|
name: string;
|
||||||
remote_url?: 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[]> {
|
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> {
|
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,13 +5,15 @@ export interface UserConfig {
|
|||||||
theme: string;
|
theme: string;
|
||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
|
last_session_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserConfigUpdate {
|
export interface UserConfigUpdate {
|
||||||
default_editor?: string;
|
default_editor?: string | null;
|
||||||
theme?: string;
|
theme?: string | null;
|
||||||
git_user_name?: string;
|
git_user_name?: string | null;
|
||||||
git_user_email?: string;
|
git_user_email?: string | null;
|
||||||
|
last_session_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUserConfig = async (): Promise<UserConfig> => {
|
export const getUserConfig = async (): Promise<UserConfig> => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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;
|
||||||
|
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;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateToolTypeRequest {
|
||||||
|
name: string;
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listToolTypes = async (): Promise<ToolType[]> => {
|
||||||
|
const response = await apiClient.get<ToolType[]>("/tool-types");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getToolType = async (id: string): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createToolType = async (data: CreateToolTypeRequest): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.post<ToolType>("/tool-types", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateToolType = async (id: string, data: UpdateToolTypeRequest): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,18 +1,61 @@
|
|||||||
|
import { useCallback, useEffect } from "react";
|
||||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
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 { useTheme } from "../hooks/use-theme";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../state/auth";
|
||||||
|
import { useSessions } from "../state/sessions";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||||
{ to: "/", label: "Dashboard" },
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
{ to: "/projects", label: "Projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/ssh-keys", label: "SSH Keys" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
{ to: "/settings", label: "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 = () => {
|
export const AppShell = () => {
|
||||||
useTheme();
|
useTheme();
|
||||||
const { user, logout } = useAuth();
|
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 (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
@@ -31,6 +74,7 @@ export const AppShell = () => {
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
<Icon name="logout" size="sm" />
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,16 +82,34 @@ export const AppShell = () => {
|
|||||||
|
|
||||||
<div className="shell-body">
|
<div className="shell-body">
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
<aside className="shell-nav" aria-label="Primary navigation">
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => {
|
||||||
<NavLink
|
const isHome = item.to === "/";
|
||||||
key={item.to}
|
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||||
to={item.to}
|
return (
|
||||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
<NavLink
|
||||||
end={item.to === "/"}
|
key={item.to}
|
||||||
>
|
to={item.to}
|
||||||
{item.label}
|
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||||
</NavLink>
|
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>
|
</aside>
|
||||||
|
|
||||||
<main className="shell-content">
|
<main className="shell-content">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
Reference in New Issue
Block a user