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:
@@ -1,15 +0,0 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.git/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
@@ -1 +0,0 @@
|
||||
3.10.18
|
||||
+41
-8
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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())
|
||||
@@ -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 ###
|
||||
@@ -1,3 +0,0 @@
|
||||
from app.auth.dependencies import get_current_active_user, get_current_user
|
||||
|
||||
__all__ = ["get_current_user", "get_current_active_user"]
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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*."""
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)}
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class OrmBase(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
@@ -1,8 +0,0 @@
|
||||
# Frontend runtime configuration
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_APP_NAME=Headquarter
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
VITE_OIDC_ISSUER=https://authentik.example.com/application/o/headquarter
|
||||
VITE_OIDC_CLIENT_ID=headquarter-web
|
||||
VITE_OIDC_REDIRECT_URI=http://localhost:5173/callback
|
||||
@@ -1 +0,0 @@
|
||||
ignore-build-scripts=false
|
||||
+37
-14
@@ -1,22 +1,45 @@
|
||||
FROM node:22-alpine AS builder
|
||||
# Build stage
|
||||
FROM node:20-alpine as builder
|
||||
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_OIDC_ISSUER
|
||||
ARG VITE_OIDC_CLIENT_ID
|
||||
ARG VITE_OIDC_REDIRECT_URI
|
||||
WORKDIR /build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install -g pnpm@11.1.1 \
|
||||
&& pnpm install --ignore-scripts \
|
||||
&& pnpm rebuild esbuild
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
# Build production bundle
|
||||
RUN npm run build
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8080
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder --chown=nextjs:nodejs /build/dist /usr/share/nginx/html
|
||||
|
||||
# Create required directories
|
||||
RUN mkdir -p /var/cache/nginx /var/run && \
|
||||
chown -R nextjs:nodejs /var/cache/nginx /var/run /usr/share/nginx/html
|
||||
|
||||
# Switch to non-root user
|
||||
USER nextjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Headquarter</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user