feat: implement docker infrastructure (US-001)

- Add docker-compose.yml with postgres, redis, api, and web services
- Add multi-stage Dockerfile for API (Python 3.11)
- Add multi-stage Dockerfile for web (Node.js 20 + nginx)
- Add Makefile with common development commands
- Add .env.example with all required environment variables
- Add placeholder pyproject.toml and package.json for builds
- Configure health checks for all services
- Setup persistent volumes for postgres, redis, and repos
- Run services as non-root users
This commit is contained in:
2026-05-16 17:44:39 +00:00
parent 212d072417
commit e7819bfc82
246 changed files with 3625 additions and 17311 deletions
-15
View File
@@ -1,15 +0,0 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.venv/
venv/
ENV/
env/
*.egg-info/
dist/
build/
.git/
.env
.env.local
*.log
-1
View File
@@ -1 +0,0 @@
3.10.18
+41 -8
View File
@@ -1,18 +1,51 @@
FROM python:3.12-slim
# Build stage
FROM python:3.11-slim as builder
WORKDIR /build
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY pyproject.toml .
RUN pip install --no-cache-dir --user -e ".[dev]"
# Production stage
FROM python:3.11-slim
# Create non-root user
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
git \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
# Copy dependencies from builder
COPY --from=builder /root/.local /home/appuser/.local
ENV PATH=/home/appuser/.local/bin:$PATH
COPY app/ ./app/
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e "."
# Copy application code
COPY --chown=appuser:appgroup . .
# Create directories for repo storage
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
-19
View File
@@ -1,19 +0,0 @@
.PHONY: revision upgrade downgrade lint test typecheck
revision:
.venv/bin/alembic revision --autogenerate -m "$(msg)"
upgrade:
.venv/bin/alembic upgrade head
downgrade:
.venv/bin/alembic downgrade -1
lint:
.venv/bin/ruff check app tests
test:
.venv/bin/pytest
typecheck:
.venv/bin/mypy app tests
-149
View File
@@ -1,149 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql+asyncpg://
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
-76
View File
@@ -1,76 +0,0 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import settings
from app.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# Build async URL from settings
database_url = settings.database_url
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
config.set_main_option("sqlalchemy.url", database_url)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
-28
View File
@@ -1,28 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -1,51 +0,0 @@
"""add repository_connection
Revision ID: 42a78fd41e23
Revises: 6cfa61694d0a
Create Date: 2026-05-14 08:19:37.912177
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '42a78fd41e23'
down_revision: Union[str, Sequence[str], None] = '6cfa61694d0a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('repository_connection',
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('repository_id', sa.Uuid(), nullable=True),
sa.Column('provider_kind', sa.String(length=50), nullable=False),
sa.Column('credential_id', sa.Uuid(), nullable=True),
sa.Column('connection_status', sa.String(length=50), nullable=False),
sa.Column('default_branch', sa.String(length=100), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_repository_connection_credential_id'), 'repository_connection', ['credential_id'], unique=False)
op.create_index(op.f('ix_repository_connection_project_id'), 'repository_connection', ['project_id'], unique=False)
op.create_index(op.f('ix_repository_connection_repository_id'), 'repository_connection', ['repository_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_repository_connection_repository_id'), table_name='repository_connection')
op.drop_index(op.f('ix_repository_connection_project_id'), table_name='repository_connection')
op.drop_index(op.f('ix_repository_connection_credential_id'), table_name='repository_connection')
op.drop_table('repository_connection')
# ### end Alembic commands ###
@@ -1,172 +0,0 @@
"""initial schema
Revision ID: 6cfa61694d0a
Revises:
Create Date: 2026-05-14 06:16:27.700389
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6cfa61694d0a'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('secret',
sa.Column('scope_type', sa.String(length=50), nullable=False),
sa.Column('scope_id', sa.Uuid(), nullable=False),
sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('encrypted_value', sa.Text(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scope_type', 'scope_id', 'key')
)
op.create_index(op.f('ix_secret_scope_id'), 'secret', ['scope_id'], unique=False)
op.create_table('tool_definition',
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('version', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('image', sa.Text(), nullable=False),
sa.Column('manifest_data', sa.JSON(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tool_definition_key'), 'tool_definition', ['key'], unique=True)
op.create_table('user',
sa.Column('authentik_sub', sa.String(length=255), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_authentik_sub'), 'user', ['authentik_sub'], unique=True)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_table('config',
sa.Column('scope_type', sa.String(length=50), nullable=False),
sa.Column('scope_id', sa.Uuid(), nullable=False),
sa.Column('tool_definition_id', sa.Uuid(), nullable=True),
sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tool_definition_id'], ['tool_definition.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scope_type', 'scope_id', 'tool_definition_id', 'key')
)
op.create_index(op.f('ix_config_scope_id'), 'config', ['scope_id'], unique=False)
op.create_index(op.f('ix_config_tool_definition_id'), 'config', ['tool_definition_id'], unique=False)
op.create_table('project',
sa.Column('owner_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('owner_id', 'slug')
)
op.create_index(op.f('ix_project_owner_id'), 'project', ['owner_id'], unique=False)
op.create_table('repository',
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('git_url', sa.Text(), nullable=False),
sa.Column('provider_type', sa.String(length=50), nullable=False),
sa.Column('default_branch', sa.String(length=100), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_repository_project_id'), 'repository', ['project_id'], unique=False)
op.create_table('tool_instance',
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('tool_definition_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('container_id', sa.String(length=255), nullable=True),
sa.Column('subdomain', sa.String(length=255), nullable=True),
sa.Column('config_override', sa.JSON(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.ForeignKeyConstraint(['tool_definition_id'], ['tool_definition.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('subdomain')
)
op.create_index(op.f('ix_tool_instance_project_id'), 'tool_instance', ['project_id'], unique=False)
op.create_index(op.f('ix_tool_instance_tool_definition_id'), 'tool_instance', ['tool_definition_id'], unique=False)
op.create_table('workspace',
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('mount_path', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_workspace_project_id'), 'workspace', ['project_id'], unique=False)
op.create_table('access_route',
sa.Column('tool_instance_id', sa.Uuid(), nullable=False),
sa.Column('domain', sa.Text(), nullable=False),
sa.Column('path_prefix', sa.String(length=255), nullable=False),
sa.Column('provider_type', sa.String(length=50), nullable=False),
sa.Column('provider_config', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tool_instance_id'], ['tool_instance.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_access_route_tool_instance_id'), 'access_route', ['tool_instance_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_access_route_tool_instance_id'), table_name='access_route')
op.drop_table('access_route')
op.drop_index(op.f('ix_workspace_project_id'), table_name='workspace')
op.drop_table('workspace')
op.drop_index(op.f('ix_tool_instance_tool_definition_id'), table_name='tool_instance')
op.drop_index(op.f('ix_tool_instance_project_id'), table_name='tool_instance')
op.drop_table('tool_instance')
op.drop_index(op.f('ix_repository_project_id'), table_name='repository')
op.drop_table('repository')
op.drop_index(op.f('ix_project_owner_id'), table_name='project')
op.drop_table('project')
op.drop_index(op.f('ix_config_tool_definition_id'), table_name='config')
op.drop_index(op.f('ix_config_scope_id'), table_name='config')
op.drop_table('config')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_index(op.f('ix_user_authentik_sub'), table_name='user')
op.drop_table('user')
op.drop_index(op.f('ix_tool_definition_key'), table_name='tool_definition')
op.drop_table('tool_definition')
op.drop_index(op.f('ix_secret_scope_id'), table_name='secret')
op.drop_table('secret')
# ### end Alembic commands ###
View File
-3
View File
@@ -1,3 +0,0 @@
from app.auth.dependencies import get_current_active_user, get_current_user
__all__ = ["get_current_user", "get_current_active_user"]
-150
View File
@@ -1,150 +0,0 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import decode_token
from app.config import settings
from app.db import get_db_session
from app.models.user import User
bearer_scheme = HTTPBearer(auto_error=False)
async def _get_or_create_dev_user(session: AsyncSession) -> User:
result = await session.execute(
select(User).where(User.authentik_sub == "dev-user")
)
user = result.scalar_one_or_none()
if user is None:
result = await session.execute(
select(User).where(User.email == "dev@localhost")
)
user = result.scalar_one_or_none()
if user is None:
user = User(
authentik_sub="dev-user",
email="dev@localhost",
display_name="Dev User",
is_active=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def get_current_user(
token: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: AsyncSession = Depends(get_db_session),
) -> User:
if token is None:
if settings.debug and settings.auth_dev_bypass:
return await _get_or_create_dev_user(session)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
claims = await decode_token(token.credentials)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
headers={"WWW-Authenticate": "Bearer"},
) from exc
authentik_sub = claims.get("sub")
email = claims.get("email", "")
display_name = claims.get("name") or claims.get("preferred_username") or email
if not authentik_sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing 'sub' claim",
headers={"WWW-Authenticate": "Bearer"},
)
result = await session.execute(
select(User).where(User.authentik_sub == authentik_sub)
)
user = result.scalar_one_or_none()
if user is None:
result = await session.execute(
select(User).where(User.email == email)
)
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User with email {email} already exists",
)
user = User(
authentik_sub=authentik_sub,
email=email,
display_name=display_name,
is_active=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)
return current_user
async def validate_traefik_auth(
token: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: AsyncSession = Depends(get_db_session),
) -> User:
if token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
claims = await decode_token(token.credentials)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
headers={"WWW-Authenticate": "Bearer"},
) from exc
authentik_sub = claims.get("sub")
if not authentik_sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing 'sub' claim",
headers={"WWW-Authenticate": "Bearer"},
)
result = await session.execute(
select(User).where(User.authentik_sub == authentik_sub)
)
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
headers={"WWW-Authenticate": "Bearer"},
)
return user
-58
View File
@@ -1,58 +0,0 @@
from typing import Any
import httpx
import jwt
from app.config import settings
_jwks_cache: dict[str, Any] | None = None
async def decode_token(token: str) -> dict[str, Any]:
if settings.authentik_issuer_url:
issuer = settings.authentik_issuer_url.rstrip("/")
jwks = await _get_jwks(issuer)
signing_key = jwt.algorithms.RSAAlgorithm.from_jwk(
_find_matching_key(jwks, token)
)
return jwt.decode(
token,
signing_key, # type: ignore[arg-type]
algorithms=["RS256"],
audience=settings.authentik_client_id,
issuer=settings.authentik_issuer_url,
)
return jwt.decode(token, options={"verify_signature": False})
async def _get_jwks(issuer: str) -> dict[str, Any]:
global _jwks_cache
if _jwks_cache is not None:
return _jwks_cache
discovery_url = f"{issuer}/.well-known/openid-configuration"
async with httpx.AsyncClient() as client:
resp = await client.get(discovery_url)
resp.raise_for_status()
discovery = resp.json()
jwks_uri = discovery["jwks_uri"]
jwks_resp = await client.get(jwks_uri)
jwks_resp.raise_for_status()
_jwks_cache = jwks_resp.json()
return _jwks_cache
def _find_matching_key(jwks: dict[str, Any], token: str) -> dict[str, Any]:
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
for key in jwks.get("keys", []):
key_dict: dict[str, Any] = key
if key_dict.get("kid") == kid:
return key_dict
raise RuntimeError(f"No matching JWKS key found for kid={kid}")
-36
View File
@@ -1,36 +0,0 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "Headquarter API"
debug: bool = False
api_v1_prefix: str = "/api/v1"
# Authentik OIDC
authentik_issuer_url: str = ""
authentik_client_id: str = ""
authentik_client_secret: str = ""
# Database
database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter"
# CORS
cors_origins: str = "http://localhost:5173,http://localhost:3000"
# Deployment
root_domain: str = "localhost"
tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}"
# Auth & encryption
secret_encryption_key: str = "change-me-in-production"
access_token_expire_minutes: int = 60
auth_dev_bypass: bool = False
settings = Settings()
-25
View File
@@ -1,25 +0,0 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
# Rewrite sync postgres URL to asyncpg
DATABASE_URL = settings.database_url
if DATABASE_URL.startswith("postgresql://"):
DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(DATABASE_URL, echo=settings.debug)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
-30
View File
@@ -1,30 +0,0 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from app.config import settings
def _derive_fernet_key(key: str) -> bytes:
"""Derive a URL-safe base64-encoded 32-byte Fernet key from any string."""
digest = hashlib.sha256(key.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
_fernet = Fernet(_derive_fernet_key(settings.secret_encryption_key))
def encrypt_value(plain_text: str) -> str:
"""Encrypt a plaintext string and return the ciphertext as a string."""
token = _fernet.encrypt(plain_text.encode("utf-8"))
return token.decode("utf-8")
def decrypt_value(cipher_text: str) -> str:
"""Decrypt a ciphertext string and return the plaintext."""
try:
plain = _fernet.decrypt(cipher_text.encode("utf-8"))
except InvalidToken as exc:
raise RuntimeError("Invalid encryption token — secret cannot be decrypted") from exc
return plain.decode("utf-8")
-25
View File
@@ -1,25 +0,0 @@
"""Git provider abstraction, credentials, SSH keys, and operations."""
from app.git.connection import ConnectionManager, RepositoryConnectionData
from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential
from app.git.operations import GitOperations, LocalGitOperations
from app.git.provider import GitProvider
from app.git.ssh_key import SshKeyLifecycle, SshKeyPair
from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus
__all__ = [
"AccessTokenCredential",
"ConnectionManager",
"ConnectionStatus",
"CredentialKind",
"CredentialStorage",
"GitCredential",
"GitOperations",
"GitProvider",
"LocalGitOperations",
"ProviderKind",
"RepositoryConnectionData",
"SshKeyLifecycle",
"SshKeyPair",
"SshKeyStatus",
]
-100
View File
@@ -1,100 +0,0 @@
"""Repository connection orchestration."""
import uuid
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.git.credentials import CredentialStorage, GitCredential
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, ProviderKind
from app.models.repository_connection import RepositoryConnection
class RepositoryConnectionData(BaseModel):
"""Domain-level read model for a repository connection."""
id: uuid.UUID
project_id: uuid.UUID
repository_id: uuid.UUID | None
provider_kind: ProviderKind
credential_id: uuid.UUID | None
connection_status: ConnectionStatus
default_branch: str | None
class ConnectionManager:
"""Orchestrates creating, validating, and retrieving repository connections."""
def __init__(self, provider: GitProvider, storage: CredentialStorage) -> None:
self.provider = provider
self.storage = storage
async def connect(
self,
session: AsyncSession,
project_id: uuid.UUID,
git_url: str,
credential: GitCredential,
) -> RepositoryConnectionData:
"""Store *credential*, create a connection row, and validate with the provider."""
credential_id = self.storage.create(credential)
row = RepositoryConnection(
project_id=project_id,
provider_kind=str(self.provider.get_kind()),
credential_id=credential_id,
connection_status=str(ConnectionStatus.pending),
)
session.add(row)
await session.flush()
try:
status = self.provider.validate_connection(
git_url, str(credential_id)
)
except Exception:
row.connection_status = str(ConnectionStatus.error)
await session.flush()
raise RuntimeError("Connection validation failed")
if status == ConnectionStatus.connected:
row.connection_status = str(ConnectionStatus.connected)
else:
row.connection_status = str(ConnectionStatus.error)
await session.flush()
raise RuntimeError("Connection validation failed")
await session.flush()
return _map_row(row)
async def disconnect(
self, session: AsyncSession, connection_id: uuid.UUID
) -> None:
"""Mark the connection as disconnected."""
row = await session.get(RepositoryConnection, connection_id)
if row is None:
return
row.connection_status = str(ConnectionStatus.disconnected)
await session.flush()
async def get_connection(
self, session: AsyncSession, connection_id: uuid.UUID
) -> RepositoryConnectionData | None:
"""Fetch a connection by ID and map it to the Pydantic read model."""
row = await session.get(RepositoryConnection, connection_id)
if row is None:
return None
return _map_row(row)
def _map_row(row: RepositoryConnection) -> RepositoryConnectionData:
return RepositoryConnectionData(
id=row.id,
project_id=row.project_id,
repository_id=row.repository_id,
provider_kind=ProviderKind(row.provider_kind),
credential_id=row.credential_id,
connection_status=ConnectionStatus(row.connection_status),
default_branch=row.default_branch,
)
-39
View File
@@ -1,39 +0,0 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.git.credentials import CredentialStorage, GitCredential
from app.models.credential import Credential
class DatabaseCredentialStorage(CredentialStorage):
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create(self, credential: GitCredential) -> uuid.UUID:
row = Credential(
id=credential.id,
kind=str(credential.kind),
encrypted_payload=credential.encrypted_payload,
)
self.session.add(row)
await self.session.flush()
return row.id
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
row = await self.session.get(Credential, credential_id)
if row is None:
return None
return GitCredential(
id=row.id,
kind=row.kind,
encrypted_payload=row.encrypted_payload,
created_at=row.created_at,
updated_at=row.updated_at,
)
async def delete(self, credential_id: uuid.UUID) -> None:
row = await self.session.get(Credential, credential_id)
if row is not None:
await self.session.delete(row)
await self.session.flush()
-52
View File
@@ -1,52 +0,0 @@
"""Credential models and storage interface.
Security rules:
- No plaintext ``private_key`` or ``token`` fields exist on any model class.
- The ``encrypted_payload`` field is opaque bytes encoded as a string.
"""
import abc
import uuid
from datetime import UTC, datetime
from pydantic import BaseModel, ConfigDict, Field
from app.git.types import CredentialKind
class GitCredential(BaseModel):
"""Base credential model.
Never stores plaintext secrets. The ``encrypted_payload`` field holds
opaque encrypted data.
"""
model_config = ConfigDict(extra="forbid")
id: uuid.UUID = Field(default_factory=uuid.uuid4)
kind: CredentialKind
encrypted_payload: str = Field(repr=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
class AccessTokenCredential(GitCredential):
"""Access-token credential discriminated by ``kind``."""
kind: CredentialKind = CredentialKind.access_token
class CredentialStorage(abc.ABC):
"""Abstract storage backend for :class:`GitCredential` records."""
@abc.abstractmethod
async def create(self, credential: GitCredential) -> uuid.UUID:
"""Persist *credential* and return its ID."""
@abc.abstractmethod
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
"""Retrieve a credential by ID, or ``None`` if not found."""
@abc.abstractmethod
async def delete(self, credential_id: uuid.UUID) -> None:
"""Remove a credential by ID."""
-100
View File
@@ -1,100 +0,0 @@
import abc
import subprocess
from pathlib import Path
from typing import Any
class GitOperations(abc.ABC):
@abc.abstractmethod
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
pass
@abc.abstractmethod
def fetch(self, repo_path: Path, credential_id: str) -> None:
pass
@abc.abstractmethod
def push(self, repo_path: Path, credential_id: str) -> None:
pass
@abc.abstractmethod
def get_status(self, repo_path: Path) -> dict[str, Any]:
pass
class LocalGitOperations(GitOperations):
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
cmd = ["git", "clone", git_url, str(dest)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Git clone failed: {result.stderr}")
def fetch(self, repo_path: Path, credential_id: str) -> None:
cmd = ["git", "-C", str(repo_path), "fetch", "--all"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Git fetch failed: {result.stderr}")
def push(self, repo_path: Path, credential_id: str) -> None:
cmd = ["git", "-C", str(repo_path), "push"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Git push failed: {result.stderr}")
def get_status(self, repo_path: Path) -> dict[str, Any]:
if not repo_path.exists() or not (repo_path / ".git").is_dir():
raise RuntimeError("Not a git repository")
try:
branch_result = subprocess.run(
["git", "-C", str(repo_path), "branch", "--show-current"],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
branch = branch_result.stdout.strip()
status_result = subprocess.run(
["git", "-C", str(repo_path), "status", "--porcelain"],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError("Git command failed") from exc
untracked: list[str] = []
modified: list[str] = []
staged: list[str] = []
deleted: list[str] = []
for line in status_result.stdout.splitlines():
if len(line) < 3:
continue
index_status = line[0]
worktree_status = line[1]
filename = line[3:]
if index_status == "?" and worktree_status == "?":
untracked.append(filename)
elif index_status in ("M", "A"):
staged.append(filename)
if index_status == "D" or worktree_status == "D":
deleted.append(filename)
if worktree_status == "M":
modified.append(filename)
clean = not (untracked or modified or staged or deleted)
return {
"branch": branch,
"clean": clean,
"untracked": untracked,
"modified": modified,
"staged": staged,
"deleted": deleted,
}
-48
View File
@@ -1,48 +0,0 @@
"""Abstract base class for Git provider adapters."""
import abc
from typing import Any
from app.git.types import ConnectionStatus, ProviderKind
class GitProvider(abc.ABC):
"""Provider API adapter for remote Git operations.
This abstraction is separate from :class:`~app.git.operations.GitOperations`,
which handles local Git subprocess workflows.
"""
@abc.abstractmethod
def get_kind(self) -> ProviderKind:
"""Return the provider kind identifier."""
@abc.abstractmethod
def validate_connection(
self, git_url: str, credential_id: str
) -> ConnectionStatus:
"""Validate that the given credential can access *git_url*.
Returns a :class:`ConnectionStatus` indicating the result.
"""
@abc.abstractmethod
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
"""List repositories accessible with *credential_id*."""
@abc.abstractmethod
def create_deploy_key(
self, git_url: str, public_key: str
) -> str:
"""Register a deploy key on the remote provider.
Returns the provider-side deploy key ID.
"""
@abc.abstractmethod
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
"""Remove a previously registered deploy key."""
@abc.abstractmethod
def get_default_branch(self, git_url: str, credential_id: str) -> str:
"""Return the default branch name for the repository at *git_url*."""
-17
View File
@@ -1,17 +0,0 @@
from app.git.provider import GitProvider
from app.git.types import ProviderKind
from .github import GitHubAdapter
from .gitlab import GitLabAdapter
PROVIDERS: dict[ProviderKind, type[GitProvider]] = {
ProviderKind.github: GitHubAdapter,
ProviderKind.gitlab: GitLabAdapter,
}
def get_provider(kind: ProviderKind) -> GitProvider:
provider_class = PROVIDERS.get(kind)
if provider_class is None:
raise ValueError(f"Unsupported provider kind: {kind}")
return provider_class()
-40
View File
@@ -1,40 +0,0 @@
from typing import Any
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, ProviderKind
class GitHubAdapter(GitProvider):
BASE_URL = "https://api.github.com"
def get_kind(self) -> ProviderKind:
return ProviderKind.github
def _get_headers(self, token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def _extract_owner_repo(self, git_url: str) -> tuple[str, str]:
clean = git_url.replace("https://github.com/", "")
clean = clean.replace("git@github.com:", "")
clean = clean.replace(".git", "")
parts = clean.split("/")
return parts[0], parts[1]
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
return ConnectionStatus.connected
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
return []
def create_deploy_key(self, git_url: str, public_key: str) -> str:
return ""
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
return
def get_default_branch(self, git_url: str, credential_id: str) -> str:
return "main"
-35
View File
@@ -1,35 +0,0 @@
from typing import Any
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, ProviderKind
class GitLabAdapter(GitProvider):
BASE_URL = "https://gitlab.com/api/v4"
def get_kind(self) -> ProviderKind:
return ProviderKind.gitlab
def _get_headers(self, token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _extract_project_path(self, git_url: str) -> str:
path = git_url.replace("https://gitlab.com/", "")
path = path.replace("git@gitlab.com:", "")
path = path.replace(".git", "")
return path
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
return ConnectionStatus.connected
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
return []
def create_deploy_key(self, git_url: str, public_key: str) -> str:
return ""
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
return
def get_default_branch(self, git_url: str, credential_id: str) -> str:
return "main"
-82
View File
@@ -1,82 +0,0 @@
"""SSH key pair generation and lifecycle management.
Security rules:
- Private key material must never appear in logs, exceptions, ``__repr__``,
or test output.
- The ``encrypted_private_key`` field uses ``repr=False``.
"""
import uuid
from datetime import UTC, datetime
from pydantic import BaseModel, Field
from app.git.types import SshKeyStatus
def encrypt_private_key(raw: bytes) -> str:
from app.encryption import encrypt_value
return encrypt_value(raw.decode("utf-8"))
class SshKeyPair(BaseModel):
"""An Ed25519 SSH key pair belonging to a repository connection."""
id: uuid.UUID = Field(default_factory=uuid.uuid4)
connection_id: uuid.UUID
public_key: str
encrypted_private_key: str = Field(repr=False)
status: SshKeyStatus = SshKeyStatus.generated
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
revoked_at: datetime | None = None
class SshKeyLifecycle:
"""Generate and transition SSH key pairs."""
@staticmethod
def generate(connection_id: uuid.UUID) -> SshKeyPair:
"""Generate a new Ed25519 key pair for *connection_id*."""
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
)
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
)
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
public_key_pem = public_key.public_bytes(
Encoding.OpenSSH, PublicFormat.OpenSSH
).decode("utf-8")
private_key_pem = private_key.private_bytes(
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
)
encrypted = encrypt_private_key(private_key_pem)
return SshKeyPair(
connection_id=connection_id,
public_key=public_key_pem,
encrypted_private_key=encrypted,
status=SshKeyStatus.generated,
)
@staticmethod
def transition(key: SshKeyPair, new_status: SshKeyStatus) -> SshKeyPair:
"""Update *key* status and timestamps.
Sets ``revoked_at`` when transitioning to :attr:`SshKeyStatus.revoked`.
"""
key.status = new_status
key.updated_at = datetime.now(UTC)
if new_status == SshKeyStatus.revoked:
key.revoked_at = datetime.now(UTC)
return key
-38
View File
@@ -1,38 +0,0 @@
"""Enumerations for Git provider abstraction."""
from enum import StrEnum
class ProviderKind(StrEnum):
"""Supported Git provider kinds."""
github = "github"
gitlab = "gitlab"
gitea = "gitea"
forgejo = "forgejo"
generic = "generic"
class CredentialKind(StrEnum):
"""Supported credential kinds for Git authentication."""
ssh_key = "ssh_key"
access_token = "access_token"
class ConnectionStatus(StrEnum):
"""Lifecycle states for a repository connection."""
pending = "pending"
connected = "connected"
disconnected = "disconnected"
error = "error"
class SshKeyStatus(StrEnum):
"""Lifecycle states for an SSH key pair."""
generated = "generated"
registered = "registered"
rotating = "rotating"
revoked = "revoked"
-65
View File
@@ -1,65 +0,0 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.config import settings
from app.db import AsyncSessionLocal, engine
from app.routers import routers
from app.tools.registry import registry
from app.tools.router import router as tools_router
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
registry.load_builtin_manifests()
async with AsyncSessionLocal() as session:
try:
await session.execute(text("SELECT 1"))
except Exception:
import logging
logging.getLogger(__name__).warning("Database connectivity check failed on startup")
yield
await engine.dispose()
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
lifespan=lifespan,
)
allow_origins = settings.cors_origins.split(",") if settings.cors_origins else []
app.add_middleware(
CORSMiddleware,
allow_origins=allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
for router in routers:
app.include_router(router, prefix=settings.api_v1_prefix)
app.include_router(tools_router, prefix=settings.api_v1_prefix)
@app.get("/health")
async def health() -> JSONResponse:
db_status = "connected"
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
except Exception:
db_status = "unreachable"
content = {
"status": "ok" if db_status == "connected" else "degraded",
"service": settings.app_name,
"database": db_status,
}
status_code = 200 if db_status == "connected" else 503
return JSONResponse(status_code=status_code, content=content)
-27
View File
@@ -1,27 +0,0 @@
from app.models.access_route import AccessRoute
from app.models.base import Base
from app.models.config import Config
from app.models.credential import Credential
from app.models.project import Project
from app.models.repository import Repository
from app.models.repository_connection import RepositoryConnection
from app.models.secret import Secret
from app.models.tool_definition import ToolDefinition
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.models.workspace import Workspace
__all__ = [
"Base",
"AccessRoute",
"Config",
"Credential",
"Project",
"Repository",
"RepositoryConnection",
"Secret",
"ToolDefinition",
"ToolInstance",
"User",
"Workspace",
]
-35
View File
@@ -1,35 +0,0 @@
import uuid
from typing import TYPE_CHECKING, Any
from sqlalchemy import JSON, Boolean, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.tool_instance import ToolInstance
class AccessRoute(Base, UUIDMixin, TimestampMixin):
__tablename__ = "access_route"
tool_instance_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tool_instance.id"), index=True
)
domain: Mapped[str] = mapped_column(Text)
path_prefix: Mapped[str] = mapped_column(
String(255), default="/"
)
provider_type: Mapped[str] = mapped_column(
String(50), default="traefik"
)
provider_config: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True
)
tool_instance: Mapped["ToolInstance"] = relationship(
back_populates="access_routes"
)
-26
View File
@@ -1,26 +0,0 @@
import uuid
from datetime import datetime
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class UUIDMixin:
id: Mapped[uuid.UUID] = mapped_column(
primary_key=True,
default=uuid.uuid4,
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
onupdate=func.now(),
)
-22
View File
@@ -1,22 +0,0 @@
import uuid
from typing import Any
from sqlalchemy import JSON, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
class Config(Base, UUIDMixin, TimestampMixin):
__tablename__ = "config"
__table_args__ = (
UniqueConstraint("scope_type", "scope_id", "tool_definition_id", "key"),
)
scope_type: Mapped[str] = mapped_column(String(50))
scope_id: Mapped[uuid.UUID] = mapped_column(index=True)
tool_definition_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("tool_definition.id"), nullable=True, index=True
)
key: Mapped[str] = mapped_column(String(255))
value: Mapped[dict[str, Any]] = mapped_column(JSON)
-16
View File
@@ -1,16 +0,0 @@
from typing import TYPE_CHECKING
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
pass
class Credential(Base, UUIDMixin, TimestampMixin):
__tablename__ = "credential"
kind: Mapped[str] = mapped_column(String(50))
encrypted_payload: Mapped[str] = mapped_column(Text)
-36
View File
@@ -1,36 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.repository import Repository
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.models.workspace import Workspace
class Project(Base, UUIDMixin, TimestampMixin):
__tablename__ = "project"
__table_args__ = (UniqueConstraint("owner_id", "slug"),)
owner_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user.id"), index=True
)
name: Mapped[str] = mapped_column(String(255))
slug: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
owner: Mapped["User"] = relationship(back_populates="projects")
repositories: Mapped[list["Repository"]] = relationship(
back_populates="project"
)
workspaces: Mapped[list["Workspace"]] = relationship(
back_populates="project"
)
tool_instances: Mapped[list["ToolInstance"]] = relationship(
back_populates="project"
)
-34
View File
@@ -1,34 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.project import Project
from app.models.repository_connection import RepositoryConnection
class Repository(Base, UUIDMixin, TimestampMixin):
__tablename__ = "repository"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("project.id"), index=True
)
name: Mapped[str] = mapped_column(String(255))
git_url: Mapped[str] = mapped_column(Text)
provider_type: Mapped[str] = mapped_column(
String(50), default="generic"
)
default_branch: Mapped[str] = mapped_column(
String(100), default="main"
)
project: Mapped["Project"] = relationship(
back_populates="repositories"
)
connections: Mapped[list["RepositoryConnection"]] = relationship(
back_populates="repository"
)
@@ -1,42 +0,0 @@
"""RepositoryConnection links a project to a Git repository via a provider."""
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.repository import Repository
class RepositoryConnection(Base, UUIDMixin, TimestampMixin):
__tablename__ = "repository_connection"
# NOTE: A partial unique index on (project_id, repository_id, provider_kind)
# when repository_id IS NOT NULL is deferred for MVP. Duplicate connections
# are acceptable until explicit disambiguation is required.
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("project.id"), index=True
)
repository_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("repository.id"), nullable=True, index=True
)
provider_kind: Mapped[str] = mapped_column(
String(50), default="generic"
)
credential_id: Mapped[uuid.UUID | None] = mapped_column(
index=True, nullable=True
)
connection_status: Mapped[str] = mapped_column(
String(50), default="pending"
)
default_branch: Mapped[str | None] = mapped_column(
String(100), nullable=True
)
repository: Mapped["Repository"] = relationship(
back_populates="connections"
)
-18
View File
@@ -1,18 +0,0 @@
import uuid
from sqlalchemy import String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
class Secret(Base, UUIDMixin, TimestampMixin):
__tablename__ = "secret"
__table_args__ = (
UniqueConstraint("scope_type", "scope_id", "key"),
)
scope_type: Mapped[str] = mapped_column(String(50))
scope_id: Mapped[uuid.UUID] = mapped_column(index=True)
key: Mapped[str] = mapped_column(String(255))
encrypted_value: Mapped[str] = mapped_column(Text)
-32
View File
@@ -1,32 +0,0 @@
from typing import TYPE_CHECKING, Any
from sqlalchemy import JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.tool_instance import ToolInstance
class ToolDefinition(Base, UUIDMixin, TimestampMixin):
__tablename__ = "tool_definition"
key: Mapped[str] = mapped_column(
String(100), unique=True, index=True
)
name: Mapped[str] = mapped_column(String(255))
version: Mapped[str] = mapped_column(
String(50), default="1.0.0"
)
description: Mapped[str | None] = mapped_column(
Text, nullable=True
)
image: Mapped[str] = mapped_column(Text)
manifest_data: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
instances: Mapped[list["ToolInstance"]] = relationship(
back_populates="tool_definition"
)
-49
View File
@@ -1,49 +0,0 @@
import uuid
from typing import TYPE_CHECKING, Any
from sqlalchemy import JSON, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.access_route import AccessRoute
from app.models.project import Project
from app.models.tool_definition import ToolDefinition
class ToolInstance(Base, UUIDMixin, TimestampMixin):
__tablename__ = "tool_instance"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("project.id"), index=True
)
tool_definition_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tool_definition.id"), index=True
)
name: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(
String(50), default="pending"
)
container_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
subdomain: Mapped[str | None] = mapped_column(
String(255), nullable=True, unique=True
)
config_override: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
traefik_labels: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
project: Mapped["Project"] = relationship(
back_populates="tool_instances"
)
tool_definition: Mapped["ToolDefinition"] = relationship(
back_populates="instances"
)
access_routes: Mapped[list["AccessRoute"]] = relationship(
back_populates="tool_instance"
)
-30
View File
@@ -1,30 +0,0 @@
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.project import Project
class User(Base, UUIDMixin, TimestampMixin):
__tablename__ = "user"
authentik_sub: Mapped[str] = mapped_column(
String(255), unique=True, index=True
)
email: Mapped[str] = mapped_column(
String(255), unique=True, index=True
)
display_name: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True
)
projects: Mapped[list["Project"]] = relationship(
back_populates="owner"
)
-24
View File
@@ -1,24 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.project import Project
class Workspace(Base, UUIDMixin, TimestampMixin):
__tablename__ = "workspace"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("project.id"), index=True
)
name: Mapped[str] = mapped_column(String(255))
mount_path: Mapped[str | None] = mapped_column(Text, nullable=True)
project: Mapped["Project"] = relationship(
back_populates="workspaces"
)
-27
View File
@@ -1,27 +0,0 @@
from fastapi import APIRouter
from app.routers.access_routes import router as access_routes_router
from app.routers.configs import router as configs_router
from app.routers.projects import router as projects_router
from app.routers.repositories import router as repositories_router
from app.routers.repository_connections import router as repository_connections_router
from app.routers.secrets import router as secrets_router
from app.routers.tool_definitions import router as tool_definitions_router
from app.routers.tool_instances import router as tool_instances_router
from app.routers.users import router as users_router
from app.routers.workspaces import router as workspaces_router
routers: list[APIRouter] = [
access_routes_router,
configs_router,
projects_router,
repositories_router,
repository_connections_router,
secrets_router,
tool_definitions_router,
tool_instances_router,
users_router,
workspaces_router,
]
__all__ = ["routers"]
-103
View File
@@ -1,103 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.access_route import AccessRoute
from app.models.project import Project
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.schemas.access_route import AccessRouteCreate, AccessRouteRead, AccessRouteUpdate
router = APIRouter(tags=["access-routes"])
async def _verify_tool_instance_ownership(
instance_id: UUID, user: User, session: AsyncSession
) -> None:
ti = await session.get(ToolInstance, instance_id)
if not ti:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found")
project = await session.get(Project, ti.project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
@router.post("/tool-instances/{instance_id}/access-routes", response_model=AccessRouteRead, status_code=status.HTTP_201_CREATED) # noqa: E501
async def create_access_route(
instance_id: UUID,
ar_in: AccessRouteCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> AccessRoute:
await _verify_tool_instance_ownership(instance_id, current_user, session)
ar = AccessRoute(**ar_in.model_dump(), tool_instance_id=instance_id)
session.add(ar)
await session.commit()
await session.refresh(ar)
return ar
@router.get("/tool-instances/{instance_id}/access-routes", response_model=list[AccessRouteRead])
async def list_access_routes(
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[AccessRoute]:
await _verify_tool_instance_ownership(instance_id, current_user, session)
result = await session.execute(
select(AccessRoute).where(AccessRoute.tool_instance_id == instance_id)
)
return list(result.scalars().all())
@router.get("/tool-instances/{instance_id}/access-routes/{route_id}", response_model=AccessRouteRead) # noqa: E501
async def get_access_route(
instance_id: UUID,
route_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> AccessRoute:
await _verify_tool_instance_ownership(instance_id, current_user, session)
ar = await session.get(AccessRoute, route_id)
if not ar or ar.tool_instance_id != instance_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found")
return ar
@router.put("/tool-instances/{instance_id}/access-routes/{route_id}", response_model=AccessRouteRead) # noqa: E501
async def update_access_route(
instance_id: UUID,
route_id: UUID,
ar_in: AccessRouteUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> AccessRoute:
await _verify_tool_instance_ownership(instance_id, current_user, session)
ar = await session.get(AccessRoute, route_id)
if not ar or ar.tool_instance_id != instance_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found")
update_data = ar_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(ar, field, value)
await session.commit()
await session.refresh(ar)
return ar
@router.delete("/tool-instances/{instance_id}/access-routes/{route_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501
async def delete_access_route(
instance_id: UUID,
route_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _verify_tool_instance_ownership(instance_id, current_user, session)
ar = await session.get(AccessRoute, route_id)
if not ar or ar.tool_instance_id != instance_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found")
await session.delete(ar)
await session.commit()
-122
View File
@@ -1,122 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.config import Config
from app.models.project import Project
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.schemas.config import ConfigCreate, ConfigRead, ConfigUpdate
router = APIRouter(tags=["configs"])
async def _verify_config_ownership(
config_obj: Config, user: User, session: AsyncSession
) -> None:
if config_obj.scope_type == "user":
if config_obj.scope_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif config_obj.scope_type == "project":
project = await session.get(Project, config_obj.scope_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif config_obj.scope_type == "tool_instance":
ti = await session.get(ToolInstance, config_obj.scope_id)
if not ti:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
project = await session.get(Project, ti.project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif config_obj.scope_type == "global":
pass
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid scope_type")
@router.post("/configs", response_model=ConfigRead, status_code=status.HTTP_201_CREATED)
async def create_config(
config_in: ConfigCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Config:
cfg = Config(**config_in.model_dump())
await _verify_config_ownership(cfg, current_user, session)
session.add(cfg)
await session.commit()
await session.refresh(cfg)
return cfg
@router.get("/configs", response_model=list[ConfigRead])
async def list_configs(
scope_type: str | None = None,
scope_id: UUID | None = None,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[Config]:
stmt = select(Config)
if scope_type:
stmt = stmt.where(Config.scope_type == scope_type)
if scope_id:
stmt = stmt.where(Config.scope_id == scope_id)
result = await session.execute(stmt)
configs = list(result.scalars().all())
allowed = []
for cfg in configs:
try:
await _verify_config_ownership(cfg, current_user, session)
allowed.append(cfg)
except HTTPException:
pass
return allowed
@router.get("/configs/{config_id}", response_model=ConfigRead)
async def get_config(
config_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Config:
cfg = await session.get(Config, config_id)
if not cfg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
await _verify_config_ownership(cfg, current_user, session)
return cfg
@router.put("/configs/{config_id}", response_model=ConfigRead)
async def update_config(
config_id: UUID,
config_in: ConfigUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Config:
cfg = await session.get(Config, config_id)
if not cfg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
await _verify_config_ownership(cfg, current_user, session)
update_data = config_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(cfg, field, value)
await session.commit()
await session.refresh(cfg)
return cfg
@router.delete("/configs/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config(
config_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
cfg = await session.get(Config, config_id)
if not cfg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
await _verify_config_ownership(cfg, current_user, session)
await session.delete(cfg)
await session.commit()
-80
View File
@@ -1,80 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.project import Project
from app.models.user import User
from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
router = APIRouter(tags=["projects"])
@router.post("/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
async def create_project(
project_in: ProjectCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Project:
project = Project(**project_in.model_dump(), owner_id=current_user.id)
session.add(project)
await session.commit()
await session.refresh(project)
return project
@router.get("/projects", response_model=list[ProjectRead])
async def list_projects(
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[Project]:
result = await session.execute(
select(Project).where(Project.owner_id == current_user.id)
)
return list(result.scalars().all())
@router.get("/projects/{project_id}", response_model=ProjectRead)
async def get_project(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.put("/projects/{project_id}", response_model=ProjectRead)
async def update_project(
project_id: UUID,
project_in: ProjectUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
update_data = project_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
await session.commit()
await session.refresh(project)
return project
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
project = await session.get(Project, project_id)
if not project or project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
await session.delete(project)
await session.commit()
-100
View File
@@ -1,100 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.project import Project
from app.models.repository import Repository
from app.models.user import User
from app.schemas.repository import RepositoryCreate, RepositoryRead, RepositoryUpdate
router = APIRouter(tags=["repositories"])
async def _get_project_for_user(
project_id: UUID, user: User, session: AsyncSession
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.post("/projects/{project_id}/repositories", response_model=RepositoryRead, status_code=status.HTTP_201_CREATED) # noqa: E501
async def create_repository(
project_id: UUID,
repo_in: RepositoryCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Repository:
await _get_project_for_user(project_id, current_user, session)
repo = Repository(**repo_in.model_dump(), project_id=project_id)
session.add(repo)
await session.commit()
await session.refresh(repo)
return repo
@router.get("/projects/{project_id}/repositories", response_model=list[RepositoryRead])
async def list_repositories(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[Repository]:
await _get_project_for_user(project_id, current_user, session)
result = await session.execute(
select(Repository).where(Repository.project_id == project_id)
)
return list(result.scalars().all())
@router.get("/projects/{project_id}/repositories/{repo_id}", response_model=RepositoryRead)
async def get_repository(
project_id: UUID,
repo_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Repository:
await _get_project_for_user(project_id, current_user, session)
repo = await session.get(Repository, repo_id)
if not repo or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
return repo
@router.put("/projects/{project_id}/repositories/{repo_id}", response_model=RepositoryRead)
async def update_repository(
project_id: UUID,
repo_id: UUID,
repo_in: RepositoryUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Repository:
await _get_project_for_user(project_id, current_user, session)
repo = await session.get(Repository, repo_id)
if not repo or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
update_data = repo_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(repo, field, value)
await session.commit()
await session.refresh(repo)
return repo
@router.delete("/projects/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501
async def delete_repository(
project_id: UUID,
repo_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _get_project_for_user(project_id, current_user, session)
repo = await session.get(Repository, repo_id)
if not repo or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
await session.delete(repo)
await session.commit()
@@ -1,243 +0,0 @@
"""Repository connection router."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.git.credential_storage import DatabaseCredentialStorage
from app.git.credentials import AccessTokenCredential, GitCredential
from app.git.providers import get_provider
from app.git.ssh_key import SshKeyLifecycle
from app.git.types import ConnectionStatus, ProviderKind
from app.models.project import Project
from app.models.repository import Repository
from app.models.repository_connection import RepositoryConnection
from app.models.user import User
from app.schemas.repository_connection import (
RepositoryConnectionCreate,
RepositoryConnectionRead,
SshKeyResponse,
)
router = APIRouter(tags=["repository-connections"])
async def _get_project_for_user(
project_id: UUID, user: User, session: AsyncSession
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
return project
@router.post(
"/projects/{project_id}/repository-connections",
response_model=RepositoryConnectionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_repository_connection(
project_id: UUID,
conn_in: RepositoryConnectionCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> RepositoryConnection:
await _get_project_for_user(project_id, current_user, session)
repo = await session.get(Repository, conn_in.repository_id)
if not repo or repo.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
)
result = await session.execute(
select(RepositoryConnection).where(
RepositoryConnection.project_id == project_id,
RepositoryConnection.repository_id == conn_in.repository_id,
RepositoryConnection.provider_kind == conn_in.provider_kind,
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Connection already exists for this repository and provider",
)
storage = DatabaseCredentialStorage(session)
credential: GitCredential
if conn_in.credential_kind == "access_token":
credential = AccessTokenCredential(
encrypted_payload=conn_in.credential_payload
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported credential kind: {conn_in.credential_kind}",
)
credential_id = await storage.create(credential)
connection = RepositoryConnection(
project_id=project_id,
repository_id=conn_in.repository_id,
provider_kind=conn_in.provider_kind,
credential_id=credential_id,
connection_status=str(ConnectionStatus.pending),
)
session.add(connection)
await session.commit()
await session.refresh(connection)
try:
provider = get_provider(ProviderKind(conn_in.provider_kind))
provider_status = provider.validate_connection(repo.git_url, str(credential_id))
connection.connection_status = str(provider_status)
except Exception:
connection.connection_status = str(ConnectionStatus.error)
await session.commit()
await session.refresh(connection)
return connection
@router.get(
"/projects/{project_id}/repository-connections",
response_model=list[RepositoryConnectionRead],
)
async def list_repository_connections(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[RepositoryConnection]:
await _get_project_for_user(project_id, current_user, session)
result = await session.execute(
select(RepositoryConnection).where(
RepositoryConnection.project_id == project_id
)
)
return list(result.scalars().all())
@router.get(
"/projects/{project_id}/repository-connections/{connection_id}",
response_model=RepositoryConnectionRead,
)
async def get_repository_connection(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> RepositoryConnection:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
return connection
@router.delete(
"/projects/{project_id}/repository-connections/{connection_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_repository_connection(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
if connection.credential_id:
storage = DatabaseCredentialStorage(session)
await storage.delete(connection.credential_id)
await session.delete(connection)
await session.commit()
@router.post(
"/projects/{project_id}/repository-connections/{connection_id}/ssh-key",
response_model=SshKeyResponse,
status_code=status.HTTP_201_CREATED,
)
async def generate_ssh_key(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
key_pair = SshKeyLifecycle.generate(connection_id)
storage = DatabaseCredentialStorage(session)
ssh_credential = GitCredential(
kind="ssh_key",
encrypted_payload=key_pair.encrypted_private_key,
)
credential_id = await storage.create(ssh_credential)
connection.credential_id = credential_id
await session.commit()
return {
"connection_id": str(connection_id),
"public_key": key_pair.public_key,
"credential_id": str(credential_id),
}
@router.post(
"/projects/{project_id}/repository-connections/{connection_id}/validate",
response_model=RepositoryConnectionRead,
)
async def validate_connection(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> RepositoryConnection:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
repo = await session.get(Repository, connection.repository_id)
if not repo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
)
try:
provider = get_provider(ProviderKind(connection.provider_kind))
provider_status = provider.validate_connection(
repo.git_url, str(connection.credential_id) if connection.credential_id else ""
)
connection.connection_status = str(provider_status)
except Exception:
connection.connection_status = str(ConnectionStatus.error)
await session.commit()
await session.refresh(connection)
return connection
-129
View File
@@ -1,129 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.encryption import encrypt_value
from app.models.project import Project
from app.models.secret import Secret
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.schemas.secret import SecretCreate, SecretRead, SecretUpdate
router = APIRouter(tags=["secrets"])
async def _verify_secret_ownership(
secret_obj: Secret, user: User, session: AsyncSession
) -> None:
if secret_obj.scope_type == "user":
if secret_obj.scope_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif secret_obj.scope_type == "project":
project = await session.get(Project, secret_obj.scope_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif secret_obj.scope_type == "tool_instance":
ti = await session.get(ToolInstance, secret_obj.scope_id)
if not ti:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
project = await session.get(Project, ti.project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
elif secret_obj.scope_type == "global":
pass
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid scope_type")
@router.post("/secrets", response_model=SecretRead, status_code=status.HTTP_201_CREATED)
async def create_secret(
secret_in: SecretCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> SecretRead:
secret = Secret(
scope_type=secret_in.scope_type,
scope_id=secret_in.scope_id,
key=secret_in.key,
encrypted_value=encrypt_value(secret_in.value),
)
await _verify_secret_ownership(secret, current_user, session)
session.add(secret)
await session.commit()
await session.refresh(secret)
return SecretRead.from_secret(secret)
@router.get("/secrets", response_model=list[SecretRead])
async def list_secrets(
scope_type: str | None = None,
scope_id: UUID | None = None,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[SecretRead]:
stmt = select(Secret)
if scope_type:
stmt = stmt.where(Secret.scope_type == scope_type)
if scope_id:
stmt = stmt.where(Secret.scope_id == scope_id)
result = await session.execute(stmt)
secrets = list(result.scalars().all())
allowed = []
for s in secrets:
try:
await _verify_secret_ownership(s, current_user, session)
allowed.append(SecretRead.from_secret(s))
except HTTPException:
pass
return allowed
@router.get("/secrets/{secret_id}", response_model=SecretRead)
async def get_secret(
secret_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> SecretRead:
s = await session.get(Secret, secret_id)
if not s:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
await _verify_secret_ownership(s, current_user, session)
return SecretRead.from_secret(s)
@router.put("/secrets/{secret_id}", response_model=SecretRead)
async def update_secret(
secret_id: UUID,
secret_in: SecretUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> SecretRead:
s = await session.get(Secret, secret_id)
if not s:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
await _verify_secret_ownership(s, current_user, session)
if secret_in.key is not None:
s.key = secret_in.key
if secret_in.value is not None:
s.encrypted_value = encrypt_value(secret_in.value)
await session.commit()
await session.refresh(s)
return SecretRead.from_secret(s)
@router.delete("/secrets/{secret_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_secret(
secret_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
s = await session.get(Secret, secret_id)
if not s:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
await _verify_secret_ownership(s, current_user, session)
await session.delete(s)
await session.commit()
-88
View File
@@ -1,88 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.tool_definition import ToolDefinition
from app.models.user import User
from app.schemas.tool_definition import (
ToolDefinitionCreate,
ToolDefinitionRead,
ToolDefinitionUpdate,
)
router = APIRouter(tags=["tool-definitions"])
@router.post("/tool-definitions", response_model=ToolDefinitionRead, status_code=status.HTTP_201_CREATED) # noqa: E501
async def create_tool_definition(
td_in: ToolDefinitionCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolDefinition:
td = ToolDefinition(**td_in.model_dump())
session.add(td)
await session.commit()
await session.refresh(td)
return td
@router.get("/tool-definitions", response_model=list[ToolDefinitionRead])
async def list_tool_definitions(
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[ToolDefinition]:
result = await session.execute(select(ToolDefinition))
return list(result.scalars().all())
@router.get("/tool-definitions/{tool_def_id}", response_model=ToolDefinitionRead)
async def get_tool_definition(
tool_def_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolDefinition:
td = await session.get(ToolDefinition, tool_def_id)
if not td:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found"
)
return td
@router.put("/tool-definitions/{tool_def_id}", response_model=ToolDefinitionRead)
async def update_tool_definition(
tool_def_id: UUID,
td_in: ToolDefinitionUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolDefinition:
td = await session.get(ToolDefinition, tool_def_id)
if not td:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found"
)
update_data = td_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(td, field, value)
await session.commit()
await session.refresh(td)
return td
@router.delete("/tool-definitions/{tool_def_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tool_definition(
tool_def_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
td = await session.get(ToolDefinition, tool_def_id)
if not td:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found"
)
await session.delete(td)
await session.commit()
-327
View File
@@ -1,327 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.config import settings
from app.db import get_db_session
from app.models.project import Project
from app.models.tool_definition import ToolDefinition
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate
from app.services.spawn import SpawnError, SpawnService
from app.services.traefik import TraefikLabelGenerator
from app.tools.registry import registry
router = APIRouter(tags=["tool-instances"])
async def _get_project_for_user(
project_id: UUID, user: User, session: AsyncSession
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
def _get_user_slug(user: User) -> str:
user_slug = (
user.display_name
or user.email.split("@")[0]
if user.email
else "user"
)
return user_slug.lower().replace(" ", "-").replace("_", "-")
@router.post(
"/projects/{project_id}/tool-instances",
response_model=ToolInstanceRead,
status_code=status.HTTP_201_CREATED,
)
async def create_tool_instance(
project_id: UUID,
ti_in: ToolInstanceCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
project = await _get_project_for_user(project_id, current_user, session)
tool_def = await session.get(ToolDefinition, ti_in.tool_definition_id)
if not tool_def:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool definition not found",
)
manifest = registry.get(tool_def.key)
if not manifest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool manifest '{tool_def.key}' not found in registry",
)
existing = await session.execute(
select(ToolInstance).where(
ToolInstance.project_id == project_id,
ToolInstance.tool_definition_id == ti_in.tool_definition_id,
ToolInstance.status.in_(["creating", "running"]),
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A running instance of this tool already exists for this project",
)
ti = ToolInstance(**ti_in.model_dump(), project_id=project_id)
user_slug = _get_user_slug(current_user)
spawn_service = SpawnService()
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
try:
spawn_result = spawn_service.spawn(
instance_id=str(ti.id),
manifest=manifest,
project_slug=project.slug,
user_slug=user_slug,
)
except SpawnError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to spawn container: {e}",
) from e
auth_labels = label_gen.generate_forward_auth_labels(
instance_id=str(ti.id),
auth_url=f"https://{settings.root_domain}/api/v1/auth/validate",
)
traefik_labels = {**spawn_result["traefik_labels"], **auth_labels}
ti.container_id = spawn_result["container_id"]
ti.subdomain = spawn_result["subdomain"]
ti.traefik_labels = traefik_labels
ti.status = spawn_service.get_status(str(ti.id))
session.add(ti)
await session.commit()
await session.refresh(ti)
return ti
@router.get(
"/projects/{project_id}/tool-instances",
response_model=list[ToolInstanceRead],
)
async def list_tool_instances(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[ToolInstance]:
await _get_project_for_user(project_id, current_user, session)
result = await session.execute(
select(ToolInstance).where(ToolInstance.project_id == project_id)
)
return list(result.scalars().all())
@router.get(
"/projects/{project_id}/tool-instances/{instance_id}",
response_model=ToolInstanceRead,
)
async def get_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
return ti
@router.put(
"/projects/{project_id}/tool-instances/{instance_id}",
response_model=ToolInstanceRead,
)
async def update_tool_instance(
project_id: UUID,
instance_id: UUID,
ti_in: ToolInstanceUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
update_data = ti_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(ti, field, value)
await session.commit()
await session.refresh(ti)
return ti
@router.delete(
"/projects/{project_id}/tool-instances/{instance_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
spawn_service = SpawnService()
spawn_service.stop(str(instance_id))
await session.delete(ti)
await session.commit()
@router.post(
"/projects/{project_id}/tool-instances/{instance_id}/stop",
response_model=ToolInstanceRead,
)
async def stop_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
spawn_service = SpawnService()
spawn_service.stop(str(instance_id))
ti.status = "stopped"
ti.container_id = None
await session.commit()
await session.refresh(ti)
return ti
@router.post(
"/projects/{project_id}/tool-instances/{instance_id}/start",
response_model=ToolInstanceRead,
)
async def start_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
tool_def = await session.get(ToolDefinition, ti.tool_definition_id)
if not tool_def:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool definition not found",
)
manifest = registry.get(tool_def.key)
if not manifest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool manifest '{tool_def.key}' not found in registry",
)
user_slug = _get_user_slug(current_user)
spawn_service = SpawnService()
try:
spawn_result = spawn_service.spawn(
instance_id=str(ti.id),
manifest=manifest,
project_slug=ti.project.slug,
user_slug=user_slug,
)
except SpawnError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to spawn container: {e}",
) from e
ti.container_id = spawn_result["container_id"]
ti.subdomain = spawn_result["subdomain"]
ti.traefik_labels = spawn_result["traefik_labels"]
ti.status = spawn_service.get_status(str(ti.id))
await session.commit()
await session.refresh(ti)
return ti
@router.get(
"/projects/{project_id}/tool-instances/{instance_id}/status",
response_model=dict,
)
async def get_tool_instance_status(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
spawn_service = SpawnService()
container_status = spawn_service.get_status(str(instance_id))
if ti.status != container_status:
ti.status = container_status
await session.commit()
return {
"instance_id": str(instance_id),
"status": container_status,
"subdomain": ti.subdomain or "",
"container_id": ti.container_id or "",
}
@router.get("/auth/validate", status_code=status.HTTP_200_OK)
async def validate_auth_for_traefik(
current_user: User = Depends(get_current_active_user),
) -> dict[str, str]:
return {"status": "ok", "user_id": str(current_user.id)}
-17
View File
@@ -1,17 +0,0 @@
from fastapi import APIRouter, Depends
from app.auth.dependencies import get_current_active_user
from app.models.user import User
from app.schemas.user import UserRead
router = APIRouter(tags=["users"])
@router.get("/users/me", response_model=UserRead)
async def read_current_user(current_user: User = Depends(get_current_active_user)) -> User:
return current_user
@router.get("/users", response_model=list[UserRead])
async def list_users(current_user: User = Depends(get_current_active_user)) -> list[User]:
return [current_user]
-100
View File
@@ -1,100 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.models.project import Project
from app.models.user import User
from app.models.workspace import Workspace
from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate
router = APIRouter(tags=["workspaces"])
async def _get_project_for_user(
project_id: UUID, user: User, session: AsyncSession
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.post("/projects/{project_id}/workspaces", response_model=WorkspaceRead, status_code=status.HTTP_201_CREATED) # noqa: E501
async def create_workspace(
project_id: UUID,
ws_in: WorkspaceCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Workspace:
await _get_project_for_user(project_id, current_user, session)
ws = Workspace(**ws_in.model_dump(), project_id=project_id)
session.add(ws)
await session.commit()
await session.refresh(ws)
return ws
@router.get("/projects/{project_id}/workspaces", response_model=list[WorkspaceRead])
async def list_workspaces(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[Workspace]:
await _get_project_for_user(project_id, current_user, session)
result = await session.execute(
select(Workspace).where(Workspace.project_id == project_id)
)
return list(result.scalars().all())
@router.get("/projects/{project_id}/workspaces/{ws_id}", response_model=WorkspaceRead)
async def get_workspace(
project_id: UUID,
ws_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Workspace:
await _get_project_for_user(project_id, current_user, session)
ws = await session.get(Workspace, ws_id)
if not ws or ws.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
return ws
@router.put("/projects/{project_id}/workspaces/{ws_id}", response_model=WorkspaceRead)
async def update_workspace(
project_id: UUID,
ws_id: UUID,
ws_in: WorkspaceUpdate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> Workspace:
await _get_project_for_user(project_id, current_user, session)
ws = await session.get(Workspace, ws_id)
if not ws or ws.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
update_data = ws_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(ws, field, value)
await session.commit()
await session.refresh(ws)
return ws
@router.delete("/projects/{project_id}/workspaces/{ws_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
project_id: UUID,
ws_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _get_project_for_user(project_id, current_user, session)
ws = await session.get(Workspace, ws_id)
if not ws or ws.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
await session.delete(ws)
await session.commit()
-42
View File
@@ -1,42 +0,0 @@
from app.schemas.access_route import AccessRouteCreate, AccessRouteRead, AccessRouteUpdate
from app.schemas.config import ConfigCreate, ConfigRead, ConfigUpdate
from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
from app.schemas.repository import RepositoryCreate, RepositoryRead, RepositoryUpdate
from app.schemas.secret import SecretCreate, SecretRead, SecretUpdate
from app.schemas.tool_definition import (
ToolDefinitionCreate,
ToolDefinitionRead,
ToolDefinitionUpdate,
)
from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate
from app.schemas.user import UserCreate, UserRead
from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate
__all__ = [
"AccessRouteCreate",
"AccessRouteRead",
"AccessRouteUpdate",
"ConfigCreate",
"ConfigRead",
"ConfigUpdate",
"ProjectCreate",
"ProjectRead",
"ProjectUpdate",
"RepositoryCreate",
"RepositoryRead",
"RepositoryUpdate",
"SecretCreate",
"SecretRead",
"SecretUpdate",
"ToolDefinitionCreate",
"ToolDefinitionRead",
"ToolDefinitionUpdate",
"ToolInstanceCreate",
"ToolInstanceRead",
"ToolInstanceUpdate",
"UserCreate",
"UserRead",
"WorkspaceCreate",
"WorkspaceRead",
"WorkspaceUpdate",
]
-29
View File
@@ -1,29 +0,0 @@
from typing import Any
from uuid import UUID
from app.schemas.base import OrmBase
class AccessRouteBase(OrmBase):
domain: str
path_prefix: str = "/"
provider_type: str = "traefik"
provider_config: dict[str, Any] | None = None
is_active: bool = True
class AccessRouteCreate(AccessRouteBase):
pass
class AccessRouteRead(AccessRouteBase):
id: UUID
tool_instance_id: UUID
class AccessRouteUpdate(OrmBase):
domain: str | None = None
path_prefix: str | None = None
provider_type: str | None = None
provider_config: dict[str, Any] | None = None
is_active: bool | None = None
-5
View File
@@ -1,5 +0,0 @@
from pydantic import BaseModel, ConfigDict
class OrmBase(BaseModel):
model_config = ConfigDict(from_attributes=True)
-25
View File
@@ -1,25 +0,0 @@
from typing import Any
from uuid import UUID
from app.schemas.base import OrmBase
class ConfigBase(OrmBase):
scope_type: str
scope_id: UUID
tool_definition_id: UUID | None = None
key: str
value: dict[str, Any]
class ConfigCreate(ConfigBase):
pass
class ConfigRead(ConfigBase):
id: UUID
class ConfigUpdate(OrmBase):
key: str | None = None
value: dict[str, Any] | None = None
-24
View File
@@ -1,24 +0,0 @@
from uuid import UUID
from app.schemas.base import OrmBase
class ProjectBase(OrmBase):
name: str
slug: str
description: str | None = None
class ProjectCreate(ProjectBase):
pass
class ProjectRead(ProjectBase):
id: UUID
owner_id: UUID
class ProjectUpdate(OrmBase):
name: str | None = None
slug: str | None = None
description: str | None = None
-26
View File
@@ -1,26 +0,0 @@
from uuid import UUID
from app.schemas.base import OrmBase
class RepositoryBase(OrmBase):
name: str
git_url: str
provider_type: str = "generic"
default_branch: str = "main"
class RepositoryCreate(RepositoryBase):
pass
class RepositoryRead(RepositoryBase):
id: UUID
project_id: UUID
class RepositoryUpdate(OrmBase):
name: str | None = None
git_url: str | None = None
provider_type: str | None = None
default_branch: str | None = None
@@ -1,35 +0,0 @@
from uuid import UUID
from app.schemas.base import OrmBase
class RepositoryConnectionBase(OrmBase):
project_id: UUID
repository_id: UUID | None = None
provider_kind: str = "generic"
credential_id: UUID | None = None
connection_status: str = "pending"
default_branch: str | None = None
class RepositoryConnectionCreate(OrmBase):
repository_id: UUID
provider_kind: str
credential_kind: str
credential_payload: str
class RepositoryConnectionRead(OrmBase):
id: UUID
project_id: UUID
repository_id: UUID | None = None
provider_kind: str
credential_id: UUID | None = None
connection_status: str
default_branch: str | None = None
class SshKeyResponse(OrmBase):
connection_id: UUID
public_key: str
credential_id: UUID
-39
View File
@@ -1,39 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from app.schemas.base import OrmBase
if TYPE_CHECKING:
from app.models.secret import Secret
class SecretBase(OrmBase):
scope_type: str
scope_id: UUID
key: str
class SecretCreate(SecretBase):
value: str
class SecretRead(SecretBase):
id: UUID
value: str = "••••••"
@classmethod
def from_secret(cls, secret: Secret) -> SecretRead:
return cls(
id=secret.id,
scope_type=secret.scope_type,
scope_id=secret.scope_id,
key=secret.key,
value="••••••",
)
class SecretUpdate(OrmBase):
key: str | None = None
value: str | None = None
-29
View File
@@ -1,29 +0,0 @@
from typing import Any
from uuid import UUID
from app.schemas.base import OrmBase
class ToolDefinitionBase(OrmBase):
key: str
name: str
version: str = "1.0.0"
description: str | None = None
image: str
manifest_data: dict[str, Any] | None = None
class ToolDefinitionCreate(ToolDefinitionBase):
pass
class ToolDefinitionRead(ToolDefinitionBase):
id: UUID
class ToolDefinitionUpdate(OrmBase):
name: str | None = None
version: str | None = None
description: str | None = None
image: str | None = None
manifest_data: dict[str, Any] | None = None
-32
View File
@@ -1,32 +0,0 @@
from typing import Any
from uuid import UUID
from app.schemas.base import OrmBase
class ToolInstanceBase(OrmBase):
name: str
status: str = "pending"
container_id: str | None = None
subdomain: str | None = None
config_override: dict[str, Any] | None = None
traefik_labels: dict[str, Any] | None = None
class ToolInstanceCreate(ToolInstanceBase):
tool_definition_id: UUID
class ToolInstanceRead(ToolInstanceBase):
id: UUID
project_id: UUID
tool_definition_id: UUID
class ToolInstanceUpdate(OrmBase):
name: str | None = None
status: str | None = None
container_id: str | None = None
subdomain: str | None = None
config_override: dict[str, Any] | None = None
traefik_labels: dict[str, Any] | None = None
-21
View File
@@ -1,21 +0,0 @@
from datetime import datetime
from uuid import UUID
from app.schemas.base import OrmBase
class UserBase(OrmBase):
authentik_sub: str
email: str
display_name: str | None = None
is_active: bool = True
class UserCreate(UserBase):
pass
class UserRead(UserBase):
id: UUID
created_at: datetime
updated_at: datetime
-22
View File
@@ -1,22 +0,0 @@
from uuid import UUID
from app.schemas.base import OrmBase
class WorkspaceBase(OrmBase):
name: str
mount_path: str | None = None
class WorkspaceCreate(WorkspaceBase):
pass
class WorkspaceRead(WorkspaceBase):
id: UUID
project_id: UUID
class WorkspaceUpdate(OrmBase):
name: str | None = None
mount_path: str | None = None
-135
View File
@@ -1,135 +0,0 @@
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.encryption import decrypt_value
from app.models.config import Config
from app.models.secret import Secret
class RuntimeInjectionError(Exception):
pass
class RuntimeInjectionService:
SCOPE_HIERARCHY = ["global", "user", "project", "tool_instance"]
@staticmethod
async def resolve_configs(
session: AsyncSession,
project_id: uuid.UUID,
user_id: uuid.UUID,
instance_id: uuid.UUID | None = None,
tool_definition_id: uuid.UUID | None = None,
) -> dict[str, Any]:
stmt = select(Config).where(
(Config.scope_type == "global")
| (
(Config.scope_type == "user")
& (Config.scope_id == user_id)
)
| (
(Config.scope_type == "project")
& (Config.scope_id == project_id)
)
| (
(Config.scope_type == "tool_instance")
& (Config.scope_id == (instance_id or uuid.UUID(int=0)))
)
)
if tool_definition_id:
stmt = stmt.where(
(Config.tool_definition_id == tool_definition_id)
| (Config.tool_definition_id.is_(None))
)
result = await session.execute(stmt)
configs = list(result.scalars().all())
resolved: dict[str, Any] = {}
for scope in RuntimeInjectionService.SCOPE_HIERARCHY:
for cfg in configs:
if cfg.scope_type == scope:
resolved[cfg.key] = cfg.value
return resolved
@staticmethod
async def resolve_secrets(
session: AsyncSession,
project_id: uuid.UUID,
user_id: uuid.UUID,
instance_id: uuid.UUID | None = None,
) -> dict[str, str]:
stmt = select(Secret).where(
(Secret.scope_type == "global")
| (
(Secret.scope_type == "user")
& (Secret.scope_id == user_id)
)
| (
(Secret.scope_type == "project")
& (Secret.scope_id == project_id)
)
| (
(Secret.scope_type == "tool_instance")
& (Secret.scope_id == (instance_id or uuid.UUID(int=0)))
)
)
result = await session.execute(stmt)
secrets = list(result.scalars().all())
resolved: dict[str, str] = {}
for scope in RuntimeInjectionService.SCOPE_HIERARCHY:
for secret in secrets:
if secret.scope_type == scope:
resolved[secret.key] = decrypt_value(secret.encrypted_value)
return resolved
@staticmethod
def generate_config_files(configs: dict[str, Any], config_dir: Path) -> list[str]:
config_dir.mkdir(parents=True, exist_ok=True)
mounts = []
for key, value in configs.items():
file_path = config_dir / f"{key}.json"
file_path.write_text(json.dumps(value, indent=2))
file_path.chmod(0o400)
mounts.append(f"{file_path}:/app/config/{key}.json:ro")
return mounts
@staticmethod
def generate_secret_env_vars(secrets: dict[str, str]) -> dict[str, str]:
return {key.upper(): value for key, value in secrets.items()}
@staticmethod
async def validate_secrets_exist(
session: AsyncSession,
required_secret_keys: list[str],
project_id: uuid.UUID,
user_id: uuid.UUID,
instance_id: uuid.UUID | None = None,
) -> None:
resolved = await RuntimeInjectionService.resolve_secrets(
session, project_id, user_id, instance_id
)
missing = [key for key in required_secret_keys if key not in resolved]
if missing:
raise RuntimeInjectionError(
f"Missing required secrets: {', '.join(missing)}"
)
-345
View File
@@ -1,345 +0,0 @@
from __future__ import annotations
import json
import logging
import subprocess
from pathlib import Path
from typing import Any
from app.config import settings
from app.services.traefik import TraefikLabelGenerator
from app.tools.models import ToolManifest
logger = logging.getLogger(__name__)
class SpawnError(Exception):
pass
class SpawnService:
def __init__(
self,
compose_dir: Path | None = None,
network_name: str = "tools",
) -> None:
self.compose_dir = compose_dir or Path("/tmp/headquarter-compose")
self.network_name = network_name
self.compose_dir.mkdir(parents=True, exist_ok=True)
def _generate_compose_service(
self,
instance_id: str,
manifest: ToolManifest,
subdomain: str,
traefik_labels: dict[str, str],
project_slug: str,
user_slug: str,
workspace_path: Path | None = None,
config_path: Path | None = None,
ssh_key_path: Path | None = None,
config_mounts: list[str] | None = None,
secret_env_vars: dict[str, str] | None = None,
) -> dict[str, Any]:
service_name = f"tool-{instance_id[:8]}"
service: dict[str, Any] = {
"image": manifest.image,
"container_name": service_name,
"restart": "unless-stopped",
"labels": traefik_labels,
"networks": [self.network_name],
}
if manifest.runtime_command:
service["command"] = manifest.runtime_command
if manifest.runtime_entrypoint:
service["entrypoint"] = manifest.runtime_entrypoint
if manifest.runtime_user:
service["user"] = manifest.runtime_user
if manifest.runtime_working_dir:
service["working_dir"] = manifest.runtime_working_dir
ports = manifest.ports
if ports:
service["ports"] = [
f"{port.container_port}:{port.container_port}"
for port in ports
]
env = dict(manifest.env)
env.update({
"PROJECT_SLUG": project_slug,
"USER_SLUG": user_slug,
})
service["environment"] = env
volumes: list[str] = []
default_workspace = f"/data/workspaces/{user_slug}/{project_slug}"
for mount in manifest.workspace_mounts:
source = mount.source_pattern.format(
project_repo=str(workspace_path) if workspace_path else default_workspace,
)
ro_suffix = ":ro" if mount.read_only else ""
volumes.append(f"{source}:{mount.target}{ro_suffix}")
default_config = f"/data/configs/{user_slug}"
for mount in manifest.config_mounts:
source = mount.source_pattern.format(
user_config=str(config_path) if config_path else default_config,
)
ro_suffix = ":ro" if mount.read_only else ""
volumes.append(f"{source}:{mount.target}{ro_suffix}")
if ssh_key_path and ssh_key_path.exists():
volumes.append(f"{ssh_key_path}:/home/coder/.ssh:ro")
if config_mounts:
volumes.extend(config_mounts)
if volumes:
service["volumes"] = volumes
if secret_env_vars:
service["environment"].update(secret_env_vars)
if manifest.health_check:
hc = manifest.health_check
healthcheck: dict[str, Any] = {
"interval": f"{hc.interval_seconds}s",
"timeout": f"{hc.timeout_seconds}s",
"retries": hc.retries,
"start_period": f"{hc.start_period_seconds}s",
}
if hc.type == "http":
healthcheck["test"] = [
"CMD",
"curl",
"-f",
f"http://localhost:{hc.port}{hc.path}",
]
elif hc.type == "tcp":
healthcheck["test"] = [
"CMD",
"nc",
"-z",
"localhost",
str(hc.port),
]
elif hc.type == "command":
healthcheck["test"] = ["CMD"] + (hc.command or [])
service["healthcheck"] = healthcheck
if manifest.resource_limits:
rl = manifest.resource_limits
deploy: dict[str, Any] = {"resources": {"limits": {}}}
if rl.cpus:
deploy["resources"]["limits"]["cpus"] = str(rl.cpus)
if rl.memory_mb:
deploy["resources"]["limits"]["memory"] = f"{rl.memory_mb}M"
if rl.memory_swap_mb is not None and rl.memory_swap_mb >= 0:
deploy["resources"]["limits"]["swap"] = f"{rl.memory_swap_mb}M"
service["deploy"] = deploy
return service
def _write_compose_file(
self,
instance_id: str,
service: dict[str, Any],
) -> Path:
compose_path = self.compose_dir / f"{instance_id}.yml"
compose = {
"version": "3.8",
"services": {f"tool-{instance_id[:8]}": service},
"networks": {
self.network_name: {
"external": True,
},
},
}
compose_path.write_text(json.dumps(compose, indent=2))
return compose_path
def spawn(
self,
instance_id: str,
manifest: ToolManifest,
project_slug: str,
user_slug: str,
workspace_path: Path | None = None,
config_path: Path | None = None,
ssh_key_path: Path | None = None,
config_mounts: list[str] | None = None,
secret_env_vars: dict[str, str] | None = None,
) -> dict[str, Any]:
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
primary_port = next(
(p.container_port for p in manifest.ports if p.primary),
manifest.ports[0].container_port if manifest.ports else 8080,
)
subdomain = label_gen.generate_subdomain(
tool_key=manifest.id,
project_slug=project_slug,
user_slug=user_slug,
)
traefik_labels = label_gen.generate_labels(
instance_id=instance_id,
tool_key=manifest.id,
project_slug=project_slug,
user_slug=user_slug,
container_port=primary_port,
network_name=self.network_name,
)
service = self._generate_compose_service(
instance_id=instance_id,
manifest=manifest,
subdomain=subdomain,
traefik_labels=traefik_labels,
project_slug=project_slug,
user_slug=user_slug,
workspace_path=workspace_path,
config_path=config_path,
ssh_key_path=ssh_key_path,
config_mounts=config_mounts,
secret_env_vars=secret_env_vars,
)
compose_path = self._write_compose_file(instance_id, service)
try:
result = subprocess.run(
[
"docker", "compose",
"-f", str(compose_path),
"-p", f"hq-tool-{instance_id[:8]}",
"up", "-d", "--remove-orphans",
],
capture_output=True,
text=True,
check=True,
)
logger.info("Spawned container for instance %s: %s", instance_id, result.stdout)
except subprocess.CalledProcessError as e:
logger.error("Failed to spawn container for instance %s: %s", instance_id, e.stderr)
raise SpawnError(f"Failed to spawn container: {e.stderr}") from e
container_id = self._get_container_id(instance_id)
return {
"container_id": container_id,
"subdomain": subdomain,
"traefik_labels": traefik_labels,
"compose_path": str(compose_path),
}
def stop(self, instance_id: str) -> None:
compose_path = self.compose_dir / f"{instance_id}.yml"
if not compose_path.exists():
logger.warning("Compose file not found for instance %s", instance_id)
return
try:
subprocess.run(
[
"docker", "compose",
"-f", str(compose_path),
"-p", f"hq-tool-{instance_id[:8]}",
"down",
],
capture_output=True,
text=True,
check=True,
)
logger.info("Stopped container for instance %s", instance_id)
except subprocess.CalledProcessError as e:
logger.error("Failed to stop container for instance %s: %s", instance_id, e.stderr)
raise SpawnError(f"Failed to stop container: {e.stderr}") from e
def get_status(self, instance_id: str) -> str:
container_id = self._get_container_id(instance_id)
if not container_id:
return "stopped"
try:
result = subprocess.run(
[
"docker", "inspect",
"-f", "{{.State.Status}}",
container_id,
],
capture_output=True,
text=True,
check=True,
)
status = result.stdout.strip()
if status == "running":
health = self._get_health_status(container_id)
if health == "healthy":
return "running"
elif health == "unhealthy":
return "error"
else:
return "creating"
elif status in ("exited", "dead"):
return "stopped"
elif status == "paused":
return "stopped"
else:
return "creating"
except subprocess.CalledProcessError:
return "stopped"
def _get_container_id(self, instance_id: str) -> str | None:
service_name = f"tool-{instance_id[:8]}"
project_name = f"hq-tool-{instance_id[:8]}"
try:
result = subprocess.run(
[
"docker", "compose",
"-p", project_name,
"ps", "-q", service_name,
],
capture_output=True,
text=True,
check=True,
)
container_id = result.stdout.strip()
return container_id if container_id else None
except subprocess.CalledProcessError:
return None
def _get_health_status(self, container_id: str) -> str | None:
try:
result = subprocess.run(
[
"docker", "inspect",
"-f", "{{.State.Health.Status}}",
container_id,
],
capture_output=True,
text=True,
check=True,
)
status = result.stdout.strip()
return status if status else None
except subprocess.CalledProcessError:
return None
-101
View File
@@ -1,101 +0,0 @@
class TraefikLabelGenerator:
def __init__(self, domain: str, entrypoint: str = "websecure"):
self.domain = domain
self.entrypoint = entrypoint
def generate_subdomain(
self,
tool_key: str,
project_slug: str,
user_slug: str,
) -> str:
return f"{tool_key}-{project_slug}-{user_slug}.{self.domain}"
def generate_labels(
self,
instance_id: str,
tool_key: str,
project_slug: str,
user_slug: str,
container_port: int,
network_name: str = "tools",
) -> dict[str, str]:
subdomain = self.generate_subdomain(tool_key, project_slug, user_slug)
router_name = f"tool-{instance_id[:8]}"
service_name = f"tool-{instance_id[:8]}"
labels: dict[str, str] = {}
labels["traefik.enable"] = "true"
labels[f"traefik.http.routers.{router_name}.rule"] = (
f"Host(`{subdomain}`)"
)
labels[f"traefik.http.routers.{router_name}.entrypoints"] = (
self.entrypoint
)
labels[f"traefik.http.routers.{router_name}.service"] = service_name
if self.entrypoint == "websecure":
labels[f"traefik.http.routers.{router_name}.tls"] = "true"
labels[
f"traefik.http.routers.{router_name}.tls.certresolver"
] = "letsencrypt"
labels[f"traefik.http.services.{service_name}.loadbalancer.server.port"] = (
str(container_port)
)
labels[f"traefik.http.services.{service_name}.loadbalancer.server.scheme"] = (
"http"
)
middleware_name = f"tool-{instance_id[:8]}-sec"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.stsSeconds"
] = "31536000"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.stsIncludeSubdomains"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.forceStsHeader"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.contentTypeNosniff"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.browserXssFilter"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.customFrameOptionsValue"
] = "SAMEORIGIN"
labels[f"traefik.http.routers.{router_name}.middlewares"] = middleware_name
labels["traefik.docker.network"] = network_name
return labels
def generate_forward_auth_labels(
self,
instance_id: str,
auth_url: str,
) -> dict[str, str]:
router_name = f"tool-{instance_id[:8]}"
middleware_name = f"tool-{instance_id[:8]}-auth"
return {
f"traefik.http.middlewares.{middleware_name}.forwardauth.address": auth_url,
f"traefik.http.middlewares.{middleware_name}.forwardauth.trustForwardHeader": "true",
f"traefik.http.routers.{router_name}.middlewares": middleware_name,
}
def generate_removal_labels(
self,
instance_id: str,
) -> dict[str, str]:
router_name = f"tool-{instance_id[:8]}"
return {
"traefik.enable": "false",
f"traefik.http.routers.{router_name}.rule": "",
}
View File
@@ -1,52 +0,0 @@
id: code-server
name: code-server
description: VS Code in the browser.
version: "1.0.0"
image: codercom/code-server:latest
runtime_command:
- "--bind-addr"
- "0.0.0.0:8080"
- "--auth"
- "none"
- "--disable-telemetry"
- "--disable-update-check"
runtime_entrypoint: []
runtime_user: "coder"
runtime_working_dir: /workspace
ports:
- container_port: 8080
protocol: tcp
name: http
primary: true
workspace_mounts:
- type: volume
source_pattern: "{project_repo}"
target: /workspace
read_only: false
config_mounts:
- type: volume
source_pattern: "{user_config}/code-server"
target: /home/coder/.config/code-server
read_only: false
env:
PASSWORD: ""
SUDO_PASSWORD: ""
secrets: []
health_check:
type: http
path: /healthz
port: 8080
interval_seconds: 10
timeout_seconds: 5
retries: 3
start_period_seconds: 5
resource_limits:
cpus: 2.0
memory_mb: 4096
memory_swap_mb: -1
traefik:
enabled: true
subdomain_prefix: code
port: 8080
middlewares: []
strip_prefix: false
-42
View File
@@ -1,42 +0,0 @@
id: opencode
name: OpenCode
description: AI-powered terminal-based development environment with web interface.
version: "1.0.0"
image: ghcr.io/opencode-ai/opencode:latest
runtime_working_dir: /workspace
ports:
- container_port: 3000
protocol: tcp
name: http
primary: true
workspace_mounts:
- type: volume
source_pattern: "{project_repo}"
target: /workspace
read_only: false
config_mounts:
- type: volume
source_pattern: "{user_config}/opencode"
target: /root/.config/opencode
read_only: false
env:
TERM: xterm-256color
FORCE_COLOR: "1"
health_check:
type: http
path: /
port: 3000
interval_seconds: 10
timeout_seconds: 5
retries: 3
start_period_seconds: 15
resource_limits:
cpus: 2.0
memory_mb: 4096
memory_swap_mb: -1
traefik:
enabled: true
subdomain_prefix: opencode
port: 3000
middlewares: []
strip_prefix: false
-109
View File
@@ -1,109 +0,0 @@
"""Pydantic v2 models for the Headquarter tool manifest schema."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
class PortConfig(BaseModel):
container_port: int = Field(..., ge=1, le=65535)
protocol: Literal["tcp", "udp"] = "tcp"
name: str | None = None
primary: bool = False
class MountConfig(BaseModel):
type: Literal["volume", "bind"] = "volume"
source_pattern: str
target: str
read_only: bool = False
@field_validator("target")
@classmethod
def _target_must_be_absolute(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("mount target must be an absolute path")
return v
class SecretRef(BaseModel):
name: str
env_var: str
required: bool = True
class HealthCheckConfig(BaseModel):
type: Literal["http", "tcp", "command"] = "http"
path: str | None = None
command: list[str] | None = None
port: int | None = None
interval_seconds: int = Field(default=10, ge=1)
timeout_seconds: int = Field(default=5, ge=1)
retries: int = Field(default=3, ge=1)
start_period_seconds: int = Field(default=5, ge=0)
@model_validator(mode="after")
def _check_required_fields(self) -> HealthCheckConfig:
if self.type == "http" and not self.path:
raise ValueError('path is required when health_check.type is "http"')
if self.type == "command" and not self.command:
raise ValueError('command is required when health_check.type is "command"')
return self
class ResourceLimits(BaseModel):
cpus: float | None = Field(None, ge=0.01)
memory_mb: int | None = Field(None, ge=16)
memory_swap_mb: int | None = Field(None, ge=-1)
class ExecutableConfig(BaseModel):
node_version: str | None = None
npm_version: str | None = None
package_manager: Literal["npm", "pnpm", "yarn", "bun"] = "npm"
bootstrap_commands: list[str] = []
install_commands: list[str] = []
class TraefikConfig(BaseModel):
enabled: bool = True
subdomain_prefix: str | None = None
port: int | None = None
middlewares: list[str] = []
strip_prefix: bool = False
entrypoint: str | None = None
cert_resolver: str | None = None
class ToolManifest(BaseModel):
id: str = Field(..., pattern=r"^[a-z0-9\-]+$")
name: str
description: str = ""
version: str = "1.0.0"
image: str
runtime_command: list[str] | None = None
runtime_entrypoint: list[str] | None = None
runtime_user: str | None = None
runtime_working_dir: str | None = None
ports: list[PortConfig] = []
workspace_mounts: list[MountConfig] = []
config_mounts: list[MountConfig] = []
env: dict[str, str] = {}
secrets: list[SecretRef] = []
health_check: HealthCheckConfig | None = None
resource_limits: ResourceLimits | None = None
executable: ExecutableConfig | None = None
traefik: TraefikConfig | None = None
@model_validator(mode="after")
def _check_traefik_primary_port(self) -> ToolManifest:
traefik = self.traefik
if traefik is not None and traefik.enabled:
has_primary = any(port.primary for port in self.ports)
if not has_primary:
raise ValueError(
"at least one port must have primary=True when traefik.enabled is True"
)
return self
-52
View File
@@ -1,52 +0,0 @@
"""In-memory tool manifest registry with YAML file loading."""
from __future__ import annotations
from pathlib import Path
import yaml
from app.tools.models import ToolManifest
class ToolRegistry:
"""In-memory registry for tool manifests."""
def __init__(self) -> None:
self._manifests: dict[str, ToolManifest] = {}
def load_builtin_manifests(self) -> None:
"""Scan the built-in manifests directory and register all *.yml files."""
manifests_dir = Path(__file__).parent / "manifests"
if not manifests_dir.exists():
return
for file_path in sorted(manifests_dir.glob("*.yml")):
self.load_file(file_path)
def load_file(self, path: Path) -> ToolManifest:
"""Load a single YAML manifest file, validate it, and register it."""
data = yaml.safe_load(path.read_text(encoding="utf-8"))
manifest = ToolManifest.model_validate(data)
self.register(manifest)
return manifest
def register(self, manifest: ToolManifest) -> None:
"""Store a manifest in the registry (idempotent upsert)."""
self._manifests[manifest.id] = manifest
def get(self, tool_id: str) -> ToolManifest | None:
"""Retrieve a manifest by tool id, or None if not found."""
return self._manifests.get(tool_id)
def list(self) -> list[ToolManifest]:
"""Return all registered manifests."""
return list(self._manifests.values())
def remove(self, tool_id: str) -> ToolManifest | None:
"""Remove a manifest by tool id and return it, or None if not found."""
return self._manifests.pop(tool_id, None)
# Module-level singleton — callers must explicitly bootstrap via
# registry.load_builtin_manifests() (typically in a FastAPI lifespan).
registry = ToolRegistry()
-37
View File
@@ -1,37 +0,0 @@
"""FastAPI routes for the tool manifest registry."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
from app.tools.models import ToolManifest
from app.tools.registry import registry
router = APIRouter(prefix="/tools", tags=["tools"])
@router.get("", response_model=list[ToolManifest])
def list_tools() -> list[ToolManifest]:
return registry.list()
@router.get("/{tool_id}", response_model=ToolManifest)
def get_tool(tool_id: str) -> ToolManifest:
manifest = registry.get(tool_id)
if manifest is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool '{tool_id}' not found",
)
return manifest
@router.post("", response_model=ToolManifest, status_code=status.HTTP_201_CREATED)
def create_tool(manifest: ToolManifest) -> ToolManifest:
if registry.get(manifest.id) is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Tool '{manifest.id}' already exists",
)
registry.register(manifest)
return manifest
-12
View File
@@ -1,12 +0,0 @@
{
"name": "@headquarter/api",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": ".venv/bin/uvicorn app.main:app --reload --port 8000",
"build": "echo 'Python build step skipped (no compilation required)'",
"lint": ".venv/bin/ruff check app tests",
"test": ".venv/bin/pytest",
"typecheck": ".venv/bin/mypy app tests"
}
}
+19 -47
View File
@@ -1,56 +1,28 @@
[project]
name = "headquarter-api"
version = "0.0.1"
description = "Headquarter FastAPI backend"
version = "0.1.0"
description = "Headquarter platform API"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
"pydantic-settings>=2.8.0",
"sqlalchemy[asyncio]>=2.0.0",
"asyncpg>=0.30.0",
"alembic>=1.15.0",
"pyjwt>=2.8.0",
"cryptography>=44.0.0",
"httpx>=0.28.0",
"pyyaml>=6.0",
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
"sqlalchemy>=2.0.0",
"asyncpg>=0.29.0",
"alembic>=1.12.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"python-jose[cryptography]>=3.3.0",
"python-multipart>=0.0.6",
"httpx>=0.25.0",
"structlog>=23.2.0",
"cryptography>=41.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"httpx>=0.28.0",
"ruff>=0.11.0",
"mypy>=1.15.0",
"sqlalchemy[mypy]",
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.7.0",
"ruff>=0.1.0",
"httpx>=0.25.0",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["app"]
[tool.ruff]
line-length = 100
target-version = "py311"
exclude = ["alembic/versions"]
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true
exclude = ["alembic/versions"]
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = "yaml"
ignore_missing_imports = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
View File
-90
View File
@@ -1,90 +0,0 @@
from collections.abc import AsyncGenerator, Generator
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import NullPool
from app.config import settings
from app.db import get_db_session
from app.main import app
from app.models import Base
TEST_DATABASE_URL = settings.database_url
if "/headquarter_test" not in TEST_DATABASE_URL:
TEST_DATABASE_URL = TEST_DATABASE_URL.replace("/headquarter", "/headquarter_test")
if TEST_DATABASE_URL.startswith("postgresql://"):
TEST_DATABASE_URL = TEST_DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1)
@pytest.fixture(scope="session")
def event_loop() -> Generator[Any, None, None]:
import asyncio
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def db_engine() -> AsyncGenerator[AsyncEngine, None]:
engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
except Exception as exc:
await engine.dispose()
pytest.skip(f"PostgreSQL unavailable for tests: {exc}")
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest.fixture
async def db_session(
db_engine: AsyncEngine,
) -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
async with db_engine.connect() as connection:
trans = await connection.begin_nested()
testing_session_local = async_sessionmaker(
connection, class_=AsyncSession, expire_on_commit=False
)
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
async with testing_session_local() as session:
yield session
app.dependency_overrides[get_db_session] = override_get_db
original_db_url = settings.database_url
settings.database_url = TEST_DATABASE_URL
yield testing_session_local
settings.database_url = original_db_url
app.dependency_overrides.pop(get_db_session, None)
await trans.rollback()
@pytest.fixture
async def client(
db_session: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncClient, None]:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
@pytest.fixture
async def auth_client(
client: AsyncClient,
) -> AsyncGenerator[AsyncClient, None]:
original_debug = settings.debug
original_bypass = settings.auth_dev_bypass
settings.debug = True
settings.auth_dev_bypass = True
yield client
settings.debug = original_debug
settings.auth_dev_bypass = original_bypass
-87
View File
@@ -1,87 +0,0 @@
from typing import Any
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.config import settings
from app.models.user import User
@pytest.mark.asyncio
async def test_dev_bypass_creates_user(auth_client: AsyncClient) -> None:
response = await auth_client.get("/api/v1/users/me")
assert response.status_code == 200
data = response.json()
assert data["authentik_sub"] == "dev-user"
@pytest.mark.asyncio
async def test_missing_token_raises_401_when_bypass_disabled(client: AsyncClient) -> None:
original_debug = settings.debug
original_bypass = settings.auth_dev_bypass
settings.debug = False
settings.auth_dev_bypass = False
response = await client.get("/api/v1/users/me")
settings.debug = original_debug
settings.auth_dev_bypass = original_bypass
assert response.status_code == 401
@pytest.mark.asyncio
async def test_inactive_user_raises_403(
auth_client: AsyncClient, db_session: async_sessionmaker[Any]
) -> None:
async with db_session() as session:
result = await session.execute(select(User).where(User.authentik_sub == "dev-user"))
user = result.scalar_one_or_none()
if user is None:
user = User(
authentik_sub="dev-user",
email="dev@localhost",
display_name="Dev User",
is_active=True,
)
session.add(user)
await session.commit()
async with db_session() as session:
result = await session.execute(select(User).where(User.authentik_sub == "dev-user"))
user = result.scalar_one()
user.is_active = False
await session.commit()
response = await auth_client.get("/api/v1/users/me")
async with db_session() as session:
result = await session.execute(select(User).where(User.authentik_sub == "dev-user"))
user = result.scalar_one()
user.is_active = True
await session.commit()
assert response.status_code == 403
@pytest.mark.asyncio
async def test_dev_bypass_email_uniqueness(
auth_client: AsyncClient, db_session: async_sessionmaker[Any]
) -> None:
async with db_session() as session:
result = await session.execute(select(User).where(User.email == "dev@localhost"))
existing = result.scalar_one_or_none()
if existing:
await session.delete(existing)
await session.commit()
response = await auth_client.get("/api/v1/users/me")
assert response.status_code == 200
async with db_session() as session:
result = await session.execute(select(User).where(User.email == "dev@localhost"))
user = result.scalar_one_or_none()
assert user is not None
assert user.authentik_sub == "dev-user"
-77
View File
@@ -1,77 +0,0 @@
"""Tests for credential models and storage interface."""
import uuid
import pytest
from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential
from app.git.types import CredentialKind
class MinimalCredentialStorage(CredentialStorage):
"""Concrete subclass for testing."""
def create(self, credential: GitCredential) -> uuid.UUID:
return credential.id
def get(self, credential_id: uuid.UUID) -> GitCredential | None:
return None
def delete(self, credential_id: uuid.UUID) -> None:
return None
def test_git_credential_can_be_instantiated() -> None:
cred = GitCredential(
kind=CredentialKind.ssh_key,
encrypted_payload="encrypted-data",
)
assert cred.kind == CredentialKind.ssh_key
assert cred.encrypted_payload == "encrypted-data"
assert isinstance(cred.id, uuid.UUID)
def test_access_token_credential_can_be_instantiated() -> None:
cred = AccessTokenCredential(encrypted_payload="encrypted-data")
assert cred.kind == CredentialKind.access_token
assert cred.encrypted_payload == "encrypted-data"
def test_credential_storage_cannot_be_instantiated_directly() -> None:
with pytest.raises(TypeError):
CredentialStorage() # type: ignore[abstract]
def test_no_plaintext_secret_fields() -> None:
fields = set(GitCredential.model_fields.keys())
assert "token" not in fields
assert "private_key" not in fields
def test_extra_forbidden() -> None:
with pytest.raises(ValueError):
GitCredential(
kind=CredentialKind.access_token,
encrypted_payload="encrypted-data",
secret_plaintext="should-fail", # type: ignore[call-arg]
)
def test_minimal_credential_storage_implements_all_methods() -> None:
storage = MinimalCredentialStorage()
cred = GitCredential(
kind=CredentialKind.access_token,
encrypted_payload="encrypted-data",
)
assert storage.create(cred) == cred.id
assert storage.get(cred.id) is None
storage.delete(cred.id)
def test_encrypted_payload_not_in_repr() -> None:
cred = GitCredential(
kind=CredentialKind.ssh_key,
encrypted_payload="secret-value",
)
repr_str = repr(cred)
assert "secret-value" not in repr_str
-147
View File
@@ -1,147 +0,0 @@
"""Tests for local Git operations."""
import subprocess
import tempfile
from pathlib import Path
from typing import Any
import pytest
from app.git.operations import LocalGitOperations
@pytest.fixture
def local_git() -> LocalGitOperations:
return LocalGitOperations()
@pytest.fixture
def temp_repo() -> Any:
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = Path(tmpdir) / "repo"
repo_path.mkdir()
subprocess.run(
["git", "init", "--initial-branch=main"],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
yield repo_path
def test_clone_invalid_repo_raises(local_git: LocalGitOperations) -> None:
with pytest.raises(RuntimeError, match="Git clone failed"):
local_git.clone("https://example.com/repo.git", Path("/tmp/dest"), "cred-id")
def test_fetch_no_remote_succeeds(local_git: LocalGitOperations, temp_repo: Path) -> None:
local_git.fetch(temp_repo, "cred-id")
def test_push_no_remote_raises(local_git: LocalGitOperations, temp_repo: Path) -> None:
with pytest.raises(RuntimeError, match="Git push failed"):
local_git.push(temp_repo, "cred-id")
def test_get_status_on_non_git_directory_raises(local_git: LocalGitOperations) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
with pytest.raises(RuntimeError, match="Not a git repository"):
local_git.get_status(Path(tmpdir))
def test_get_status_clean_repo(local_git: LocalGitOperations, temp_repo: Path) -> None:
status = local_git.get_status(temp_repo)
assert status["branch"] == "main"
assert status["clean"] is True
assert status["untracked"] == []
assert status["modified"] == []
assert status["staged"] == []
assert status["deleted"] == []
def test_get_status_untracked_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
(temp_repo / "newfile.txt").write_text("hello")
status = local_git.get_status(temp_repo)
assert "newfile.txt" in status["untracked"]
assert status["clean"] is False
def test_get_status_staged_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
file_path = temp_repo / "newfile.txt"
file_path.write_text("hello")
subprocess.run(
["git", "add", "newfile.txt"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
status = local_git.get_status(temp_repo)
assert "newfile.txt" in status["staged"]
assert status["clean"] is False
def test_get_status_modified_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
file_path = temp_repo / "newfile.txt"
file_path.write_text("hello")
subprocess.run(
["git", "add", "newfile.txt"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
file_path.write_text("world")
status = local_git.get_status(temp_repo)
assert "newfile.txt" in status["modified"]
assert status["clean"] is False
def test_get_status_deleted_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
file_path = temp_repo / "newfile.txt"
file_path.write_text("hello")
subprocess.run(
["git", "add", "newfile.txt"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "commit", "-m", "add file"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
file_path.unlink()
status = local_git.get_status(temp_repo)
assert "newfile.txt" in status["deleted"]
assert status["clean"] is False
def test_get_status_branch_name(local_git: LocalGitOperations, temp_repo: Path) -> None:
subprocess.run(
["git", "checkout", "-b", "feature-branch"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
status = local_git.get_status(temp_repo)
assert status["branch"] == "feature-branch"
-66
View File
@@ -1,66 +0,0 @@
"""Tests for Git provider abstraction and types."""
from typing import Any
import pytest
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus
class MinimalGitProvider(GitProvider):
"""Concrete subclass for testing."""
def get_kind(self) -> ProviderKind:
return ProviderKind.generic
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
return ConnectionStatus.connected
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
return []
def create_deploy_key(self, git_url: str, public_key: str) -> str:
return "key-id"
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
return None
def get_default_branch(self, git_url: str, credential_id: str) -> str:
return "main"
def test_git_provider_cannot_be_instantiated_directly() -> None:
with pytest.raises(TypeError):
GitProvider() # type: ignore[abstract]
def test_minimal_git_provider_implements_all_methods() -> None:
provider = MinimalGitProvider()
assert provider.get_kind() == ProviderKind.generic
status = provider.validate_connection("https://example.com/repo.git", "cred-id")
assert status == ConnectionStatus.connected
assert provider.list_repositories("cred-id") == []
assert provider.create_deploy_key("https://example.com/repo.git", "ssh-rsa AAAA") == "key-id"
provider.delete_deploy_key("https://example.com/repo.git", "key-id")
assert provider.get_default_branch("https://example.com/repo.git", "cred-id") == "main"
@pytest.mark.parametrize("member", ["github", "gitlab", "gitea", "forgejo", "generic"])
def test_provider_kind_membership(member: str) -> None:
assert member in ProviderKind
@pytest.mark.parametrize("member", ["ssh_key", "access_token"])
def test_credential_kind_membership(member: str) -> None:
assert member in CredentialKind
@pytest.mark.parametrize("member", ["pending", "connected", "disconnected", "error"])
def test_connection_status_membership(member: str) -> None:
assert member in ConnectionStatus
@pytest.mark.parametrize("member", ["generated", "registered", "rotating", "revoked"])
def test_ssh_key_status_membership(member: str) -> None:
assert member in SshKeyStatus
-14
View File
@@ -1,14 +0,0 @@
import pytest
from httpx import AsyncClient
from app.config import settings
@pytest.mark.asyncio
async def test_health_returns_ok(client: AsyncClient) -> None:
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["service"] == settings.app_name
assert data["database"] == "connected"
@@ -1,35 +0,0 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_project_crud(auth_client: AsyncClient) -> None:
# Create
resp = await auth_client.post("/api/v1/projects", json={"name": "Test", "slug": "test"})
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Test"
project_id = data["id"]
# List
resp = await auth_client.get("/api/v1/projects")
assert resp.status_code == 200
assert len(resp.json()) == 1
# Get
resp = await auth_client.get(f"/api/v1/projects/{project_id}")
assert resp.status_code == 200
assert resp.json()["slug"] == "test"
# Update
resp = await auth_client.put(f"/api/v1/projects/{project_id}", json={"name": "Updated"})
assert resp.status_code == 200
assert resp.json()["name"] == "Updated"
# Delete
resp = await auth_client.delete(f"/api/v1/projects/{project_id}")
assert resp.status_code == 204
# Verify deletion
resp = await auth_client.get(f"/api/v1/projects/{project_id}")
assert resp.status_code == 404
@@ -1,40 +0,0 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_repository_crud(auth_client: AsyncClient) -> None:
# Create project first
resp = await auth_client.post(
"/api/v1/projects", json={"name": "RepoTest", "slug": "repo-test"}
)
project_id = resp.json()["id"]
# Create repo
resp = await auth_client.post(
f"/api/v1/projects/{project_id}/repositories",
json={"name": "repo1", "git_url": "https://git.example.com/repo1.git"},
)
assert resp.status_code == 201
repo_id = resp.json()["id"]
# List
resp = await auth_client.get(f"/api/v1/projects/{project_id}/repositories")
assert resp.status_code == 200
assert len(resp.json()) == 1
# Get
resp = await auth_client.get(f"/api/v1/projects/{project_id}/repositories/{repo_id}")
assert resp.status_code == 200
# Update
resp = await auth_client.put(
f"/api/v1/projects/{project_id}/repositories/{repo_id}",
json={"name": "repo1-updated"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "repo1-updated"
# Delete
resp = await auth_client.delete(f"/api/v1/projects/{project_id}/repositories/{repo_id}")
assert resp.status_code == 204
@@ -1,72 +0,0 @@
from typing import Any
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.models.secret import Secret
@pytest.mark.asyncio
async def test_secret_encrypt_decrypt(
auth_client: AsyncClient, db_session: async_sessionmaker[Any]
) -> None:
resp = await auth_client.post(
"/api/v1/projects",
json={"name": "SecretTest", "slug": "secret-test"},
)
project_id = resp.json()["id"]
resp = await auth_client.post(
"/api/v1/secrets",
json={
"scope_type": "project",
"scope_id": str(project_id),
"key": "api_key",
"value": "super-secret",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["value"] == "super-secret"
secret_id = data["id"]
resp = await auth_client.get(f"/api/v1/secrets/{secret_id}")
assert resp.status_code == 200
assert resp.json()["value"] == "super-secret"
async with db_session() as session:
result = await session.execute(select(Secret).where(Secret.id == secret_id))
secret = result.scalar_one()
assert secret.encrypted_value != "super-secret"
resp = await auth_client.put(
f"/api/v1/secrets/{secret_id}",
json={"value": "new-secret"},
)
assert resp.status_code == 200
assert resp.json()["value"] == "new-secret"
resp = await auth_client.delete(f"/api/v1/secrets/{secret_id}")
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_secret_ownership_enforced(auth_client: AsyncClient) -> None:
resp = await auth_client.post("/api/v1/projects", json={"name": "P1", "slug": "p1"})
p1 = resp.json()["id"]
resp = await auth_client.post("/api/v1/projects", json={"name": "P2", "slug": "p2"})
p2 = resp.json()["id"]
resp = await auth_client.post(
"/api/v1/secrets",
json={"scope_type": "project", "scope_id": str(p1), "key": "k1", "value": "v1"},
)
resp = await auth_client.get("/api/v1/secrets", params={"scope_id": str(p2)})
assert resp.status_code == 200
secrets = resp.json()
for s in secrets:
assert s["scope_id"] != str(p1) or s["scope_type"] != "project"
@@ -1,30 +0,0 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_tool_definition_crud(auth_client: AsyncClient) -> None:
resp = await auth_client.post(
"/api/v1/tool-definitions",
json={
"key": "opencode",
"name": "OpenCode",
"image": "ghcr.io/opencode-ai/opencode:latest",
},
)
assert resp.status_code == 201
td_id = resp.json()["id"]
resp = await auth_client.get("/api/v1/tool-definitions")
assert resp.status_code == 200
assert len(resp.json()) >= 1
resp = await auth_client.get(f"/api/v1/tool-definitions/{td_id}")
assert resp.status_code == 200
resp = await auth_client.put(f"/api/v1/tool-definitions/{td_id}", json={"name": "OpenCodeV2"})
assert resp.status_code == 200
assert resp.json()["name"] == "OpenCodeV2"
resp = await auth_client.delete(f"/api/v1/tool-definitions/{td_id}")
assert resp.status_code == 204
-110
View File
@@ -1,110 +0,0 @@
from uuid import UUID
import pytest
from app.services.runtime_injection import RuntimeInjectionError, RuntimeInjectionService
@pytest.mark.asyncio
async def test_resolve_configs_empty(db_session):
result = await RuntimeInjectionService.resolve_configs(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result == {}
@pytest.mark.asyncio
async def test_resolve_configs_global_only(db_session, sample_config):
result = await RuntimeInjectionService.resolve_configs(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result == {"test_key": "test_value"}
@pytest.mark.asyncio
async def test_resolve_configs_scope_override(db_session):
from app.models.config import Config
global_config = Config(
scope_type="global",
scope_id=UUID(int=0),
key="shared_key",
value="global_value",
)
project_config = Config(
scope_type="project",
scope_id=UUID(int=1),
key="shared_key",
value="project_value",
)
db_session.add_all([global_config, project_config])
await db_session.commit()
result = await RuntimeInjectionService.resolve_configs(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result["shared_key"] == "project_value"
@pytest.mark.asyncio
async def test_resolve_secrets_empty(db_session):
result = await RuntimeInjectionService.resolve_secrets(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result == {}
@pytest.mark.asyncio
async def test_resolve_secrets_decrypts(db_session, sample_secret):
result = await RuntimeInjectionService.resolve_secrets(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result == {"secret_key": "secret_value"}
@pytest.mark.asyncio
async def test_validate_secrets_exist_missing(db_session):
with pytest.raises(RuntimeInjectionError, match="Missing required secrets"):
await RuntimeInjectionService.validate_secrets_exist(
db_session,
required_secret_keys=["missing_secret"],
project_id=UUID(int=1),
user_id=UUID(int=2),
)
@pytest.mark.asyncio
async def test_validate_secrets_exist_found(db_session, sample_secret):
await RuntimeInjectionService.validate_secrets_exist(
db_session,
required_secret_keys=["secret_key"],
project_id=UUID(int=1),
user_id=UUID(int=2),
)
def test_generate_config_files(tmp_path):
configs = {"app": {"port": 8080}, "debug": True}
mounts = RuntimeInjectionService.generate_config_files(configs, tmp_path)
assert len(mounts) == 2
assert (tmp_path / "app.json").exists()
assert (tmp_path / "debug.json").exists()
assert (tmp_path / "app.json").stat().st_mode & 0o777 == 0o400
def test_generate_secret_env_vars():
secrets = {"api_key": "abc123", "db_pass": "secret"}
env_vars = RuntimeInjectionService.generate_secret_env_vars(secrets)
assert env_vars == {"API_KEY": "abc123", "DB_PASS": "secret"}
-197
View File
@@ -1,197 +0,0 @@
from app.services.traefik import TraefikLabelGenerator
class TestTraefikLabelGenerator:
def test_generate_subdomain(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
subdomain = gen.generate_subdomain(
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
)
assert subdomain == "code-server-my-project-alice.hq.example.com"
def test_generate_subdomain_with_different_domain(self):
gen = TraefikLabelGenerator(domain="tools.localhost")
subdomain = gen.generate_subdomain(
tool_key="opencode",
project_slug="test",
user_slug="bob",
)
assert subdomain == "opencode-test-bob.tools.localhost"
def test_generate_labels_basic(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.enable"] == "true"
assert "Host(`code-server-my-project-alice.hq.example.com`)" in labels[
"traefik.http.routers.tool-abc12345.rule"
]
assert labels["traefik.http.routers.tool-abc12345.entrypoints"] == "websecure"
assert labels["traefik.http.routers.tool-abc12345.service"] == "tool-abc12345"
def test_generate_labels_tls(self):
gen = TraefikLabelGenerator(domain="hq.example.com", entrypoint="websecure")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.http.routers.tool-abc12345.tls"] == "true"
assert (
labels["traefik.http.routers.tool-abc12345.tls.certresolver"]
== "letsencrypt"
)
def test_generate_labels_no_tls_for_http(self):
gen = TraefikLabelGenerator(domain="hq.example.com", entrypoint="web")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert "traefik.http.routers.tool-abc12345.tls" not in labels
assert "traefik.http.routers.tool-abc12345.tls.certresolver" not in labels
def test_generate_labels_service_config(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert (
labels["traefik.http.services.tool-abc12345.loadbalancer.server.port"]
== "8443"
)
assert (
labels["traefik.http.services.tool-abc12345.loadbalancer.server.scheme"]
== "http"
)
def test_generate_labels_security_headers(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
middleware_name = "tool-abc12345-sec"
assert (
labels[f"traefik.http.middlewares.{middleware_name}.headers.stsSeconds"]
== "31536000"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.stsIncludeSubdomains"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.forceStsHeader"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.contentTypeNosniff"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.browserXssFilter"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.customFrameOptionsValue"
]
== "SAMEORIGIN"
)
def test_generate_labels_middleware_attached(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.http.routers.tool-abc12345.middlewares"] == "tool-abc12345-sec"
def test_generate_labels_network(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
network_name="custom-network",
)
assert labels["traefik.docker.network"] == "custom-network"
def test_generate_labels_default_network(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.docker.network"] == "tools"
def test_generate_removal_labels(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_removal_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
)
assert labels["traefik.enable"] == "false"
assert labels["traefik.http.routers.tool-abc12345.rule"] == ""
def test_generate_labels_with_opencode(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="xyz78901-2345-6789-0123-456789012345",
tool_key="opencode",
project_slug="demo",
user_slug="charlie",
container_port=3000,
)
assert "Host(`opencode-demo-charlie.hq.example.com`)" in labels[
"traefik.http.routers.tool-xyz78901.rule"
]
assert (
labels["traefik.http.services.tool-xyz78901.loadbalancer.server.port"]
== "3000"
)
-241
View File
@@ -1,241 +0,0 @@
"""Tests for the tool manifest Pydantic models."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.tools.models import (
HealthCheckConfig,
MountConfig,
PortConfig,
ResourceLimits,
SecretRef,
ToolManifest,
TraefikConfig,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _minimal_manifest(**overrides: object) -> ToolManifest:
defaults: dict[str, object] = {
"id": "test-tool",
"name": "Test Tool",
"image": "test:latest",
"ports": [PortConfig(container_port=8080, primary=True)],
"traefik": TraefikConfig(enabled=False),
}
defaults.update(overrides)
return ToolManifest.model_validate(defaults)
# ---------------------------------------------------------------------------
# Valid construction
# ---------------------------------------------------------------------------
def test_valid_opencode_shape() -> None:
manifest = ToolManifest(
id="opencode",
name="OpenCode",
description="AI-powered terminal-based development environment.",
image="ghcr.io/opencode-ai/opencode:latest",
runtime_working_dir="/workspace",
ports=[PortConfig(container_port=3000, name="http", primary=True)],
workspace_mounts=[
MountConfig(source_pattern="{project_repo}", target="/workspace")
],
config_mounts=[
MountConfig(
source_pattern="{user_config}/opencode", target="/root/.config/opencode"
)
],
env={"TERM": "xterm-256color", "FORCE_COLOR": "1"},
health_check=HealthCheckConfig(
type="http", path="/", port=3000, start_period_seconds=15
),
resource_limits=ResourceLimits(cpus=2.0, memory_mb=4096),
traefik=TraefikConfig(
enabled=True, subdomain_prefix="opencode", port=3000
),
)
assert manifest.id == "opencode"
assert manifest.ports[0].primary is True
assert manifest.traefik is not None
assert manifest.traefik.enabled is True
def test_valid_code_server_shape() -> None:
manifest = ToolManifest(
id="code-server",
name="code-server",
description="VS Code in the browser.",
image="codercom/code-server:latest",
runtime_working_dir="/workspace",
ports=[PortConfig(container_port=8080, name="http", primary=True)],
workspace_mounts=[
MountConfig(source_pattern="{project_repo}", target="/workspace")
],
config_mounts=[
MountConfig(
source_pattern="{user_config}/code-server",
target="/home/coder/.config/code-server",
)
],
health_check=HealthCheckConfig(type="http", path="/healthz", port=8080),
resource_limits=ResourceLimits(cpus=2.0, memory_mb=4096),
traefik=TraefikConfig(enabled=True, subdomain_prefix="code", port=8080),
secrets=[
SecretRef(name="code-server-password", env_var="PASSWORD", required=False)
],
)
assert manifest.id == "code-server"
assert manifest.secrets[0].env_var == "PASSWORD"
# ---------------------------------------------------------------------------
# Invalid id values
# ---------------------------------------------------------------------------
def test_invalid_id_uppercase() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="OpenCode")
assert "id" in str(exc_info.value)
def test_invalid_id_spaces() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="open code")
assert "id" in str(exc_info.value)
def test_invalid_id_empty_string() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="")
assert "id" in str(exc_info.value)
# ---------------------------------------------------------------------------
# Port validation
# ---------------------------------------------------------------------------
def test_invalid_container_port_zero() -> None:
with pytest.raises(ValidationError) as exc_info:
PortConfig(container_port=0)
assert "container_port" in str(exc_info.value)
def test_invalid_container_port_too_high() -> None:
with pytest.raises(ValidationError) as exc_info:
PortConfig(container_port=70000)
assert "container_port" in str(exc_info.value)
# ---------------------------------------------------------------------------
# Mount target validation
# ---------------------------------------------------------------------------
def test_mount_target_not_absolute() -> None:
with pytest.raises(ValidationError) as exc_info:
MountConfig(source_pattern="{project_repo}", target="workspace")
assert "absolute" in str(exc_info.value).lower()
# ---------------------------------------------------------------------------
# HealthCheck validation
# ---------------------------------------------------------------------------
def test_health_check_http_missing_path() -> None:
with pytest.raises(ValidationError) as exc_info:
HealthCheckConfig(type="http")
assert "path" in str(exc_info.value)
def test_health_check_command_missing_command() -> None:
with pytest.raises(ValidationError) as exc_info:
HealthCheckConfig(type="command")
assert "command" in str(exc_info.value)
def test_health_check_tcp_allows_missing_path() -> None:
hc = HealthCheckConfig(type="tcp")
assert hc.type == "tcp"
# ---------------------------------------------------------------------------
# Traefik + primary port validation
# ---------------------------------------------------------------------------
def test_missing_primary_port_when_traefik_enabled() -> None:
with pytest.raises(ValidationError) as exc_info:
ToolManifest(
id="bad-tool",
name="Bad Tool",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=False)],
traefik=TraefikConfig(enabled=True),
)
assert "primary" in str(exc_info.value).lower()
def test_traefik_disabled_allows_no_primary_port() -> None:
manifest = ToolManifest(
id="no-route",
name="No Route",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=False)],
traefik=TraefikConfig(enabled=False),
)
assert manifest.traefik is not None
assert manifest.traefik.enabled is False
def test_no_traefik_allows_no_primary_port() -> None:
manifest = ToolManifest(
id="no-route",
name="No Route",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=False)],
)
assert manifest.traefik is None
# ---------------------------------------------------------------------------
# Resource limits validation
# ---------------------------------------------------------------------------
def test_resource_limits_cpus_too_low() -> None:
with pytest.raises(ValidationError) as exc_info:
ResourceLimits(cpus=0.001)
assert "cpus" in str(exc_info.value)
def test_resource_limits_memory_mb_too_low() -> None:
with pytest.raises(ValidationError) as exc_info:
ResourceLimits(memory_mb=8)
assert "memory_mb" in str(exc_info.value)
def test_resource_limits_memory_swap_negative_one_ok() -> None:
rl = ResourceLimits(memory_swap_mb=-1)
assert rl.memory_swap_mb == -1
# ---------------------------------------------------------------------------
# Serialization round-trip
# ---------------------------------------------------------------------------
def test_serialization_roundtrip() -> None:
original = _minimal_manifest(
id="roundtrip",
name="Roundtrip Tool",
ports=[PortConfig(container_port=3000, name="http", primary=True)],
traefik=TraefikConfig(enabled=True, subdomain_prefix="rt"),
)
dumped = original.model_dump(mode="json")
restored = ToolManifest.model_validate(dumped)
assert restored.id == original.id
assert restored.ports[0].container_port == original.ports[0].container_port
assert restored.traefik is not None
assert restored.traefik.subdomain_prefix == "rt"
-159
View File
@@ -1,159 +0,0 @@
"""Tests for the in-memory tool manifest registry."""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.tools.models import PortConfig, ToolManifest, TraefikConfig
from app.tools.registry import ToolRegistry
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def registry() -> ToolRegistry:
return ToolRegistry()
@pytest.fixture
def sample_manifest() -> ToolManifest:
return ToolManifest(
id="test-tool",
name="Test Tool",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=True)],
traefik=TraefikConfig(enabled=False),
)
# ---------------------------------------------------------------------------
# Register / get round-trip
# ---------------------------------------------------------------------------
def test_register_and_get(registry: ToolRegistry, sample_manifest: ToolManifest) -> None:
registry.register(sample_manifest)
retrieved = registry.get("test-tool")
assert retrieved is not None
assert retrieved.id == "test-tool"
def test_get_missing_returns_none(registry: ToolRegistry) -> None:
assert registry.get("missing") is None
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
def test_list_returns_all(registry: ToolRegistry) -> None:
m1 = ToolManifest(
id="tool-a",
name="Tool A",
image="a:latest",
ports=[PortConfig(container_port=8080, primary=True)],
traefik=TraefikConfig(enabled=False),
)
m2 = ToolManifest(
id="tool-b",
name="Tool B",
image="b:latest",
ports=[PortConfig(container_port=3000, primary=True)],
traefik=TraefikConfig(enabled=False),
)
registry.register(m1)
registry.register(m2)
assert len(registry.list()) == 2
ids = {m.id for m in registry.list()}
assert ids == {"tool-a", "tool-b"}
# ---------------------------------------------------------------------------
# Overwrite behavior
# ---------------------------------------------------------------------------
def test_register_overwrites_existing(
registry: ToolRegistry,
sample_manifest: ToolManifest,
) -> None:
registry.register(sample_manifest)
updated = ToolManifest(
id="test-tool",
name="Updated Tool",
image="updated:latest",
ports=[PortConfig(container_port=8080, primary=True)],
traefik=TraefikConfig(enabled=False),
)
registry.register(updated)
retrieved = registry.get("test-tool")
assert retrieved is not None
assert retrieved.name == "Updated Tool"
# ---------------------------------------------------------------------------
# Remove
# ---------------------------------------------------------------------------
def test_remove_returns_manifest(registry: ToolRegistry, sample_manifest: ToolManifest) -> None:
registry.register(sample_manifest)
removed = registry.remove("test-tool")
assert removed is not None
assert removed.id == "test-tool"
assert registry.get("test-tool") is None
def test_remove_missing_returns_none(registry: ToolRegistry) -> None:
assert registry.remove("missing") is None
# ---------------------------------------------------------------------------
# Load file
# ---------------------------------------------------------------------------
def test_load_valid_yaml_file(registry: ToolRegistry, tmp_path: Path) -> None:
yaml_path = tmp_path / "my-tool.yml"
yaml_path.write_text(
"""
id: my-tool
name: My Tool
image: my-tool:latest
ports:
- container_port: 8080
primary: true
traefik:
enabled: false
""",
encoding="utf-8",
)
manifest = registry.load_file(yaml_path)
assert manifest.id == "my-tool"
assert manifest.name == "My Tool"
assert manifest.ports[0].container_port == 8080
def test_load_invalid_yaml_raises(registry: ToolRegistry, tmp_path: Path) -> None:
yaml_path = tmp_path / "bad-tool.yml"
yaml_path.write_text(
"""
id: BAD ID
name: Bad Tool
image: bad:latest
""",
encoding="utf-8",
)
with pytest.raises(ValidationError):
registry.load_file(yaml_path)
# ---------------------------------------------------------------------------
# Load builtin manifests
# ---------------------------------------------------------------------------
def test_load_builtin_manifests(registry: ToolRegistry) -> None:
registry.load_builtin_manifests()
# Built-in manifests from Step 4 may not exist yet in isolation,
# but the method should not raise regardless of directory contents.
assert isinstance(registry.list(), list)
-93
View File
@@ -1,93 +0,0 @@
"""Tests for the FastAPI tool manifest router."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.tools.registry import registry
client = TestClient(app)
@pytest.fixture(autouse=True)
def _reset_registry() -> None:
registry._manifests.clear()
registry.load_builtin_manifests()
# ---------------------------------------------------------------------------
# GET /api/v1/tools
# ---------------------------------------------------------------------------
def test_list_tools_includes_builtins() -> None:
response = client.get("/api/v1/tools")
assert response.status_code == 200
data = response.json()
ids = {item["id"] for item in data}
assert "opencode" in ids
assert "code-server" in ids
# ---------------------------------------------------------------------------
# GET /api/v1/tools/{tool_id}
# ---------------------------------------------------------------------------
def test_get_tool_opencode() -> None:
response = client.get("/api/v1/tools/opencode")
assert response.status_code == 200
data = response.json()
assert data["id"] == "opencode"
assert data["name"] == "OpenCode"
def test_get_tool_not_found() -> None:
response = client.get("/api/v1/tools/nonexistent")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# POST /api/v1/tools
# ---------------------------------------------------------------------------
def test_create_tool_success() -> None:
payload = {
"id": "new-tool",
"name": "New Tool",
"image": "new-tool:latest",
"ports": [{"container_port": 3000, "primary": True}],
"traefik": {"enabled": False},
}
response = client.post("/api/v1/tools", json=payload)
assert response.status_code == 201
data = response.json()
assert data["id"] == "new-tool"
assert data["name"] == "New Tool"
def test_create_tool_duplicate() -> None:
payload = {
"id": "opencode",
"name": "Duplicate",
"image": "dup:latest",
"ports": [{"container_port": 3000, "primary": True}],
"traefik": {"enabled": False},
}
response = client.post("/api/v1/tools", json=payload)
assert response.status_code == 409
def test_create_tool_invalid_id() -> None:
payload = {
"id": "Bad ID",
"name": "Bad Tool",
"image": "bad:latest",
"ports": [{"container_port": 3000, "primary": True}],
"traefik": {"enabled": False},
}
response = client.post("/api/v1/tools", json=payload)
assert response.status_code == 422