Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c051929f8c |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"fingerprint": "639c16d45210921c3c8ece071ef18bbe0c426ea2"
|
||||
"fingerprint": "fdea8a74bb4c7449c01c4bd61646c895b10ede78"
|
||||
}
|
||||
+10
-7
@@ -1,13 +1,14 @@
|
||||
# Skill Registry — workspace
|
||||
# Skill Registry — headquarter
|
||||
|
||||
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||
|
||||
Last updated: 2026-06-05
|
||||
Last updated: 2026-05-28
|
||||
|
||||
## Sources scanned
|
||||
|
||||
- .opencode/skills
|
||||
- .claude/skills
|
||||
- /home/alex/.config/opencode/skills
|
||||
|
||||
## Contract
|
||||
|
||||
@@ -19,11 +20,13 @@ Last updated: 2026-06-05
|
||||
|
||||
| Skill | Trigger / description | Scope | Path |
|
||||
| --- | --- | --- | --- |
|
||||
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/workspace/.opencode/skills/openspec-apply-change/SKILL.md` |
|
||||
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/workspace/.opencode/skills/openspec-archive-change/SKILL.md` |
|
||||
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/workspace/.opencode/skills/openspec-explore/SKILL.md` |
|
||||
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/workspace/.opencode/skills/openspec-propose/SKILL.md` |
|
||||
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/workspace/.claude/skills/sift-backlog/SKILL.md` |
|
||||
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
|
||||
| `openspec` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/SKILL.md` |
|
||||
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
|
||||
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
|
||||
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
|
||||
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
|
||||
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/home/alex/projects/headquarter/.claude/skills/sift-backlog/SKILL.md` |
|
||||
|
||||
## Loading protocol
|
||||
|
||||
|
||||
@@ -54,5 +54,3 @@ Thumbs.db
|
||||
.atl/
|
||||
.sisyphus/
|
||||
.pi-lens/
|
||||
minerv3/
|
||||
.cache/
|
||||
|
||||
@@ -75,7 +75,6 @@ Do not:
|
||||
* Introduce new dependencies without clear justification.
|
||||
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
||||
* Decide product behavior silently when the spec is unclear.
|
||||
* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first.
|
||||
|
||||
If scope must change, propose an OpenSpec update first.
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Alembic cache
|
||||
alembic/versions/__pycache__/
|
||||
|
||||
# Pi lens cache
|
||||
.pi-lens/
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
*.md
|
||||
|
||||
# Scripts not needed in container
|
||||
scripts/
|
||||
+2
-2
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
|
||||
# Copy application code
|
||||
COPY --chown=appuser:appgroup . .
|
||||
|
||||
# Create directories for repo, instance, and workspace storage
|
||||
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
|
||||
# Create directories for repo and instance storage
|
||||
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
||||
|
||||
# Copy wait-for-db script
|
||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
"""add config profiles, includes, mounts, and tool instance profile selection
|
||||
|
||||
Revision ID: 0013_add_config_profiles
|
||||
Revises: 0012_default_port_req
|
||||
Create Date: 2026-05-24 12:00:00.000000
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0013_add_config_profiles"
|
||||
down_revision: str | None = "0012_default_port_req"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
return column_name in {
|
||||
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
return index_name in {
|
||||
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _foreign_key_exists(
|
||||
table_name: str,
|
||||
constrained_columns: list[str],
|
||||
referred_table: str,
|
||||
) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
|
||||
if (
|
||||
foreign_key.get("constrained_columns") == constrained_columns
|
||||
and foreign_key.get("referred_table") == referred_table
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Earlier branches may already have created config_profiles. Keep this
|
||||
# migration defensive so databases can converge onto the current graph.
|
||||
if not _table_exists("config_profiles"):
|
||||
op.create_table(
|
||||
"config_profiles",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"user_id", "name", name="uq_config_profiles_user_name"
|
||||
),
|
||||
)
|
||||
if not _index_exists("config_profiles", "idx_config_profiles_user"):
|
||||
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||
|
||||
if not _table_exists("config_includes"):
|
||||
op.create_table(
|
||||
"config_includes",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column(
|
||||
"included_profile_id", postgresql.UUID(as_uuid=True), nullable=False
|
||||
),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["profile_id"], ["config_profiles.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["included_profile_id"],
|
||||
["config_profiles.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"profile_id", "included_profile_id", name="uq_config_includes_pair"
|
||||
),
|
||||
)
|
||||
if not _index_exists("config_includes", "idx_config_includes_profile"):
|
||||
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
|
||||
if not _index_exists("config_includes", "idx_config_includes_included"):
|
||||
op.create_index(
|
||||
"idx_config_includes_included", "config_includes", ["included_profile_id"]
|
||||
)
|
||||
|
||||
if not _table_exists("config_mounts"):
|
||||
op.create_table(
|
||||
"config_mounts",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("mount_path", sa.String(length=1024), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["profile_id"], ["config_profiles.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
if not _index_exists("config_mounts", "idx_config_mounts_profile"):
|
||||
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
|
||||
|
||||
if not _column_exists("tool_instances", "selected_profile_id"):
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
if not _foreign_key_exists(
|
||||
"tool_instances", ["selected_profile_id"], "config_profiles"
|
||||
):
|
||||
op.create_foreign_key(
|
||||
"fk_tool_instances_selected_profile",
|
||||
"tool_instances",
|
||||
"config_profiles",
|
||||
["selected_profile_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
if not _index_exists("tool_instances", "idx_tool_instances_selected_profile"):
|
||||
op.create_index(
|
||||
"idx_tool_instances_selected_profile",
|
||||
"tool_instances",
|
||||
["selected_profile_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove selected_profile_id from tool_instances
|
||||
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
|
||||
op.drop_constraint(
|
||||
"fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("tool_instances", "selected_profile_id")
|
||||
|
||||
# Drop config_mounts
|
||||
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
|
||||
op.drop_table("config_mounts")
|
||||
|
||||
# Drop config_includes
|
||||
op.drop_index("idx_config_includes_included", table_name="config_includes")
|
||||
op.drop_index("idx_config_includes_profile", table_name="config_includes")
|
||||
op.drop_table("config_includes")
|
||||
|
||||
# Drop config_profiles
|
||||
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
||||
op.drop_table("config_profiles")
|
||||
@@ -1,180 +0,0 @@
|
||||
"""add profile resolver fields to config profiles and mounts
|
||||
|
||||
Revision ID: 0014_add_profile_resolver_fields
|
||||
Revises: 0013_add_config_profiles
|
||||
Create Date: 2026-05-24 14:00:00.000000
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0014_add_profile_resolver_fields"
|
||||
down_revision: str | None = "0013_add_config_profiles"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
return column_name in {
|
||||
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
return index_name in {
|
||||
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _foreign_key_exists(
|
||||
table_name: str,
|
||||
constrained_columns: list[str],
|
||||
referred_table: str,
|
||||
) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
|
||||
if (
|
||||
foreign_key.get("constrained_columns") == constrained_columns
|
||||
and foreign_key.get("referred_table") == referred_table
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _foreign_key_names_for_column(table_name: str, column_name: str) -> list[str]:
|
||||
if not _table_exists(table_name):
|
||||
return []
|
||||
names: list[str] = []
|
||||
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
|
||||
if column_name in foreign_key.get("constrained_columns", []):
|
||||
name = foreign_key.get("name")
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("config_profiles", "project_id"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "tool_type_id"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "environment_variables"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("environment_variables", sa.JSON(), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "start_command"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("start_command", sa.Text(), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "working_directory"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("working_directory", sa.Text(), nullable=True),
|
||||
)
|
||||
if not _column_exists("config_profiles", "port"):
|
||||
op.add_column("config_profiles", sa.Column("port", sa.Integer(), nullable=True))
|
||||
if not _column_exists("config_profiles", "is_default"):
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
if not _foreign_key_exists("config_profiles", ["project_id"], "projects"):
|
||||
op.create_foreign_key(
|
||||
"fk_config_profiles_project",
|
||||
"config_profiles",
|
||||
"projects",
|
||||
["project_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
if not _foreign_key_exists("config_profiles", ["tool_type_id"], "tool_types"):
|
||||
op.create_foreign_key(
|
||||
"fk_config_profiles_tool_type",
|
||||
"config_profiles",
|
||||
"tool_types",
|
||||
["tool_type_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
if not _index_exists("config_profiles", "idx_config_profiles_project"):
|
||||
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||
if not _index_exists("config_profiles", "idx_config_profiles_tool_type"):
|
||||
op.create_index(
|
||||
"idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]
|
||||
)
|
||||
|
||||
if _column_exists("config_mounts", "mount_path") and not _column_exists(
|
||||
"config_mounts", "target_path"
|
||||
):
|
||||
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
|
||||
if not _column_exists("config_mounts", "mode"):
|
||||
op.add_column(
|
||||
"config_mounts",
|
||||
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
|
||||
)
|
||||
if not _column_exists("config_mounts", "files"):
|
||||
op.add_column(
|
||||
"config_mounts",
|
||||
sa.Column("files", sa.JSON(), nullable=True),
|
||||
)
|
||||
for constraint_name in _foreign_key_names_for_column(
|
||||
"config_mounts", "source_profile_id"
|
||||
):
|
||||
op.drop_constraint(constraint_name, "config_mounts", type_="foreignkey")
|
||||
if _column_exists("config_mounts", "content"):
|
||||
op.drop_column("config_mounts", "content")
|
||||
if _column_exists("config_mounts", "source_profile_id"):
|
||||
op.drop_column("config_mounts", "source_profile_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Restore config_mounts
|
||||
op.add_column(
|
||||
"config_mounts",
|
||||
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"config_mounts",
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
)
|
||||
op.drop_column("config_mounts", "files")
|
||||
op.drop_column("config_mounts", "mode")
|
||||
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
|
||||
|
||||
# Restore config_profiles
|
||||
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
|
||||
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
|
||||
op.drop_constraint(
|
||||
"fk_config_profiles_tool_type", "config_profiles", type_="foreignkey"
|
||||
)
|
||||
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
|
||||
op.drop_column("config_profiles", "is_default")
|
||||
op.drop_column("config_profiles", "port")
|
||||
op.drop_column("config_profiles", "working_directory")
|
||||
op.drop_column("config_profiles", "start_command")
|
||||
op.drop_column("config_profiles", "environment_variables")
|
||||
op.drop_column("config_profiles", "tool_type_id")
|
||||
op.drop_column("config_profiles", "project_id")
|
||||
@@ -1,81 +0,0 @@
|
||||
"""add workspaces table
|
||||
|
||||
Revision ID: 2026_06_01_add_workspaces
|
||||
Revises: 2026_05_29_fix_code_server_bind_addr_port
|
||||
Create Date: 2026-06-01 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_06_01_add_workspaces"
|
||||
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr_port"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create workspaces table
|
||||
op.create_table(
|
||||
"workspaces",
|
||||
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"repo_id",
|
||||
sa.Uuid(as_uuid=True),
|
||||
sa.ForeignKey("git_repositories.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Uuid(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("branch", sa.String(255), nullable=False, server_default="main"),
|
||||
sa.Column("path", sa.String(2048), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="ready"),
|
||||
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
op.create_index("idx_workspaces_repo_id", "workspaces", ["repo_id"])
|
||||
op.create_index("idx_workspaces_user_id", "workspaces", ["user_id"])
|
||||
op.create_index("idx_workspaces_status", "workspaces", ["status"])
|
||||
|
||||
# Add workspace_id to tool_instances
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
sa.Uuid(as_uuid=True),
|
||||
sa.ForeignKey("workspaces.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_tool_instances_workspace_id", "tool_instances", ["workspace_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_tool_instances_workspace_id", table_name="tool_instances")
|
||||
op.drop_column("tool_instances", "workspace_id")
|
||||
op.drop_table("workspaces")
|
||||
@@ -1,20 +0,0 @@
|
||||
"""merge profile resolver and workspaces heads
|
||||
|
||||
Revision ID: 86cec91fdb00
|
||||
Revises: 0014_add_profile_resolver_fields, 2026_06_01_add_workspaces
|
||||
Create Date: 2026-06-03 12:48:36.145702
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "86cec91fdb00"
|
||||
down_revision = ("0014_add_profile_resolver_fields", "2026_06_01_add_workspaces")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1 +1,6 @@
|
||||
"""API routers package."""
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.events import router as events_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.users import router as users_router
|
||||
|
||||
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Config API routers module."""
|
||||
|
||||
from src.api.config.config_profiles import router as config_profiles_router
|
||||
from src.api.config.user_config import router as user_config_router
|
||||
|
||||
__all__ = ["config_profiles_router", "user_config_router"]
|
||||
@@ -1,300 +0,0 @@
|
||||
"""Config profile API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
||||
from src.schemas.config import (
|
||||
ConfigProfileCreate,
|
||||
ConfigProfileIncludeUpdate,
|
||||
ConfigProfileResponse,
|
||||
ConfigProfileUpdate,
|
||||
DefaultProfilesUpdate,
|
||||
ValidateGitUrlRequest,
|
||||
ValidateGitUrlResponse,
|
||||
)
|
||||
from src.services.config.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
resolve_profile,
|
||||
resolved_profile_to_dict,
|
||||
)
|
||||
from src.services.config.crud_service import (
|
||||
create_profile,
|
||||
get_or_create_user_config,
|
||||
get_profile_with_includes,
|
||||
profile_to_response,
|
||||
update_includes,
|
||||
update_profile,
|
||||
validate_default_profiles,
|
||||
)
|
||||
from src.services.config.resolver_service import (
|
||||
resolve_default_profile,
|
||||
validate_git_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ConfigProfileResponse])
|
||||
async def list_config_profiles(
|
||||
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||
tool_type_id: str | None = Query(
|
||||
None, description="Filter by tool type compatibility"
|
||||
),
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""List config profiles, optionally filtered by compatibility."""
|
||||
user_uuid = current_user_id
|
||||
query = (
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.user_id == user_uuid)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
|
||||
if project_id or tool_type_id:
|
||||
from sqlalchemy import or_
|
||||
|
||||
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||
|
||||
conditions: list = []
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id.is_(None))
|
||||
& (ConfigProfile.tool_type_id.is_(None))
|
||||
)
|
||||
if project_uuid:
|
||||
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||
if tool_uuid:
|
||||
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||
if project_uuid and tool_uuid:
|
||||
conditions.append(
|
||||
(ConfigProfile.project_id == project_uuid)
|
||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||
)
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
result = await session.execute(query)
|
||||
profiles = result.scalars().all()
|
||||
return [profile_to_response(p) for p in profiles]
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_config_profile(
|
||||
data: ConfigProfileCreate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Create a new config profile."""
|
||||
profile = await create_profile(session, current_user_id, data)
|
||||
logger.debug("Created config profile %s for user %s", profile.id, current_user_id)
|
||||
return profile_to_response(profile)
|
||||
|
||||
|
||||
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
async def get_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Get a config profile by ID."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
return profile_to_response(profile)
|
||||
|
||||
|
||||
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
async def update_config_profile(
|
||||
profile_id: str,
|
||||
data: ConfigProfileUpdate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Update a config profile."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
profile = await update_profile(session, profile, data)
|
||||
logger.debug("Updated config profile %s", profile.id)
|
||||
return profile_to_response(profile)
|
||||
|
||||
|
||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Delete a config profile."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
await session.delete(profile)
|
||||
await session.commit()
|
||||
|
||||
logger.debug("Deleted config profile %s", profile_id)
|
||||
return None
|
||||
|
||||
|
||||
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||
async def update_profile_includes_endpoint(
|
||||
profile_id: str,
|
||||
data: ConfigProfileIncludeUpdate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Update the ordered includes for a config profile."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||
profile = await update_includes(session, profile, included_uuids, current_user_id)
|
||||
|
||||
inc_result = await session.execute(
|
||||
select(ConfigProfileInclude).where(
|
||||
ConfigProfileInclude.profile_id == profile.id
|
||||
)
|
||||
)
|
||||
direct_includes = inc_result.scalars().all()
|
||||
|
||||
logger.debug("Updated includes for config profile %s", profile.id)
|
||||
return profile_to_response(profile, list(direct_includes))
|
||||
|
||||
|
||||
@router.get("/{profile_id}/preview")
|
||||
async def preview_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Preview the resolved output of a config profile."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
try:
|
||||
resolved = await resolve_profile(session, profile.id)
|
||||
except ConfigProfileCycleError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
return resolved_profile_to_dict(resolved)
|
||||
|
||||
|
||||
@router.get("/defaults/resolve")
|
||||
async def resolve_default_profile_endpoint(
|
||||
project_id: str = Query(..., description="Project ID"),
|
||||
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Resolve the default config profile for a project/tool combination."""
|
||||
return await resolve_default_profile(
|
||||
session,
|
||||
current_user_id,
|
||||
uuid.UUID(project_id),
|
||||
uuid.UUID(tool_type_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/defaults")
|
||||
async def get_default_profiles_endpoint(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get all default profile mappings for the current user."""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
user_config = result.scalar_one_or_none()
|
||||
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
||||
|
||||
|
||||
@router.put("/defaults")
|
||||
async def set_default_profiles_endpoint(
|
||||
data: DefaultProfilesUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Set default profile mappings for the current user."""
|
||||
await validate_default_profiles(session, user_id, data.default_profiles)
|
||||
user_config = await get_or_create_user_config(session, user_id)
|
||||
user_config.config = {
|
||||
**user_config.config,
|
||||
"default_profiles": data.default_profiles,
|
||||
}
|
||||
await session.commit()
|
||||
await session.refresh(user_config)
|
||||
return {"default_profiles": user_config.default_profiles}
|
||||
|
||||
|
||||
@router.get("/defaults/{tool_type_id}")
|
||||
async def get_default_profile_for_tool_type_endpoint(
|
||||
tool_type_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get the default profile ID for a specific tool type."""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
user_config = result.scalar_one_or_none()
|
||||
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||
|
||||
|
||||
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||
async def validate_git_url_endpoint(
|
||||
data: ValidateGitUrlRequest,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ValidateGitUrlResponse:
|
||||
"""Validate a git remote URL and list available branches."""
|
||||
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,9 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import GitRepository
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models import SSHKey
|
||||
from src.models.ssh_key import SSHKey
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
@@ -10,13 +10,13 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from src.auth.dependencies import get_current_user_id
|
||||
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
|
||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
# In-memory connection counter per user (single-process assumption)
|
||||
_connection_counts: dict[uuid.UUID, int] = {}
|
||||
MAX_CONNECTIONS_PER_USER = 20
|
||||
MAX_CONNECTIONS_PER_USER = 5
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,10 @@ from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.database import SessionLocal
|
||||
from src.schemas.system import (
|
||||
DatabaseHealth,
|
||||
DatabaseHealthResponse,
|
||||
DiskHealth,
|
||||
HealthChecks,
|
||||
HealthResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,6 +16,45 @@ router = APIRouter()
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
class DatabaseHealth(BaseModel):
|
||||
"""Database health check result."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||
|
||||
|
||||
class DiskHealth(BaseModel):
|
||||
"""Disk space health check result."""
|
||||
|
||||
status: str = Field(description="Disk health status", examples=["healthy"])
|
||||
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
|
||||
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
|
||||
|
||||
|
||||
class HealthChecks(BaseModel):
|
||||
"""Individual health checks."""
|
||||
|
||||
database: DatabaseHealth | None = None
|
||||
disk: DiskHealth | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Overall health check response."""
|
||||
|
||||
status: str = Field(description="Overall health status", examples=["healthy"])
|
||||
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
|
||||
version: str = Field(description="API version", examples=["0.1.0"])
|
||||
checks: HealthChecks = Field(description="Individual health checks")
|
||||
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
|
||||
|
||||
|
||||
class DatabaseHealthResponse(BaseModel):
|
||||
"""Database-specific health check response."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
response_model=HealthResponse,
|
||||
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import ToolInstance
|
||||
from src.models import ToolType
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -9,8 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user, get_db_session
|
||||
from src.models.user import User
|
||||
from src.models import UserConfig
|
||||
from src.services.shared.notification_service import notification_service
|
||||
from src.models.user_config import UserConfig
|
||||
from src.services.notification_service import notification_service
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Project API routers module."""
|
||||
|
||||
from src.api.project.git_repositories import router as git_repositories_router
|
||||
from src.api.project.projects import router as projects_router
|
||||
|
||||
__all__ = ["git_repositories_router", "projects_router"]
|
||||
@@ -1,500 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.models import GitRepository, SSHKey
|
||||
from src.schemas.project.git_repository import (
|
||||
BranchCreateRequest,
|
||||
BranchesResponse,
|
||||
CheckoutRequest,
|
||||
CommitRequest,
|
||||
CommitResponse,
|
||||
FetchResponse,
|
||||
FileContentResponse,
|
||||
FileListResponse,
|
||||
FileUpdateRequest,
|
||||
FileUpdateResponse,
|
||||
GitRepositoryCreate,
|
||||
GitRepositoryResponse,
|
||||
MergeRequest,
|
||||
MergeResponse,
|
||||
PullResponse,
|
||||
PushResponse,
|
||||
StatusResponse,
|
||||
UpdateSSHKeyRequest,
|
||||
URLParseRequest,
|
||||
URLParseResponse,
|
||||
)
|
||||
from src.services.git.operations import (
|
||||
clone_working_repository,
|
||||
get_repo_path,
|
||||
init_working_repository,
|
||||
list_remote_branches,
|
||||
preflight_remote_repository,
|
||||
)
|
||||
from src.utils.git_control import (
|
||||
checkout_branch,
|
||||
commit_changes,
|
||||
create_branch,
|
||||
delete_branch,
|
||||
fetch,
|
||||
get_status,
|
||||
merge,
|
||||
pull,
|
||||
push,
|
||||
)
|
||||
from src.utils.git_files import commit_file, get_file_content, list_branches, list_tree
|
||||
from src.utils.git_history import get_commit_detail, get_commit_history
|
||||
from src.utils.git_url_parser import parse_git_url
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_repo(
|
||||
session: AsyncSession, user_id: uuid.UUID, project_id: uuid.UUID, repo_id: uuid.UUID
|
||||
) -> GitRepository:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
return repo
|
||||
|
||||
|
||||
async def _get_repo_on_disk(
|
||||
session: AsyncSession, user_id: uuid.UUID, project_id: uuid.UUID, repo_id: uuid.UUID
|
||||
) -> GitRepository:
|
||||
repo = await _get_repo(session, user_id, project_id, repo_id)
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
return repo
|
||||
|
||||
|
||||
def _parse_remote_url(remote_url: str | None, force_original: bool) -> str | None:
|
||||
if not remote_url or force_original:
|
||||
return remote_url
|
||||
parse_result = parse_git_url(remote_url)
|
||||
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||
"suggested_url": parse_result["base_url"],
|
||||
"original_url": remote_url,
|
||||
"error_code": "URL_NEEDS_PARSING",
|
||||
},
|
||||
)
|
||||
return parse_result.get("base_url") or remote_url
|
||||
|
||||
|
||||
async def _commit_author(session: AsyncSession, user_id: uuid.UUID) -> tuple[str, str]:
|
||||
user = await _get_user(session, user_id)
|
||||
return user.name or "Unknown", user.email or "unknown@example.com"
|
||||
|
||||
|
||||
@router.get("/repositories", response_model=list[GitRepositoryResponse])
|
||||
async def list_user_repositories(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[GitRepository]:
|
||||
"""List all repositories owned by the user."""
|
||||
result = await session.execute(select(GitRepository).where(GitRepository.owner_id == user_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/repositories/parse-url", response_model=URLParseResponse)
|
||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||
"""Parse a git URL and detect if it's a browser URL that needs correction."""
|
||||
return URLParseResponse(**parse_git_url(data.url))
|
||||
|
||||
|
||||
@router.post("/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_external_repository(
|
||||
data: GitRepositoryCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> GitRepository:
|
||||
"""Create a new external git repository (not tied to any project)."""
|
||||
_user = await _get_user(session, user_id)
|
||||
existing = await session.execute(
|
||||
select(GitRepository).where(
|
||||
GitRepository.project_id.is_(None),
|
||||
GitRepository.owner_id == user_id,
|
||||
GitRepository.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
remote_url = _parse_remote_url(data.remote_url, data.force_original_url)
|
||||
ssh_key_id, ssh_key = None, None
|
||||
if data.ssh_key_id:
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
if ssh_key.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
||||
if remote_url:
|
||||
preflight_remote_repository(remote_url, ssh_key)
|
||||
repo = GitRepository(name=data.name, path="", project_id=None, owner_id=user_id, remote_url=remote_url, ssh_key_id=ssh_key_id)
|
||||
session.add(repo)
|
||||
await session.flush()
|
||||
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
|
||||
repo.path = repo_path
|
||||
if remote_url:
|
||||
try:
|
||||
clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
repo.is_mirror = False
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
||||
else:
|
||||
init_working_repository(repo_path)
|
||||
repo.is_mirror = False
|
||||
await session.commit()
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
|
||||
async def list_repositories(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[GitRepository]:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_repository(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
repo = await _get_repo(session, user_id, project_id, repo_id)
|
||||
if os.path.exists(repo.path):
|
||||
shutil.rmtree(repo.path)
|
||||
await session.delete(repo)
|
||||
await session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_repository(
|
||||
project_id: uuid.UUID,
|
||||
data: GitRepositoryCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> GitRepository:
|
||||
"""Create a new git repository in a project."""
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
existing = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id, GitRepository.name == data.name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
remote_url = _parse_remote_url(data.remote_url, data.force_original_url)
|
||||
ssh_key_id, ssh_key = None, None
|
||||
if data.ssh_key_id:
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||
if remote_url:
|
||||
preflight_remote_repository(remote_url, ssh_key)
|
||||
repo_path = get_repo_path(user_id, project_id, data.name)
|
||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||
if remote_url:
|
||||
clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
else:
|
||||
init_working_repository(repo_path)
|
||||
repo = GitRepository(
|
||||
name=data.name, path=repo_path, project_id=project_id, owner_id=user_id,
|
||||
is_mirror=False, remote_url=remote_url, ssh_key_id=ssh_key_id,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@router.patch("/{project_id}/repositories/{repo_id}/ssh-key", response_model=GitRepositoryResponse)
|
||||
async def update_repository_ssh_key(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: UpdateSSHKeyRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> GitRepository:
|
||||
repo = await _get_repo(session, user_id, project_id, repo_id)
|
||||
if data.ssh_key_id:
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||
repo.ssh_key_id = ssh_key_id
|
||||
else:
|
||||
repo.ssh_key_id = None
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/history")
|
||||
async def get_repository_history(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
view: str = "graph", branch: str | None = None, limit: int = 100, offset: int = 0,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
|
||||
async def get_repository_commit(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, commit_hash: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
return get_commit_detail(repo.path, commit_hash)
|
||||
except (RuntimeError, ValueError) as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse)
|
||||
async def list_repository_files(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str = "main", path: str = "",
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> FileListResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
entries = list_tree(repo.path, branch=branch, path=path)
|
||||
return FileListResponse(path=path, branch=branch, entries=[
|
||||
{"name": e.name, "type": e.type, "path": e.path, "size": e.size, "mode": e.mode, "last_commit": e.last_commit}
|
||||
for e in entries
|
||||
])
|
||||
except RuntimeError as e:
|
||||
logger.error("Failed to list files for repo %s: %s", repo_id, str(e))
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse)
|
||||
async def get_repository_file_content(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str, path: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> FileContentResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
fc = get_file_content(repo.path, branch=branch, path=path)
|
||||
return FileContentResponse(path=fc.path, branch=fc.branch, content=fc.content, size=fc.size,
|
||||
encoding=fc.encoding, language=fc.language, is_binary=fc.is_binary, last_commit=fc.last_commit)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/branches", response_model=BranchesResponse)
|
||||
async def get_repository_branches(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> BranchesResponse:
|
||||
repo = await _get_repo(session, user_id, project_id, repo_id)
|
||||
is_valid = os.path.isdir(os.path.join(repo.path, ".git")) or os.path.isfile(os.path.join(repo.path, "HEAD"))
|
||||
if is_valid:
|
||||
try:
|
||||
branches, default_branch = list_branches(repo.path)
|
||||
return BranchesResponse(branches=[
|
||||
{"name": b.name, "is_default": b.is_default, "last_commit": b.last_commit} for b in branches
|
||||
], default_branch=default_branch)
|
||||
except RuntimeError as e:
|
||||
logger.error("Failed to list branches for repo %s: %s", repo_id, str(e))
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e
|
||||
if repo.remote_url:
|
||||
ssh_key = await session.get(SSHKey, repo.ssh_key_id) if repo.ssh_key_id else None
|
||||
try:
|
||||
remote_branches, default_branch = list_remote_branches(repo.remote_url, ssh_key)
|
||||
if remote_branches:
|
||||
return BranchesResponse(branches=[
|
||||
{"name": b, "is_default": b == default_branch, "last_commit": None} for b in remote_branches
|
||||
], default_branch=default_branch)
|
||||
except RuntimeError:
|
||||
pass
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk — re-clone or re-create the repository")
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse)
|
||||
async def update_repository_file(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: FileUpdateRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> FileUpdateResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
author_name, author_email = await _commit_author(session, user_id)
|
||||
try:
|
||||
commit_hash = commit_file(repo.path, data.branch, data.path, data.content, data.commit_message, author_name, author_email)
|
||||
return FileUpdateResponse(commit_hash=commit_hash, message=data.commit_message, branch=data.branch)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse)
|
||||
async def get_repository_status(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> StatusResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
s = get_status(repo.path)
|
||||
return StatusResponse(branch=s.branch, modified=s.modified, added=s.added, deleted=s.deleted,
|
||||
untracked=s.untracked, renamed=s.renamed, ahead=s.ahead, behind=s.behind)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/branches")
|
||||
async def create_repository_branch(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: BranchCreateRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
create_branch(repo.path, data.name, data.base_branch)
|
||||
return {"message": f"Branch '{data.name}' created", "branch": data.name}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}")
|
||||
async def delete_repository_branch(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch_name: str, force: bool = False,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
delete_branch(repo.path, branch_name, force)
|
||||
return {"message": f"Branch '{branch_name}' deleted"}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/checkout")
|
||||
async def checkout_repository_branch(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: CheckoutRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
checkout_branch(repo.path, data.branch)
|
||||
return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse)
|
||||
async def commit_repository_changes(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: CommitRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> CommitResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
author_name, author_email = await _commit_author(session, user_id)
|
||||
try:
|
||||
commit_hash = commit_changes(repo.path, data.message, author_name, author_email, data.files)
|
||||
return CommitResponse(commit_hash=commit_hash, message=data.message)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
|
||||
async def fetch_repository(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> FetchResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
fetch(repo.path)
|
||||
return FetchResponse(message="Fetched from remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse)
|
||||
async def pull_repository(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> PullResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
pull(repo.path, branch)
|
||||
return PullResponse(message="Pulled from remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse)
|
||||
async def push_repository(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> PushResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
push(repo.path, branch)
|
||||
return PushResponse(message="Pushed to remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse)
|
||||
async def merge_repository_branches(
|
||||
project_id: uuid.UUID, repo_id: uuid.UUID, data: MergeRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> MergeResponse:
|
||||
repo = await _get_repo_on_disk(session, user_id, project_id, repo_id)
|
||||
try:
|
||||
commit_hash = merge(repo.path, data.source_branch, data.target_branch, data.message)
|
||||
return MergeResponse(commit_hash=commit_hash, message=data.message or f"Merge {data.source_branch}")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
@@ -3,29 +3,42 @@ import shutil
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import func, select
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.models import GitRepository
|
||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models import SSHKey
|
||||
from src.models import ToolInstance
|
||||
from src.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectResponse,
|
||||
ProjectUpdate,
|
||||
SetDefaultSSHKeyRequest,
|
||||
)
|
||||
from src.models.ssh_key import SSHKey
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
owner_id: uuid.UUID
|
||||
default_ssh_key_id: uuid.UUID | None
|
||||
|
||||
|
||||
class SetDefaultSSHKeyRequest(BaseModel):
|
||||
ssh_key_id: uuid.UUID
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ProjectResponse,
|
||||
@@ -63,77 +76,26 @@ async def create_project(
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[ProjectResponse],
|
||||
summary="List all projects",
|
||||
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
|
||||
description="Retrieve all projects owned by the authenticated user.",
|
||||
)
|
||||
async def list_projects(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
) -> list[Project]:
|
||||
"""List all projects for the authenticated user.
|
||||
|
||||
Returns projects with nested repositories and workspaces for inline display.
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
List of projects owned by the user.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
result = await session.execute(
|
||||
select(Project)
|
||||
.where(Project.owner_id == user.id)
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
|
||||
from src.models import Workspace
|
||||
|
||||
enriched = []
|
||||
for project in projects:
|
||||
repos_result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project.id)
|
||||
)
|
||||
repositories = []
|
||||
for repo in repos_result.scalars().all():
|
||||
ws_result = await session.execute(
|
||||
select(Workspace).where(Workspace.repo_id == repo.id)
|
||||
)
|
||||
workspaces = []
|
||||
for ws in ws_result.scalars().all():
|
||||
# Count instances
|
||||
inst_result = await session.execute(
|
||||
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
||||
)
|
||||
instance_count = inst_result.scalar() or 0
|
||||
workspaces.append(
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"branch": ws.branch,
|
||||
"status": ws.status,
|
||||
"instance_count": instance_count,
|
||||
}
|
||||
)
|
||||
|
||||
repositories.append(
|
||||
{
|
||||
"id": str(repo.id),
|
||||
"name": repo.name,
|
||||
"remote_url": repo.remote_url,
|
||||
"workspaces": workspaces,
|
||||
}
|
||||
)
|
||||
|
||||
enriched.append(
|
||||
{
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"owner_id": str(project.owner_id),
|
||||
"repositories": repositories,
|
||||
"created_at": project.created_at.isoformat()
|
||||
if project.created_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return enriched
|
||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -222,9 +184,7 @@ async def delete_project(
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
# Delete repositories from disk and database
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||
)
|
||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
||||
repositories = result.scalars().all()
|
||||
for repo in repositories:
|
||||
if os.path.exists(repo.path):
|
||||
@@ -1,24 +1,18 @@
|
||||
import base64
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.config import Settings
|
||||
from src.models import SSHKey
|
||||
from src.schemas.project import (
|
||||
SSHKeyCreate,
|
||||
SSHKeyResponse,
|
||||
SignPayloadRequest,
|
||||
SignatureResponse,
|
||||
VerifySignatureRequest,
|
||||
VerifySignatureResponse,
|
||||
)
|
||||
from src.models.ssh_key import SSHKey
|
||||
|
||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||
|
||||
@@ -27,7 +21,7 @@ def _get_fernet() -> Fernet:
|
||||
"""Generate a valid Fernet key from the session secret."""
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
|
||||
settings = Settings()
|
||||
# Derive a 32-byte key from the session secret using SHA256
|
||||
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
|
||||
@@ -59,6 +53,36 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
||||
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
||||
|
||||
|
||||
class SSHKeyCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class SSHKeyResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
public_key: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SignPayloadRequest(BaseModel):
|
||||
payload: str
|
||||
|
||||
|
||||
class SignatureResponse(BaseModel):
|
||||
signature: str
|
||||
|
||||
|
||||
class VerifySignatureRequest(BaseModel):
|
||||
payload: str
|
||||
signature: str
|
||||
|
||||
|
||||
class VerifySignatureResponse(BaseModel):
|
||||
valid: bool
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SSHKeyResponse,
|
||||
@@ -147,9 +171,7 @@ async def delete_ssh_key(
|
||||
user = await _get_user(session, user_id)
|
||||
ssh_key = await session.get(SSHKey, key_id)
|
||||
if ssh_key is None or ssh_key.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
|
||||
await session.delete(ssh_key)
|
||||
await session.commit()
|
||||
@@ -181,9 +203,7 @@ async def sign_payload(
|
||||
user = await _get_user(session, user_id)
|
||||
ssh_key = await session.get(SSHKey, key_id)
|
||||
if ssh_key is None or ssh_key.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
|
||||
fernet = _get_fernet()
|
||||
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||
@@ -222,9 +242,7 @@ async def verify_signature(
|
||||
user = await _get_user(session, user_id)
|
||||
ssh_key = await session.get(SSHKey, key_id)
|
||||
if ssh_key is None or ssh_key.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
|
||||
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""System API routers module."""
|
||||
|
||||
from src.api.system.dashboard import router as dashboard_router
|
||||
from src.api.system.events import router as events_router
|
||||
from src.api.system.health import router as health_router
|
||||
from src.api.system.instance_proxy import router as instance_proxy_router
|
||||
from src.api.system.notifications import router as notifications_router
|
||||
from src.api.system.terminal import router as terminal_router
|
||||
|
||||
__all__ = [
|
||||
"dashboard_router",
|
||||
"events_router",
|
||||
"health_router",
|
||||
"instance_proxy_router",
|
||||
"notifications_router",
|
||||
"terminal_router",
|
||||
]
|
||||
@@ -12,10 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import TerminalSessionModel
|
||||
from src.models import ToolInstance
|
||||
from src.models import ToolType
|
||||
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -222,7 +222,8 @@ async def _handle_terminal_websocket(
|
||||
# Use mutable session reference so loops can survive reset
|
||||
session_ref = SessionRef(session, slot_session_id)
|
||||
|
||||
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
|
||||
# Start I/O loops and heartbeat
|
||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||
write_task = asyncio.create_task(
|
||||
_write_loop(session_ref, websocket, instance_id)
|
||||
)
|
||||
@@ -231,7 +232,7 @@ async def _handle_terminal_websocket(
|
||||
|
||||
# Wait for either task to complete (indicating disconnect or error)
|
||||
done, pending = await asyncio.wait(
|
||||
[write_task, heartbeat_task],
|
||||
[read_task, write_task, heartbeat_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
@@ -266,6 +267,28 @@ async def _handle_terminal_websocket(
|
||||
)
|
||||
|
||||
|
||||
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
||||
"""Read output from the container and send to WebSocket."""
|
||||
try:
|
||||
while True:
|
||||
session = session_ref.session
|
||||
if not session.is_alive() or session._closed:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
data = await session.read_output()
|
||||
if data:
|
||||
try:
|
||||
await websocket.send_bytes(data)
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
else:
|
||||
await asyncio.sleep(0.01)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||
"""Read input from WebSocket and send to container."""
|
||||
try:
|
||||
@@ -296,10 +319,6 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
||||
rows,
|
||||
)
|
||||
await session.resize(cols, rows)
|
||||
elif msg_type == "ack":
|
||||
char_count = ctrl.get("chars", 0)
|
||||
if char_count > 0:
|
||||
session.acknowledge_data(char_count)
|
||||
elif msg_type == "reset":
|
||||
# Reset terminal session (scoped to current slot)
|
||||
logger.debug(
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Tool API routers module."""
|
||||
|
||||
from src.api.tool.sessions import sessions_router
|
||||
from src.api.tool.tool_definitions import router as tool_definitions_router
|
||||
from src.api.tool.tool_instances import router as tool_instances_router
|
||||
from src.api.tool.tool_types import router as tool_types_router
|
||||
|
||||
__all__ = [
|
||||
"sessions_router",
|
||||
"tool_definitions_router",
|
||||
"tool_instances_router",
|
||||
"tool_types_router",
|
||||
]
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Sessions API endpoints (running instances for current user)."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models import GitRepository
|
||||
from src.models import Project
|
||||
from src.models import ToolInstance
|
||||
from src.models import ToolType
|
||||
|
||||
sessions_router = APIRouter(prefix="/users", tags=["sessions"])
|
||||
|
||||
|
||||
@sessions_router.get(
|
||||
"/me/sessions",
|
||||
summary="Get user sessions",
|
||||
description="Get all active sessions (running instances) for the current user.",
|
||||
)
|
||||
async def get_user_sessions(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get all active sessions for the current user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary containing list of active sessions with instance details.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
|
||||
result = await session.execute(
|
||||
select(ToolInstance)
|
||||
.where(ToolInstance.owner_id == user_id)
|
||||
.where(
|
||||
ToolInstance.status.in_(
|
||||
["running", "building", "pending", "stopped", "error"]
|
||||
)
|
||||
)
|
||||
.order_by(ToolInstance.created_at.desc())
|
||||
)
|
||||
instances = result.scalars().all()
|
||||
|
||||
sessions = []
|
||||
for instance in instances:
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
repo = await session.get(GitRepository, instance.repository_id)
|
||||
project = await session.get(Project, instance.project_id)
|
||||
|
||||
workspace_name = None
|
||||
if instance.workspace_id:
|
||||
from src.models import Workspace as WorkspaceModel
|
||||
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
||||
if workspace:
|
||||
workspace_name = workspace.name
|
||||
|
||||
sessions.append(
|
||||
{
|
||||
"id": str(instance.id),
|
||||
"display_name": instance.display_name,
|
||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||
"tool_icon": tool_type.name if tool_type else "code",
|
||||
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
|
||||
"repository_name": repo.name if repo else "unknown",
|
||||
"repository_id": str(instance.repository_id),
|
||||
"project_name": project.name if project else "unknown",
|
||||
"project_id": str(instance.project_id),
|
||||
"workspace_name": workspace_name,
|
||||
"status": instance.status,
|
||||
"url": instance.url,
|
||||
"clone_mode": instance.clone_mode,
|
||||
"branch": instance.branch,
|
||||
"selected_config_profile_id": str(instance.selected_config_profile_id)
|
||||
if instance.selected_config_profile_id
|
||||
else None,
|
||||
"created_at": instance.created_at.isoformat()
|
||||
if instance.created_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {"sessions": sessions}
|
||||
@@ -1,536 +0,0 @@
|
||||
"""Tool instance API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.models import ToolInstance
|
||||
from src.services.docker import get_container_logs, get_container_status
|
||||
from src.services.shared.tunnel import check_tunnel_health
|
||||
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
|
||||
from src.services.tool.instance_service import (
|
||||
create_tool_instance,
|
||||
delete_tool_instance,
|
||||
recreate_instance_tunnel,
|
||||
rename_tool_instance,
|
||||
restart_tool_instance,
|
||||
start_tool_instance,
|
||||
stop_tool_instance,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances",
|
||||
summary="Create tool instance",
|
||||
description="Create a new tool instance for a repository.",
|
||||
)
|
||||
async def create_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: CreateInstanceRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
instance = await create_tool_instance(session, user_id, project_id, repo_id, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
return {
|
||||
"id": str(instance.id),
|
||||
"name": instance.name,
|
||||
"display_name": instance.display_name,
|
||||
"tool_type_id": str(instance.tool_type_id),
|
||||
"status": instance.status,
|
||||
"clone_mode": instance.clone_mode,
|
||||
"branch": instance.branch,
|
||||
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||
"created_at": instance.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances",
|
||||
summary="List instances",
|
||||
description="List all tool instances for a repository.",
|
||||
)
|
||||
async def list_instances(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
result = await session.execute(
|
||||
select(ToolInstance)
|
||||
.where(ToolInstance.repository_id == repo_id)
|
||||
.where(ToolInstance.owner_id == user_id)
|
||||
.order_by(ToolInstance.created_at.desc())
|
||||
)
|
||||
instances = result.scalars().all()
|
||||
|
||||
return [
|
||||
{"id": str(i.id), "name": i.name, "display_name": i.display_name,
|
||||
"tool_type_id": str(i.tool_type_id), "status": i.status, "url": i.url,
|
||||
"port": i.port, "container_id": i.container_id,
|
||||
"created_at": i.created_at.isoformat() if i.created_at else None}
|
||||
for i in instances
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
|
||||
summary="Get instance",
|
||||
description="Get details for a specific tool instance.",
|
||||
)
|
||||
async def get_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
docker_status = None
|
||||
if instance.container_id:
|
||||
docker_status = get_container_status(instance.container_id)
|
||||
|
||||
return {
|
||||
"id": str(instance.id),
|
||||
"name": instance.name,
|
||||
"display_name": instance.display_name,
|
||||
"tool_type_id": str(instance.tool_type_id),
|
||||
"status": instance.status,
|
||||
"url": instance.url,
|
||||
"public_url": instance.public_url,
|
||||
"port": instance.port,
|
||||
"container_id": instance.container_id,
|
||||
"container_name": instance.container_name,
|
||||
"compose_path": instance.compose_path,
|
||||
"clone_mode": instance.clone_mode,
|
||||
"branch": instance.branch,
|
||||
"workspace_id": str(instance.workspace_id) if instance.workspace_id else None,
|
||||
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||
"ssh_key_ids": instance.ssh_key_ids,
|
||||
"created_at": instance.created_at.isoformat() if instance.created_at else None,
|
||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
||||
"docker_status": docker_status,
|
||||
}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
|
||||
summary="Rename instance",
|
||||
description="Update the display name of a tool instance.",
|
||||
)
|
||||
async def rename_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
display_name = data.get("display_name", "").strip()
|
||||
if not display_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="display_name is required",
|
||||
)
|
||||
|
||||
try:
|
||||
instance = await rename_tool_instance(
|
||||
session, user_id, project_id, repo_id, instance_id, display_name
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(instance.id),
|
||||
"name": instance.name,
|
||||
"display_name": instance.display_name,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/start",
|
||||
summary="Start instance",
|
||||
description="Start a tool instance using Docker Compose.",
|
||||
)
|
||||
async def start_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
data: StartInstanceRequest | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
return await start_tool_instance(
|
||||
session, user_id, project_id, repo_id, instance_id, data
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop",
|
||||
summary="Stop instance",
|
||||
description="Stop a running tool instance.",
|
||||
)
|
||||
async def stop_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
return await stop_tool_instance(session, user_id, project_id, repo_id, instance_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart",
|
||||
summary="Restart instance",
|
||||
description="Restart a tool instance.",
|
||||
)
|
||||
async def restart_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
return await restart_tool_instance(
|
||||
session, user_id, project_id, repo_id, instance_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
|
||||
summary="Delete instance",
|
||||
description="Delete a tool instance and remove its Docker containers and files.",
|
||||
)
|
||||
async def delete_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
force: bool = False,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
await delete_tool_instance(
|
||||
session, user_id, project_id, repo_id, instance_id, force
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
detail = str(exc)
|
||||
if "uncommitted changes" in detail.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "Repository has uncommitted changes",
|
||||
"changed_files": detail,
|
||||
"force_required": True,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs",
|
||||
summary="Get instance logs",
|
||||
description="Get container logs for a tool instance.",
|
||||
)
|
||||
async def get_instance_logs(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
tail: int = 100,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
if not instance.container_id:
|
||||
return {"logs": "No container running"}
|
||||
|
||||
logs = get_container_logs(instance.container_id, tail)
|
||||
return {"logs": logs}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel",
|
||||
summary="Recreate tunnel",
|
||||
description="Recreate the temporary Cloudflare tunnel for a running instance.",
|
||||
)
|
||||
async def recreate_tunnel_endpoint(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
try:
|
||||
return await recreate_instance_tunnel(
|
||||
session, user_id, project_id, repo_id, instance_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
||||
summary="Check instance health",
|
||||
description="Check container and tunnel health for an instance.",
|
||||
)
|
||||
async def check_instance_tunnel_health(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
container_info = {"status": "not_found", "exit_code": None, "health": None}
|
||||
if instance.container_id:
|
||||
container_info = get_container_status(instance.container_id)
|
||||
|
||||
response = {
|
||||
"healthy": False,
|
||||
"container_status": container_info["status"],
|
||||
"container_health": container_info["health"],
|
||||
"tunnel_status": "not_applicable",
|
||||
"tunnel_status_code": None,
|
||||
"probe_status": "not_applicable",
|
||||
"last_probe_output": None,
|
||||
"error": None,
|
||||
}
|
||||
if instance.status == "probing":
|
||||
response["probe_status"] = "pending"
|
||||
elif instance.probe_result:
|
||||
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
|
||||
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))
|
||||
if instance.url and instance.status in ("running", "unhealthy"):
|
||||
tunnel_health = check_tunnel_health(instance.url)
|
||||
response["tunnel_status"] = tunnel_health["tunnel_status"]
|
||||
response["tunnel_status_code"] = tunnel_health.get("status_code")
|
||||
if tunnel_health.get("error"):
|
||||
response["error"] = tunnel_health["error"]
|
||||
container_healthy = container_info["status"] == "running"
|
||||
if instance.url:
|
||||
response["healthy"] = container_healthy and response["tunnel_status"] == "healthy"
|
||||
else:
|
||||
response["healthy"] = container_healthy
|
||||
if not container_healthy:
|
||||
response["error"] = f"Container is {container_info['status']}"
|
||||
if container_info["exit_code"] is not None:
|
||||
response["error"] += f" (exit code: {container_info['exit_code']})"
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/events",
|
||||
summary="Get instance events history",
|
||||
description="Get lifecycle event history for a tool instance.",
|
||||
)
|
||||
async def get_instance_events(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
limit: int = 50,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
from sqlalchemy import select
|
||||
from src.models import InstanceEvent
|
||||
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(InstanceEvent)
|
||||
.where(InstanceEvent.instance_id == instance_id)
|
||||
.order_by(InstanceEvent.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"event_type": row.event_type,
|
||||
"status": row.status,
|
||||
"message": row.message,
|
||||
"metadata": row.event_metadata,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
||||
summary="Proxy to instance",
|
||||
description="Proxy HTTP requests to a running tool instance.",
|
||||
)
|
||||
async def proxy_to_instance(
|
||||
request: Request,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
path: str = "",
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
if instance.owner_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="not authorized to access this instance",
|
||||
)
|
||||
|
||||
if instance.status != "running" or not instance.container_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="instance is not running",
|
||||
)
|
||||
|
||||
target_url = f"http://{instance.container_name}:{instance.port}"
|
||||
if path:
|
||||
target_url += f"/{path}"
|
||||
|
||||
query_string = str(request.query_params)
|
||||
if query_string:
|
||||
target_url += f"?{query_string}"
|
||||
|
||||
headers = dict(request.headers)
|
||||
headers.pop("host", None)
|
||||
headers.pop("cookie", None)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
body = await request.body()
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
follow_redirects=False,
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Proxy error: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"failed to reach instance: {exc}",
|
||||
)
|
||||
|
||||
response_headers = dict(response.headers)
|
||||
for header in ["content-encoding", "transfer-encoding", "connection"]:
|
||||
response_headers.pop(header, None)
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
@@ -9,9 +9,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import ToolDefinitionManifest
|
||||
from src.models import ToolType
|
||||
from src.services.build.manifest_compiler import (
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.manifest_compiler import (
|
||||
compile_compose,
|
||||
compile_dockerfile,
|
||||
compile_entrypoint,
|
||||
@@ -173,7 +173,7 @@ async def list_tool_definitions(
|
||||
"""
|
||||
query = select(ToolDefinitionManifest)
|
||||
if not include_bases:
|
||||
query = query.where(ToolDefinitionManifest.is_base.is_(False))
|
||||
query = query.where(ToolDefinitionManifest.is_base == False)
|
||||
|
||||
result = await session.execute(
|
||||
query.order_by(ToolDefinitionManifest.created_at.desc())
|
||||
+1330
-715
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,19 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.tool.tool_types_validation import (
|
||||
from src.api.tool_types_validation import (
|
||||
check_port_exposed,
|
||||
validate_compose_yaml,
|
||||
validate_required_variables,
|
||||
)
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models import ToolType
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.schemas.tool import (
|
||||
ToolTypeCreate,
|
||||
ToolTypeResponse,
|
||||
ToolTypeUpdate,
|
||||
ToolTypeValidateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||
|
||||
@@ -33,6 +29,237 @@ async def _require_admin(user: User) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class ToolTypeCreate(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
default_port: int = 0
|
||||
definition_type: str = "compose"
|
||||
manifest_id: uuid.UUID | None = None
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] = []
|
||||
category: str = "other"
|
||||
interface_type: str = "web"
|
||||
requires_port: bool = True
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
def validate_definition_type(cls, v: str) -> str:
|
||||
if v not in ("compose", "dockerfile", "manifest"):
|
||||
raise ValueError(
|
||||
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
if v is None or not v.strip():
|
||||
raise ValueError(
|
||||
"compose_template is required when definition_type is 'compose'"
|
||||
)
|
||||
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@classmethod
|
||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||
data = info.data
|
||||
if data.get("definition_type") != "dockerfile":
|
||||
return v
|
||||
|
||||
if v is None or not v.strip():
|
||||
raise ValueError(
|
||||
"dockerfile_template is required when definition_type is 'dockerfile'"
|
||||
)
|
||||
|
||||
if not v.strip().startswith("FROM"):
|
||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str) -> str:
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("default_port")
|
||||
@classmethod
|
||||
def validate_default_port(cls, v: int, info) -> int:
|
||||
data = info.data
|
||||
requires_port = data.get("requires_port", True)
|
||||
if not requires_port:
|
||||
return v
|
||||
if v <= 0 or v > 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return v
|
||||
|
||||
@field_validator("required_variables")
|
||||
@classmethod
|
||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||
if not v:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
template = data.get("compose_template")
|
||||
if not template:
|
||||
return v
|
||||
|
||||
for var in v:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise ValueError(
|
||||
f"Required variable '{var}' not found in compose template"
|
||||
)
|
||||
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_templates(self) -> "ToolTypeCreate":
|
||||
if self.definition_type == "manifest":
|
||||
if self.manifest_id is None:
|
||||
raise ValueError(
|
||||
"manifest_id is required when definition_type is 'manifest'"
|
||||
)
|
||||
return self
|
||||
|
||||
if self.definition_type == "dockerfile" and (
|
||||
self.dockerfile_template is None or not self.dockerfile_template.strip()
|
||||
):
|
||||
raise ValueError(
|
||||
"dockerfile_template is required when definition_type is 'dockerfile'"
|
||||
)
|
||||
if self.definition_type == "compose" and (
|
||||
self.compose_template is None or not self.compose_template.strip()
|
||||
):
|
||||
raise ValueError(
|
||||
"compose_template is required when definition_type is 'compose'"
|
||||
)
|
||||
|
||||
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||
if (
|
||||
self.requires_port
|
||||
and self.definition_type == "compose"
|
||||
and self.compose_template
|
||||
):
|
||||
try:
|
||||
parsed = validate_compose_yaml(self.compose_template)
|
||||
except ValueError:
|
||||
return self
|
||||
|
||||
if not check_port_exposed(parsed, self.default_port):
|
||||
raise ValueError(
|
||||
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class ToolTypeUpdate(BaseModel):
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
default_port: int | None = None
|
||||
definition_type: str | None = None
|
||||
manifest_id: uuid.UUID | None = None
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] | None = None
|
||||
category: str | None = None
|
||||
interface_type: str | None = None
|
||||
requires_port: bool | None = None
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in ("compose", "dockerfile", "manifest"):
|
||||
raise ValueError(
|
||||
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
definition_type = data.get("definition_type")
|
||||
if definition_type and definition_type != "compose":
|
||||
return v
|
||||
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@classmethod
|
||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
definition_type = data.get("definition_type")
|
||||
if definition_type and definition_type != "dockerfile":
|
||||
return v
|
||||
|
||||
if not v.strip().startswith("FROM"):
|
||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class ToolTypeResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None
|
||||
category: str
|
||||
interface_type: str
|
||||
requires_port: bool
|
||||
default_port: int
|
||||
definition_type: str
|
||||
manifest_id: uuid.UUID | None
|
||||
compose_template: str | None
|
||||
dockerfile_template: str | None
|
||||
build_context: dict | None
|
||||
readiness_probe: dict | None
|
||||
startup_command: str | None
|
||||
required_variables: list[str]
|
||||
created_by_id: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ToolTypeResponse,
|
||||
@@ -234,6 +461,12 @@ async def update_tool_type(
|
||||
return tool_type
|
||||
|
||||
|
||||
class ToolTypeValidateRequest(BaseModel):
|
||||
definition_type: str
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/validate",
|
||||
summary="Validate tool type template",
|
||||
@@ -1,7 +0,0 @@
|
||||
"""User API routers module."""
|
||||
|
||||
from src.api.user.auth import router as auth_router
|
||||
from src.api.user.ssh_keys import router as ssh_keys_router
|
||||
from src.api.user.users import router as users_router
|
||||
|
||||
__all__ = ["auth_router", "ssh_keys_router", "users_router"]
|
||||
@@ -2,12 +2,12 @@ import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models import UserConfig
|
||||
from src.schemas.user import UserConfigResponse, UserConfigUpdate
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,6 +38,28 @@ async def _get_or_create_config(
|
||||
return config
|
||||
|
||||
|
||||
class UserConfigResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
default_editor: str | None = None
|
||||
theme: str = "system"
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
notification_mute_categories: list[str] | None = None
|
||||
notification_toast_level: str | None = None
|
||||
|
||||
|
||||
class UserConfigUpdate(BaseModel):
|
||||
default_editor: str | None = None
|
||||
theme: str | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
notification_mute_categories: list[str] | None = None
|
||||
notification_toast_level: str | None = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/config",
|
||||
response_model=UserConfigResponse,
|
||||
@@ -2,11 +2,11 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models.user import User
|
||||
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
@@ -16,6 +16,20 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
name: str
|
||||
avatar_url: str | None
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserProfileResponse,
|
||||
@@ -63,16 +77,12 @@ async def update_profile(
|
||||
|
||||
if data.name is not None:
|
||||
if len(data.name.strip()) == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
|
||||
user.name = data.name.strip()
|
||||
|
||||
if data.email is not None:
|
||||
if "@" not in data.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
|
||||
user.email = data.email.strip()
|
||||
|
||||
await session.commit()
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Workspace API routers module."""
|
||||
|
||||
from src.api.workspace.workspace_files import router as workspace_files_router
|
||||
from src.api.workspace.workspace_git import router as workspace_git_router
|
||||
from src.api.workspace.workspace_instances import router as workspace_instances_router
|
||||
from src.api.workspace.workspaces import (
|
||||
all_workspaces_router,
|
||||
router as workspaces_router,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"all_workspaces_router",
|
||||
"workspace_files_router",
|
||||
"workspace_git_router",
|
||||
"workspace_instances_router",
|
||||
"workspaces_router",
|
||||
]
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Workspace file API endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import Workspace
|
||||
from src.services.shared.file_service import FileService
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
|
||||
|
||||
|
||||
async def _get_workspace(
|
||||
session: AsyncSession,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Workspace:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.id == workspace_id,
|
||||
Workspace.user_id == user_id,
|
||||
)
|
||||
)
|
||||
workspace = result.scalar_one_or_none()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return workspace
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_files(
|
||||
workspace_id: uuid.UUID,
|
||||
path: str = "",
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List files in a workspace directory."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
service = FileService()
|
||||
try:
|
||||
entries = service.list_directory(workspace, path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"name": e.name,
|
||||
"path": e.path,
|
||||
"type": e.type,
|
||||
"size": e.size,
|
||||
}
|
||||
for e in entries
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/content")
|
||||
async def get_file_content(
|
||||
workspace_id: uuid.UUID,
|
||||
path: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get the content of a text file."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
service = FileService()
|
||||
try:
|
||||
content = service.read_file(workspace, path)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return {"content": content, "path": path}
|
||||
|
||||
|
||||
@router.post("/content")
|
||||
async def write_file(
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Write a file and optionally commit."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
service = FileService()
|
||||
|
||||
file_path = data.get("path", "").strip()
|
||||
content = data.get("content", "")
|
||||
commit_message = data.get("message", "").strip()
|
||||
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=400, detail="File path is required")
|
||||
|
||||
try:
|
||||
service.write_file(workspace, file_path, content)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
if commit_message:
|
||||
from src.services.git.git_operations import GitOperations
|
||||
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.commit(commit_message)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "saved", "path": file_path}
|
||||
@@ -1,203 +0,0 @@
|
||||
"""Workspace git API endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import Workspace
|
||||
from src.services.git.git_operations import GitOperations
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
|
||||
|
||||
|
||||
async def _get_workspace(
|
||||
session: AsyncSession,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Workspace:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.id == workspace_id,
|
||||
Workspace.user_id == user_id,
|
||||
)
|
||||
)
|
||||
workspace = result.scalar_one_or_none()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return workspace
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def git_status(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get git status for the workspace."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
status = await git.status()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"branch": status.branch,
|
||||
"modified": status.modified,
|
||||
"added": status.added,
|
||||
"deleted": status.deleted,
|
||||
"untracked": status.untracked,
|
||||
"ahead": status.ahead,
|
||||
"behind": status.behind,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/branches")
|
||||
async def git_branches(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List branches for the workspace."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
branches, current = await git.branches()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"branches": branches,
|
||||
"current_branch": current,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/commit")
|
||||
async def git_commit(
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Stage all changes and commit."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
message = data.get("message", "").strip()
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="Commit message is required")
|
||||
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.commit(message)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "committed"}
|
||||
|
||||
|
||||
@router.post("/push")
|
||||
async def git_push(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Push current branch."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.push()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "pushed"}
|
||||
|
||||
|
||||
@router.post("/pull")
|
||||
async def git_pull(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Pull current branch."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.pull()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "pulled"}
|
||||
|
||||
|
||||
@router.post("/fetch")
|
||||
async def git_fetch(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Fetch from origin."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.fetch()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "fetched"}
|
||||
|
||||
|
||||
@router.post("/checkout")
|
||||
async def git_checkout(
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Checkout a branch."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
branch = data.get("branch", "").strip()
|
||||
if not branch:
|
||||
raise HTTPException(status_code=400, detail="Branch name is required")
|
||||
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
await git.checkout(branch)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
workspace.branch = branch
|
||||
await session.commit()
|
||||
|
||||
return {"status": "checked_out", "branch": branch}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def git_history(
|
||||
workspace_id: uuid.UUID,
|
||||
path: str | None = None,
|
||||
limit: int = 50,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get commit history."""
|
||||
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||
git = GitOperations(workspace)
|
||||
try:
|
||||
commits = await git.history(path, limit)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"commits": [
|
||||
{
|
||||
"hash": c.hash,
|
||||
"message": c.message,
|
||||
"author": c.author,
|
||||
"date": c.date,
|
||||
}
|
||||
for c in commits
|
||||
],
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Workspace instance API endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import ToolInstance
|
||||
from src.models import Workspace
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
|
||||
|
||||
|
||||
async def _get_workspace(
|
||||
session: AsyncSession,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Workspace:
|
||||
result = await session.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.id == workspace_id,
|
||||
Workspace.user_id == user_id,
|
||||
)
|
||||
)
|
||||
workspace = result.scalar_one_or_none()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return workspace
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_workspace_instances(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
"""List tool instances using this workspace."""
|
||||
await _get_workspace(session, workspace_id, user_id)
|
||||
result = await session.execute(
|
||||
select(ToolInstance)
|
||||
.where(ToolInstance.workspace_id == workspace_id)
|
||||
.order_by(ToolInstance.created_at.desc())
|
||||
)
|
||||
instances = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(i.id),
|
||||
"name": i.name,
|
||||
"display_name": i.display_name,
|
||||
"status": i.status,
|
||||
"tool_type_id": str(i.tool_type_id),
|
||||
"url": i.url,
|
||||
"port": i.port,
|
||||
"created_at": i.created_at.isoformat() if i.created_at else None,
|
||||
}
|
||||
for i in instances
|
||||
]
|
||||
@@ -1,450 +0,0 @@
|
||||
"""Workspace CRUD API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models import GitRepository
|
||||
from src.models import ToolInstance
|
||||
from src.models import Workspace
|
||||
from src.services.shared.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
||||
all_workspaces_router = APIRouter(prefix="/workspaces")
|
||||
|
||||
|
||||
@all_workspaces_router.get("/")
|
||||
async def list_all_workspaces(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
"""List all workspaces for the current user across all repos."""
|
||||
instance_count = (
|
||||
select(func.count(ToolInstance.id))
|
||||
.where(ToolInstance.workspace_id == Workspace.id)
|
||||
.correlate(Workspace)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
Workspace,
|
||||
GitRepository.name.label("repo_name"),
|
||||
GitRepository.project_id,
|
||||
GitRepository.ssh_key_id.label("repo_ssh_key_id"),
|
||||
instance_count.label("instance_count"),
|
||||
)
|
||||
.join(GitRepository, Workspace.repo_id == GitRepository.id)
|
||||
.where(Workspace.user_id == user_id)
|
||||
.order_by(Workspace.created_at.desc())
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"repo_id": str(ws.repo_id),
|
||||
"repo_name": repo_name or "",
|
||||
"repo_ssh_key_id": str(ssh_key_id) if ssh_key_id else None,
|
||||
"project_id": str(project_id) if project_id else "",
|
||||
"project_name": "",
|
||||
"user_id": str(ws.user_id),
|
||||
"branch": ws.branch,
|
||||
"path": ws.path,
|
||||
"status": ws.status,
|
||||
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
||||
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
||||
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
||||
"instance_count": count or 0,
|
||||
}
|
||||
for ws, repo_name, project_id, ssh_key_id, count in rows
|
||||
]
|
||||
|
||||
|
||||
@all_workspaces_router.delete("/{workspace_id}")
|
||||
async def delete_workspace_top_level(
|
||||
workspace_id: uuid.UUID,
|
||||
force: bool = Query(False),
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Delete a workspace via top-level path."""
|
||||
workspace = await session.get(Workspace, workspace_id)
|
||||
if not workspace or workspace.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
|
||||
manager = WorkspaceManager()
|
||||
try:
|
||||
await manager.delete(workspace, force=force, session=session)
|
||||
await session.commit()
|
||||
except WorkspaceHasInstancesError as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Workspace has running tool instances",
|
||||
"instances": exc.instances,
|
||||
},
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to delete workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to delete workspace"
|
||||
) from exc
|
||||
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@all_workspaces_router.post("/")
|
||||
async def create_workspace_top_level(
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a workspace directly (no nested project/repo path)."""
|
||||
repo_id_str = data.get("repo_id", "").strip()
|
||||
if not repo_id_str:
|
||||
raise HTTPException(status_code=400, detail="repo_id is required")
|
||||
|
||||
try:
|
||||
repo_id = uuid.UUID(repo_id_str)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if not repo or repo.owner_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
|
||||
name = data.get("name", "").strip()
|
||||
branch = data.get("branch", "main").strip()
|
||||
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Workspace name is required")
|
||||
|
||||
manager = WorkspaceManager()
|
||||
try:
|
||||
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||
session.add(workspace)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to create workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Workspace name already exists for this repository",
|
||||
) from exc
|
||||
|
||||
await session.refresh(workspace)
|
||||
return {
|
||||
"id": str(workspace.id),
|
||||
"name": workspace.name,
|
||||
"repo_id": str(workspace.repo_id),
|
||||
"branch": workspace.branch,
|
||||
"path": workspace.path,
|
||||
"status": workspace.status,
|
||||
"created_at": workspace.created_at.isoformat()
|
||||
if workspace.created_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_workspaces(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
"""List workspaces for a repository, with instance counts."""
|
||||
# Verify repo belongs to project and user
|
||||
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||
|
||||
# Build subquery for instance counts
|
||||
instance_count = (
|
||||
select(func.count(ToolInstance.id))
|
||||
.where(ToolInstance.workspace_id == Workspace.id)
|
||||
.correlate(Workspace)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
Workspace,
|
||||
instance_count.label("instance_count"),
|
||||
)
|
||||
.where(Workspace.repo_id == repo_id)
|
||||
.order_by(Workspace.created_at.desc())
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"repo_id": str(ws.repo_id),
|
||||
"repo_name": repo.name,
|
||||
"repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None,
|
||||
"project_id": str(repo.project_id) if repo.project_id else "",
|
||||
"project_name": repo.project.name if repo.project else "",
|
||||
"user_id": str(ws.user_id),
|
||||
"branch": ws.branch,
|
||||
"path": ws.path,
|
||||
"status": ws.status,
|
||||
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
||||
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
||||
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
||||
"instance_count": count or 0,
|
||||
}
|
||||
for ws, count in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a new workspace by cloning a repository branch."""
|
||||
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||
|
||||
name = data.get("name", "").strip()
|
||||
branch = data.get("branch", "main").strip()
|
||||
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Workspace name is required")
|
||||
if not branch:
|
||||
raise HTTPException(status_code=400, detail="Branch is required")
|
||||
|
||||
manager = WorkspaceManager()
|
||||
try:
|
||||
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||
session.add(workspace)
|
||||
await session.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to create workspace: %s", exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to create workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Workspace name already exists for this repository",
|
||||
) from exc
|
||||
|
||||
await session.refresh(workspace)
|
||||
return {
|
||||
"id": str(workspace.id),
|
||||
"name": workspace.name,
|
||||
"repo_id": str(workspace.repo_id),
|
||||
"branch": workspace.branch,
|
||||
"path": workspace.path,
|
||||
"status": workspace.status,
|
||||
"created_at": workspace.created_at.isoformat()
|
||||
if workspace.created_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{workspace_id}")
|
||||
async def get_workspace_detail(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get workspace details."""
|
||||
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||
|
||||
# Count instances
|
||||
result = await session.execute(
|
||||
select(func.count(ToolInstance.id)).where(
|
||||
ToolInstance.workspace_id == workspace_id
|
||||
)
|
||||
)
|
||||
instance_count = result.scalar() or 0
|
||||
|
||||
return {
|
||||
"id": str(workspace.id),
|
||||
"name": workspace.name,
|
||||
"repo_id": str(workspace.repo_id),
|
||||
"repo_name": repo.name,
|
||||
"user_id": str(workspace.user_id),
|
||||
"branch": workspace.branch,
|
||||
"path": workspace.path,
|
||||
"status": workspace.status,
|
||||
"last_sync_at": workspace.last_sync_at.isoformat()
|
||||
if workspace.last_sync_at
|
||||
else None,
|
||||
"created_at": workspace.created_at.isoformat()
|
||||
if workspace.created_at
|
||||
else None,
|
||||
"updated_at": workspace.updated_at.isoformat()
|
||||
if workspace.updated_at
|
||||
else None,
|
||||
"instance_count": instance_count,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}")
|
||||
async def update_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update workspace name or branch."""
|
||||
await _get_repo(session, repo_id, project_id, user_id)
|
||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||
|
||||
new_name = data.get("name", "").strip()
|
||||
new_branch = data.get("branch", "").strip()
|
||||
|
||||
if new_name:
|
||||
workspace.name = new_name
|
||||
if new_branch:
|
||||
workspace.branch = new_branch
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to update workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Workspace name already exists for this repository",
|
||||
) from exc
|
||||
|
||||
return {
|
||||
"id": str(workspace.id),
|
||||
"name": workspace.name,
|
||||
"branch": workspace.branch,
|
||||
"status": workspace.status,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}")
|
||||
async def delete_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
force: bool = Query(False),
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Delete a workspace. Returns 409 if instances exist and force=False."""
|
||||
await _get_repo(session, repo_id, project_id, user_id)
|
||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||
|
||||
manager = WorkspaceManager()
|
||||
try:
|
||||
await manager.delete(workspace, force=force, session=session)
|
||||
await session.commit()
|
||||
except WorkspaceHasInstancesError as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Workspace has running tool instances",
|
||||
"instances": exc.instances,
|
||||
},
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to delete workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to delete workspace"
|
||||
) from exc
|
||||
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/sync")
|
||||
async def sync_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Sync workspace with remote. Returns 409 if branch was deleted."""
|
||||
await _get_repo(session, repo_id, project_id, user_id)
|
||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||
|
||||
manager = WorkspaceManager()
|
||||
result = await manager.sync(workspace, session=session)
|
||||
|
||||
if result.branch_deleted:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Branch '{workspace.branch}' was deleted from remote",
|
||||
"branch_deleted": True,
|
||||
},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return {
|
||||
"branch_deleted": False,
|
||||
"pulled": True,
|
||||
"last_sync_at": workspace.last_sync_at.isoformat()
|
||||
if workspace.last_sync_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
async def _get_repo(
|
||||
session: AsyncSession,
|
||||
repo_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> GitRepository:
|
||||
"""Fetch and validate repository access."""
|
||||
result = await session.execute(
|
||||
select(GitRepository)
|
||||
.where(
|
||||
GitRepository.id == repo_id,
|
||||
GitRepository.project_id == project_id,
|
||||
)
|
||||
.options(selectinload(GitRepository.project))
|
||||
)
|
||||
repo = result.scalar_one_or_none()
|
||||
if not repo:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return repo
|
||||
|
||||
|
||||
async def _get_workspace(
|
||||
session: AsyncSession,
|
||||
workspace_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
) -> Workspace:
|
||||
"""Fetch and validate workspace."""
|
||||
result = await session.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.id == workspace_id,
|
||||
Workspace.repo_id == repo_id,
|
||||
)
|
||||
)
|
||||
workspace = result.scalar_one_or_none()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return workspace
|
||||
@@ -10,7 +10,7 @@ from collections.abc import Callable
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from src.services.shared.correlation import get_correlation_id
|
||||
from src.services.correlation import get_correlation_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
+22
-38
@@ -7,42 +7,35 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from src.api.config import config_profiles_router, user_config_router
|
||||
from src.api.project import git_repositories_router, projects_router
|
||||
from src.api.system import (
|
||||
dashboard_router,
|
||||
events_router,
|
||||
health_router,
|
||||
instance_proxy_router,
|
||||
notifications_router,
|
||||
terminal_router,
|
||||
)
|
||||
from src.api.tool import (
|
||||
sessions_router,
|
||||
tool_definitions_router,
|
||||
tool_instances_router,
|
||||
tool_types_router,
|
||||
)
|
||||
from src.api.user import auth_router, ssh_keys_router, users_router
|
||||
from src.api.workspace import (
|
||||
all_workspaces_router,
|
||||
workspace_files_router,
|
||||
workspace_git_router,
|
||||
workspace_instances_router,
|
||||
workspaces_router,
|
||||
)
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.dashboard import router as dashboard_router
|
||||
from src.api.events import router as events_router
|
||||
from src.api.git_repositories import router as git_repositories_router
|
||||
from src.api.health import router as health_router
|
||||
from src.api.projects import router as projects_router
|
||||
from src.api.ssh_keys import router as ssh_keys_router
|
||||
from src.api.terminal import router as terminal_router
|
||||
from src.api.instance_proxy import router as instance_proxy_router
|
||||
from src.api.config_profiles import router as config_profiles_router
|
||||
from src.api.tool_definitions import router as tool_definitions_router
|
||||
from src.api.tool_instances import router as tool_instances_router
|
||||
from src.api.tool_instances import sessions_router
|
||||
from src.api.tool_types import router as tool_types_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.user_config import router as user_config_router
|
||||
from src.api.users import router as users_router
|
||||
from src.config import Settings
|
||||
from src.models import Notification # noqa: F401 – Alembic model discovery
|
||||
from src.models import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||
from src.database import init_database
|
||||
from src.logging_config import (
|
||||
ExceptionLoggingMiddleware,
|
||||
RequestLoggingMiddleware,
|
||||
configure_logging,
|
||||
)
|
||||
from src.seeds.builtin_tool_types import seed_builtin_tool_types
|
||||
from src.services.instance import InstanceEventBus, HealthMonitor
|
||||
from src.services.shared import CorrelationIdMiddleware
|
||||
from src.services.correlation import CorrelationIdMiddleware
|
||||
from src.services.event_bus import InstanceEventBus
|
||||
from src.services.health_monitor import HealthMonitor
|
||||
|
||||
# Configure logging early
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
@@ -138,10 +131,6 @@ async def on_startup():
|
||||
_health_monitor.start()
|
||||
logger.info("Health monitor started")
|
||||
|
||||
# Seed built-in tool types
|
||||
await seed_builtin_tool_types()
|
||||
logger.info("Built-in tool types seeded")
|
||||
|
||||
logger.info("Startup complete.")
|
||||
|
||||
|
||||
@@ -170,9 +159,4 @@ app.include_router(instance_proxy_router)
|
||||
app.include_router(terminal_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(all_workspaces_router)
|
||||
app.include_router(workspaces_router)
|
||||
app.include_router(workspace_files_router)
|
||||
app.include_router(workspace_git_router)
|
||||
app.include_router(workspace_instances_router)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
from src.models.base import Base
|
||||
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.project.git_repository import GitRepository
|
||||
from src.models.project.project import Project
|
||||
from src.models.project.workspace import Workspace
|
||||
from src.models.system.health_check import HealthCheck
|
||||
from src.models.system.instance_event import InstanceEvent
|
||||
from src.models.system.notification import Notification
|
||||
from src.models.system.terminal_session import TerminalSessionModel
|
||||
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.tool.tool_instance import ToolInstance
|
||||
from src.models.tool.tool_type import ToolType
|
||||
from src.models.user.ssh_key import SSHKey
|
||||
from src.models.user.user import User
|
||||
from src.models.user.user_config import UserConfig
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.health_check import HealthCheck
|
||||
from src.models.instance_event import InstanceEvent
|
||||
from src.models.notification import Notification
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -30,5 +29,4 @@ __all__ = [
|
||||
"ToolType",
|
||||
"User",
|
||||
"UserConfig",
|
||||
"Workspace",
|
||||
]
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Config models module."""
|
||||
|
||||
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
|
||||
__all__ = ["ConfigProfile", "ConfigProfileInclude"]
|
||||
+2
-13
@@ -1,15 +1,7 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
JSON,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -17,15 +9,12 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models import ToolType
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "config_profiles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||
)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
+1
-1
@@ -10,7 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models import SSHKey
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models import GitRepository
|
||||
from src.models import SSHKey
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Project models module."""
|
||||
|
||||
from src.models.project.git_repository import GitRepository
|
||||
from src.models.project.project import Project
|
||||
from src.models.project.workspace import Workspace
|
||||
|
||||
__all__ = ["GitRepository", "Project", "Workspace"]
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Workspace model for persistent writable repo clones."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models import GitRepository
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class Workspace(Base, TimestampMixin):
|
||||
"""A persistent, writable local clone of a Git repository.
|
||||
|
||||
Users create workspaces explicitly, then start tool instances on them.
|
||||
Multiple tool instances can share the same workspace.
|
||||
"""
|
||||
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("git_repositories.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main")
|
||||
path: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready")
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||
)
|
||||
|
||||
repository: Mapped[GitRepository] = relationship("GitRepository")
|
||||
owner: Mapped[User] = relationship("User")
|
||||
@@ -1,8 +0,0 @@
|
||||
"""System models module."""
|
||||
|
||||
from src.models.system.health_check import HealthCheck
|
||||
from src.models.system.instance_event import InstanceEvent
|
||||
from src.models.system.notification import Notification
|
||||
from src.models.system.terminal_session import TerminalSessionModel
|
||||
|
||||
__all__ = ["HealthCheck", "InstanceEvent", "Notification", "TerminalSessionModel"]
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Tool models module."""
|
||||
|
||||
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.tool.tool_instance import ToolInstance
|
||||
from src.models.tool.tool_type import ToolType
|
||||
|
||||
__all__ = ["ToolDefinitionManifest", "ToolInstance", "ToolType"]
|
||||
@@ -9,12 +9,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models import ConfigProfile
|
||||
from src.models import GitRepository
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models import ToolType
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models import Workspace
|
||||
|
||||
|
||||
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
@@ -61,12 +60,8 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
tool_type: Mapped["ToolType"] = relationship()
|
||||
workspace: Mapped["Workspace | None"] = relationship()
|
||||
repository: Mapped["GitRepository"] = relationship()
|
||||
project: Mapped["Project"] = relationship()
|
||||
owner: Mapped["User"] = relationship()
|
||||
@@ -6,9 +6,9 @@ from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models import SSHKey
|
||||
from src.models import UserConfig
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
|
||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
@@ -1,7 +0,0 @@
|
||||
"""User models module."""
|
||||
|
||||
from src.models.user.ssh_key import SSHKey
|
||||
from src.models.user.user import User
|
||||
from src.models.user.user_config import UserConfig
|
||||
|
||||
__all__ = ["SSHKey", "User", "UserConfig"]
|
||||
@@ -1,51 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, JSON
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "user_configs"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("users.id"), nullable=False, unique=True
|
||||
)
|
||||
config: Mapped[dict[str, object]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||
|
||||
@property
|
||||
def default_profile_id(self) -> uuid.UUID | None:
|
||||
"""Return the legacy global default profile ID from config JSON."""
|
||||
profile_id = self.config.get("default_profile_id")
|
||||
if isinstance(profile_id, str):
|
||||
return uuid.UUID(profile_id)
|
||||
return None
|
||||
|
||||
@default_profile_id.setter
|
||||
def default_profile_id(self, value: uuid.UUID | None) -> None:
|
||||
if value is not None:
|
||||
self.config["default_profile_id"] = str(value)
|
||||
elif "default_profile_id" in self.config:
|
||||
del self.config["default_profile_id"]
|
||||
|
||||
@property
|
||||
def default_profiles(self) -> dict[str, str]:
|
||||
"""Return per-tool-type default profile IDs from config JSON."""
|
||||
value = self.config.get("default_profiles", {})
|
||||
if isinstance(value, dict):
|
||||
return {str(k): str(v) for k, v in value.items()}
|
||||
return {}
|
||||
|
||||
@default_profiles.setter
|
||||
def default_profiles(self, value: dict[str, str]) -> None:
|
||||
self.config["default_profiles"] = value
|
||||
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import JSON, Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "user_configs"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False, unique=True)
|
||||
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Config schemas module."""
|
||||
|
||||
from src.schemas.config.config_profile import (
|
||||
ConfigProfileCreate,
|
||||
ConfigProfileIncludeUpdate,
|
||||
ConfigProfileResponse,
|
||||
ConfigProfileUpdate,
|
||||
DefaultProfilesUpdate,
|
||||
GitMountItem,
|
||||
GitMountMapping,
|
||||
MountItem,
|
||||
ValidateGitUrlRequest,
|
||||
ValidateGitUrlResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfigProfileCreate",
|
||||
"ConfigProfileIncludeUpdate",
|
||||
"ConfigProfileResponse",
|
||||
"ConfigProfileUpdate",
|
||||
"DefaultProfilesUpdate",
|
||||
"GitMountItem",
|
||||
"GitMountMapping",
|
||||
"MountItem",
|
||||
"ValidateGitUrlRequest",
|
||||
"ValidateGitUrlResponse",
|
||||
]
|
||||
@@ -1,280 +0,0 @@
|
||||
"""Config profile request/response schemas."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from src.api.shared_validators import validate_env_vars as _validate_env_vars
|
||||
|
||||
|
||||
def _validate_uuid(v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
try:
|
||||
uuid.UUID(v)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid UUID: {v}") from exc
|
||||
return v
|
||||
|
||||
|
||||
class GitMountMapping(BaseModel):
|
||||
source_path: str = Field(
|
||||
description="Path within repository (supports glob patterns)"
|
||||
)
|
||||
target_path: str = Field(description="Absolute path inside container")
|
||||
|
||||
@field_validator("source_path")
|
||||
@classmethod
|
||||
def validate_source_path(cls, v: str) -> str:
|
||||
if v.startswith("/"):
|
||||
raise ValueError("source_path must be relative (no leading /)")
|
||||
if ".." in v:
|
||||
raise ValueError("source_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
@field_validator("target_path")
|
||||
@classmethod
|
||||
def validate_target_path(cls, v: str) -> str:
|
||||
if ".." in v:
|
||||
raise ValueError("target_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
|
||||
class GitMountItem(BaseModel):
|
||||
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||
source_path: str | None = Field(
|
||||
default=None, description="Path within repository (legacy single mapping)"
|
||||
)
|
||||
target_path: str | None = Field(
|
||||
default=None,
|
||||
description="Absolute path inside container (legacy single mapping)",
|
||||
)
|
||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||
mappings: list[GitMountMapping] | None = Field(
|
||||
default=None, description="Multiple source/target mappings from the same repo"
|
||||
)
|
||||
|
||||
@field_validator("remote_url")
|
||||
@classmethod
|
||||
def validate_remote_url(cls, v: str) -> str:
|
||||
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise ValueError(
|
||||
"remote_url must be a valid git URL (https://, git@, or ssh://)"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("source_path")
|
||||
@classmethod
|
||||
def validate_source_path(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v.startswith("/"):
|
||||
raise ValueError("source_path must be relative (no leading /)")
|
||||
if ".." in v:
|
||||
raise ValueError("source_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
@field_validator("target_path")
|
||||
@classmethod
|
||||
def validate_target_path(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if ".." in v:
|
||||
raise ValueError("target_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_mappings_or_legacy(self):
|
||||
has_legacy = self.source_path is not None and self.target_path is not None
|
||||
has_mappings = self.mappings is not None and len(self.mappings) > 0
|
||||
if not has_legacy and not has_mappings:
|
||||
raise ValueError(
|
||||
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class MountItem(BaseModel):
|
||||
target: str = Field(description="Absolute mount target path")
|
||||
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||
files: dict = Field(
|
||||
default_factory=dict, description="Files as {relative_path: content}"
|
||||
)
|
||||
|
||||
@field_validator("target")
|
||||
@classmethod
|
||||
def validate_target(cls, v: str) -> str:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount target must be absolute (start with /)")
|
||||
return v
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
if v not in ("ro", "rw"):
|
||||
raise ValueError("Mount mode must be 'ro' or 'rw'")
|
||||
return v
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
for path in v:
|
||||
if ".." in path or not path:
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
if path.startswith("/"):
|
||||
raise ValueError(
|
||||
f"Mount file paths must be relative (got: {path}). "
|
||||
f"The mount target defines the absolute container path."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileCreate(BaseModel):
|
||||
name: str = Field(description="Profile name (unique per user)")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
||||
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||
mounts: list[MountItem] = Field(
|
||||
default_factory=list, description="Mount definitions"
|
||||
)
|
||||
files: dict = Field(
|
||||
default_factory=dict, description="Files as {relative_path: content}"
|
||||
)
|
||||
git_mounts: list[GitMountItem] = Field(
|
||||
default_factory=list, description="Git repository mounts"
|
||||
)
|
||||
is_default: bool = Field(
|
||||
default=False, description="Whether this is the default profile for its scope"
|
||||
)
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@classmethod
|
||||
def validate_uuids(cls, v: str | None) -> str | None:
|
||||
return _validate_uuid(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
for path in v:
|
||||
if ".." in path or not path:
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
if path.startswith("/"):
|
||||
raise ValueError(
|
||||
f"File paths must be relative (got: {path}). "
|
||||
f"Use Mounts for absolute container paths."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("env_vars")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict) -> dict:
|
||||
result = _validate_env_vars(v)
|
||||
if result is None:
|
||||
raise ValueError("env_vars must be a JSON object")
|
||||
return result
|
||||
|
||||
@field_validator("runtime_hints")
|
||||
@classmethod
|
||||
def validate_runtime_hints(cls, v: dict) -> dict:
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("runtime_hints must be a JSON object")
|
||||
return v
|
||||
|
||||
@field_validator("mounts")
|
||||
@classmethod
|
||||
def validate_mounts(cls, v: list) -> list:
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("mounts must be a JSON array")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, description="Profile name")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||
env_vars: dict | None = Field(default=None, description="Environment variables")
|
||||
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||
mounts: list[MountItem] | None = Field(
|
||||
default=None, description="Mount definitions"
|
||||
)
|
||||
files: dict | None = Field(
|
||||
default=None, description="Files as {relative_path: content}"
|
||||
)
|
||||
git_mounts: list[GitMountItem] | None = Field(
|
||||
default=None, description="Git repository mounts"
|
||||
)
|
||||
is_default: bool | None = Field(
|
||||
default=None, description="Whether this is the default profile"
|
||||
)
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@classmethod
|
||||
def validate_uuids(cls, v: str | None) -> str | None:
|
||||
return _validate_uuid(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
for path in v:
|
||||
if ".." in path or path.startswith("/") or not path:
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileIncludeUpdate(BaseModel):
|
||||
includes: list[str] = Field(description="Ordered list of included profile IDs")
|
||||
|
||||
@field_validator("includes")
|
||||
@classmethod
|
||||
def validate_includes(cls, v: list) -> list:
|
||||
for item in v:
|
||||
try:
|
||||
uuid.UUID(item)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid UUID in includes: {item}") from exc
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
project_id: str | None
|
||||
tool_type_id: str | None
|
||||
env_vars: dict
|
||||
runtime_hints: dict
|
||||
mounts: list
|
||||
files: dict
|
||||
git_mounts: list
|
||||
is_default: bool
|
||||
includes: list[dict]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class DefaultProfilesUpdate(BaseModel):
|
||||
default_profiles: dict[str, str] = Field(
|
||||
description="Mapping of tool_type_id -> profile_id for default profiles"
|
||||
)
|
||||
|
||||
|
||||
class ValidateGitUrlRequest(BaseModel):
|
||||
url: str = Field(description="Git remote URL to validate")
|
||||
ssh_key_id: str | None = Field(
|
||||
default=None, description="Optional SSH key ID for private repos"
|
||||
)
|
||||
|
||||
|
||||
class ValidateGitUrlResponse(BaseModel):
|
||||
valid: bool
|
||||
suggested_url: str | None = None
|
||||
branches: list[str] | None = None
|
||||
default_branch: str | None = None
|
||||
error: str | None = None
|
||||
error_code: str | None = None
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Project schemas module."""
|
||||
|
||||
from src.schemas.project.git_repository import (
|
||||
GitRepositoryCreate,
|
||||
GitRepositoryResponse,
|
||||
UpdateSSHKeyRequest,
|
||||
URLParseRequest,
|
||||
URLParseResponse,
|
||||
)
|
||||
from src.schemas.project.project import (
|
||||
ProjectCreate,
|
||||
ProjectResponse,
|
||||
ProjectUpdate,
|
||||
SetDefaultSSHKeyRequest,
|
||||
)
|
||||
from src.schemas.project.ssh_key import (
|
||||
SSHKeyCreate,
|
||||
SSHKeyResponse,
|
||||
SignPayloadRequest,
|
||||
SignatureResponse,
|
||||
VerifySignatureRequest,
|
||||
VerifySignatureResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GitRepositoryCreate",
|
||||
"GitRepositoryResponse",
|
||||
"ProjectCreate",
|
||||
"ProjectResponse",
|
||||
"ProjectUpdate",
|
||||
"SSHKeyCreate",
|
||||
"SSHKeyResponse",
|
||||
"SetDefaultSSHKeyRequest",
|
||||
"SignPayloadRequest",
|
||||
"SignatureResponse",
|
||||
"URLParseRequest",
|
||||
"URLParseResponse",
|
||||
"UpdateSSHKeyRequest",
|
||||
"VerifySignatureRequest",
|
||||
"VerifySignatureResponse",
|
||||
]
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Git repository request/response schemas."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class GitRepositoryCreate(BaseModel):
|
||||
name: str
|
||||
remote_url: str | None = None
|
||||
force_original_url: bool = False
|
||||
ssh_key_id: str | None = None
|
||||
|
||||
|
||||
class URLParseRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class URLParseResponse(BaseModel):
|
||||
original_url: str
|
||||
base_url: str | None
|
||||
is_valid_clone_url: bool
|
||||
needs_parsing: bool
|
||||
host: str | None
|
||||
message: str
|
||||
error_code: str | None
|
||||
|
||||
|
||||
class GitRepositoryResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
path: str
|
||||
project_id: uuid.UUID | None
|
||||
owner_id: uuid.UUID
|
||||
is_mirror: bool
|
||||
remote_url: str | None
|
||||
last_push: datetime | None
|
||||
ssh_key_id: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UpdateSSHKeyRequest(BaseModel):
|
||||
ssh_key_id: str | None = None
|
||||
|
||||
|
||||
class FileListResponse(BaseModel):
|
||||
path: str
|
||||
branch: str
|
||||
entries: list[dict]
|
||||
|
||||
|
||||
class FileContentResponse(BaseModel):
|
||||
path: str
|
||||
branch: str
|
||||
content: str
|
||||
size: int
|
||||
encoding: str
|
||||
language: str | None
|
||||
is_binary: bool
|
||||
last_commit: dict | None
|
||||
|
||||
|
||||
class BranchesResponse(BaseModel):
|
||||
branches: list[dict]
|
||||
default_branch: str
|
||||
|
||||
|
||||
class FileUpdateRequest(BaseModel):
|
||||
path: str
|
||||
branch: str
|
||||
content: str
|
||||
commit_message: str
|
||||
|
||||
|
||||
class FileUpdateResponse(BaseModel):
|
||||
commit_hash: str
|
||||
message: str
|
||||
branch: str
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
branch: str
|
||||
modified: list[str]
|
||||
added: list[str]
|
||||
deleted: list[str]
|
||||
untracked: list[str]
|
||||
renamed: list[str]
|
||||
ahead: int
|
||||
behind: int
|
||||
|
||||
|
||||
class BranchCreateRequest(BaseModel):
|
||||
name: str
|
||||
base_branch: str = "HEAD"
|
||||
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
branch: str
|
||||
|
||||
|
||||
class CommitRequest(BaseModel):
|
||||
message: str
|
||||
files: list[str] | None = None
|
||||
|
||||
|
||||
class CommitResponse(BaseModel):
|
||||
commit_hash: str
|
||||
message: str
|
||||
|
||||
|
||||
class FetchResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class PullResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class PushResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class MergeRequest(BaseModel):
|
||||
source_branch: str
|
||||
target_branch: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class MergeResponse(BaseModel):
|
||||
commit_hash: str
|
||||
message: str
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Project request/response schemas."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
owner_id: uuid.UUID
|
||||
default_ssh_key_id: uuid.UUID | None
|
||||
|
||||
|
||||
class SetDefaultSSHKeyRequest(BaseModel):
|
||||
ssh_key_id: uuid.UUID
|
||||
@@ -1,36 +0,0 @@
|
||||
"""SSH key request/response schemas."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class SSHKeyCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class SSHKeyResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
public_key: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SignPayloadRequest(BaseModel):
|
||||
payload: str
|
||||
|
||||
|
||||
class SignatureResponse(BaseModel):
|
||||
signature: str
|
||||
|
||||
|
||||
class VerifySignatureRequest(BaseModel):
|
||||
payload: str
|
||||
signature: str
|
||||
|
||||
|
||||
class VerifySignatureResponse(BaseModel):
|
||||
valid: bool
|
||||
@@ -1,17 +0,0 @@
|
||||
"""System schemas module."""
|
||||
|
||||
from src.schemas.system.health import (
|
||||
DatabaseHealth,
|
||||
DatabaseHealthResponse,
|
||||
DiskHealth,
|
||||
HealthChecks,
|
||||
HealthResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseHealth",
|
||||
"DatabaseHealthResponse",
|
||||
"DiskHealth",
|
||||
"HealthChecks",
|
||||
"HealthResponse",
|
||||
]
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Health check response schemas."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DatabaseHealth(BaseModel):
|
||||
"""Database health check result."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(
|
||||
description="Query response time in milliseconds", examples=[5.2]
|
||||
)
|
||||
|
||||
|
||||
class DiskHealth(BaseModel):
|
||||
"""Disk space health check result."""
|
||||
|
||||
status: str = Field(description="Disk health status", examples=["healthy"])
|
||||
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
|
||||
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
|
||||
|
||||
|
||||
class HealthChecks(BaseModel):
|
||||
"""Individual health checks."""
|
||||
|
||||
database: DatabaseHealth | None = None
|
||||
disk: DiskHealth | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Overall health check response."""
|
||||
|
||||
status: str = Field(description="Overall health status", examples=["healthy"])
|
||||
timestamp: str = Field(
|
||||
description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"]
|
||||
)
|
||||
version: str = Field(description="API version", examples=["0.1.0"])
|
||||
checks: HealthChecks = Field(description="Individual health checks")
|
||||
uptime_seconds: float = Field(
|
||||
description="Server uptime in seconds", examples=[3600.0]
|
||||
)
|
||||
|
||||
|
||||
class DatabaseHealthResponse(BaseModel):
|
||||
"""Database-specific health check response."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(
|
||||
description="Query response time in milliseconds", examples=[5.2]
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Tool schemas module."""
|
||||
|
||||
from src.schemas.tool.tool_instance import CreateInstanceRequest, StartInstanceRequest
|
||||
from src.schemas.tool.tool_type import (
|
||||
ToolTypeCreate,
|
||||
ToolTypeResponse,
|
||||
ToolTypeUpdate,
|
||||
ToolTypeValidateRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CreateInstanceRequest",
|
||||
"StartInstanceRequest",
|
||||
"ToolTypeCreate",
|
||||
"ToolTypeResponse",
|
||||
"ToolTypeUpdate",
|
||||
"ToolTypeValidateRequest",
|
||||
]
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Tool instance request/response schemas."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateInstanceRequest(BaseModel):
|
||||
"""Request body for creating a tool instance."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||
display_name: str | None = Field(
|
||||
default=None, description="Optional display name for the instance"
|
||||
)
|
||||
workspace_id: str | None = Field(
|
||||
default=None, description="UUID of workspace to mount (replaces clone_mode)"
|
||||
)
|
||||
clone_mode: str = Field(
|
||||
default="mount", description="Repository access mode: 'mount' or 'clone'"
|
||||
)
|
||||
branch: str | None = Field(
|
||||
default="main", description="Branch to clone (when clone_mode='clone')"
|
||||
)
|
||||
new_branch: str | None = Field(
|
||||
default=None, description="Create a new local branch after cloning"
|
||||
)
|
||||
config_profile_id: str | None = Field(
|
||||
default=None, description="Optional config profile ID for launch"
|
||||
)
|
||||
ssh_key_ids: list[str] = Field(
|
||||
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
|
||||
)
|
||||
|
||||
|
||||
class StartInstanceRequest(BaseModel):
|
||||
"""Request body for starting a tool instance."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
config_profile_id: str | None = Field(
|
||||
default=None, description="Config profile ID to apply, or null for none"
|
||||
)
|
||||
ssh_key_ids: list[str] = Field(
|
||||
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
|
||||
)
|
||||
@@ -1,230 +0,0 @@
|
||||
"""Tool type request/response schemas."""
|
||||
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
|
||||
from src.api.tool.tool_types_validation import (
|
||||
check_port_exposed,
|
||||
validate_compose_yaml,
|
||||
validate_required_variables,
|
||||
)
|
||||
|
||||
|
||||
class ToolTypeCreate(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
default_port: int = 0
|
||||
definition_type: str = "compose"
|
||||
manifest_id: uuid.UUID | None = None
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] = []
|
||||
category: str = "other"
|
||||
interface_type: str = "web"
|
||||
requires_port: bool = True
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
def validate_definition_type(cls, v: str) -> str:
|
||||
if v not in ("compose", "dockerfile", "manifest"):
|
||||
raise ValueError(
|
||||
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
if v is None or not v.strip():
|
||||
raise ValueError(
|
||||
"compose_template is required when definition_type is 'compose'"
|
||||
)
|
||||
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@classmethod
|
||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||
data = info.data
|
||||
if data.get("definition_type") != "dockerfile":
|
||||
return v
|
||||
|
||||
if v is None or not v.strip():
|
||||
raise ValueError(
|
||||
"dockerfile_template is required when definition_type is 'dockerfile'"
|
||||
)
|
||||
|
||||
if not v.strip().startswith("FROM"):
|
||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str) -> str:
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("default_port")
|
||||
@classmethod
|
||||
def validate_default_port(cls, v: int, info) -> int:
|
||||
data = info.data
|
||||
requires_port = data.get("requires_port", True)
|
||||
if not requires_port:
|
||||
return v
|
||||
if v <= 0 or v > 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return v
|
||||
|
||||
@field_validator("required_variables")
|
||||
@classmethod
|
||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||
if not v:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
template = data.get("compose_template")
|
||||
if not template:
|
||||
return v
|
||||
|
||||
validate_required_variables(template, v)
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_templates(self) -> "ToolTypeCreate":
|
||||
if self.definition_type == "manifest":
|
||||
if self.manifest_id is None:
|
||||
raise ValueError(
|
||||
"manifest_id is required when definition_type is 'manifest'"
|
||||
)
|
||||
return self
|
||||
|
||||
if self.definition_type == "dockerfile" and (
|
||||
self.dockerfile_template is None or not self.dockerfile_template.strip()
|
||||
):
|
||||
raise ValueError(
|
||||
"dockerfile_template is required when definition_type is 'dockerfile'"
|
||||
)
|
||||
if self.definition_type == "compose" and (
|
||||
self.compose_template is None or not self.compose_template.strip()
|
||||
):
|
||||
raise ValueError(
|
||||
"compose_template is required when definition_type is 'compose'"
|
||||
)
|
||||
|
||||
if (
|
||||
self.requires_port
|
||||
and self.definition_type == "compose"
|
||||
and self.compose_template
|
||||
):
|
||||
try:
|
||||
parsed = validate_compose_yaml(self.compose_template)
|
||||
except ValueError:
|
||||
return self
|
||||
|
||||
if not check_port_exposed(parsed, self.default_port):
|
||||
raise ValueError(
|
||||
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class ToolTypeUpdate(BaseModel):
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
default_port: int | None = None
|
||||
definition_type: str | None = None
|
||||
manifest_id: uuid.UUID | None = None
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] | None = None
|
||||
category: str | None = None
|
||||
interface_type: str | None = None
|
||||
requires_port: bool | None = None
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in ("compose", "dockerfile", "manifest"):
|
||||
raise ValueError(
|
||||
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@classmethod
|
||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not v.strip().startswith("FROM"):
|
||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||
return v
|
||||
|
||||
|
||||
class ToolTypeResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None
|
||||
category: str
|
||||
interface_type: str
|
||||
requires_port: bool
|
||||
default_port: int
|
||||
definition_type: str
|
||||
manifest_id: uuid.UUID | None
|
||||
compose_template: str | None
|
||||
dockerfile_template: str | None
|
||||
build_context: dict | None
|
||||
readiness_probe: dict | None
|
||||
startup_command: str | None
|
||||
required_variables: list[str]
|
||||
created_by_id: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ToolTypeValidateRequest(BaseModel):
|
||||
definition_type: str
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
@@ -1,11 +0,0 @@
|
||||
"""User schemas module."""
|
||||
|
||||
from src.schemas.user.user import UserProfileResponse, UserProfileUpdate
|
||||
from src.schemas.user.user_config import UserConfigResponse, UserConfigUpdate
|
||||
|
||||
__all__ = [
|
||||
"UserConfigResponse",
|
||||
"UserConfigUpdate",
|
||||
"UserProfileResponse",
|
||||
"UserProfileUpdate",
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
"""User response schemas."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
name: str
|
||||
avatar_url: str | None
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
@@ -1,25 +0,0 @@
|
||||
"""User config response schemas."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class UserConfigResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
default_editor: str | None = None
|
||||
theme: str = "system"
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
notification_mute_categories: list[str] | None = None
|
||||
notification_toast_level: str | None = None
|
||||
|
||||
|
||||
class UserConfigUpdate(BaseModel):
|
||||
default_editor: str | None = None
|
||||
theme: str | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
notification_mute_categories: list[str] | None = None
|
||||
notification_toast_level: str | None = None
|
||||
@@ -1 +0,0 @@
|
||||
"""Database seeding utilities."""
|
||||
@@ -1,168 +0,0 @@
|
||||
"""Seed built-in tool types into the database."""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from src.database import SessionLocal
|
||||
from src.models import ToolType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _table_exists(session, table_name: str) -> bool:
|
||||
"""Check if a table exists in the database."""
|
||||
try:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = :table_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.scalar() or False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def seed_builtin_tool_types():
|
||||
"""Create or update built-in tool types in the database.
|
||||
|
||||
Built-in tool types have no creator (created_by_id=None) and provide
|
||||
out-of-the-box tools for users without requiring manual tool creation.
|
||||
"""
|
||||
async with SessionLocal() as session:
|
||||
# Check if tool_types table exists before attempting to seed
|
||||
if not await _table_exists(session, "tool_types"):
|
||||
logger.warning(
|
||||
"tool_types table does not exist. Skipping seeding. "
|
||||
"Migrations may not have run yet."
|
||||
)
|
||||
return
|
||||
|
||||
builtin_types = [
|
||||
{
|
||||
"name": "code-server",
|
||||
"display_name": "VS Code Server",
|
||||
"description": "VS Code running in the browser via code-server",
|
||||
"category": "editor",
|
||||
"interface_type": "web",
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
code-server:
|
||||
image: lscr.io/linuxserver/code-server:latest
|
||||
container_name: {{TOOL_NAME}}
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Europe/London
|
||||
volumes:
|
||||
- {{REPO_PATH}}:/config/workspace
|
||||
ports:
|
||||
- "8443:8443"
|
||||
restart: unless-stopped""",
|
||||
"default_port": 8443,
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
{
|
||||
"name": "jupyter-notebook",
|
||||
"display_name": "Jupyter Notebook",
|
||||
"description": "Jupyter Lab for interactive development",
|
||||
"category": "notebook",
|
||||
"interface_type": "web",
|
||||
"default_port": 8888,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
jupyter:
|
||||
image: jupyter/scipy-notebook:latest
|
||||
container_name: {{TOOL_NAME}}
|
||||
environment:
|
||||
- JUPYTER_ENABLE_LAB=yes
|
||||
volumes:
|
||||
- {{REPO_PATH}}:/home/jovyan/work
|
||||
ports:
|
||||
- "8888:8888"
|
||||
restart: unless-stopped""",
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
{
|
||||
"name": "opencode",
|
||||
"display_name": "OpenCode",
|
||||
"description": "AI coding assistant - run opencode in terminal",
|
||||
"category": "ai-assistant",
|
||||
"interface_type": "terminal",
|
||||
"default_port": 3000,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
opencode:
|
||||
image: node:20-slim
|
||||
container_name: {{TOOL_NAME}}
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- HOME=/tmp
|
||||
volumes:
|
||||
- {{REPO_PATH}}:/workspace
|
||||
- opencode_home:/tmp
|
||||
ports:
|
||||
- "3000:3000"
|
||||
command: >
|
||||
sh -c "set -x &&
|
||||
apt-get update && apt-get install -y git ca-certificates &&
|
||||
echo 'Installing opencode...' &&
|
||||
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
||||
which opencode || echo 'ERROR: opencode not in PATH' &&
|
||||
npm bin -g &&
|
||||
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
||||
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
|
||||
echo 'cd /workspace' >> /root/.bashrc &&
|
||||
echo 'OpenCode installation complete' &&
|
||||
cd /workspace &&
|
||||
exec tail -f /dev/null"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
opencode_home:""",
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
]
|
||||
|
||||
for tool_data in builtin_types:
|
||||
existing = await session.scalar(
|
||||
select(ToolType).where(ToolType.name == tool_data["name"])
|
||||
)
|
||||
if not existing:
|
||||
tool_type = ToolType(
|
||||
name=tool_data["name"],
|
||||
display_name=tool_data["display_name"],
|
||||
description=tool_data["description"],
|
||||
category=tool_data["category"],
|
||||
interface_type=tool_data["interface_type"],
|
||||
definition_type="compose",
|
||||
compose_template=tool_data["compose_template"],
|
||||
required_variables=tool_data["required_variables"],
|
||||
default_port=tool_data.get("default_port", 0),
|
||||
created_by_id=None,
|
||||
)
|
||||
session.add(tool_type)
|
||||
logger.info("Created built-in tool type: %s", tool_data["name"])
|
||||
else:
|
||||
# Update existing built-in tool types to reflect code changes
|
||||
existing.display_name = tool_data["display_name"]
|
||||
existing.description = tool_data["description"]
|
||||
existing.category = tool_data["category"]
|
||||
existing.interface_type = tool_data["interface_type"]
|
||||
existing.definition_type = "compose"
|
||||
existing.compose_template = tool_data["compose_template"]
|
||||
existing.required_variables = tool_data["required_variables"]
|
||||
existing.default_port = tool_data.get("default_port", 0)
|
||||
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
||||
|
||||
await session.commit()
|
||||
logger.info("Built-in tool types seeded successfully.")
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Config profile services module."""
|
||||
|
||||
from src.services.config.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
ConfigProfileNotFoundError,
|
||||
ResolvedMount,
|
||||
ResolvedProfile,
|
||||
apply_resolved_profile,
|
||||
check_include_cycle,
|
||||
expand_container_path,
|
||||
resolve_profile,
|
||||
resolved_profile_to_dict,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfigProfileCycleError",
|
||||
"ConfigProfileNotFoundError",
|
||||
"ResolvedMount",
|
||||
"ResolvedProfile",
|
||||
"apply_resolved_profile",
|
||||
"check_include_cycle",
|
||||
"expand_container_path",
|
||||
"resolve_profile",
|
||||
"resolved_profile_to_dict",
|
||||
]
|
||||
@@ -1,355 +0,0 @@
|
||||
"""Config profile CRUD service functions."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.models import ConfigProfile, ConfigProfileInclude, ToolType, UserConfig
|
||||
from src.models.project import Project
|
||||
|
||||
MAX_PROFILE_SIZE_MB = 10
|
||||
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
|
||||
def calculate_profile_size(data: dict) -> int:
|
||||
"""Calculate approximate serialized size of profile data."""
|
||||
total = 0
|
||||
for key, value in data.get("env_vars", {}).items():
|
||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||
for key, value in data.get("runtime_hints", {}).items():
|
||||
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||
for mount in data.get("mounts", []):
|
||||
total += len(str(mount.get("target", "")).encode("utf-8"))
|
||||
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
||||
for path, content in mount.get("files", {}).items():
|
||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||
for path, content in data.get("files", {}).items():
|
||||
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||
return total
|
||||
|
||||
|
||||
async def get_profile_with_includes(
|
||||
session: AsyncSession, profile_id: uuid.UUID
|
||||
) -> ConfigProfile | None:
|
||||
"""Fetch a profile with includes eagerly loaded."""
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile_id)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def check_access(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID | None = None,
|
||||
tool_type_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Verify user has access to referenced project and tool type."""
|
||||
if project_id is not None:
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||
)
|
||||
if tool_type_id is not None:
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
|
||||
)
|
||||
|
||||
|
||||
async def validate_git_mounts(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
git_mounts: list[Any],
|
||||
project_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate git mount URLs."""
|
||||
for mount in git_mounts:
|
||||
remote_url = mount.get("remote_url")
|
||||
if not remote_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Git mount missing remote_url",
|
||||
)
|
||||
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid git URL: {remote_url}",
|
||||
)
|
||||
|
||||
|
||||
def profile_to_response(
|
||||
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
|
||||
) -> dict:
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
"user_id": str(profile.user_id),
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"project_id": str(profile.project_id) if profile.project_id else None,
|
||||
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
||||
"env_vars": profile.env_vars or {},
|
||||
"runtime_hints": profile.runtime_hints or {},
|
||||
"mounts": profile.mounts or [],
|
||||
"git_mounts": profile.git_mounts or [],
|
||||
"files": profile.files or {},
|
||||
"is_default": profile.is_default,
|
||||
"includes": [
|
||||
{
|
||||
"id": str(inc.id),
|
||||
"included_profile_id": str(inc.included_profile_id),
|
||||
"order_index": inc.order_index,
|
||||
}
|
||||
for inc in (includes or profile.includes)
|
||||
],
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def get_or_create_user_config(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> UserConfig:
|
||||
"""Get existing user config or create a new one."""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
user_config = result.scalar_one_or_none()
|
||||
if user_config is None:
|
||||
user_config = UserConfig(user_id=user_id, config={})
|
||||
session.add(user_config)
|
||||
return user_config
|
||||
|
||||
|
||||
async def validate_default_profiles(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
default_profiles: dict[str, str],
|
||||
) -> None:
|
||||
"""Validate that all profile IDs in default_profiles belong to the user."""
|
||||
for tool_type_id, profile_id_str in default_profiles.items():
|
||||
try:
|
||||
profile_uuid = uuid.UUID(profile_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
|
||||
)
|
||||
profile = await session.get(ConfigProfile, profile_uuid)
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Profile not found: {profile_id_str}",
|
||||
)
|
||||
if profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Profile does not belong to user: {profile_id_str}",
|
||||
)
|
||||
|
||||
|
||||
async def create_profile(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
data: Any,
|
||||
) -> ConfigProfile:
|
||||
"""Create a new config profile after validation."""
|
||||
existing = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(
|
||||
ConfigProfile.user_id == user_id,
|
||||
ConfigProfile.name == data.name,
|
||||
)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Profile with name '{data.name}' already exists",
|
||||
)
|
||||
|
||||
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||
await check_access(session, user_id, project_uuid, tool_uuid)
|
||||
|
||||
if data.git_mounts:
|
||||
git_mounts_data = [
|
||||
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
|
||||
]
|
||||
await validate_git_mounts(session, user_id, git_mounts_data, project_uuid)
|
||||
|
||||
size = calculate_profile_size(data.model_dump())
|
||||
if size > MAX_PROFILE_SIZE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="Profile size exceeds 10MB limit",
|
||||
)
|
||||
|
||||
profile = ConfigProfile(
|
||||
user_id=user_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
project_id=project_uuid,
|
||||
tool_type_id=tool_uuid,
|
||||
env_vars=data.env_vars,
|
||||
runtime_hints=data.runtime_hints,
|
||||
mounts=[m.model_dump() for m in data.mounts],
|
||||
git_mounts=[m.model_dump() for m in data.git_mounts],
|
||||
files=data.files,
|
||||
is_default=data.is_default,
|
||||
)
|
||||
session.add(profile)
|
||||
await session.commit()
|
||||
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile.id)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def update_profile(
|
||||
session: AsyncSession,
|
||||
profile: ConfigProfile,
|
||||
data: Any,
|
||||
) -> ConfigProfile:
|
||||
"""Update a config profile after validation."""
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
if "name" in update_data:
|
||||
existing = await session.execute(
|
||||
select(ConfigProfile).where(
|
||||
ConfigProfile.user_id == profile.user_id,
|
||||
ConfigProfile.name == update_data["name"],
|
||||
ConfigProfile.id != profile.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||
)
|
||||
|
||||
project_uuid = (
|
||||
uuid.UUID(update_data["project_id"])
|
||||
if "project_id" in update_data and update_data["project_id"]
|
||||
else (profile.project_id if "project_id" not in update_data else None)
|
||||
)
|
||||
tool_uuid = (
|
||||
uuid.UUID(update_data["tool_type_id"])
|
||||
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
||||
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||
)
|
||||
await check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||
|
||||
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||
git_mounts_data = [
|
||||
m.model_dump() if hasattr(m, "model_dump") else m
|
||||
for m in update_data["git_mounts"]
|
||||
]
|
||||
await validate_git_mounts(
|
||||
session, profile.user_id, git_mounts_data, project_uuid
|
||||
)
|
||||
|
||||
current_data = profile_to_response(profile)
|
||||
merged = {**current_data, **update_data}
|
||||
size = calculate_profile_size(merged)
|
||||
if size > MAX_PROFILE_SIZE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="Profile size exceeds 10MB limit",
|
||||
)
|
||||
|
||||
for field_name, value in update_data.items():
|
||||
if field_name in ("project_id", "tool_type_id"):
|
||||
value = uuid.UUID(value) if value else None
|
||||
elif field_name == "mounts" and value is not None:
|
||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||
elif field_name == "git_mounts" and value is not None:
|
||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||
setattr(profile, field_name, value)
|
||||
|
||||
await session.commit()
|
||||
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile.id)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def update_includes(
|
||||
session: AsyncSession,
|
||||
profile: ConfigProfile,
|
||||
included_ids: list[uuid.UUID],
|
||||
user_id: uuid.UUID,
|
||||
) -> ConfigProfile:
|
||||
"""Replace profile includes after cycle check."""
|
||||
for inc_uuid in included_ids:
|
||||
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||
if inc_profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Included profile not found: {inc_uuid}",
|
||||
)
|
||||
if inc_profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Not authorized to include profile: {inc_uuid}",
|
||||
)
|
||||
if inc_uuid == profile.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Profile cannot include itself",
|
||||
)
|
||||
|
||||
from src.services.config.config_profile_resolver import check_include_cycle
|
||||
|
||||
cycle = await check_include_cycle(session, profile.id, None)
|
||||
if cycle is None and included_ids:
|
||||
for inc_uuid in included_ids:
|
||||
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||
if cycle is not None:
|
||||
break
|
||||
|
||||
if cycle is not None:
|
||||
cycle_str = " -> ".join(str(c) for c in cycle)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Include cycle detected: {cycle_str}",
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(ConfigProfileInclude).where(
|
||||
ConfigProfileInclude.profile_id == profile.id
|
||||
)
|
||||
)
|
||||
for existing in result.scalars().all():
|
||||
await session.delete(existing)
|
||||
await session.flush()
|
||||
|
||||
for order_index, inc_uuid in enumerate(included_ids):
|
||||
include = ConfigProfileInclude(
|
||||
profile_id=profile.id,
|
||||
included_profile_id=inc_uuid,
|
||||
order_index=order_index,
|
||||
)
|
||||
session.add(include)
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
result = await session.execute(
|
||||
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Config profile resolver service functions."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import ConfigProfile, SSHKey, UserConfig
|
||||
from src.services.shared.ssh_keys import _get_fernet
|
||||
from src.utils.git_url_parser import parse_git_url
|
||||
from src.schemas.config import ValidateGitUrlResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def resolve_default_profile(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
tool_type_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""Resolve the default config profile for a project/tool combination."""
|
||||
query = (
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.user_id == user_id)
|
||||
.where(
|
||||
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
||||
| (ConfigProfile.project_id == project_id)
|
||||
| (ConfigProfile.tool_type_id == tool_type_id)
|
||||
| (
|
||||
(ConfigProfile.project_id == project_id)
|
||||
& (ConfigProfile.tool_type_id == tool_type_id)
|
||||
)
|
||||
)
|
||||
.order_by(ConfigProfile.created_at)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
profiles = result.scalars().all()
|
||||
|
||||
if not profiles:
|
||||
return {"profile_id": None, "profile_name": None}
|
||||
|
||||
explicit_defaults = [p for p in profiles if p.is_default]
|
||||
|
||||
for p in explicit_defaults:
|
||||
if p.project_id == project_id and p.tool_type_id == tool_type_id:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
for p in explicit_defaults:
|
||||
if p.project_id == project_id and p.tool_type_id is None:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
for p in explicit_defaults:
|
||||
if p.project_id is None and p.tool_type_id == tool_type_id:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
for p in explicit_defaults:
|
||||
if p.project_id is None and p.tool_type_id is None:
|
||||
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||
|
||||
first = profiles[0]
|
||||
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||
|
||||
|
||||
async def validate_git_url(
|
||||
session: AsyncSession,
|
||||
current_user_id: uuid.UUID,
|
||||
url: str,
|
||||
ssh_key_id: str | None,
|
||||
) -> ValidateGitUrlResponse:
|
||||
"""Validate a git remote URL and list available branches."""
|
||||
parse_result = parse_git_url(url)
|
||||
original_url = url.strip()
|
||||
url_to_check = parse_result.get("base_url") or original_url
|
||||
|
||||
if not url_to_check:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error=parse_result.get("message", "Invalid URL"),
|
||||
error_code=parse_result.get("error_code", "INVALID_URL"),
|
||||
)
|
||||
|
||||
if parse_result.get("needs_parsing") and url_to_check != original_url:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
suggested_url=url_to_check,
|
||||
error=parse_result.get("message"),
|
||||
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
|
||||
)
|
||||
|
||||
env = None
|
||||
key_path = None
|
||||
if ssh_key_id:
|
||||
try:
|
||||
ssh_key_uuid = uuid.UUID(ssh_key_id)
|
||||
except ValueError:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="Invalid SSH key ID format",
|
||||
error_code="INVALID_SSH_KEY",
|
||||
)
|
||||
|
||||
ssh_key = await session.get(SSHKey, ssh_key_uuid)
|
||||
if ssh_key is None or ssh_key.user_id != current_user_id:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="SSH key not found or not authorized",
|
||||
error_code="SSH_KEY_NOT_FOUND",
|
||||
)
|
||||
|
||||
import tempfile
|
||||
|
||||
fernet = _get_fernet()
|
||||
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||
try:
|
||||
os.write(fd, private_key.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chmod(key_path, 0o600)
|
||||
env = {
|
||||
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "ls-remote", "--heads", url_to_check],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="Remote repository check timed out",
|
||||
error_code="TIMEOUT",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="git command not found on server",
|
||||
error_code="GIT_NOT_FOUND",
|
||||
)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
if (
|
||||
"could not resolve" in stderr.lower()
|
||||
or "unable to access" in stderr.lower()
|
||||
):
|
||||
error_msg = "Could not reach repository. Check the URL and network access."
|
||||
error_code = "UNREACHABLE"
|
||||
elif (
|
||||
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
|
||||
):
|
||||
error_msg = (
|
||||
"Authentication failed. Provide an SSH key for private repositories."
|
||||
)
|
||||
error_code = "AUTH_FAILED"
|
||||
else:
|
||||
error_msg = f"Repository not accessible: {stderr[:200]}"
|
||||
error_code = "REMOTE_ERROR"
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error=error_msg,
|
||||
error_code=error_code,
|
||||
)
|
||||
|
||||
branches: list[str] = []
|
||||
default_branch = "main"
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) == 2:
|
||||
ref = parts[1]
|
||||
if ref.startswith("refs/heads/"):
|
||||
branch_name = ref[len("refs/heads/") :]
|
||||
branches.append(branch_name)
|
||||
if branch_name in ("main", "master"):
|
||||
default_branch = branch_name
|
||||
|
||||
if not branches:
|
||||
return ValidateGitUrlResponse(
|
||||
valid=False,
|
||||
error="No branches found in remote repository",
|
||||
error_code="NO_BRANCHES",
|
||||
)
|
||||
|
||||
return ValidateGitUrlResponse(
|
||||
valid=True,
|
||||
suggested_url=url_to_check if url_to_check != original_url else None,
|
||||
branches=branches,
|
||||
default_branch=default_branch,
|
||||
)
|
||||
+1
-1
@@ -13,7 +13,7 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
"""Docker service for managing tool instances."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
|
||||
"""Sort volume strings so parent paths come before child paths.
|
||||
|
||||
Docker Compose mounts volumes in array order. A later mount at a parent
|
||||
path hides earlier mounts at child paths. By sorting shallow paths first
|
||||
and deep paths last, deeper (more specific) mounts overlay correctly.
|
||||
|
||||
Volume format: source:target or source:target:type
|
||||
|
||||
Args:
|
||||
volumes: List of Docker volume mount strings.
|
||||
|
||||
Returns:
|
||||
Sorted list with parent paths before child paths.
|
||||
"""
|
||||
|
||||
def _target_depth(vol: str) -> int:
|
||||
parts = vol.split(":")
|
||||
if len(parts) < 2:
|
||||
return 0
|
||||
target = parts[1].rstrip("/")
|
||||
if not target or target == "/":
|
||||
return 0
|
||||
return target.count("/")
|
||||
|
||||
# Detect duplicate targets and warn
|
||||
targets = []
|
||||
for vol in volumes:
|
||||
parts = vol.split(":")
|
||||
targets.append(parts[1] if len(parts) > 1 else "")
|
||||
dupes = [t for t, c in Counter(targets).items() if c > 1]
|
||||
if dupes:
|
||||
logger.warning("Duplicate mount targets detected: %s", dupes)
|
||||
|
||||
# Stable sort: parent paths first, child paths last
|
||||
return sorted(volumes, key=_target_depth)
|
||||
|
||||
|
||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||
"""Render a Docker Compose template with variable substitution.
|
||||
|
||||
Args:
|
||||
template: The compose template string
|
||||
variables: Dictionary of variable names to values
|
||||
|
||||
Returns:
|
||||
Rendered compose file content
|
||||
"""
|
||||
result = template
|
||||
for key, value in variables.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
result = result.replace(placeholder, str(value))
|
||||
return result
|
||||
|
||||
|
||||
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||
"""Create and return the instance directory path.
|
||||
|
||||
Args:
|
||||
instance_id: Unique instance identifier
|
||||
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||
|
||||
Returns:
|
||||
Absolute path to instance directory
|
||||
"""
|
||||
if base_path is None:
|
||||
from src.config import Settings
|
||||
|
||||
base_path = Settings().instance_base_path
|
||||
instance_dir = Path(base_path) / instance_id
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(instance_dir.absolute())
|
||||
|
||||
|
||||
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||
"""Write the rendered compose file to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
content: Rendered compose content
|
||||
|
||||
Returns:
|
||||
Path to the compose file
|
||||
"""
|
||||
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||
compose_path.write_text(content)
|
||||
return str(compose_path)
|
||||
|
||||
|
||||
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||
"""Write environment variables to a .env file.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
env_vars: Dictionary of env var names to values
|
||||
|
||||
Returns:
|
||||
Path to the env file
|
||||
"""
|
||||
env_path = Path(instance_dir) / ".env"
|
||||
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||
env_path.write_text("\n".join(lines) + "\n")
|
||||
return str(env_path)
|
||||
|
||||
|
||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
"""Write config files to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
files: Dictionary of file paths (relative to instance dir) to content
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
for file_path, content in files.items():
|
||||
# Ensure the path is within the instance directory (security)
|
||||
full_path = instance_path / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
"""Execute a docker compose command.
|
||||
|
||||
Args:
|
||||
compose_path: Path to docker-compose.yml
|
||||
action: The compose action (up, down, start, stop, restart)
|
||||
timeout: Command timeout in seconds
|
||||
env_file: Optional path to .env file for environment variables
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
if action == "up":
|
||||
cmd.extend(["up", "-d", "--force-recreate"])
|
||||
elif action == "down":
|
||||
cmd.extend(["down", "-v"])
|
||||
elif action in ("start", "stop", "restart"):
|
||||
cmd.append(action)
|
||||
else:
|
||||
raise ValueError(f"Unknown compose action: {action}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(instance_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
Searches all containers including stopped/exited ones.
|
||||
|
||||
Args:
|
||||
instance_name: The service name in compose
|
||||
|
||||
Returns:
|
||||
Container ID or None if not found
|
||||
"""
|
||||
# Docker container names are lowercase internally; normalize to ensure match
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name.lower()}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split("\n")[0]
|
||||
return None
|
||||
|
||||
|
||||
def get_container_name(instance_name: str) -> str | None:
|
||||
"""Get the full container name for a compose service.
|
||||
|
||||
Searches all containers including stopped/exited ones.
|
||||
|
||||
Args:
|
||||
instance_name: The service name in compose
|
||||
|
||||
Returns:
|
||||
Container name or None if not found
|
||||
"""
|
||||
# Docker container names are lowercase internally; normalize to ensure match
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
f"name={instance_name.lower()}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split("\n")[0]
|
||||
return None
|
||||
|
||||
|
||||
def connect_container_to_network(
|
||||
container_name: str, network_name: str = "backend"
|
||||
) -> bool:
|
||||
"""Connect a Docker container to an existing network.
|
||||
|
||||
Args:
|
||||
container_name: Name or ID of the container
|
||||
network_name: Name of the Docker network (default: backend)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "network", "connect", network_name, container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
'exit_code' (int or None), and 'health' (health status or None)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"status": "not_found", "exit_code": None, "health": None}
|
||||
|
||||
parts = result.stdout.strip().split("|")
|
||||
status = parts[0] if parts else "unknown"
|
||||
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||
|
||||
return {"status": status, "exit_code": exit_code, "health": health}
|
||||
|
||||
|
||||
def wait_for_container_running(
|
||||
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for a container to reach the running state.
|
||||
|
||||
Polls docker inspect until the container status is "running" or timeout.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
timeout: Maximum seconds to wait
|
||||
interval: Seconds between polls
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||
and 'waited_seconds' (float)
|
||||
"""
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
info = get_container_status(container_id)
|
||||
|
||||
if info["status"] == "running":
|
||||
return {
|
||||
"success": True,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "exited":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "exited",
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "not_found":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "not_found",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
# Timeout reached
|
||||
info = get_container_status(container_id)
|
||||
return {
|
||||
"success": False,
|
||||
"status": info["status"],
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
"""Get the logs of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
tail: Number of lines to return
|
||||
|
||||
Returns:
|
||||
Container logs
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "logs", "--tail", str(tail), container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return f"Failed to get logs: {result.stderr}"
|
||||
|
||||
|
||||
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||
"""Find a free TCP port in the given range.
|
||||
|
||||
Args:
|
||||
start: Start of port range
|
||||
end: End of port range
|
||||
|
||||
Returns:
|
||||
Free port number
|
||||
"""
|
||||
import socket
|
||||
|
||||
for port in range(start, end):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
if s.connect_ex(("localhost", port)) != 0:
|
||||
return port
|
||||
|
||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||
|
||||
|
||||
def _check_app_binding(container_name: str, port: int) -> dict[str, str | bool]:
|
||||
"""Diagnose whether the app is bound to 127.0.0.1 or 0.0.0.0.
|
||||
|
||||
Checks from both inside the container (localhost) and outside
|
||||
(via Docker network) to detect binding issues.
|
||||
|
||||
Returns:
|
||||
Dict with 'internal_ok', 'external_ok', 'internal_status',
|
||||
'external_status', and 'diagnosis'.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"internal_ok": False,
|
||||
"external_ok": False,
|
||||
"internal_status": None,
|
||||
"external_status": None,
|
||||
"diagnosis": "unknown",
|
||||
}
|
||||
|
||||
# Check from inside the container (loopback)
|
||||
internal = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
container_name,
|
||||
"sh",
|
||||
"-c",
|
||||
f"curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{port}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if internal.returncode == 0:
|
||||
try:
|
||||
result["internal_status"] = int(internal.stdout.strip())
|
||||
result["internal_ok"] = result["internal_status"] > 0
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Check from outside the container (Docker network)
|
||||
external = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
f"http://{container_name}:{port}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if external.returncode == 0:
|
||||
try:
|
||||
result["external_status"] = int(external.stdout.strip())
|
||||
result["external_ok"] = result["external_status"] > 0
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Diagnose binding issue
|
||||
if result["internal_ok"] and not result["external_ok"]:
|
||||
result["diagnosis"] = (
|
||||
f"App appears to be bound to 127.0.0.1:{port} inside the container. "
|
||||
f"It must bind to 0.0.0.0:{port} to be accessible from the tunnel."
|
||||
)
|
||||
elif result["internal_ok"] and result["external_ok"]:
|
||||
result["diagnosis"] = "App is accessible on both interfaces."
|
||||
elif not result["internal_ok"] and not result["external_ok"]:
|
||||
result["diagnosis"] = f"App is not responding on port {port} at all."
|
||||
else:
|
||||
result["diagnosis"] = "Unexpected binding state."
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def start_cloudflared_tunnel(
|
||||
container_name: str, port: int, timeout: int = 30
|
||||
) -> dict[str, str]:
|
||||
"""Start a temporary Cloudflare tunnel for a container.
|
||||
|
||||
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
||||
with a random trycloudflare.com URL.
|
||||
|
||||
Args:
|
||||
container_name: Name of the Docker container to tunnel to
|
||||
port: Port number the container listens on
|
||||
timeout: Maximum seconds to wait for tunnel URL
|
||||
|
||||
Returns:
|
||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||
"""
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# First verify the container is accessible from the Docker network
|
||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||
accessible = False
|
||||
last_status = None
|
||||
for attempt in range(30): # 30 attempts × 1s = 30s max wait for app startup
|
||||
check = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"--max-time",
|
||||
"3",
|
||||
f"http://{container_name}:{port}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
status_str = check.stdout.strip()
|
||||
logger.info(
|
||||
"Connectivity check %d/%d: http_code=%s (rc=%d)",
|
||||
attempt + 1,
|
||||
30,
|
||||
status_str,
|
||||
check.returncode,
|
||||
)
|
||||
try:
|
||||
last_status = int(status_str)
|
||||
# Accept 2xx, 3xx, 401, 403 as "app is listening"
|
||||
if last_status in (401, 403) or 200 <= last_status < 400:
|
||||
accessible = True
|
||||
logger.info(
|
||||
"App on %s:%d is ready (HTTP %d)",
|
||||
container_name,
|
||||
port,
|
||||
last_status,
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if check.returncode != 0:
|
||||
logger.debug(
|
||||
"curl failed: stderr=%s", check.stderr.strip() if check.stderr else ""
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
if not accessible:
|
||||
logger.warning(
|
||||
"Container %s:%d not responding after 30s (last status: %s). "
|
||||
"Running binding diagnostics...",
|
||||
container_name,
|
||||
port,
|
||||
last_status,
|
||||
)
|
||||
diagnosis = _check_app_binding(container_name, port)
|
||||
logger.warning(
|
||||
"Binding diagnosis: internal=%s (HTTP %s), external=%s (HTTP %s). %s",
|
||||
diagnosis["internal_ok"],
|
||||
diagnosis["internal_status"],
|
||||
diagnosis["external_ok"],
|
||||
diagnosis["external_status"],
|
||||
diagnosis["diagnosis"],
|
||||
)
|
||||
|
||||
# Run cloudflared in background, capture output
|
||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||
proc = subprocess.Popen(
|
||||
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Wait for the URL to appear in output
|
||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||
start_time = time.time()
|
||||
url = None
|
||||
|
||||
if proc.stdout is None:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
raise RuntimeError("Failed to capture cloudflared output")
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
# Read available output
|
||||
import select
|
||||
|
||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||
if readable:
|
||||
line = proc.stdout.readline()
|
||||
if line:
|
||||
match = url_pattern.search(line)
|
||||
if match:
|
||||
url = match.group(0)
|
||||
break
|
||||
|
||||
if not url:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
raise RuntimeError(
|
||||
f"Failed to get tunnel URL within {timeout}s. "
|
||||
f"cloudflared output may contain errors."
|
||||
)
|
||||
|
||||
return {"url": url, "pid": str(proc.pid)}
|
||||
|
||||
|
||||
def stop_cloudflared_tunnel(pid: str) -> None:
|
||||
"""Stop a cloudflared tunnel process.
|
||||
|
||||
Args:
|
||||
pid: Process ID of the cloudflared tunnel
|
||||
"""
|
||||
import signal
|
||||
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass # Already stopped
|
||||
|
||||
|
||||
def recreate_tunnel(
|
||||
container_name: str, port: int, old_pid: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Recreate a temporary Cloudflare tunnel.
|
||||
|
||||
Stops the old tunnel (if pid provided) and starts a new one.
|
||||
|
||||
Args:
|
||||
container_name: Name of the Docker container to tunnel to
|
||||
port: Port number the container listens on
|
||||
old_pid: Optional PID of the old tunnel process to stop
|
||||
|
||||
Returns:
|
||||
Dict with 'url' and 'pid' for the new tunnel
|
||||
"""
|
||||
if old_pid:
|
||||
stop_cloudflared_tunnel(old_pid)
|
||||
|
||||
return start_cloudflared_tunnel(container_name, port)
|
||||
|
||||
|
||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
"""Check if a tunnel URL is healthy with smart error classification.
|
||||
|
||||
Args:
|
||||
url: The tunnel URL to check
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
|
||||
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"--max-time",
|
||||
str(timeout),
|
||||
url,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
status_code = int(result.stdout.strip())
|
||||
|
||||
if 200 <= status_code < 400:
|
||||
return {
|
||||
"tunnel_status": "healthy",
|
||||
"status_code": status_code,
|
||||
"healthy": True,
|
||||
"error": None,
|
||||
}
|
||||
elif status_code in (502, 503, 504):
|
||||
# Application error, not tunnel error
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"Application returned HTTP {status_code}",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"HTTP {status_code}",
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": "Tunnel request timed out",
|
||||
}
|
||||
except (ValueError, Exception) as e:
|
||||
error_str = str(e).lower()
|
||||
# Classify connection errors
|
||||
if any(
|
||||
err in error_str
|
||||
for err in [
|
||||
"connection refused",
|
||||
"econnrefused",
|
||||
"could not resolve",
|
||||
"nodename",
|
||||
]
|
||||
):
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": f"Tunnel unreachable: {e}",
|
||||
}
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": str(e),
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Docker services package for container and compose operations."""
|
||||
|
||||
from src.services.docker.compose import (
|
||||
execute_compose_command,
|
||||
render_compose_template,
|
||||
sort_volumes_by_specificity,
|
||||
write_compose_file,
|
||||
)
|
||||
from src.services.docker.config_staging import (
|
||||
ensure_instance_directory,
|
||||
write_config_files,
|
||||
write_env_file,
|
||||
)
|
||||
from src.services.docker.container import (
|
||||
connect_container_to_network,
|
||||
find_free_port,
|
||||
get_backend_network_name,
|
||||
get_container_id,
|
||||
get_container_ip_on_network,
|
||||
get_container_logs,
|
||||
get_container_name,
|
||||
get_container_status,
|
||||
is_container_on_network,
|
||||
wait_for_container_running,
|
||||
)
|
||||
from src.services.docker.tunnel import (
|
||||
check_tunnel_health,
|
||||
recreate_tunnel,
|
||||
start_tunnel,
|
||||
stop_tunnel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"check_tunnel_health",
|
||||
"connect_container_to_network",
|
||||
"ensure_instance_directory",
|
||||
"execute_compose_command",
|
||||
"find_free_port",
|
||||
"get_backend_network_name",
|
||||
"get_container_id",
|
||||
"get_container_ip_on_network",
|
||||
"get_container_logs",
|
||||
"get_container_name",
|
||||
"get_container_status",
|
||||
"is_container_on_network",
|
||||
"recreate_tunnel",
|
||||
"render_compose_template",
|
||||
"sort_volumes_by_specificity",
|
||||
"start_tunnel",
|
||||
"stop_tunnel",
|
||||
"wait_for_container_running",
|
||||
"write_compose_file",
|
||||
"write_config_files",
|
||||
"write_env_file",
|
||||
]
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Docker Compose file generation and manipulation."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
|
||||
"""Sort volume strings so parent paths come before child paths.
|
||||
|
||||
Docker Compose mounts volumes in array order. A later mount at a parent
|
||||
path hides earlier mounts at child paths. By sorting shallow paths first
|
||||
and deep paths last, deeper (more specific) mounts overlay correctly.
|
||||
|
||||
Volume format: source:target or source:target:type
|
||||
|
||||
Args:
|
||||
volumes: List of Docker volume mount strings.
|
||||
|
||||
Returns:
|
||||
Sorted list with parent paths before child paths.
|
||||
"""
|
||||
|
||||
def _target_depth(vol: str) -> int:
|
||||
parts = vol.split(":")
|
||||
if len(parts) < 2:
|
||||
return 0
|
||||
target = parts[1].rstrip("/")
|
||||
if not target or target == "/":
|
||||
return 0
|
||||
return target.count("/")
|
||||
|
||||
# Detect duplicate targets and warn
|
||||
targets = []
|
||||
for vol in volumes:
|
||||
parts = vol.split(":")
|
||||
targets.append(parts[1] if len(parts) > 1 else "")
|
||||
dupes = [t for t, c in Counter(targets).items() if c > 1]
|
||||
if dupes:
|
||||
logger.warning("Duplicate mount targets detected: %s", dupes)
|
||||
|
||||
# Stable sort: parent paths first, child paths last
|
||||
return sorted(volumes, key=_target_depth)
|
||||
|
||||
|
||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||
"""Render a Docker Compose template with variable substitution.
|
||||
|
||||
Args:
|
||||
template: The compose template string
|
||||
variables: Dictionary of variable names to values
|
||||
|
||||
Returns:
|
||||
Rendered compose file content
|
||||
"""
|
||||
result = template
|
||||
for key, value in variables.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
result = result.replace(placeholder, str(value))
|
||||
return result
|
||||
|
||||
|
||||
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||
"""Write the rendered compose file to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
content: Rendered compose content
|
||||
|
||||
Returns:
|
||||
Path to the compose file
|
||||
"""
|
||||
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||
compose_path.write_text(content)
|
||||
return str(compose_path)
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
"""Execute a docker compose command.
|
||||
|
||||
Args:
|
||||
compose_path: Path to docker-compose.yml
|
||||
action: The compose action (up, down, start, stop, restart)
|
||||
timeout: Command timeout in seconds
|
||||
env_file: Optional path to .env file for environment variables
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
if action == "up":
|
||||
cmd.extend(["up", "-d", "--force-recreate"])
|
||||
elif action == "down":
|
||||
cmd.extend(["down", "-v"])
|
||||
elif action in ("start", "stop", "restart"):
|
||||
cmd.append(action)
|
||||
else:
|
||||
raise ValueError(f"Unknown compose action: {action}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(instance_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Staging configuration files into instance directories."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||
"""Create and return the instance directory path.
|
||||
|
||||
Args:
|
||||
instance_id: Unique instance identifier
|
||||
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||
|
||||
Returns:
|
||||
Absolute path to instance directory
|
||||
"""
|
||||
if base_path is None:
|
||||
from src.config import Settings
|
||||
|
||||
base_path = Settings().instance_base_path
|
||||
instance_dir = Path(base_path) / instance_id
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(instance_dir.absolute())
|
||||
|
||||
|
||||
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||
"""Write environment variables to a .env file.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
env_vars: Dictionary of env var names to values
|
||||
|
||||
Returns:
|
||||
Path to the env file
|
||||
"""
|
||||
env_path = Path(instance_dir) / ".env"
|
||||
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||
env_path.write_text("\n".join(lines) + "\n")
|
||||
return str(env_path)
|
||||
|
||||
|
||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
"""Write config files to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
files: Dictionary of file paths (relative to instance dir) to content
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
for file_path, content in files.items():
|
||||
# Ensure the path is within the instance directory (security)
|
||||
full_path = instance_path / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
@@ -1,316 +0,0 @@
|
||||
"""Docker container runtime queries and network management."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
Uses exact name matching to avoid substring collisions with tunnel
|
||||
containers (e.g. tunnel-code-server-... matching code-server-...).
|
||||
Falls back to case-insensitive matching since Docker DNS is case-
|
||||
insensitive but docker inspect is case-sensitive.
|
||||
|
||||
Args:
|
||||
instance_name: The expected container name.
|
||||
|
||||
Returns:
|
||||
Container ID or None if not found.
|
||||
"""
|
||||
expected = instance_name.lower()
|
||||
|
||||
# Fast path: exact match via docker inspect
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.Id}}", expected],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
|
||||
# Fallback: list all containers and do case-insensitive exact match
|
||||
ps_result = subprocess.run(
|
||||
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ps_result.returncode == 0:
|
||||
for line in ps_result.stdout.strip().splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 2:
|
||||
name, cid = parts
|
||||
if name.lower() == expected:
|
||||
return cid
|
||||
return None
|
||||
|
||||
|
||||
def get_container_name(instance_name: str) -> str | None:
|
||||
"""Get the full container name for a compose service.
|
||||
|
||||
Uses exact name matching via docker inspect to avoid substring collisions.
|
||||
|
||||
Args:
|
||||
instance_name: The exact container name (case-insensitive for Docker).
|
||||
|
||||
Returns:
|
||||
Container name or None if not found.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().lstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def get_backend_network_name() -> str:
|
||||
"""Auto-detect the actual Docker network name for the backend network.
|
||||
|
||||
Docker Compose prefixes network names with the project directory name
|
||||
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
|
||||
container itself to find the real network name it's connected to.
|
||||
|
||||
Returns:
|
||||
The actual Docker network name, or 'backend' as fallback.
|
||||
"""
|
||||
# Try to find the API container by its known name
|
||||
api_container = "hq-api"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
|
||||
api_container,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
networks = result.stdout.strip().split()
|
||||
for net in networks:
|
||||
if "backend" in net.lower():
|
||||
return net
|
||||
# API container is on some network — return the first one
|
||||
return networks[0]
|
||||
return "backend"
|
||||
|
||||
|
||||
def connect_container_to_network(
|
||||
container_name: str, network_name: str | None = None
|
||||
) -> bool:
|
||||
"""Connect a Docker container to an existing network.
|
||||
|
||||
Args:
|
||||
container_name: Name or ID of the container
|
||||
network_name: Name of the Docker network. If None, auto-detects
|
||||
from the API container's own network membership.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
["docker", "network", "connect", network_name, container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_container_ip_on_network(
|
||||
container_id: str, network_name: str | None = None
|
||||
) -> str | None:
|
||||
"""Get a container's IP address on a specific Docker network.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
network_name: Network name. If None, auto-detects from the API container.
|
||||
|
||||
Returns:
|
||||
IP address string, or None if the container is not on that network.
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
ip = result.stdout.strip()
|
||||
if ip and ip != "<no value>":
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
|
||||
"""Check whether a container is already attached to a Docker network.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
network_name: Network name. If None, auto-detects from the API container.
|
||||
|
||||
Returns:
|
||||
True if the container is on the network.
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0 and "<no value>" not in result.stdout
|
||||
|
||||
|
||||
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
'exit_code' (int or None), and 'health' (health status or None)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"status": "not_found", "exit_code": None, "health": None}
|
||||
|
||||
parts = result.stdout.strip().split("|")
|
||||
status = parts[0] if parts else "unknown"
|
||||
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||
|
||||
return {"status": status, "exit_code": exit_code, "health": health}
|
||||
|
||||
|
||||
def wait_for_container_running(
|
||||
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for a container to reach the running state.
|
||||
|
||||
Polls docker inspect until the container status is "running" or timeout.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
timeout: Maximum seconds to wait
|
||||
interval: Seconds between polls
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||
and 'waited_seconds' (float)
|
||||
"""
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
info = get_container_status(container_id)
|
||||
|
||||
if info["status"] == "running":
|
||||
return {
|
||||
"success": True,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "exited":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "exited",
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "not_found":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "not_found",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
# Timeout reached
|
||||
info = get_container_status(container_id)
|
||||
return {
|
||||
"success": False,
|
||||
"status": info["status"],
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
"""Get the logs of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
tail: Number of lines to return
|
||||
|
||||
Returns:
|
||||
Container logs
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "logs", "--tail", str(tail), container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return f"Failed to get logs: {result.stderr}"
|
||||
|
||||
|
||||
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||
"""Find a free TCP port in the given range.
|
||||
|
||||
Args:
|
||||
start: Start of port range
|
||||
end: End of port range
|
||||
|
||||
Returns:
|
||||
Free port number
|
||||
"""
|
||||
import socket
|
||||
|
||||
for port in range(start, end):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
if s.connect_ex(("localhost", port)) != 0:
|
||||
return port
|
||||
|
||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||
@@ -1,281 +0,0 @@
|
||||
"""Cloudflare tunnel management using cloudflared Docker containers.
|
||||
|
||||
Each tunnel runs as a Docker container on the same 'backend' network as the API.
|
||||
cloudflared connects to the tool container by its Docker Compose service name
|
||||
(e.g. http://code-server-headquarter-34837cd3:8443).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from src.services.docker.container import get_backend_network_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
|
||||
|
||||
|
||||
def _tunnel_container_name(instance_name: str) -> str:
|
||||
return f"tunnel-{instance_name.lower()}"
|
||||
|
||||
|
||||
def _ensure_image() -> None:
|
||||
"""Pull cloudflared image if not already present."""
|
||||
result = subprocess.run(
|
||||
["docker", "images", "-q", TUNNEL_IMAGE],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if not result.stdout.strip():
|
||||
logger.info("Pulling %s ...", TUNNEL_IMAGE)
|
||||
pull = subprocess.run(
|
||||
["docker", "pull", TUNNEL_IMAGE],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if pull.returncode != 0:
|
||||
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
|
||||
|
||||
|
||||
def _cleanup_stale_tunnel(tunnel_name: str) -> None:
|
||||
"""Remove any existing tunnel container with this name."""
|
||||
subprocess.run(
|
||||
["docker", "stop", "-t", "3", tunnel_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", tunnel_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _get_tunnel_logs(tunnel_name: str) -> tuple[str, str]:
|
||||
"""Get stdout and stderr logs from a container."""
|
||||
result = subprocess.run(
|
||||
["docker", "logs", tunnel_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout, result.stderr
|
||||
|
||||
|
||||
def _get_tunnel_exit_code(tunnel_name: str) -> int | None:
|
||||
"""Get exit code of a container if it has exited."""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def start_tunnel(
|
||||
instance_name: str,
|
||||
container_port: int,
|
||||
timeout: int = 30,
|
||||
target_url: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Start a temporary Cloudflare tunnel for an instance.
|
||||
|
||||
Args:
|
||||
instance_name: The tool instance name (used for tunnel naming).
|
||||
container_port: The port the tool container listens on internally.
|
||||
timeout: Seconds to wait for the tunnel URL.
|
||||
target_url: Optional explicit URL to proxy to. If omitted, derives
|
||||
http://{instance_name.lower()}:{container_port}.
|
||||
|
||||
Returns:
|
||||
Dict with 'url' and 'container_name'.
|
||||
"""
|
||||
_ensure_image()
|
||||
|
||||
tunnel_name = _tunnel_container_name(instance_name)
|
||||
_cleanup_stale_tunnel(tunnel_name)
|
||||
|
||||
# Target the tool container by name on the backend network
|
||||
if target_url is None:
|
||||
target_url = f"http://{instance_name.lower()}:{container_port}"
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--network",
|
||||
get_backend_network_name(),
|
||||
"--name",
|
||||
tunnel_name,
|
||||
TUNNEL_IMAGE,
|
||||
"tunnel",
|
||||
"--no-autoupdate",
|
||||
"--url",
|
||||
target_url,
|
||||
]
|
||||
|
||||
logger.debug("Running: %s", " ".join(cmd))
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to start tunnel container {tunnel_name}: {proc.stderr}"
|
||||
)
|
||||
|
||||
container_id = proc.stdout.strip()
|
||||
logger.debug("Tunnel container started: %s", container_id)
|
||||
|
||||
# Wait for URL to appear in logs
|
||||
# Exclude api.trycloudflare.com which is the Cloudflare API endpoint,
|
||||
# not a tunnel URL. Real tunnel URLs have random subdomains (10+ chars).
|
||||
url_pattern = re.compile(r"https://(?!api\.)[a-z0-9-]{10,}\.trycloudflare\.com")
|
||||
start_time = __import__("time").time()
|
||||
url: str | None = None
|
||||
combined_logs = ""
|
||||
|
||||
while __import__("time").time() - start_time < timeout:
|
||||
stdout, stderr = _get_tunnel_logs(tunnel_name)
|
||||
combined_logs = stdout + "\n" + stderr
|
||||
|
||||
match = url_pattern.search(combined_logs)
|
||||
if match:
|
||||
url = match.group(0)
|
||||
break
|
||||
|
||||
# Check if container exited early
|
||||
exit_code = _get_tunnel_exit_code(tunnel_name)
|
||||
if exit_code is not None and exit_code != 0:
|
||||
_cleanup_stale_tunnel(tunnel_name)
|
||||
raise RuntimeError(
|
||||
f"Tunnel container {tunnel_name} exited with code {exit_code}. "
|
||||
f"Logs:\n{combined_logs[-3000:]}"
|
||||
)
|
||||
|
||||
__import__("time").sleep(0.5)
|
||||
|
||||
if not url:
|
||||
stdout, stderr = _get_tunnel_logs(tunnel_name)
|
||||
combined_logs = stdout + "\n" + stderr
|
||||
exit_code = _get_tunnel_exit_code(tunnel_name)
|
||||
|
||||
_cleanup_stale_tunnel(tunnel_name)
|
||||
raise RuntimeError(
|
||||
f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. "
|
||||
f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}"
|
||||
)
|
||||
|
||||
# Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain
|
||||
__import__("time").sleep(2)
|
||||
|
||||
logger.info(
|
||||
"Tunnel %s started for %s → %s (%s)",
|
||||
tunnel_name,
|
||||
instance_name,
|
||||
target_url,
|
||||
url,
|
||||
)
|
||||
return {"url": url, "container_name": tunnel_name}
|
||||
|
||||
|
||||
def stop_tunnel(instance_name: str) -> None:
|
||||
"""Stop and remove the tunnel container for an instance."""
|
||||
tunnel_name = _tunnel_container_name(instance_name)
|
||||
_cleanup_stale_tunnel(tunnel_name)
|
||||
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
|
||||
|
||||
|
||||
def recreate_tunnel(
|
||||
instance_name: str, container_port: int, target_url: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Recreate a tunnel for an instance.
|
||||
|
||||
Args:
|
||||
instance_name: The tool instance name.
|
||||
container_port: The port the tool container listens on internally.
|
||||
target_url: Optional explicit origin URL. If omitted, derives
|
||||
http://{instance_name.lower()}:{container_port}.
|
||||
"""
|
||||
stop_tunnel(instance_name)
|
||||
return start_tunnel(instance_name, container_port, target_url=target_url)
|
||||
|
||||
|
||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
"""Check if a tunnel URL is healthy.
|
||||
|
||||
Returns:
|
||||
Dict with 'tunnel_status', 'status_code', 'healthy', 'error'.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"--max-time",
|
||||
str(timeout),
|
||||
url,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
status_code = int(result.stdout.strip())
|
||||
|
||||
if 200 <= status_code < 400:
|
||||
return {
|
||||
"tunnel_status": "healthy",
|
||||
"status_code": status_code,
|
||||
"healthy": True,
|
||||
"error": None,
|
||||
}
|
||||
if status_code in (502, 503, 504):
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"Application returned HTTP {status_code}",
|
||||
}
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"HTTP {status_code}",
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": "Tunnel request timed out",
|
||||
}
|
||||
except (ValueError, Exception) as exc:
|
||||
error_str = str(exc).lower()
|
||||
if any(
|
||||
err in error_str
|
||||
for err in [
|
||||
"connection refused",
|
||||
"econnrefused",
|
||||
"could not resolve",
|
||||
"nodename",
|
||||
]
|
||||
):
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": f"Tunnel unreachable: {exc}",
|
||||
}
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Git services module."""
|
||||
|
||||
from src.services.git.clone import (
|
||||
check_dirty_state,
|
||||
clone_repository,
|
||||
remove_clone_directory,
|
||||
)
|
||||
from src.services.git.git_operations import Commit, GitOperations, GitStatus
|
||||
from src.services.git.git_service import GitService
|
||||
|
||||
__all__ = [
|
||||
"check_dirty_state",
|
||||
"clone_repository",
|
||||
"remove_clone_directory",
|
||||
"Commit",
|
||||
"GitOperations",
|
||||
"GitStatus",
|
||||
"GitService",
|
||||
]
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Git commands scoped to a workspace directory."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.models import Workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitStatus:
|
||||
"""Parsed git status output."""
|
||||
|
||||
branch: str
|
||||
modified: list[str]
|
||||
added: list[str]
|
||||
deleted: list[str]
|
||||
untracked: list[str]
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Commit:
|
||||
"""A single git commit."""
|
||||
|
||||
hash: str
|
||||
message: str
|
||||
author: str
|
||||
date: str
|
||||
|
||||
|
||||
class GitOperations:
|
||||
"""Run git commands within a workspace directory."""
|
||||
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self.cwd = workspace.path
|
||||
self.branch = workspace.branch
|
||||
|
||||
async def _run(self, *cmd: str) -> tuple[int, str, str]:
|
||||
"""Run a git command and return (returncode, stdout, stderr)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
||||
|
||||
async def status(self) -> GitStatus:
|
||||
"""Get git status for the workspace."""
|
||||
returncode, stdout, _ = await self._run(
|
||||
"git", "-C", self.cwd, "status", "--porcelain", "-b"
|
||||
)
|
||||
|
||||
modified: list[str] = []
|
||||
added: list[str] = []
|
||||
deleted: list[str] = []
|
||||
untracked: list[str] = []
|
||||
branch = self.branch
|
||||
ahead = 0
|
||||
behind = 0
|
||||
|
||||
for line in stdout.splitlines():
|
||||
if line.startswith("##"):
|
||||
# Branch info line
|
||||
branch_info = line[3:].strip()
|
||||
if "..." in branch_info:
|
||||
branch = branch_info.split("...")[0]
|
||||
if "[ahead " in branch_info:
|
||||
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
|
||||
ahead = int(ahead_str.split(",")[0])
|
||||
if "[behind " in branch_info:
|
||||
behind_str = branch_info.split("[behind ")[1].split("]")[0]
|
||||
behind = int(behind_str.split(",")[0])
|
||||
else:
|
||||
branch = branch_info
|
||||
continue
|
||||
|
||||
if len(line) < 3:
|
||||
continue
|
||||
|
||||
status_code = line[:2]
|
||||
file_path = line[3:]
|
||||
|
||||
# XY format: X = index status, Y = working tree status
|
||||
if status_code == "??":
|
||||
untracked.append(file_path)
|
||||
elif status_code[1] == "D" or status_code[0] == "D":
|
||||
deleted.append(file_path)
|
||||
elif status_code[0] == "A" or status_code[1] == "A":
|
||||
added.append(file_path)
|
||||
else:
|
||||
modified.append(file_path)
|
||||
|
||||
return GitStatus(
|
||||
branch=branch,
|
||||
modified=modified,
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
untracked=untracked,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
)
|
||||
|
||||
async def commit(self, message: str) -> None:
|
||||
"""Stage all changes and commit."""
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git add failed: {err}")
|
||||
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git commit failed: {err}")
|
||||
|
||||
logger.info("Committed in workspace: %s", self.cwd)
|
||||
|
||||
async def push(self) -> None:
|
||||
"""Push current branch to origin."""
|
||||
rc, _, err = await self._run(
|
||||
"git", "-C", self.cwd, "push", "origin", self.branch
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git push failed: {err}")
|
||||
|
||||
logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd)
|
||||
|
||||
async def pull(self) -> None:
|
||||
"""Pull current branch from origin."""
|
||||
rc, _, err = await self._run(
|
||||
"git", "-C", self.cwd, "pull", "origin", self.branch
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git pull failed: {err}")
|
||||
|
||||
logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd)
|
||||
|
||||
async def fetch(self) -> None:
|
||||
"""Fetch from origin."""
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git fetch failed: {err}")
|
||||
|
||||
logger.info("Fetched origin for workspace: %s", self.cwd)
|
||||
|
||||
async def checkout(self, branch: str) -> None:
|
||||
"""Checkout a branch."""
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git checkout failed: {err}")
|
||||
|
||||
self.branch = branch
|
||||
logger.info("Checked out branch %s in workspace: %s", branch, self.cwd)
|
||||
|
||||
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
|
||||
"""Get commit history.
|
||||
|
||||
Args:
|
||||
path: Optional file path to filter history.
|
||||
limit: Maximum number of commits.
|
||||
|
||||
Returns:
|
||||
List of commits.
|
||||
"""
|
||||
cmd = [
|
||||
"git",
|
||||
"-C",
|
||||
self.cwd,
|
||||
"log",
|
||||
f"--max-count={limit}",
|
||||
"--pretty=format:%H|%s|%an|%ad",
|
||||
"--date=iso",
|
||||
]
|
||||
if path:
|
||||
cmd.extend(["--", path])
|
||||
|
||||
rc, stdout, err = await self._run(*cmd)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git log failed: {err}")
|
||||
|
||||
commits = []
|
||||
for line in stdout.strip().splitlines():
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) >= 4:
|
||||
commits.append(
|
||||
Commit(
|
||||
hash=parts[0],
|
||||
message=parts[1],
|
||||
author=parts[2],
|
||||
date=parts[3],
|
||||
)
|
||||
)
|
||||
|
||||
return commits
|
||||
|
||||
async def branches(self) -> tuple[list[str], str]:
|
||||
"""List all branches and current branch.
|
||||
|
||||
Returns:
|
||||
Tuple of (all_branches, current_branch).
|
||||
"""
|
||||
rc, stdout, err = await self._run(
|
||||
"git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)"
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git branch failed: {err}")
|
||||
|
||||
branches = []
|
||||
current = self.branch
|
||||
for line in stdout.strip().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("HEAD") or line.endswith("/HEAD"):
|
||||
continue
|
||||
if line.startswith("remotes/origin/"):
|
||||
branch_name = line.replace("remotes/origin/", "")
|
||||
if branch_name not in branches:
|
||||
branches.append(branch_name)
|
||||
elif line and line not in branches:
|
||||
branches.append(line)
|
||||
|
||||
return branches, current
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Git operations for workspace management."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitService:
|
||||
"""Low-level git operations for creating and syncing workspaces."""
|
||||
|
||||
@staticmethod
|
||||
def _prepare_ssh_env(
|
||||
ssh_key: str | None,
|
||||
) -> tuple[dict[str, str] | None, str | None]:
|
||||
"""Prepare environment for git commands with SSH authentication.
|
||||
|
||||
Returns a tuple of (env_dict, temp_key_path). Caller must clean up key_path.
|
||||
"""
|
||||
if not ssh_key:
|
||||
return None, None
|
||||
|
||||
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||
try:
|
||||
os.write(fd, ssh_key.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chmod(key_path, 0o600)
|
||||
|
||||
env = {
|
||||
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
}
|
||||
return env, key_path
|
||||
|
||||
@staticmethod
|
||||
async def clone(
|
||||
remote_url: str, branch: str, path: str, ssh_key: str | None = None
|
||||
) -> None:
|
||||
"""Clone a repository to the given path.
|
||||
|
||||
Args:
|
||||
remote_url: The git remote URL.
|
||||
branch: The branch to clone.
|
||||
path: The destination path for the clone.
|
||||
ssh_key: Optional decrypted SSH private key for authentication.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the clone fails.
|
||||
"""
|
||||
cmd = [
|
||||
"git",
|
||||
"clone",
|
||||
"--branch",
|
||||
branch,
|
||||
"--single-branch",
|
||||
remote_url,
|
||||
path,
|
||||
]
|
||||
|
||||
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git clone failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git clone failed: {error_msg}")
|
||||
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
@staticmethod
|
||||
async def fetch(path: str, ssh_key: str | None = None) -> None:
|
||||
"""Fetch from origin.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
ssh_key: Optional decrypted SSH private key for authentication.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If fetch fails.
|
||||
"""
|
||||
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"fetch",
|
||||
"origin",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git fetch failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git fetch failed: {error_msg}")
|
||||
logger.debug("Fetched origin for %s", path)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
@staticmethod
|
||||
async def pull(path: str, branch: str, ssh_key: str | None = None) -> None:
|
||||
"""Pull latest changes from origin.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
branch: The branch to pull.
|
||||
ssh_key: Optional decrypted SSH private key for authentication.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If pull fails.
|
||||
"""
|
||||
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"pull",
|
||||
"origin",
|
||||
branch,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git pull failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git pull failed: {error_msg}")
|
||||
logger.debug("Pulled origin/%s for %s", branch, path)
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
|
||||
@staticmethod
|
||||
def branch_exists_remotely(
|
||||
path: str, branch: str, ssh_key: str | None = None
|
||||
) -> bool:
|
||||
"""Check if a branch exists on the remote.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
branch: The branch name to check.
|
||||
ssh_key: Optional decrypted SSH private key for authentication.
|
||||
|
||||
Returns:
|
||||
True if the branch exists on origin, False otherwise.
|
||||
"""
|
||||
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, **env} if env else None,
|
||||
)
|
||||
exists = result.returncode == 0 and result.stdout.strip() != ""
|
||||
logger.debug("Branch %s exists on remote: %s", branch, exists)
|
||||
return exists
|
||||
finally:
|
||||
if key_path and os.path.exists(key_path):
|
||||
os.unlink(key_path)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user