Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c051929f8c |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812"
|
"fingerprint": "fdea8a74bb4c7449c01c4bd61646c895b10ede78"
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||||
|
|
||||||
Last updated: 2026-06-02
|
Last updated: 2026-05-28
|
||||||
|
|
||||||
## Sources scanned
|
## Sources scanned
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ Last updated: 2026-06-02
|
|||||||
| Skill | Trigger / description | Scope | Path |
|
| Skill | Trigger / description | Scope | Path |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `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` |
|
| `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-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-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-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` |
|
||||||
|
|||||||
+5
-4
@@ -48,8 +48,9 @@ apps/web/dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/.stoneforge/.worktrees/
|
||||||
# Local runtime state
|
# Pi / agent cache
|
||||||
.atl/
|
|
||||||
.pi/
|
.pi/
|
||||||
swap-pane
|
.atl/
|
||||||
|
.sisyphus/
|
||||||
|
.pi-lens/
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ Do not:
|
|||||||
* Introduce new dependencies without clear justification.
|
* Introduce new dependencies without clear justification.
|
||||||
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
||||||
* Decide product behavior silently when the spec is unclear.
|
* 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.
|
If scope must change, propose an OpenSpec update first.
|
||||||
|
|
||||||
@@ -90,13 +89,6 @@ Before completion, report:
|
|||||||
|
|
||||||
Do not claim completion without verification evidence.
|
Do not claim completion without verification evidence.
|
||||||
|
|
||||||
## Git branch policy
|
|
||||||
|
|
||||||
- **Default working branch:** `dev` — all commits and pushes target `dev` unless the user explicitly requests otherwise.
|
|
||||||
- `main` is the stable/production branch; merge to `main` only when explicitly instructed.
|
|
||||||
- After committing, push to `origin/dev`.
|
|
||||||
- If `dev` does not exist locally, create it from `main` or fetch it from origin.
|
|
||||||
|
|
||||||
## Git workflow
|
## Git workflow
|
||||||
|
|
||||||
### Branching strategy
|
### Branching strategy
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **User Settings** - Theme selection, git identity, and preference management
|
- **User Settings** - Theme selection, git identity, and preference management
|
||||||
- **SSH Key Management** - Ed25519 key generation with secure storage
|
- **SSH Key Management** - Ed25519 key generation with secure storage
|
||||||
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
||||||
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
|
|
||||||
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+2
-2
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
# Create directories for repo, instance, and workspace storage
|
# Create directories for repo and instance storage
|
||||||
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
|
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
||||||
|
|
||||||
# Copy wait-for-db script
|
# Copy wait-for-db script
|
||||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
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
|
|
||||||
+953
-244
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/events", tags=["events"])
|
|||||||
|
|
||||||
# In-memory connection counter per user (single-process assumption)
|
# In-memory connection counter per user (single-process assumption)
|
||||||
_connection_counts: dict[uuid.UUID, int] = {}
|
_connection_counts: dict[uuid.UUID, int] = {}
|
||||||
MAX_CONNECTIONS_PER_USER = 20
|
MAX_CONNECTIONS_PER_USER = 5
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
@router.get("/stream")
|
||||||
|
|||||||
+1279
-142
File diff suppressed because it is too large
Load Diff
@@ -4,18 +4,11 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.config import Settings
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.schemas.health import (
|
|
||||||
DatabaseHealth,
|
|
||||||
DatabaseHealthResponse,
|
|
||||||
DiskHealth,
|
|
||||||
HealthChecks,
|
|
||||||
HealthResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -23,6 +16,45 @@ router = APIRouter()
|
|||||||
_start_time = time.time()
|
_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(
|
@router.get(
|
||||||
"/health",
|
"/health",
|
||||||
response_model=HealthResponse,
|
response_model=HealthResponse,
|
||||||
|
|||||||
@@ -3,24 +3,41 @@ import shutil
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
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.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.project import (
|
|
||||||
ProjectCreate,
|
|
||||||
ProjectUpdate,
|
|
||||||
ProjectResponse,
|
|
||||||
SetDefaultSSHKeyRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
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(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
@@ -31,7 +48,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||||||
)
|
)
|
||||||
async def create_project(
|
async def create_project(
|
||||||
data: ProjectCreate,
|
data: ProjectCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Create a new project.
|
"""Create a new project.
|
||||||
@@ -44,6 +61,7 @@ async def create_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created project.
|
The newly created project.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
project = Project(
|
project = Project(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -63,7 +81,7 @@ async def create_project(
|
|||||||
description="Retrieve all projects owned by the authenticated user.",
|
description="Retrieve all projects owned by the authenticated user.",
|
||||||
)
|
)
|
||||||
async def list_projects(
|
async def list_projects(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[Project]:
|
) -> list[Project]:
|
||||||
"""List all projects for the authenticated user.
|
"""List all projects for the authenticated user.
|
||||||
@@ -75,6 +93,7 @@ async def list_projects(
|
|||||||
Returns:
|
Returns:
|
||||||
List of projects owned by the user.
|
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))
|
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -87,8 +106,7 @@ async def list_projects(
|
|||||||
)
|
)
|
||||||
async def get_project(
|
async def get_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Get a specific project by ID.
|
"""Get a specific project by ID.
|
||||||
@@ -101,8 +119,8 @@ async def get_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The requested project.
|
The requested project.
|
||||||
"""
|
"""
|
||||||
return project
|
await _get_user(session, user_id)
|
||||||
|
return await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -114,8 +132,7 @@ async def get_project(
|
|||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: ProjectUpdate,
|
data: ProjectUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Update a project.
|
"""Update a project.
|
||||||
@@ -129,6 +146,8 @@ async def update_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
project.name = data.name
|
project.name = data.name
|
||||||
@@ -148,8 +167,7 @@ async def update_project(
|
|||||||
)
|
)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Delete a project and all its repositories.
|
"""Delete a project and all its repositories.
|
||||||
@@ -162,6 +180,8 @@ async def delete_project(
|
|||||||
Returns:
|
Returns:
|
||||||
Empty response with 204 status code.
|
Empty response with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
# Delete repositories from disk and database
|
# 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))
|
||||||
@@ -185,8 +205,7 @@ async def delete_project(
|
|||||||
async def set_default_ssh_key(
|
async def set_default_ssh_key(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: SetDefaultSSHKeyRequest,
|
data: SetDefaultSSHKeyRequest,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Set the default SSH key for a project.
|
"""Set the default SSH key for a project.
|
||||||
@@ -200,6 +219,8 @@ async def set_default_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -5,19 +6,17 @@ from cryptography.fernet import Fernet
|
|||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.ssh_key import SSHKeyCreate, SSHKeyResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet() -> Fernet:
|
def _get_fernet() -> Fernet:
|
||||||
"""Generate a valid Fernet key from the session secret."""
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
import base64
|
import base64
|
||||||
@@ -54,6 +53,36 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
|||||||
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
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(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=SSHKeyResponse,
|
response_model=SSHKeyResponse,
|
||||||
@@ -63,7 +92,7 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
|||||||
)
|
)
|
||||||
async def create_ssh_key(
|
async def create_ssh_key(
|
||||||
data: SSHKeyCreate,
|
data: SSHKeyCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> SSHKey:
|
) -> SSHKey:
|
||||||
"""Create a new SSH key pair.
|
"""Create a new SSH key pair.
|
||||||
@@ -76,6 +105,7 @@ async def create_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created SSH key with public key exposed.
|
The newly created SSH key with public key exposed.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
private_key, public_key = generate_ssh_key_pair()
|
private_key, public_key = generate_ssh_key_pair()
|
||||||
|
|
||||||
fernet = _get_fernet()
|
fernet = _get_fernet()
|
||||||
@@ -100,7 +130,7 @@ async def create_ssh_key(
|
|||||||
description="List all SSH keys for the authenticated user.",
|
description="List all SSH keys for the authenticated user.",
|
||||||
)
|
)
|
||||||
async def list_ssh_keys(
|
async def list_ssh_keys(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SSHKey]:
|
) -> list[SSHKey]:
|
||||||
"""List all SSH keys for the authenticated user.
|
"""List all SSH keys for the authenticated user.
|
||||||
@@ -112,6 +142,7 @@ async def list_ssh_keys(
|
|||||||
Returns:
|
Returns:
|
||||||
List of SSH keys owned by the user.
|
List of SSH keys owned by the user.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -124,7 +155,7 @@ async def list_ssh_keys(
|
|||||||
)
|
)
|
||||||
async def delete_ssh_key(
|
async def delete_ssh_key(
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete an SSH key.
|
"""Delete an SSH key.
|
||||||
@@ -137,9 +168,87 @@ async def delete_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
None with 204 status code.
|
None with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
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.delete(ssh_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/sign",
|
||||||
|
response_model=SignatureResponse,
|
||||||
|
summary="Sign payload",
|
||||||
|
description="Sign a payload using the SSH private key.",
|
||||||
|
)
|
||||||
|
async def sign_payload(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: SignPayloadRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SignatureResponse:
|
||||||
|
"""Sign a payload with an SSH key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for signing.
|
||||||
|
data: Sign request containing the payload string.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded Ed25519 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")
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
private_key = serialization.load_ssh_private_key(
|
||||||
|
private_key_pem.encode(), password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
signature = private_key.sign(data.payload.encode())
|
||||||
|
return SignatureResponse(signature=base64.b64encode(signature).decode())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/verify",
|
||||||
|
response_model=VerifySignatureResponse,
|
||||||
|
summary="Verify signature",
|
||||||
|
description="Verify a signature against a payload using the SSH public key.",
|
||||||
|
)
|
||||||
|
async def verify_signature(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: VerifySignatureRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> VerifySignatureResponse:
|
||||||
|
"""Verify a signature with an SSH key's public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for verification.
|
||||||
|
data: Verify request containing payload and base64-encoded signature.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether the signature is valid.
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
signature = base64.b64decode(data.signature)
|
||||||
|
public_key.verify(signature, data.payload.encode())
|
||||||
|
return VerifySignatureResponse(valid=True)
|
||||||
|
except Exception:
|
||||||
|
return VerifySignatureResponse(valid=False)
|
||||||
|
|||||||
+660
-50
@@ -1,65 +1,102 @@
|
|||||||
"""WebSocket terminal endpoint for tool instances."""
|
"""WebSocket terminal endpoint for tool instances."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, WebSocket
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.terminal_session import TerminalSessionModel
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.services.terminal_manager import terminal_manager
|
from src.models.tool_type import ToolType
|
||||||
|
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRef:
|
||||||
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||||
|
|
||||||
|
def __init__(self, session, slot_session_id: str | None = None):
|
||||||
|
self.session = session
|
||||||
|
self.slot_session_id = slot_session_id or session.session_id
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket(
|
||||||
"/ws/tool-instances/{instance_id}/terminal",
|
"/ws/tool-instances/{instance_id}/terminal",
|
||||||
)
|
)
|
||||||
async def terminal_websocket(
|
async def terminal_websocket_default(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
instance_id: str,
|
instance_id: str,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""WebSocket endpoint for terminal access to a tool instance.
|
"""WebSocket endpoint for terminal access (default session alias).
|
||||||
|
|
||||||
Provides an interactive terminal session inside a running tool instance container.
|
Backward-compatible route that maps to the default session.
|
||||||
Supports:
|
"""
|
||||||
- Auto-reconnection (client reconnects, server spawns new session)
|
await _handle_terminal_websocket(websocket, instance_id, None, db_session)
|
||||||
- Heartbeat ping/pong
|
|
||||||
- Binary and text input frames
|
|
||||||
- Graceful session end notifications
|
@router.websocket(
|
||||||
|
"/ws/tool-instances/{instance_id}/terminal/{session_id}",
|
||||||
|
)
|
||||||
|
async def terminal_websocket_specific(
|
||||||
|
websocket: WebSocket,
|
||||||
|
instance_id: str,
|
||||||
|
session_id: str,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""WebSocket endpoint for a specific terminal session."""
|
||||||
|
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_terminal_websocket(
|
||||||
|
websocket: WebSocket,
|
||||||
|
instance_id: str,
|
||||||
|
target_session_id: str | None,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Shared WebSocket handler for terminal sessions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
websocket: The WebSocket connection.
|
websocket: The WebSocket connection.
|
||||||
instance_id: UUID string of the tool instance.
|
instance_id: UUID string of the tool instance.
|
||||||
|
target_session_id: Specific session ID (slot key). None means default session.
|
||||||
db_session: Database session.
|
db_session: Database session.
|
||||||
|
|
||||||
Returns:
|
|
||||||
None. Communicates via WebSocket messages.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
logger.debug(
|
||||||
|
"Terminal WebSocket connection attempt for instance %s (session=%s)",
|
||||||
|
instance_id,
|
||||||
|
target_session_id or "default",
|
||||||
|
)
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Parse instance_id
|
||||||
instance_uuid = uuid.UUID(instance_id)
|
instance_uuid = uuid.UUID(instance_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error("Invalid instance ID: %s", instance_id)
|
logger.error("Invalid instance ID: %s", instance_id)
|
||||||
await websocket.close(code=4001, reason="Invalid instance ID")
|
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Authenticate user from session cookie
|
||||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Unauthorized terminal access attempt for instance %s",
|
"Unauthorized terminal access attempt for instance %s", instance_id
|
||||||
instance_id,
|
|
||||||
)
|
)
|
||||||
await websocket.close(code=4003, reason="Unauthorized")
|
await websocket.close(code=4003, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Get instance and verify ownership
|
||||||
instance = await db_session.get(ToolInstance, instance_uuid)
|
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
logger.warning("Instance %s not found", instance_id)
|
logger.warning("Instance %s not found", instance_id)
|
||||||
@@ -85,50 +122,624 @@ async def terminal_websocket(
|
|||||||
await websocket.close(code=4004, reason="Instance not running")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||||
"Creating terminal session for instance %s (container_id=%s)",
|
|
||||||
instance_id,
|
# Verify the container actually exists (may have been removed/recreated)
|
||||||
instance.container_id,
|
from src.services.docker import get_container_status
|
||||||
)
|
|
||||||
try:
|
container_status = get_container_status(instance.container_id)
|
||||||
session = await terminal_manager.create_session(
|
if container_status["status"] == "not_found":
|
||||||
instance_uuid,
|
logger.error(
|
||||||
|
"Container %s for instance %s not found (may have been removed)",
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
websocket,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Terminal session created successfully for instance %s",
|
|
||||||
instance_id,
|
instance_id,
|
||||||
)
|
)
|
||||||
|
await websocket.close(
|
||||||
|
code=4004, reason="Container not found — restart the tool instance"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
if startup_command:
|
||||||
|
logger.debug(
|
||||||
|
"Using startup command for instance %s: %s",
|
||||||
|
instance_id,
|
||||||
|
startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
session = None
|
||||||
|
|
||||||
|
# Get or create terminal session
|
||||||
|
try:
|
||||||
|
if target_session_id is None:
|
||||||
|
# Default session alias
|
||||||
|
session = await terminal_manager.get_or_create_session(
|
||||||
|
instance_uuid,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
)
|
||||||
|
slot_session_id = "default"
|
||||||
|
else:
|
||||||
|
# Specific session
|
||||||
|
session = terminal_manager.get_session(
|
||||||
|
instance_id,
|
||||||
|
target_session_id,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
# Session not in memory — may have been lost on server restart.
|
||||||
|
# Try to restore from the DB row.
|
||||||
|
db_row = await db_session.get(
|
||||||
|
TerminalSessionModel, uuid.UUID(target_session_id)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
db_row is not None
|
||||||
|
and db_row.instance_id == instance_uuid
|
||||||
|
and db_row.status != "closed"
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Restoring terminal session %s for instance %s from DB",
|
||||||
|
target_session_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
session = await terminal_manager.create_session(
|
||||||
|
instance_uuid,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
name=db_row.name,
|
||||||
|
session_id=target_session_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Session %s not found for instance %s",
|
||||||
|
target_session_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
await websocket.close(code=4004, reason="Session not found")
|
||||||
|
return
|
||||||
|
# Determine slot key for reset scoping
|
||||||
|
key = terminal_manager._find_key_by_internal_id(
|
||||||
|
instance_id, session.session_id
|
||||||
|
)
|
||||||
|
slot_session_id = key[1] if key else target_session_id
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Terminal session ready for instance %s (session_id=%s, slot=%s)",
|
||||||
|
instance_id,
|
||||||
|
session.session_id,
|
||||||
|
slot_session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach WebSocket to session
|
||||||
|
await terminal_manager.attach_websocket(session, websocket)
|
||||||
|
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||||
|
|
||||||
# Send connected status
|
# Send connected status
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
logger.debug("Sent connected status for instance %s", instance_id)
|
||||||
|
|
||||||
# Monitor session health and echo state
|
# Use mutable session reference so loops can survive reset
|
||||||
while session.is_alive() and not session.closed:
|
session_ref = SessionRef(session, slot_session_id)
|
||||||
# Check echo state periodically
|
|
||||||
new_echo_state = await session.check_echo_state()
|
|
||||||
if new_echo_state is not None:
|
|
||||||
await websocket.send_json(
|
|
||||||
{"type": "set_echo_state", "enabled": new_echo_state},
|
|
||||||
)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
# Session ended — determine reason and notify client
|
# Start I/O loops and heartbeat
|
||||||
exit_reason = session.get_exit_reason() or "process_exit"
|
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||||
await websocket.send_json({"type": "session_ended", "reason": exit_reason})
|
write_task = asyncio.create_task(
|
||||||
await websocket.close(code=1000, reason=f"Session ended: {exit_reason}")
|
_write_loop(session_ref, websocket, instance_id)
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Terminal session error for instance %s",
|
|
||||||
instance_id,
|
|
||||||
)
|
)
|
||||||
await websocket.close(code=4000, reason="Terminal session error")
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
|
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||||
|
|
||||||
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[read_task, write_task, heartbeat_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Terminal loop completed for instance %s, done=%s",
|
||||||
|
instance_id,
|
||||||
|
len(done),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel remaining tasks
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("WebSocket disconnected for instance %s", instance_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Terminal session error for instance %s: %s",
|
||||||
|
instance_id,
|
||||||
|
str(exc),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
with suppress(Exception):
|
||||||
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
finally:
|
finally:
|
||||||
|
# Detach WebSocket, don't kill session
|
||||||
|
with suppress(Exception):
|
||||||
|
if session is not None:
|
||||||
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
|
logger.debug(
|
||||||
|
"WebSocket detached from session for instance %s", instance_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
|
"""Read input from WebSocket and send to container."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
session = session_ref.session
|
||||||
|
if not session.is_alive() or session._closed:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.receive":
|
||||||
|
if "bytes" in message:
|
||||||
|
await session.write_input(message["bytes"])
|
||||||
|
elif "text" in message:
|
||||||
|
text = message["text"]
|
||||||
|
if text.startswith("{"):
|
||||||
|
# Control message (JSON)
|
||||||
|
try:
|
||||||
|
ctrl = json.loads(text)
|
||||||
|
msg_type = ctrl.get("type")
|
||||||
|
|
||||||
|
if msg_type == "resize":
|
||||||
|
cols = ctrl.get("cols", 80)
|
||||||
|
rows = ctrl.get("rows", 24)
|
||||||
|
logger.debug(
|
||||||
|
"Received resize message for instance %s: %sx%s",
|
||||||
|
instance_id,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
await session.resize(cols, rows)
|
||||||
|
elif msg_type == "reset":
|
||||||
|
# Reset terminal session (scoped to current slot)
|
||||||
|
logger.debug(
|
||||||
|
"Resetting terminal session for instance %s (slot=%s)",
|
||||||
|
session.instance_id,
|
||||||
|
session_ref.slot_session_id,
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "status": "resetting"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset the session scoped to its slot
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
session.instance_id,
|
||||||
|
session.container_id,
|
||||||
|
startup_command=session.startup_command,
|
||||||
|
session_id=session_ref.slot_session_id,
|
||||||
|
name=session.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the mutable session reference
|
||||||
|
session_ref.session = new_session
|
||||||
|
|
||||||
|
# Attach to new session
|
||||||
|
await terminal_manager.attach_websocket(
|
||||||
|
new_session, websocket
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "status": "connected"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Continue the loop with the new session
|
||||||
|
continue
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not a valid JSON control message, treat as regular input
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
else:
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
elif message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||||
|
"""Send periodic ping messages to detect disconnections."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30) # Ping every 30 seconds
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "ping"})
|
||||||
|
except Exception:
|
||||||
|
# WebSocket is closed or broken
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_terminal_instance(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> ToolInstance:
|
||||||
|
"""Fetch instance and validate auth, ownership, and running status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated ToolInstance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If instance not found, not owned, or not running.
|
||||||
|
"""
|
||||||
|
instance = await db_session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None:
|
||||||
|
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_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running"
|
||||||
|
)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/terminal/sessions",
|
||||||
|
summary="List terminal sessions",
|
||||||
|
description="List terminal sessions for a tool instance with live WebSocket state.",
|
||||||
|
)
|
||||||
|
async def list_terminal_sessions(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List terminal sessions for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with sessions list.
|
||||||
|
"""
|
||||||
|
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
|
||||||
|
# Query active DB rows for this instance
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(TerminalSessionModel)
|
||||||
|
.where(TerminalSessionModel.instance_id == instance_id)
|
||||||
|
.where(TerminalSessionModel.status != "closed")
|
||||||
|
.order_by(TerminalSessionModel.created_at.asc())
|
||||||
|
)
|
||||||
|
db_rows = result.scalars().all()
|
||||||
|
|
||||||
|
# Build response with live has_websockets flag.
|
||||||
|
# Include DB rows even without in-memory counterparts (e.g. after
|
||||||
|
# server restart) so the frontend can display tabs and reconnect.
|
||||||
|
sessions = []
|
||||||
|
for row in db_rows:
|
||||||
|
live_session = terminal_manager.get_session(str(instance_id), str(row.id))
|
||||||
|
sessions.append(
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"name": row.name,
|
||||||
|
"status": row.status,
|
||||||
|
"has_websockets": live_session.has_websockets()
|
||||||
|
if live_session
|
||||||
|
else False,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"last_activity_at": row.last_activity_at.isoformat()
|
||||||
|
if row.last_activity_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"sessions": sessions}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/sessions",
|
||||||
|
summary="Create terminal session",
|
||||||
|
description="Create a new terminal session for a running tool instance.",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
data: Request body with optional name.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with new session details.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 409 if max sessions reached.
|
||||||
|
"""
|
||||||
|
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
assert instance.container_id is not None
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
name = data.get("name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = await terminal_manager.create_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
except MaxSessionsExceededError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Maximum of 5 terminal sessions reached for this instance",
|
||||||
|
) from None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": session.session_id,
|
||||||
|
"name": session.name,
|
||||||
|
"status": session.status,
|
||||||
|
"created_at": session.last_activity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/instances/{instance_id}/terminal/sessions/{session_id}",
|
||||||
|
summary="Close terminal session",
|
||||||
|
description="Close a specific terminal session.",
|
||||||
|
)
|
||||||
|
async def close_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
session_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Close a terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
session_id: ID of the session to close.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with closure status.
|
||||||
|
"""
|
||||||
|
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
|
||||||
|
# Find the session by internal ID to determine its slot key
|
||||||
|
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||||
|
if (
|
||||||
|
key is None
|
||||||
|
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||||
|
):
|
||||||
|
key = (str(instance_id), session_id)
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
await terminal_manager.close_session(key[0], key[1])
|
||||||
|
|
||||||
|
return {"status": "closed", "session_id": session_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/sessions/{session_id}/reset",
|
||||||
|
summary="Reset terminal session",
|
||||||
|
description="Reset a specific terminal session, killing the current shell and starting fresh.",
|
||||||
|
)
|
||||||
|
async def reset_specific_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
session_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset a specific terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
session_id: ID of the session to reset.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with reset session details.
|
||||||
|
"""
|
||||||
|
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
assert instance.container_id is not None
|
||||||
|
|
||||||
|
# Determine slot key for reset
|
||||||
|
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||||
|
if (
|
||||||
|
key is None
|
||||||
|
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||||
|
):
|
||||||
|
key = (str(instance_id), session_id)
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
# Preserve name if possible
|
||||||
|
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||||
|
name = live_session.name if live_session else None
|
||||||
|
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
session_id=key[1],
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": new_session.session_id,
|
||||||
|
"name": new_session.name,
|
||||||
|
"status": new_session.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/sessions/{session_id}/rename",
|
||||||
|
summary="Rename terminal session",
|
||||||
|
description="Rename a specific terminal session.",
|
||||||
|
)
|
||||||
|
async def rename_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
session_id: str,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Rename a terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
session_id: ID of the session to rename.
|
||||||
|
data: Request body with new name.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with updated session details.
|
||||||
|
"""
|
||||||
|
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
|
||||||
|
new_name = data.get("name")
|
||||||
|
if not new_name or not isinstance(new_name, str):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update in-memory session name if live
|
||||||
|
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||||
|
if live_session:
|
||||||
|
live_session.name = new_name
|
||||||
|
|
||||||
|
# Update DB row
|
||||||
|
db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id))
|
||||||
|
if db_row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_row.name = new_name
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
return {"id": str(db_row.id), "name": new_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/reset",
|
||||||
|
summary="Reset terminal session (legacy alias)",
|
||||||
|
description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.",
|
||||||
|
)
|
||||||
|
async def reset_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset the default terminal session for an instance (legacy alias).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status message.
|
||||||
|
"""
|
||||||
|
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
assert instance.container_id is not None
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reset the default session
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Terminal session reset for instance %s (new session_id=%s)",
|
||||||
|
instance_id,
|
||||||
|
new_session.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Terminal session reset successfully",
|
||||||
|
"instance_id": str(instance_id),
|
||||||
|
"session_id": new_session.session_id,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to reset terminal session for instance %s: %s",
|
||||||
|
instance_id,
|
||||||
|
str(exc),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to reset terminal session: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
async def _get_user_from_websocket(
|
async def _get_user_from_websocket(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
@@ -141,7 +752,6 @@ async def _get_user_from_websocket(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The user's UUID if authenticated, None otherwise.
|
The user's UUID if authenticated, None otherwise.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from src.auth.session import decode_session_cookie
|
from src.auth.session import decode_session_cookie
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|||||||
+2667
-148
File diff suppressed because it is too large
Load Diff
+298
-90
@@ -1,19 +1,19 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
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.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.schemas.tool_type import (
|
|
||||||
ToolTypeCreate,
|
|
||||||
ToolTypeResponse,
|
|
||||||
ToolTypeUpdate,
|
|
||||||
ToolTypeValidateRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||||
|
|
||||||
@@ -29,6 +29,237 @@ async def _require_admin(user: User) -> None:
|
|||||||
pass
|
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(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=ToolTypeResponse,
|
response_model=ToolTypeResponse,
|
||||||
@@ -38,7 +269,7 @@ async def _require_admin(user: User) -> None:
|
|||||||
)
|
)
|
||||||
async def create_tool_type(
|
async def create_tool_type(
|
||||||
data: ToolTypeCreate,
|
data: ToolTypeCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Create a new tool type.
|
"""Create a new tool type.
|
||||||
@@ -51,6 +282,7 @@ async def create_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created tool type.
|
The newly created tool type.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
@@ -67,13 +299,16 @@ async def create_tool_type(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
default_port=data.default_port,
|
default_port=data.default_port,
|
||||||
definition_type=data.definition_type,
|
definition_type=data.definition_type,
|
||||||
|
manifest_id=data.manifest_id,
|
||||||
compose_template=data.compose_template,
|
compose_template=data.compose_template,
|
||||||
dockerfile_template=data.dockerfile_template,
|
dockerfile_template=data.dockerfile_template,
|
||||||
build_context=data.build_context,
|
build_context=data.build_context,
|
||||||
readiness_probe=data.readiness_probe,
|
readiness_probe=data.readiness_probe,
|
||||||
|
startup_command=data.startup_command,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
category=data.category,
|
category=data.category,
|
||||||
interfaces=data.interfaces,
|
interface_type=data.interface_type,
|
||||||
|
requires_port=data.requires_port,
|
||||||
created_by_id=user.id,
|
created_by_id=user.id,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
@@ -89,7 +324,7 @@ async def create_tool_type(
|
|||||||
description="List all available tool types including built-in and custom ones.",
|
description="List all available tool types including built-in and custom ones.",
|
||||||
)
|
)
|
||||||
async def list_tool_types(
|
async def list_tool_types(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[ToolType]:
|
) -> list[ToolType]:
|
||||||
"""List all tool types.
|
"""List all tool types.
|
||||||
@@ -101,6 +336,7 @@ async def list_tool_types(
|
|||||||
Returns:
|
Returns:
|
||||||
List of all tool types ordered by name.
|
List of all tool types ordered by name.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -113,7 +349,7 @@ async def list_tool_types(
|
|||||||
)
|
)
|
||||||
async def get_tool_type(
|
async def get_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Get a specific tool type by ID.
|
"""Get a specific tool type by ID.
|
||||||
@@ -126,6 +362,7 @@ async def get_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The requested tool type.
|
The requested tool type.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -143,7 +380,7 @@ async def get_tool_type(
|
|||||||
async def update_tool_type(
|
async def update_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
data: ToolTypeUpdate,
|
data: ToolTypeUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Update a tool type.
|
"""Update a tool type.
|
||||||
@@ -157,6 +394,7 @@ async def update_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated tool type.
|
The updated tool type.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
@@ -165,16 +403,13 @@ async def update_tool_type(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be modified
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="cannot modify built-in tool types",
|
|
||||||
)
|
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate port if being updated
|
# Validate port if being updated
|
||||||
if "default_port" in update_data:
|
requires_port = update_data.get("requires_port", tool_type.requires_port)
|
||||||
|
if "default_port" in update_data and requires_port:
|
||||||
new_port = update_data["default_port"]
|
new_port = update_data["default_port"]
|
||||||
if new_port <= 0 or new_port > 65535:
|
if new_port <= 0 or new_port > 65535:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -188,62 +423,35 @@ async def update_tool_type(
|
|||||||
template = update_data.get("compose_template", tool_type.compose_template)
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
if template:
|
if template:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(template)
|
parsed = validate_compose_yaml(template)
|
||||||
except yaml.YAMLError:
|
if not check_port_exposed(parsed, new_port):
|
||||||
parsed = None
|
|
||||||
|
|
||||||
if parsed and isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
port_str = str(new_port)
|
|
||||||
port_exposed = False
|
|
||||||
for service_config in parsed["services"].values():
|
|
||||||
if (
|
|
||||||
isinstance(service_config, dict)
|
|
||||||
and "ports" in service_config
|
|
||||||
):
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if (
|
|
||||||
isinstance(port_mapping, str)
|
|
||||||
and port_str in port_mapping
|
|
||||||
):
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif (
|
|
||||||
isinstance(port_mapping, int)
|
|
||||||
and port_mapping == new_port
|
|
||||||
):
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not port_exposed:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Port {new_port} is not exposed in the compose template",
|
detail=f"Port {new_port} is not exposed in the compose template",
|
||||||
)
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
# Validate required variables for compose definitions
|
# Validate required variables for compose definitions
|
||||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
if definition_type == "compose":
|
if definition_type == "compose":
|
||||||
if "required_variables" in update_data and "compose_template" in update_data:
|
if "required_variables" in update_data and "compose_template" in update_data:
|
||||||
template = update_data["compose_template"]
|
validate_required_variables(
|
||||||
for var in update_data["required_variables"]:
|
update_data["compose_template"], update_data["required_variables"]
|
||||||
placeholder = f"{{{{{var}}}}}"
|
)
|
||||||
if placeholder not in template:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Required variable '{var}' not found in compose template",
|
|
||||||
)
|
|
||||||
elif "required_variables" in update_data:
|
elif "required_variables" in update_data:
|
||||||
template = tool_type.compose_template
|
template = tool_type.compose_template
|
||||||
if template:
|
if template:
|
||||||
for var in update_data["required_variables"]:
|
validate_required_variables(template, update_data["required_variables"])
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
# When switching to manifest, clear legacy templates
|
||||||
raise HTTPException(
|
if definition_type == "manifest":
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
if "manifest_id" in update_data:
|
||||||
detail=f"Required variable '{var}' not found in compose template",
|
tool_type.manifest_id = update_data["manifest_id"]
|
||||||
)
|
tool_type.compose_template = None
|
||||||
|
tool_type.dockerfile_template = None
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tool_type, field, value)
|
setattr(tool_type, field, value)
|
||||||
@@ -253,6 +461,12 @@ async def update_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeValidateRequest(BaseModel):
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/validate",
|
"/validate",
|
||||||
summary="Validate tool type template",
|
summary="Validate tool type template",
|
||||||
@@ -260,7 +474,7 @@ async def update_tool_type(
|
|||||||
)
|
)
|
||||||
async def validate_tool_type_template(
|
async def validate_tool_type_template(
|
||||||
data: ToolTypeValidateRequest,
|
data: ToolTypeValidateRequest,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate a tool type template syntax.
|
"""Validate a tool type template syntax.
|
||||||
@@ -273,6 +487,7 @@ async def validate_tool_type_template(
|
|||||||
Returns:
|
Returns:
|
||||||
Validation result with success status and any errors.
|
Validation result with success status and any errors.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -281,15 +496,9 @@ async def validate_tool_type_template(
|
|||||||
errors.append("Compose template is required")
|
errors.append("Compose template is required")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(data.compose_template)
|
validate_compose_yaml(data.compose_template)
|
||||||
if not isinstance(parsed, dict):
|
except ValueError as e:
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append(str(e))
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif data.definition_type == "dockerfile":
|
elif data.definition_type == "dockerfile":
|
||||||
if not data.dockerfile_template:
|
if not data.dockerfile_template:
|
||||||
@@ -297,8 +506,11 @@ async def validate_tool_type_template(
|
|||||||
elif not data.dockerfile_template.strip().startswith("FROM"):
|
elif not data.dockerfile_template.strip().startswith("FROM"):
|
||||||
errors.append("Dockerfile must start with a FROM instruction")
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
elif data.definition_type == "manifest":
|
||||||
|
pass # Manifest validation is handled separately
|
||||||
|
|
||||||
else:
|
else:
|
||||||
errors.append("definition_type must be 'compose' or 'dockerfile'")
|
errors.append("definition_type must be 'compose', 'dockerfile', or 'manifest'")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": len(errors) == 0,
|
"valid": len(errors) == 0,
|
||||||
@@ -313,7 +525,7 @@ async def validate_tool_type_template(
|
|||||||
)
|
)
|
||||||
async def validate_tool_type(
|
async def validate_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate a tool type's template syntax.
|
"""Validate a tool type's template syntax.
|
||||||
@@ -326,6 +538,7 @@ async def validate_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
Validation result with success status and any errors.
|
Validation result with success status and any errors.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -339,15 +552,9 @@ async def validate_tool_type(
|
|||||||
errors.append("Compose template is empty")
|
errors.append("Compose template is empty")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(tool_type.compose_template)
|
validate_compose_yaml(tool_type.compose_template)
|
||||||
if not isinstance(parsed, dict):
|
except ValueError as e:
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append(str(e))
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif tool_type.definition_type == "dockerfile":
|
elif tool_type.definition_type == "dockerfile":
|
||||||
if not tool_type.dockerfile_template:
|
if not tool_type.dockerfile_template:
|
||||||
@@ -355,6 +562,10 @@ async def validate_tool_type(
|
|||||||
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
|
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
|
||||||
errors.append("Dockerfile must start with a FROM instruction")
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
elif tool_type.definition_type == "manifest":
|
||||||
|
if not tool_type.manifest_id:
|
||||||
|
errors.append("Manifest reference is missing")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": len(errors) == 0,
|
"valid": len(errors) == 0,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
@@ -369,7 +580,7 @@ async def validate_tool_type(
|
|||||||
)
|
)
|
||||||
async def delete_tool_type(
|
async def delete_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a tool type.
|
"""Delete a tool type.
|
||||||
@@ -382,6 +593,7 @@ async def delete_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
None with 204 status code.
|
None with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
@@ -390,11 +602,7 @@ async def delete_tool_type(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be deleted
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="cannot delete built-in tool types",
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.delete(tool_type)
|
await session.delete(tool_type)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
from src.schemas.user_config import UserConfigResponse, UserConfigUpdate
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_config(
|
||||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
session: AsyncSession, user_id: uuid.UUID
|
||||||
|
) -> UserConfig:
|
||||||
"""Get or create user config record.
|
"""Get or create user config record.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -24,16 +26,40 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
Returns:
|
Returns:
|
||||||
The user's config, creating a new one if it doesn't exist.
|
The user's config, creating a new one if it doesn't exist.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user.id))
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
config = UserConfig(user_id=user.id, config={})
|
config = UserConfig(user_id=user_id, config={})
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
return 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(
|
@router.get(
|
||||||
"/config",
|
"/config",
|
||||||
response_model=UserConfigResponse,
|
response_model=UserConfigResponse,
|
||||||
@@ -41,7 +67,7 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
description="Get the current user's configuration settings.",
|
description="Get the current user's configuration settings.",
|
||||||
)
|
)
|
||||||
async def get_user_config(
|
async def get_user_config(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Get the current user's configuration.
|
"""Get the current user's configuration.
|
||||||
@@ -53,7 +79,8 @@ async def get_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's configuration settings.
|
The user's configuration settings.
|
||||||
"""
|
"""
|
||||||
config = await _get_or_create_config(session, user.id)
|
_user = await _get_user(session, user_id)
|
||||||
|
config = await _get_or_create_config(session, user_id)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +92,7 @@ async def get_user_config(
|
|||||||
)
|
)
|
||||||
async def update_user_config(
|
async def update_user_config(
|
||||||
data: UserConfigUpdate,
|
data: UserConfigUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Update the current user's configuration.
|
"""Update the current user's configuration.
|
||||||
@@ -78,15 +105,16 @@ async def update_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user configuration.
|
The updated user configuration.
|
||||||
"""
|
"""
|
||||||
config = await _get_or_create_config(session, user.id)
|
_user = await _get_user(session, user_id)
|
||||||
|
config = await _get_or_create_config(session, user_id)
|
||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user.id, update_data)
|
logger.debug("Updating user config for user %s: %s", user_id, update_data)
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
config.config = {**config.config, **update_data}
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
logger.info("Updated config: %s", config.config)
|
logger.debug("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|||||||
+24
-53
@@ -2,14 +2,11 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
from sqlalchemy import select
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.schemas.tool_instance import SessionItemResponse, SessionListResponse
|
|
||||||
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
@@ -19,6 +16,20 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
|||||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
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(
|
@router.get(
|
||||||
"/me",
|
"/me",
|
||||||
response_model=UserProfileResponse,
|
response_model=UserProfileResponse,
|
||||||
@@ -26,7 +37,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
|||||||
description="Retrieve the profile of the currently authenticated user.",
|
description="Retrieve the profile of the currently authenticated user.",
|
||||||
)
|
)
|
||||||
async def get_profile(
|
async def get_profile(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Get the current user's profile.
|
"""Get the current user's profile.
|
||||||
@@ -38,7 +49,7 @@ async def get_profile(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's profile information.
|
The user's profile information.
|
||||||
"""
|
"""
|
||||||
return user
|
return await _get_user(session, user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -49,7 +60,7 @@ async def get_profile(
|
|||||||
)
|
)
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
data: UserProfileUpdate,
|
data: UserProfileUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Update the current user's profile.
|
"""Update the current user's profile.
|
||||||
@@ -62,19 +73,16 @@ async def update_profile(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user profile.
|
The updated user profile.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
if len(data.name.strip()) == 0:
|
if len(data.name.strip()) == 0:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
|
|
||||||
)
|
|
||||||
user.name = data.name.strip()
|
user.name = data.name.strip()
|
||||||
|
|
||||||
if data.email is not None:
|
if data.email is not None:
|
||||||
if "@" not in data.email:
|
if "@" not in data.email:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
|
|
||||||
)
|
|
||||||
user.email = data.email.strip()
|
user.email = data.email.strip()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -90,7 +98,7 @@ async def update_profile(
|
|||||||
)
|
)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Upload a profile avatar image.
|
"""Upload a profile avatar image.
|
||||||
@@ -103,6 +111,7 @@ async def upload_avatar(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user profile with new avatar URL.
|
The updated user profile with new avatar URL.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -137,41 +146,3 @@ async def upload_avatar(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/me/sessions",
|
|
||||||
response_model=SessionListResponse,
|
|
||||||
summary="Get current user sessions",
|
|
||||||
description="Retrieve all tool instances (sessions) for the authenticated user.",
|
|
||||||
)
|
|
||||||
async def get_user_sessions(
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> SessionListResponse:
|
|
||||||
"""Return all tool instances for the current user with related names."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ToolInstance)
|
|
||||||
.where(ToolInstance.owner_id == user.id)
|
|
||||||
.order_by(ToolInstance.created_at.desc())
|
|
||||||
)
|
|
||||||
instances = result.scalars().all()
|
|
||||||
|
|
||||||
sessions = [
|
|
||||||
SessionItemResponse(
|
|
||||||
id=str(inst.id),
|
|
||||||
display_name=inst.display_name,
|
|
||||||
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
|
|
||||||
tool_icon=None,
|
|
||||||
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
|
|
||||||
repository_name=inst.repository.name if inst.repository else "Unknown",
|
|
||||||
repository_id=str(inst.repository_id),
|
|
||||||
project_name=inst.project.name if inst.project else "Unknown",
|
|
||||||
project_id=str(inst.project_id),
|
|
||||||
status=inst.status,
|
|
||||||
url=inst.url,
|
|
||||||
)
|
|
||||||
for inst in instances
|
|
||||||
]
|
|
||||||
|
|
||||||
return SessionListResponse(sessions=sessions)
|
|
||||||
|
|||||||
@@ -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.workspace import Workspace
|
|
||||||
from src.services.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_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.workspace import Workspace
|
|
||||||
from src.services.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.tool_instance import ToolInstance
|
|
||||||
from src.models.workspace 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.git_repository import GitRepository
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.models.workspace import Workspace
|
|
||||||
from src.services.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
|
|
||||||
@@ -50,17 +50,25 @@ async def get_current_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_owned_project(
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_owned_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession,
|
||||||
) -> Project:
|
) -> "Project":
|
||||||
"""Fetch a project and verify ownership.
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: UUID of the project (injected from path parameter).
|
project_id: UUID of the project.
|
||||||
user: The currently authenticated user.
|
user_id: ID of the authenticated user.
|
||||||
db_session: Database session.
|
session: Database session.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The project if found and owned by the user.
|
The project if found and owned by the user.
|
||||||
@@ -68,9 +76,11 @@ async def get_owned_project(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if project not found, 403 if user is not the owner.
|
HTTPException: 404 if project not found, 403 if user is not the owner.
|
||||||
"""
|
"""
|
||||||
project = await db_session.get(Project, project_id)
|
from src.models.project import Project
|
||||||
|
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
if project.owner_id != user.id:
|
if project.owner_id != user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||||
return project
|
return project
|
||||||
|
|||||||
+32
-3
@@ -6,8 +6,10 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.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.git_repositories import router as git_repositories_router
|
||||||
from src.api.health import router as health_router
|
from src.api.health import router as health_router
|
||||||
from src.api.projects import router as projects_router
|
from src.api.projects import router as projects_router
|
||||||
@@ -15,18 +17,25 @@ from src.api.ssh_keys import router as ssh_keys_router
|
|||||||
from src.api.terminal import router as terminal_router
|
from src.api.terminal import router as terminal_router
|
||||||
from src.api.instance_proxy import router as instance_proxy_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.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 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.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.user_config import router as user_config_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
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.database import init_database
|
||||||
from src.logging_config import (
|
from src.logging_config import (
|
||||||
ExceptionLoggingMiddleware,
|
ExceptionLoggingMiddleware,
|
||||||
RequestLoggingMiddleware,
|
RequestLoggingMiddleware,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
)
|
)
|
||||||
from src.seeds.builtin_tool_types import seed_builtin_tool_types
|
from src.services.correlation import CorrelationIdMiddleware
|
||||||
|
from src.services.event_bus import InstanceEventBus
|
||||||
|
from src.services.health_monitor import HealthMonitor
|
||||||
|
|
||||||
# Configure logging early
|
# Configure logging early
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -51,6 +60,7 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
app.add_middleware(RequestLoggingMiddleware)
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
app.add_middleware(ExceptionLoggingMiddleware)
|
app.add_middleware(ExceptionLoggingMiddleware)
|
||||||
|
|
||||||
@@ -100,6 +110,11 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global services
|
||||||
|
_event_bus = InstanceEventBus()
|
||||||
|
_health_monitor = HealthMonitor(_event_bus)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
logger.info("Starting up Headquarter API...")
|
logger.info("Starting up Headquarter API...")
|
||||||
@@ -112,11 +127,21 @@ async def on_startup():
|
|||||||
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Seed built-in data
|
# Start background health monitor
|
||||||
await seed_builtin_tool_types()
|
_health_monitor.start()
|
||||||
|
logger.info("Health monitor started")
|
||||||
|
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def on_shutdown():
|
||||||
|
logger.info("Shutting down Headquarter API...")
|
||||||
|
_health_monitor.stop()
|
||||||
|
logger.info("Health monitor stopped")
|
||||||
|
logger.info("Shutdown complete.")
|
||||||
|
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
@@ -126,8 +151,12 @@ app.include_router(ssh_keys_router)
|
|||||||
app.include_router(git_repositories_router)
|
app.include_router(git_repositories_router)
|
||||||
app.include_router(user_config_router)
|
app.include_router(user_config_router)
|
||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
|
app.include_router(tool_definitions_router)
|
||||||
app.include_router(config_profiles_router)
|
app.include_router(config_profiles_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
|
app.include_router(sessions_router)
|
||||||
app.include_router(instance_proxy_router)
|
app.include_router(instance_proxy_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
|
app.include_router(events_router)
|
||||||
|
app.include_router(notifications_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_include import ConfigInclude
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.git_repository import GitRepository
|
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.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
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_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -12,12 +15,16 @@ from src.models.user_config import UserConfig
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"ConfigInclude",
|
|
||||||
"ConfigMount",
|
|
||||||
"ConfigProfile",
|
"ConfigProfile",
|
||||||
|
"ConfigProfileInclude",
|
||||||
"GitRepository",
|
"GitRepository",
|
||||||
|
"HealthCheck",
|
||||||
|
"InstanceEvent",
|
||||||
|
"Notification",
|
||||||
"Project",
|
"Project",
|
||||||
"SSHKey",
|
"SSHKey",
|
||||||
|
"TerminalSessionModel",
|
||||||
|
"ToolDefinitionManifest",
|
||||||
"ToolInstance",
|
"ToolInstance",
|
||||||
"ToolType",
|
"ToolType",
|
||||||
"User",
|
"User",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
|
|
||||||
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.config_profile import ConfigProfile
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "config_includes"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
|
||||||
)
|
|
||||||
|
|
||||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
profile: Mapped["ConfigProfile"] = relationship(
|
|
||||||
"ConfigProfile",
|
|
||||||
foreign_keys=[profile_id],
|
|
||||||
back_populates="includes",
|
|
||||||
)
|
|
||||||
included_profile: Mapped["ConfigProfile"] = relationship(
|
|
||||||
"ConfigProfile",
|
|
||||||
foreign_keys=[included_profile_id],
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, JSON, String
|
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "config_mounts"
|
|
||||||
|
|
||||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
||||||
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
|
|
||||||
files: Mapped[dict[str, str] | None] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=True
|
|
||||||
)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
profile: Mapped["ConfigProfile"] = relationship(
|
|
||||||
"ConfigProfile",
|
|
||||||
foreign_keys=[profile_id],
|
|
||||||
back_populates="mounts",
|
|
||||||
)
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
|
||||||
from sqlalchemy import Uuid as UUID
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -17,43 +15,63 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
__tablename__ = "config_profiles"
|
__tablename__ = "config_profiles"
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
|
||||||
)
|
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||||
)
|
)
|
||||||
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
env_vars: Mapped[dict] = mapped_column(
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
JSON, default=dict, nullable=False
|
||||||
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
|
) # {"VAR_NAME": "value", ...}
|
||||||
JSON, default=dict, nullable=True
|
runtime_hints: Mapped[dict] = mapped_column(
|
||||||
)
|
JSON, default=dict, nullable=False
|
||||||
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
) # {"start_command": "...", "working_dir": "...", ...}
|
||||||
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
|
mounts: Mapped[list] = mapped_column(
|
||||||
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
JSON, default=list, nullable=False
|
||||||
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
|
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
|
||||||
|
files: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"rel/path": "content", ...}
|
||||||
|
git_mounts: Mapped[list] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
project: Mapped["Project | None"] = relationship()
|
project: Mapped["Project | None"] = relationship()
|
||||||
tool_type: Mapped["ToolType | None"] = relationship()
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
includes: Mapped[list["ConfigInclude"]] = relationship(
|
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||||
"ConfigInclude",
|
"ConfigProfileInclude",
|
||||||
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
|
foreign_keys="ConfigProfileInclude.profile_id",
|
||||||
back_populates="profile",
|
order_by="ConfigProfileInclude.order_index",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
order_by="ConfigInclude.order_index",
|
|
||||||
)
|
)
|
||||||
mounts: Mapped[list["ConfigMount"]] = relationship(
|
|
||||||
"ConfigMount",
|
|
||||||
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
|
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
back_populates="profile",
|
__tablename__ = "config_profile_includes"
|
||||||
cascade="all, delete-orphan",
|
|
||||||
order_by="ConfigMount.order_index",
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="includes",
|
||||||
|
)
|
||||||
|
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[included_profile_id],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
|
||||||
from sqlalchemy import Uuid as UUID
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -33,42 +33,36 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(), ForeignKey("users.id"), nullable=False
|
UUID(), ForeignKey("users.id"), nullable=False
|
||||||
)
|
)
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
|
||||||
String(50), nullable=False, default="pending"
|
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
)
|
container_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
container_id: Mapped[str | None] = mapped_column(
|
compose_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
String(255), nullable=True
|
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
)
|
public_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
container_name: Mapped[str | None] = mapped_column(
|
tunnel_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
String(255), nullable=True
|
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
)
|
|
||||||
compose_path: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
)
|
|
||||||
url: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
)
|
|
||||||
public_url: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
)
|
|
||||||
tunnel_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(255), nullable=True
|
|
||||||
)
|
|
||||||
port: Mapped[int | None] = mapped_column(
|
|
||||||
Integer, nullable=True
|
|
||||||
)
|
|
||||||
last_started_at: Mapped[datetime | None] = mapped_column(
|
last_started_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
manifest_compiled_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
clone_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mount")
|
||||||
|
branch: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, default="main"
|
||||||
|
)
|
||||||
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
project: Mapped["Project"] = relationship()
|
project: Mapped["Project"] = relationship()
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
selected_profile: Mapped["ConfigProfile | None"] = relationship()
|
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, Uuid as UUID
|
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -52,21 +52,3 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
foreign_keys=[manifest_id],
|
foreign_keys=[manifest_id],
|
||||||
)
|
)
|
||||||
created_by: Mapped["User | None"] = relationship()
|
created_by: Mapped["User | None"] = relationship()
|
||||||
|
|
||||||
@property
|
|
||||||
def interfaces(self) -> list[str]:
|
|
||||||
"""Backward-compatible API view for the single interface type."""
|
|
||||||
return [self.interface_type]
|
|
||||||
|
|
||||||
@interfaces.setter
|
|
||||||
def interfaces(self, value: list[str] | str) -> None:
|
|
||||||
"""Accept legacy interface lists and store the first interface type."""
|
|
||||||
if isinstance(value, str):
|
|
||||||
self.interface_type = value
|
|
||||||
return
|
|
||||||
self.interface_type = value[0] if value else "web"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_builtin(self) -> bool:
|
|
||||||
"""Built-in tools are seeded system tools without a creating user."""
|
|
||||||
return self.created_by_id is None
|
|
||||||
|
|||||||
@@ -18,23 +18,3 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||||
|
|
||||||
@property
|
|
||||||
def default_profile_id(self) -> uuid.UUID | None:
|
|
||||||
profile_id = self.config.get("default_profile_id")
|
|
||||||
return uuid.UUID(profile_id) if profile_id else 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 self.config.get("default_profiles", {})
|
|
||||||
|
|
||||||
@default_profiles.setter
|
|
||||||
def default_profiles(self, value: dict[str, str]) -> None:
|
|
||||||
self.config["default_profiles"] = value
|
|
||||||
|
|||||||
@@ -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.git_repository 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 +0,0 @@
|
|||||||
"""Pydantic request/response schemas."""
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Config profile request/response schemas."""
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
MAX_MOUNT_PATH_LENGTH = 1024
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileCreate(BaseModel):
|
|
||||||
name: str = Field(description="Profile name (unique per user)")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, v: str) -> str:
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
raise ValueError("Profile name cannot be empty")
|
|
||||||
if len(v) > 255:
|
|
||||||
raise ValueError("Profile name must be 255 characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileUpdate(BaseModel):
|
|
||||||
name: str | None = Field(default=None, description="Profile name")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
raise ValueError("Profile name cannot be empty")
|
|
||||||
if len(v) > 255:
|
|
||||||
raise ValueError("Profile name must be 255 characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileDetailResponse(ConfigProfileResponse):
|
|
||||||
includes: list[dict[str, Any]]
|
|
||||||
mounts: list[dict[str, Any]]
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeCreate(BaseModel):
|
|
||||||
included_profile_id: str = Field(description="UUID of the profile to include")
|
|
||||||
order_index: int = Field(default=0, description="Order index for include resolution")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeUpdate(BaseModel):
|
|
||||||
order_index: int = Field(description="Order index for include resolution")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
profile_id: str
|
|
||||||
included_profile_id: str
|
|
||||||
included_profile_name: str | None
|
|
||||||
order_index: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountCreate(BaseModel):
|
|
||||||
target_path: str = Field(description="Absolute target path in container")
|
|
||||||
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Files as {path: content}"
|
|
||||||
)
|
|
||||||
order_index: int = Field(default=0, description="Order index for mount resolution")
|
|
||||||
|
|
||||||
@field_validator("target_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_target_path(cls, v: str) -> str:
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Target path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
|
||||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
|
||||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountUpdate(BaseModel):
|
|
||||||
target_path: str | None = Field(default=None, description="Absolute target path in container")
|
|
||||||
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Files as {path: content}"
|
|
||||||
)
|
|
||||||
order_index: int | None = Field(default=None, description="Order index for mount resolution")
|
|
||||||
|
|
||||||
@field_validator("target_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_target_path(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Target path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
|
||||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
|
||||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
profile_id: str
|
|
||||||
target_path: str
|
|
||||||
mode: str
|
|
||||||
files: dict[str, str] | None
|
|
||||||
order_index: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class DefaultProfilesUpdate(BaseModel):
|
|
||||||
default_profiles: dict[str, str] = Field(
|
|
||||||
description="Mapping of tool_type_id to profile_id"
|
|
||||||
)
|
|
||||||
@@ -1,129 +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
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
owner_id: uuid.UUID
|
|
||||||
is_mirror: bool
|
|
||||||
remote_url: str | None
|
|
||||||
last_push: datetime | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
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,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,25 +0,0 @@
|
|||||||
"""Project request/response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class SetDefaultSSHKeyRequest(BaseModel):
|
|
||||||
ssh_key_id: str
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""SSH key request/response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
public_key: str
|
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
public_key: str
|
|
||||||
fingerprint: str
|
|
||||||
created_at: str
|
|
||||||
@@ -1,41 +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"
|
|
||||||
)
|
|
||||||
config_profile_id: str | None = Field(
|
|
||||||
default=None, description="Optional config profile ID to apply to the instance"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionItemResponse(BaseModel):
|
|
||||||
"""Lightweight session summary for sidebar and dashboard."""
|
|
||||||
|
|
||||||
model_config = {"extra": "ignore"}
|
|
||||||
|
|
||||||
id: str = Field(description="Session (tool instance) ID")
|
|
||||||
display_name: str = Field(description="Display name of the session")
|
|
||||||
tool_type_name: str = Field(description="Name of the tool type")
|
|
||||||
tool_icon: str | None = Field(default=None, description="Icon URL for the tool type")
|
|
||||||
tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces")
|
|
||||||
repository_name: str = Field(description="Name of the repository")
|
|
||||||
repository_id: str = Field(description="Repository ID")
|
|
||||||
project_name: str = Field(description="Name of the project")
|
|
||||||
project_id: str = Field(description="Project ID")
|
|
||||||
status: str = Field(description="Current status")
|
|
||||||
url: str | None = Field(default=None, description="Access URL")
|
|
||||||
|
|
||||||
|
|
||||||
class SessionListResponse(BaseModel):
|
|
||||||
"""Response wrapping a list of session summaries."""
|
|
||||||
|
|
||||||
sessions: list[SessionItemResponse]
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
"""Tool type request/response schemas."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
display_name: str
|
|
||||||
description: str | None = None
|
|
||||||
default_port: int
|
|
||||||
definition_type: str = "compose"
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
build_context: dict | None = None
|
|
||||||
readiness_probe: dict | None = None
|
|
||||||
required_variables: list[str] = []
|
|
||||||
category: str = "other"
|
|
||||||
interfaces: list[str] = ["web"]
|
|
||||||
|
|
||||||
@field_validator("definition_type")
|
|
||||||
@classmethod
|
|
||||||
def validate_definition_type(cls, v: str) -> str:
|
|
||||||
if v not in ("compose", "dockerfile"):
|
|
||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("compose_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
if v is None:
|
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(v)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "dockerfile":
|
|
||||||
return v
|
|
||||||
if v is None:
|
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
|
||||||
if not v.strip().startswith("FROM"):
|
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("default_port")
|
|
||||||
@classmethod
|
|
||||||
def validate_default_port(cls, v: int, info) -> int:
|
|
||||||
if v <= 0 or v > 65535:
|
|
||||||
raise ValueError("Port must be between 1 and 65535")
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
template = data.get("compose_template")
|
|
||||||
if not template:
|
|
||||||
return v
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(template)
|
|
||||||
except yaml.YAMLError:
|
|
||||||
return v
|
|
||||||
port_str = str(v)
|
|
||||||
port_exposed = False
|
|
||||||
if isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
for service_config in parsed["services"].values():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == v:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
if not port_exposed:
|
|
||||||
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("required_variables")
|
|
||||||
@classmethod
|
|
||||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
|
||||||
if not v:
|
|
||||||
return v
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
template = data.get("compose_template")
|
|
||||||
if not template:
|
|
||||||
return v
|
|
||||||
for var in v:
|
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
|
||||||
raise ValueError(f"Required variable '{var}' not found in compose template")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_templates(self) -> "ToolTypeCreate":
|
|
||||||
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
|
||||||
if self.definition_type == "compose" and self.compose_template is None:
|
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeUpdate(BaseModel):
|
|
||||||
display_name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
default_port: int | None = None
|
|
||||||
definition_type: str | None = None
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
build_context: dict | None = None
|
|
||||||
readiness_probe: dict | None = None
|
|
||||||
required_variables: list[str] | None = None
|
|
||||||
category: str | None = None
|
|
||||||
interfaces: list[str] | None = None
|
|
||||||
|
|
||||||
@field_validator("definition_type")
|
|
||||||
@classmethod
|
|
||||||
def validate_definition_type(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if v not in ("compose", "dockerfile"):
|
|
||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("compose_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
data = info.data
|
|
||||||
definition_type = data.get("definition_type")
|
|
||||||
if definition_type and definition_type != "compose":
|
|
||||||
return v
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(v)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
data = info.data
|
|
||||||
definition_type = data.get("definition_type")
|
|
||||||
if definition_type and definition_type != "dockerfile":
|
|
||||||
return v
|
|
||||||
if not v.strip().startswith("FROM"):
|
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
display_name: str
|
|
||||||
description: str | None
|
|
||||||
category: str
|
|
||||||
interfaces: list[str]
|
|
||||||
default_port: int
|
|
||||||
definition_type: str
|
|
||||||
compose_template: str | None
|
|
||||||
dockerfile_template: str | None
|
|
||||||
build_context: dict | None
|
|
||||||
readiness_probe: dict | None
|
|
||||||
required_variables: list[str]
|
|
||||||
is_builtin: bool
|
|
||||||
created_by_id: uuid.UUID | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeValidateRequest(BaseModel):
|
|
||||||
definition_type: str
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""User request/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,21 +0,0 @@
|
|||||||
"""User config request/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
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from src.database import SessionLocal
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
|
||||||
"""Check if a table exists in the database."""
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = :table_name
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"table_name": table_name},
|
|
||||||
)
|
|
||||||
return result.scalar() or False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
# Check if tool_types table exists before attempting to seed
|
|
||||||
if not await _table_exists(session, "tool_types"):
|
|
||||||
logger.warning(
|
|
||||||
"tool_types table does not exist. Skipping seeding. "
|
|
||||||
"Migrations may not have run yet."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
builtin_types = [
|
|
||||||
{
|
|
||||||
"name": "code-server",
|
|
||||||
"display_name": "VS Code Server",
|
|
||||||
"description": "VS Code running in the browser via code-server",
|
|
||||||
"category": "editor",
|
|
||||||
"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["default_port"],
|
|
||||||
)
|
|
||||||
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["default_port"]
|
|
||||||
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
logger.info("Built-in tool types seeded successfully.")
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
"""Config profile business logic."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user_config import UserConfig
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
MAX_INCLUDES_DEPTH = 10
|
|
||||||
|
|
||||||
|
|
||||||
async def get_owned_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> ConfigProfile:
|
|
||||||
"""Fetch a config profile and verify ownership."""
|
|
||||||
profile = await session.get(ConfigProfile, profile_id)
|
|
||||||
if profile is None or profile.user_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="config profile not found",
|
|
||||||
)
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
async def _detect_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
visited: set[uuid.UUID] | None = None,
|
|
||||||
depth: int = 0,
|
|
||||||
) -> bool:
|
|
||||||
"""Detect cycles in profile includes using DFS.
|
|
||||||
|
|
||||||
Returns True if a cycle is detected.
|
|
||||||
"""
|
|
||||||
if depth > MAX_INCLUDES_DEPTH:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if visited is None:
|
|
||||||
visited = set()
|
|
||||||
|
|
||||||
if profile_id in visited:
|
|
||||||
return True
|
|
||||||
|
|
||||||
visited.add(profile_id)
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigInclude.included_profile_id).where(
|
|
||||||
ConfigInclude.profile_id == profile_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
included_ids = result.scalars().all()
|
|
||||||
|
|
||||||
for included_id in included_ids:
|
|
||||||
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_includes_no_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
new_included_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Validate that adding an include wouldn't create a cycle."""
|
|
||||||
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="adding this include would create a circular reference",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Profile CRUD helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_duplicate_name(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
name: str,
|
|
||||||
exclude_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 409 if a profile with the given name already exists."""
|
|
||||||
query = select(ConfigProfile).where(
|
|
||||||
ConfigProfile.user_id == user_id,
|
|
||||||
ConfigProfile.name == name,
|
|
||||||
)
|
|
||||||
if exclude_id:
|
|
||||||
query = query.where(ConfigProfile.id != exclude_id)
|
|
||||||
existing = await session.scalar(query)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config profile with name '{name}' already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def profile_to_dict(profile: ConfigProfile) -> dict:
|
|
||||||
"""Serialize a ConfigProfile to a dict."""
|
|
||||||
return {
|
|
||||||
"id": str(profile.id),
|
|
||||||
"user_id": str(profile.user_id),
|
|
||||||
"name": profile.name,
|
|
||||||
"description": profile.description,
|
|
||||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
|
||||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Include helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_duplicate_include(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
included_profile_id: uuid.UUID,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 409 if the include already exists."""
|
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigInclude).where(
|
|
||||||
ConfigInclude.profile_id == profile_id,
|
|
||||||
ConfigInclude.included_profile_id == included_profile_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="this include already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict:
|
|
||||||
"""Serialize a ConfigInclude to a dict."""
|
|
||||||
return {
|
|
||||||
"id": str(inc.id),
|
|
||||||
"profile_id": str(inc.profile_id),
|
|
||||||
"included_profile_id": str(inc.included_profile_id),
|
|
||||||
"included_profile_name": included_name,
|
|
||||||
"order_index": inc.order_index,
|
|
||||||
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
|
||||||
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mount helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_duplicate_mount_path(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
target_path: str,
|
|
||||||
exclude_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 409 if a mount with the given path already exists."""
|
|
||||||
query = select(ConfigMount).where(
|
|
||||||
ConfigMount.profile_id == profile_id,
|
|
||||||
ConfigMount.target_path == target_path,
|
|
||||||
)
|
|
||||||
if exclude_id:
|
|
||||||
query = query.where(ConfigMount.id != exclude_id)
|
|
||||||
existing = await session.scalar(query)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"mount with path '{target_path}' already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mount_to_dict(mount: ConfigMount) -> dict:
|
|
||||||
"""Serialize a ConfigMount to a dict."""
|
|
||||||
return {
|
|
||||||
"id": str(mount.id),
|
|
||||||
"profile_id": str(mount.profile_id),
|
|
||||||
"target_path": mount.target_path,
|
|
||||||
"files": mount.files,
|
|
||||||
"mode": mount.mode,
|
|
||||||
"order_index": mount.order_index,
|
|
||||||
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
|
||||||
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Default profile helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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():
|
|
||||||
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
|
||||||
if profile is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
|
|
||||||
if profile.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_default_profiles(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
"""Get default profiles for a 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 {}}
|
|
||||||
|
|
||||||
|
|
||||||
async def set_default_profiles(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
default_profiles: dict[str, str],
|
|
||||||
) -> dict:
|
|
||||||
"""Set default profiles for a user."""
|
|
||||||
user_config = await get_or_create_user_config(session, user_id)
|
|
||||||
await validate_default_profiles(session, user_id, default_profiles)
|
|
||||||
user_config.config = {**user_config.config, "default_profiles": default_profiles}
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user_config)
|
|
||||||
return {"default_profiles": user_config.default_profiles}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_default_profile_for_tool_type(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
tool_type_id: str,
|
|
||||||
) -> dict:
|
|
||||||
"""Get default profile 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}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Include list helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_includes_for_profile(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
"""List all includes for a profile."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigInclude)
|
|
||||||
.where(ConfigInclude.profile_id == profile_id)
|
|
||||||
.order_by(ConfigInclude.order_index)
|
|
||||||
)
|
|
||||||
includes_data = []
|
|
||||||
for inc in result.scalars().all():
|
|
||||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
|
||||||
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
|
||||||
return {"includes": includes_data}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mount list helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_mounts_for_profile(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
"""List all mounts for a profile."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigMount)
|
|
||||||
.where(ConfigMount.profile_id == profile_id)
|
|
||||||
.order_by(ConfigMount.order_index)
|
|
||||||
)
|
|
||||||
return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]}
|
|
||||||
@@ -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,43 +0,0 @@
|
|||||||
"""Docker services for container and tunnel management."""
|
|
||||||
|
|
||||||
from .compose import (
|
|
||||||
ensure_instance_directory,
|
|
||||||
execute_compose_command,
|
|
||||||
render_compose_template,
|
|
||||||
write_compose_file,
|
|
||||||
write_env_file,
|
|
||||||
)
|
|
||||||
from .config_staging import write_config_files
|
|
||||||
from .container import (
|
|
||||||
connect_container_to_network,
|
|
||||||
find_free_port,
|
|
||||||
get_container_id,
|
|
||||||
get_container_logs,
|
|
||||||
get_container_name,
|
|
||||||
get_container_status,
|
|
||||||
)
|
|
||||||
from .tunnel import (
|
|
||||||
check_tunnel_health,
|
|
||||||
recreate_tunnel,
|
|
||||||
start_cloudflared_tunnel,
|
|
||||||
stop_cloudflared_tunnel,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"render_compose_template",
|
|
||||||
"ensure_instance_directory",
|
|
||||||
"write_compose_file",
|
|
||||||
"write_env_file",
|
|
||||||
"execute_compose_command",
|
|
||||||
"write_config_files",
|
|
||||||
"get_container_id",
|
|
||||||
"get_container_name",
|
|
||||||
"connect_container_to_network",
|
|
||||||
"get_container_status",
|
|
||||||
"get_container_logs",
|
|
||||||
"find_free_port",
|
|
||||||
"start_cloudflared_tunnel",
|
|
||||||
"stop_cloudflared_tunnel",
|
|
||||||
"recreate_tunnel",
|
|
||||||
"check_tunnel_health",
|
|
||||||
]
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
"""Docker Compose file generation and command execution."""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.services.profile_resolver import resolve_profile
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_name(name: str) -> str:
|
|
||||||
"""Sanitize a string for use in Docker/container names."""
|
|
||||||
sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower())
|
|
||||||
sanitized = re.sub(r"-+", "-", sanitized)
|
|
||||||
return sanitized.strip("-")
|
|
||||||
|
|
||||||
|
|
||||||
async def _generate_instance_name(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_name: str,
|
|
||||||
tool_type_name: str,
|
|
||||||
) -> str:
|
|
||||||
"""Generate a unique instance name: project-tool-NUM."""
|
|
||||||
base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}"
|
|
||||||
base = base.strip("-") or "instance"
|
|
||||||
result = await session.execute(
|
|
||||||
select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%"))
|
|
||||||
)
|
|
||||||
names = result.scalars().all()
|
|
||||||
max_num = 0
|
|
||||||
for name in names:
|
|
||||||
parts = name.rsplit("-", 1)
|
|
||||||
if len(parts) == 2 and parts[0] == base and parts[1].isdigit():
|
|
||||||
max_num = max(max_num, int(parts[1]))
|
|
||||||
return f"{base}-{max_num + 1:03d}"
|
|
||||||
|
|
||||||
|
|
||||||
def _modify_compose_file(
|
|
||||||
compose_path: str,
|
|
||||||
port_override: int | None = None,
|
|
||||||
start_command: str | None = None,
|
|
||||||
working_directory: str | None = None,
|
|
||||||
extra_volumes: list[dict] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Modify compose file with runtime overrides."""
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
compose_file = Path(compose_path)
|
|
||||||
content = compose_file.read_text()
|
|
||||||
compose_data = yaml.safe_load(content)
|
|
||||||
|
|
||||||
if not compose_data or "services" not in compose_data:
|
|
||||||
return
|
|
||||||
|
|
||||||
for service_name, service_config in compose_data["services"].items():
|
|
||||||
if port_override and "ports" in service_config:
|
|
||||||
for i, port_mapping in enumerate(service_config["ports"]):
|
|
||||||
if isinstance(port_mapping, str) and ":" in port_mapping:
|
|
||||||
_host_port, container_port = port_mapping.split(":", 1)
|
|
||||||
service_config["ports"][i] = f"{port_override}:{container_port}"
|
|
||||||
break
|
|
||||||
|
|
||||||
if start_command:
|
|
||||||
service_config["command"] = start_command
|
|
||||||
|
|
||||||
if working_directory:
|
|
||||||
service_config["working_dir"] = working_directory
|
|
||||||
|
|
||||||
if extra_volumes:
|
|
||||||
if "volumes" not in service_config:
|
|
||||||
service_config["volumes"] = []
|
|
||||||
for vol in extra_volumes:
|
|
||||||
source = vol.get("source", "")
|
|
||||||
target = vol.get("target", "")
|
|
||||||
vol_type = vol.get("type", "bind")
|
|
||||||
if vol_type == "bind":
|
|
||||||
service_config["volumes"].append(f"{source}:{target}")
|
|
||||||
else:
|
|
||||||
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_resolved_profile(
|
|
||||||
profile: ConfigProfile,
|
|
||||||
instance_dir: str,
|
|
||||||
env_vars: dict[str, str],
|
|
||||||
port_override: int | None,
|
|
||||||
start_command: str | None,
|
|
||||||
working_directory: str | None,
|
|
||||||
extra_volumes: list[dict],
|
|
||||||
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
|
|
||||||
"""Resolve a profile and apply its output to instance configuration."""
|
|
||||||
resolved = resolve_profile(profile)
|
|
||||||
|
|
||||||
if resolved.environment_variables:
|
|
||||||
env_vars.update(resolved.environment_variables)
|
|
||||||
|
|
||||||
if resolved.runtime_hints.start_command is not None:
|
|
||||||
start_command = resolved.runtime_hints.start_command
|
|
||||||
if resolved.runtime_hints.working_directory is not None:
|
|
||||||
working_directory = resolved.runtime_hints.working_directory
|
|
||||||
if resolved.runtime_hints.port is not None:
|
|
||||||
port_override = resolved.runtime_hints.port
|
|
||||||
|
|
||||||
for target_path, mount in resolved.mounts.items():
|
|
||||||
safe_name = target_path.strip("/").replace("/", "_")
|
|
||||||
mount_dir = Path(instance_dir) / "mounts" / safe_name
|
|
||||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for rel_path, content in mount.files.items():
|
|
||||||
file_path = mount_dir / rel_path
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_path.write_text(content)
|
|
||||||
|
|
||||||
extra_volumes.append({
|
|
||||||
"source": str(mount_dir),
|
|
||||||
"target": target_path,
|
|
||||||
"type": mount.mode,
|
|
||||||
})
|
|
||||||
|
|
||||||
return env_vars, port_override, start_command, working_directory, extra_volumes
|
|
||||||
|
|
||||||
|
|
||||||
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 execute_compose_command(
|
|
||||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
|
||||||
) -> tuple[int, str, str]:
|
|
||||||
"""Execute a docker compose command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
compose_path: Path to docker-compose.yml
|
|
||||||
action: The compose action (up, down, start, stop, restart)
|
|
||||||
timeout: Command timeout in seconds
|
|
||||||
env_file: Optional path to .env file for environment variables
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (returncode, stdout, stderr)
|
|
||||||
"""
|
|
||||||
instance_dir = Path(compose_path).parent
|
|
||||||
|
|
||||||
cmd = ["docker", "compose", "-f", compose_path]
|
|
||||||
|
|
||||||
if env_file:
|
|
||||||
cmd.extend(["--env-file", env_file])
|
|
||||||
|
|
||||||
if action == "up":
|
|
||||||
cmd.extend(["up", "-d"])
|
|
||||||
elif action == "down":
|
|
||||||
cmd.extend(["down", "-v"])
|
|
||||||
elif action in ("start", "stop", "restart"):
|
|
||||||
cmd.append(action)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown compose action: {action}")
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
cwd=str(instance_dir),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.returncode, result.stdout, result.stderr
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Config file staging for Docker instances."""
|
|
||||||
|
|
||||||
from pathlib import 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,121 +0,0 @@
|
|||||||
"""Docker container lifecycle and query operations."""
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_id(instance_name: str) -> str | None:
|
|
||||||
"""Get the container ID for a compose service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_name: The service name in compose
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container ID or None if not found
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
return result.stdout.strip().split("\n")[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_name(instance_name: str) -> str | None:
|
|
||||||
"""Get the full container name for a compose service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_name: The service name in compose
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container name or None if not found
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
return result.stdout.strip().split("\n")[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
|
||||||
"""Connect a Docker container to an existing network.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name or ID of the container
|
|
||||||
network_name: Name of the Docker network (default: backend)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "network", "connect", network_name, container_name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_status(container_id: str) -> str:
|
|
||||||
"""Get the status of a Docker container.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_id: Docker container ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container status string (running, exited, etc.)
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result.stdout.strip()
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
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,146 +0,0 @@
|
|||||||
"""Cloudflare tunnel management for Docker instances."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
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 select as sel
|
|
||||||
|
|
||||||
# First verify the container is accessible
|
|
||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
|
||||||
for attempt in range(10):
|
|
||||||
check = subprocess.run(
|
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
|
||||||
f"http://{container_name}:{port}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
|
||||||
if check.returncode == 0:
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
|
||||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for the URL to appear in output
|
|
||||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
||||||
start_time = time.time()
|
|
||||||
url = None
|
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
|
||||||
# Read available output
|
|
||||||
readable, _, _ = sel.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
|
|
||||||
"""
|
|
||||||
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.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: The tunnel URL to check
|
|
||||||
timeout: Request timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
|
||||||
"""
|
|
||||||
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())
|
|
||||||
return {
|
|
||||||
"healthy": 200 <= status_code < 400,
|
|
||||||
"status_code": status_code,
|
|
||||||
}
|
|
||||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
|
||||||
return {
|
|
||||||
"healthy": False,
|
|
||||||
"status_code": None,
|
|
||||||
"error": str(e),
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
"""File operations scoped to a workspace directory."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from src.models.workspace import Workspace
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FileEntry:
|
|
||||||
"""A single file or directory entry."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
path: str
|
|
||||||
type: str # "file" or "directory"
|
|
||||||
size: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class FileService:
|
|
||||||
"""Read and write files within a workspace directory."""
|
|
||||||
|
|
||||||
def list_directory(
|
|
||||||
self,
|
|
||||||
workspace: Workspace,
|
|
||||||
relative_path: str = "",
|
|
||||||
) -> list[FileEntry]:
|
|
||||||
"""List entries in a workspace directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workspace: The workspace to list files in.
|
|
||||||
relative_path: Path relative to workspace root.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of file entries sorted by name (directories first).
|
|
||||||
"""
|
|
||||||
abs_path = os.path.join(workspace.path, relative_path)
|
|
||||||
abs_path = os.path.normpath(abs_path)
|
|
||||||
|
|
||||||
# Security: ensure we stay within workspace
|
|
||||||
if not abs_path.startswith(os.path.normpath(workspace.path)):
|
|
||||||
raise ValueError("Path escapes workspace directory")
|
|
||||||
|
|
||||||
if not os.path.exists(abs_path):
|
|
||||||
return []
|
|
||||||
|
|
||||||
entries = []
|
|
||||||
for item in sorted(os.listdir(abs_path)):
|
|
||||||
full = os.path.join(abs_path, item)
|
|
||||||
rel = os.path.join(relative_path, item) if relative_path else item
|
|
||||||
is_dir = os.path.isdir(full)
|
|
||||||
size = os.path.getsize(full) if os.path.isfile(full) else None
|
|
||||||
entries.append(
|
|
||||||
FileEntry(
|
|
||||||
name=item,
|
|
||||||
path=rel.replace("\\", "/"),
|
|
||||||
type="directory" if is_dir else "file",
|
|
||||||
size=size,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Directories first, then files, both alphabetical
|
|
||||||
entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower()))
|
|
||||||
return entries
|
|
||||||
|
|
||||||
def read_file(self, workspace: Workspace, relative_path: str) -> str:
|
|
||||||
"""Read a text file from the workspace.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workspace: The workspace to read from.
|
|
||||||
relative_path: Path relative to workspace root.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
File contents as string.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If path escapes workspace or file is binary.
|
|
||||||
FileNotFoundError: If file does not exist.
|
|
||||||
"""
|
|
||||||
abs_path = self._resolve_path(workspace, relative_path)
|
|
||||||
|
|
||||||
if not os.path.isfile(abs_path):
|
|
||||||
raise FileNotFoundError(f"Not a file: {relative_path}")
|
|
||||||
|
|
||||||
# Basic binary check — read first 8KB and look for null bytes
|
|
||||||
with open(abs_path, "rb") as f:
|
|
||||||
chunk = f.read(8192)
|
|
||||||
if b"\x00" in chunk:
|
|
||||||
raise ValueError("Binary files cannot be viewed")
|
|
||||||
|
|
||||||
with open(abs_path, encoding="utf-8", errors="replace") as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
def write_file(
|
|
||||||
self,
|
|
||||||
workspace: Workspace,
|
|
||||||
relative_path: str,
|
|
||||||
content: str,
|
|
||||||
) -> None:
|
|
||||||
"""Write a text file to the workspace.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workspace: The workspace to write to.
|
|
||||||
relative_path: Path relative to workspace root.
|
|
||||||
content: File contents.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If path escapes workspace.
|
|
||||||
"""
|
|
||||||
abs_path = self._resolve_path(workspace, relative_path)
|
|
||||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
|
||||||
|
|
||||||
with open(abs_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
logger.info("Wrote file %s in workspace %s", relative_path, workspace.id)
|
|
||||||
|
|
||||||
def _resolve_path(self, workspace: Workspace, relative_path: str) -> str:
|
|
||||||
"""Resolve a relative path to absolute, with security check."""
|
|
||||||
abs_path = os.path.normpath(os.path.join(workspace.path, relative_path))
|
|
||||||
workspace_root = os.path.normpath(workspace.path)
|
|
||||||
|
|
||||||
if not abs_path.startswith(workspace_root):
|
|
||||||
raise ValueError("Path escapes workspace directory")
|
|
||||||
|
|
||||||
return abs_path
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Git services package."""
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"""Git control operations with repo validation."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import (
|
|
||||||
BranchCreateRequest,
|
|
||||||
CheckoutRequest,
|
|
||||||
CommitRequest,
|
|
||||||
FetchResponse,
|
|
||||||
MergeRequest,
|
|
||||||
MergeResponse,
|
|
||||||
PullResponse,
|
|
||||||
PushResponse,
|
|
||||||
StatusResponse,
|
|
||||||
)
|
|
||||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
|
||||||
from src.utils.git_control import (
|
|
||||||
checkout_branch,
|
|
||||||
commit_changes,
|
|
||||||
create_branch,
|
|
||||||
delete_branch,
|
|
||||||
fetch,
|
|
||||||
get_status,
|
|
||||||
merge,
|
|
||||||
pull,
|
|
||||||
push,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_status_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
) -> StatusResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
result = get_status(repo.path)
|
|
||||||
return StatusResponse(
|
|
||||||
branch=result.branch,
|
|
||||||
modified=result.modified,
|
|
||||||
added=result.added,
|
|
||||||
deleted=result.deleted,
|
|
||||||
untracked=result.untracked,
|
|
||||||
renamed=result.renamed,
|
|
||||||
ahead=result.ahead,
|
|
||||||
behind=result.behind,
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def create_branch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: BranchCreateRequest,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_branch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch_name: str,
|
|
||||||
force: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
async def checkout_branch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CheckoutRequest,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
async def commit_changes_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CommitRequest,
|
|
||||||
user: User,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
author_name = user.name or "Unknown"
|
|
||||||
author_email = user.email or "unknown@example.com"
|
|
||||||
try:
|
|
||||||
commit_hash = commit_changes(
|
|
||||||
repo_path=repo.path,
|
|
||||||
message=data.message,
|
|
||||||
author_name=author_name,
|
|
||||||
author_email=author_email,
|
|
||||||
files=data.files,
|
|
||||||
)
|
|
||||||
return {"commit_hash": commit_hash, "message": data.message}
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
) -> FetchResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
async def pull_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str | None = None,
|
|
||||||
) -> PullResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
async def push_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str | None = None,
|
|
||||||
) -> PushResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
async def merge_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: MergeRequest,
|
|
||||||
) -> MergeResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
commit_hash = merge(
|
|
||||||
repo_path=repo.path,
|
|
||||||
source_branch=data.source_branch,
|
|
||||||
target_branch=data.target_branch,
|
|
||||||
message=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))
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""Git file operations with repo validation."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import (
|
|
||||||
FileContentResponse,
|
|
||||||
FileListResponse,
|
|
||||||
FileUpdateRequest,
|
|
||||||
FileUpdateResponse,
|
|
||||||
)
|
|
||||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
|
||||||
from src.utils.git_files import (
|
|
||||||
commit_file,
|
|
||||||
get_file_content,
|
|
||||||
list_branches,
|
|
||||||
list_tree,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_files(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str = "main",
|
|
||||||
path: str = "",
|
|
||||||
) -> FileListResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
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 (path=%s, branch=%s): %s",
|
|
||||||
repo_id,
|
|
||||||
path,
|
|
||||||
branch,
|
|
||||||
str(e),
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_file(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str,
|
|
||||||
path: str,
|
|
||||||
) -> FileContentResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
file_content = get_file_content(repo.path, branch=branch, path=path)
|
|
||||||
return FileContentResponse(
|
|
||||||
path=file_content.path,
|
|
||||||
branch=file_content.branch,
|
|
||||||
content=file_content.content,
|
|
||||||
size=file_content.size,
|
|
||||||
encoding=file_content.encoding,
|
|
||||||
language=file_content.language,
|
|
||||||
is_binary=file_content.is_binary,
|
|
||||||
last_commit=file_content.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))
|
|
||||||
|
|
||||||
|
|
||||||
async def update_file(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: FileUpdateRequest,
|
|
||||||
user: User,
|
|
||||||
) -> FileUpdateResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
author_name = user.name or "Unknown"
|
|
||||||
author_email = user.email or "unknown@example.com"
|
|
||||||
try:
|
|
||||||
commit_hash = commit_file(
|
|
||||||
repo_path=repo.path,
|
|
||||||
branch=data.branch,
|
|
||||||
path=data.path,
|
|
||||||
content=data.content,
|
|
||||||
commit_message=data.commit_message,
|
|
||||||
author_name=author_name,
|
|
||||||
author_email=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))
|
|
||||||
|
|
||||||
|
|
||||||
async def list_branches_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
branches, default_branch = list_branches(repo.path)
|
|
||||||
return {
|
|
||||||
"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),
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
"""Repository lifecycle and path helpers."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.config import Settings
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import GitRepositoryCreate
|
|
||||||
from src.utils.git_url_parser import parse_git_url
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
|
||||||
"""Generate the filesystem path for a repository."""
|
|
||||||
base = Settings().repo_base_path or "/data/repos"
|
|
||||||
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_provider_clone_url(owner: str, repo: str) -> str:
|
|
||||||
"""Build the SSH clone URL for the fixed git provider."""
|
|
||||||
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
|
||||||
|
|
||||||
|
|
||||||
def _preflight_remote_repository(remote_url: str) -> None:
|
|
||||||
"""Verify a remote repository is reachable before cloning."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "ls-remote", remote_url],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="repository not found or inaccessible",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "clone", remote_url, repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"failed to clone repository: {result.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _init_working_repository(repo_path: str) -> None:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "init", "-b", "main", repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
fallback = subprocess.run(
|
|
||||||
["git", "init", repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if fallback.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"failed to initialize repository: {fallback.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
ref_result = subprocess.run(
|
|
||||||
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if ref_result.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"failed to set initial branch: {ref_result.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_repo_and_validate(
|
|
||||||
session: AsyncSession,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
) -> GitRepository:
|
|
||||||
"""Fetch a repository and validate ownership + disk presence."""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_repo_on_disk(repo: GitRepository) -> None:
|
|
||||||
"""Raise 404 if the repository is not present on disk."""
|
|
||||||
if not os.path.exists(repo.path):
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_repository(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
data: GitRepositoryCreate,
|
|
||||||
user: User,
|
|
||||||
) -> GitRepository:
|
|
||||||
"""Create a new git repository (clone or init)."""
|
|
||||||
# Check for duplicate name
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Validate and potentially correct the URL
|
|
||||||
remote_url = data.remote_url
|
|
||||||
if remote_url and not data.force_original_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",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if parse_result["base_url"]:
|
|
||||||
remote_url = parse_result["base_url"]
|
|
||||||
|
|
||||||
if remote_url:
|
|
||||||
_preflight_remote_repository(remote_url)
|
|
||||||
|
|
||||||
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)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
session.add(repo)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(repo)
|
|
||||||
return repo
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_repository(
|
|
||||||
session: AsyncSession,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
) -> None:
|
|
||||||
"""Delete a repository from DB and disk."""
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
|
|
||||||
if os.path.exists(repo.path):
|
|
||||||
shutil.rmtree(repo.path)
|
|
||||||
|
|
||||||
await session.delete(repo)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_repositories(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
) -> list[GitRepository]:
|
|
||||||
"""List all repositories in a project."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
"""Git commands scoped to a workspace directory."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from src.models.workspace 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)
|
|
||||||
@@ -13,8 +13,7 @@ from src.database import SessionLocal
|
|||||||
from src.models.health_check import HealthCheck
|
from src.models.health_check import HealthCheck
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.services.correlation import get_correlation_id
|
from src.services.correlation import get_correlation_id
|
||||||
from src.services.docker import get_container_status
|
from src.services.docker import check_tunnel_health, get_container_status
|
||||||
from src.services.tunnel import check_tunnel_health
|
|
||||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
from src.services.notification_service import notification_service
|
from src.services.notification_service import notification_service
|
||||||
|
|
||||||
|
|||||||
@@ -1,418 +0,0 @@
|
|||||||
"""High-level tool instance lifecycle orchestration.
|
|
||||||
|
|
||||||
Coordinates Docker compose, container, tunnel, and config staging services
|
|
||||||
to create, start, stop, restart, and delete tool instances.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user import User
|
|
||||||
from src.services.docker import compose as compose_svc
|
|
||||||
from src.services.docker import config_staging
|
|
||||||
from src.services.docker import container as container_svc
|
|
||||||
from src.services.docker import tunnel as tunnel_svc
|
|
||||||
from src.services.docker_build import build_image
|
|
||||||
from src.services.readiness_probe import execute_probe
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_new_instance(
|
|
||||||
session: AsyncSession,
|
|
||||||
project: Project,
|
|
||||||
repo: GitRepository,
|
|
||||||
tool_type: ToolType,
|
|
||||||
user: User,
|
|
||||||
display_name: str | None,
|
|
||||||
selected_profile: ConfigProfile | None,
|
|
||||||
) -> ToolInstance:
|
|
||||||
"""Create a new tool instance record and its compose file."""
|
|
||||||
instance_name = await compose_svc._generate_instance_name(
|
|
||||||
session, project.name, tool_type.name
|
|
||||||
)
|
|
||||||
instance_dir = compose_svc.ensure_instance_directory(instance_name)
|
|
||||||
tool_port = container_svc.find_free_port()
|
|
||||||
|
|
||||||
compose_path = await _build_or_render_compose(
|
|
||||||
tool_type, instance_name, instance_dir, repo, user, project.id, tool_port
|
|
||||||
)
|
|
||||||
|
|
||||||
instance = ToolInstance(
|
|
||||||
name=instance_name,
|
|
||||||
display_name=display_name
|
|
||||||
or f"{project.name} / {repo.name} / {tool_type.display_name}",
|
|
||||||
tool_type_id=tool_type.id,
|
|
||||||
repository_id=repo.id,
|
|
||||||
project_id=project.id,
|
|
||||||
owner_id=user.id,
|
|
||||||
status="pending",
|
|
||||||
compose_path=compose_path,
|
|
||||||
port=tool_port,
|
|
||||||
selected_profile_id=selected_profile.id if selected_profile else None,
|
|
||||||
)
|
|
||||||
session.add(instance)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(instance)
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
async def start_existing_instance(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: ToolInstance,
|
|
||||||
user: User,
|
|
||||||
project_id: Any,
|
|
||||||
) -> dict:
|
|
||||||
"""Start an existing instance: stage configs, compose up, probe, tunnel."""
|
|
||||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
instance.status = "building"
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
env_vars: dict[str, str] = {}
|
|
||||||
config_files: dict[str, str] = {}
|
|
||||||
port_override = None
|
|
||||||
start_command = None
|
|
||||||
working_directory = None
|
|
||||||
extra_volumes: list[dict] = []
|
|
||||||
|
|
||||||
selected_profile = None
|
|
||||||
if instance.selected_profile_id:
|
|
||||||
selected_profile = await session.get(
|
|
||||||
ConfigProfile, instance.selected_profile_id
|
|
||||||
)
|
|
||||||
if selected_profile and selected_profile.user_id == user.id:
|
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
|
||||||
(
|
|
||||||
env_vars,
|
|
||||||
port_override,
|
|
||||||
start_command,
|
|
||||||
working_directory,
|
|
||||||
extra_volumes,
|
|
||||||
) = await compose_svc._apply_resolved_profile(
|
|
||||||
selected_profile,
|
|
||||||
instance_dir,
|
|
||||||
env_vars,
|
|
||||||
port_override,
|
|
||||||
start_command,
|
|
||||||
working_directory,
|
|
||||||
extra_volumes,
|
|
||||||
)
|
|
||||||
|
|
||||||
env_file_path, extra_volumes = await _stage_configs(
|
|
||||||
os.path.dirname(instance.compose_path), env_vars, config_files, extra_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
if port_override or start_command or working_directory or extra_volumes:
|
|
||||||
compose_svc._modify_compose_file(
|
|
||||||
instance.compose_path,
|
|
||||||
port_override,
|
|
||||||
start_command,
|
|
||||||
working_directory,
|
|
||||||
extra_volumes,
|
|
||||||
)
|
|
||||||
|
|
||||||
returncode, _stdout, stderr = compose_svc.execute_compose_command(
|
|
||||||
instance.compose_path, "up", env_file=env_file_path
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
instance.status = "error"
|
|
||||||
await session.commit()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"failed to start instance: {stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
container_id = container_svc.get_container_id(instance.name)
|
|
||||||
if container_id:
|
|
||||||
instance.container_id = container_id
|
|
||||||
container_name = container_svc.get_container_name(instance.name)
|
|
||||||
if container_name:
|
|
||||||
instance.container_name = container_name
|
|
||||||
container_svc.connect_container_to_network(container_name, "backend")
|
|
||||||
|
|
||||||
instance.status = "starting"
|
|
||||||
instance.last_started_at = datetime.now()
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
|
||||||
if not tool_type:
|
|
||||||
instance.status = "error"
|
|
||||||
await session.commit()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="tool type not found for instance",
|
|
||||||
)
|
|
||||||
|
|
||||||
success, probe_logs = await _run_readiness_probe(instance, tool_type)
|
|
||||||
if not success:
|
|
||||||
instance.status = "failed"
|
|
||||||
instance.url = None
|
|
||||||
instance.public_url = None
|
|
||||||
await session.commit()
|
|
||||||
return {
|
|
||||||
"status": "failed",
|
|
||||||
"error": f"Readiness probe failed: {' '.join(probe_logs)}",
|
|
||||||
}
|
|
||||||
|
|
||||||
instance.status = "running"
|
|
||||||
await session.commit()
|
|
||||||
await _start_tunnel_if_web(instance, tool_type)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {"status": instance.status, "url": instance.url}
|
|
||||||
|
|
||||||
|
|
||||||
async def restart_existing_instance(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: ToolInstance,
|
|
||||||
user: User,
|
|
||||||
project_id: Any,
|
|
||||||
) -> dict:
|
|
||||||
"""Restart an instance: re-stage configs, compose restart, tunnel."""
|
|
||||||
if instance.tunnel_id:
|
|
||||||
try:
|
|
||||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to stop old tunnel: %s", exc)
|
|
||||||
|
|
||||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
|
||||||
instance.status = "error"
|
|
||||||
await session.commit()
|
|
||||||
return {"status": instance.status}
|
|
||||||
|
|
||||||
env_vars: dict[str, str] = {}
|
|
||||||
config_files: dict[str, str] = {}
|
|
||||||
port_override = None
|
|
||||||
start_command = None
|
|
||||||
working_directory = None
|
|
||||||
extra_volumes: list[dict] = []
|
|
||||||
|
|
||||||
stored_profile = None
|
|
||||||
if instance.selected_profile_id:
|
|
||||||
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
|
||||||
if stored_profile and stored_profile.user_id == user.id:
|
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
|
||||||
(
|
|
||||||
env_vars,
|
|
||||||
port_override,
|
|
||||||
start_command,
|
|
||||||
working_directory,
|
|
||||||
extra_volumes,
|
|
||||||
) = await compose_svc._apply_resolved_profile(
|
|
||||||
stored_profile,
|
|
||||||
instance_dir,
|
|
||||||
env_vars,
|
|
||||||
port_override,
|
|
||||||
start_command,
|
|
||||||
working_directory,
|
|
||||||
extra_volumes,
|
|
||||||
)
|
|
||||||
|
|
||||||
env_file_path, extra_volumes = await _stage_configs(
|
|
||||||
os.path.dirname(instance.compose_path), env_vars, config_files, extra_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
if port_override or start_command or working_directory or extra_volumes:
|
|
||||||
compose_svc._modify_compose_file(
|
|
||||||
instance.compose_path,
|
|
||||||
port_override,
|
|
||||||
start_command,
|
|
||||||
working_directory,
|
|
||||||
extra_volumes,
|
|
||||||
)
|
|
||||||
|
|
||||||
returncode, _stdout, _stderr = compose_svc.execute_compose_command(
|
|
||||||
instance.compose_path, "restart", env_file=env_file_path
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
instance.status = "error"
|
|
||||||
await session.commit()
|
|
||||||
return {"status": instance.status}
|
|
||||||
|
|
||||||
instance.status = "running"
|
|
||||||
instance.last_started_at = datetime.now()
|
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
|
||||||
if not tool_type:
|
|
||||||
instance.status = "error"
|
|
||||||
await session.commit()
|
|
||||||
return {"status": instance.status}
|
|
||||||
|
|
||||||
await _start_tunnel_if_web(instance, tool_type)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {"status": instance.status, "url": instance.url}
|
|
||||||
|
|
||||||
|
|
||||||
async def stop_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
|
||||||
"""Stop an instance and its tunnel."""
|
|
||||||
if instance.tunnel_id:
|
|
||||||
try:
|
|
||||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to stop tunnel: %s", exc)
|
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
|
||||||
compose_svc.execute_compose_command(instance.compose_path, "stop")
|
|
||||||
|
|
||||||
instance.status = "stopped"
|
|
||||||
instance.last_stopped_at = datetime.now()
|
|
||||||
instance.url = None
|
|
||||||
instance.public_url = None
|
|
||||||
instance.tunnel_id = None
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_existing_instance(
|
|
||||||
session: AsyncSession, instance: ToolInstance
|
|
||||||
) -> None:
|
|
||||||
"""Delete an instance, its containers, and its directory."""
|
|
||||||
if instance.tunnel_id:
|
|
||||||
try:
|
|
||||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to stop tunnel: %s", exc)
|
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
|
||||||
compose_svc.execute_compose_command(instance.compose_path, "down")
|
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
|
||||||
if os.path.exists(instance_dir):
|
|
||||||
shutil.rmtree(instance_dir)
|
|
||||||
|
|
||||||
await session.delete(instance)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Internal helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def _build_or_render_compose(
|
|
||||||
tool_type: ToolType,
|
|
||||||
instance_name: str,
|
|
||||||
instance_dir: str,
|
|
||||||
repo: GitRepository,
|
|
||||||
user: User,
|
|
||||||
project_id: Any,
|
|
||||||
tool_port: int,
|
|
||||||
) -> str:
|
|
||||||
"""Build Dockerfile or render compose template."""
|
|
||||||
if tool_type.definition_type == "dockerfile":
|
|
||||||
image_tag = f"headquarter/{instance_name}:latest"
|
|
||||||
if tool_type.dockerfile_template:
|
|
||||||
returncode, _stdout, stderr = build_image(
|
|
||||||
instance_dir=instance_dir,
|
|
||||||
dockerfile=tool_type.dockerfile_template,
|
|
||||||
tag=image_tag,
|
|
||||||
build_context=tool_type.build_context,
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
logger.error("Build failed for %s: %s", instance_name, stderr)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to build Docker image: {stderr[:500]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
compose_content = (
|
|
||||||
f'version: "3.8"\nservices:\n app:\n'
|
|
||||||
f" image: {image_tag}\n"
|
|
||||||
f" container_name: {instance_name}\n"
|
|
||||||
f' ports:\n - "{tool_port}:{tool_type.default_port}"\n'
|
|
||||||
f" volumes:\n - {repo.path}:/workspace\n"
|
|
||||||
f" restart: unless-stopped\n"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
variables = {
|
|
||||||
"REPO_PATH": repo.path,
|
|
||||||
"INSTANCE_NAME": instance_name,
|
|
||||||
"INSTANCE_ID": instance_name,
|
|
||||||
"TOOL_NAME": instance_name,
|
|
||||||
"TOOL_PORT": tool_port,
|
|
||||||
"USER_ID": str(user.id),
|
|
||||||
"PROJECT_ID": str(project_id),
|
|
||||||
}
|
|
||||||
if not tool_type.compose_template:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="tool type has no compose template",
|
|
||||||
)
|
|
||||||
compose_content = compose_svc.render_compose_template(
|
|
||||||
tool_type.compose_template, variables
|
|
||||||
)
|
|
||||||
|
|
||||||
compose_svc.write_compose_file(instance_dir, compose_content)
|
|
||||||
return os.path.join(instance_dir, "docker-compose.yml")
|
|
||||||
|
|
||||||
|
|
||||||
async def _stage_configs(
|
|
||||||
instance_dir: str,
|
|
||||||
env_vars: dict[str, str],
|
|
||||||
config_files: dict[str, str],
|
|
||||||
extra_volumes: list[dict],
|
|
||||||
) -> tuple[str | None, list[dict]]:
|
|
||||||
"""Write env/config files for the resolved profile."""
|
|
||||||
env_file_path: str | None = None
|
|
||||||
if env_vars:
|
|
||||||
env_file_path = compose_svc.write_env_file(instance_dir, env_vars)
|
|
||||||
if config_files:
|
|
||||||
config_staging.write_config_files(instance_dir, config_files)
|
|
||||||
|
|
||||||
return env_file_path, extra_volumes
|
|
||||||
|
|
||||||
|
|
||||||
async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None:
|
|
||||||
"""Create Cloudflare tunnel for web-enabled tools."""
|
|
||||||
if tool_type.interface_type != "web" or not tool_type.default_port:
|
|
||||||
instance.url = None
|
|
||||||
instance.public_url = None
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
tunnel_info = tunnel_svc.start_cloudflared_tunnel(
|
|
||||||
container_name=instance.container_name or instance.name,
|
|
||||||
port=tool_type.default_port,
|
|
||||||
)
|
|
||||||
instance.tunnel_id = tunnel_info["pid"]
|
|
||||||
instance.public_url = tunnel_info["url"]
|
|
||||||
instance.url = tunnel_info["url"]
|
|
||||||
logger.info(
|
|
||||||
"Created tunnel for instance %s: %s", instance.id, tunnel_info["url"]
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc)
|
|
||||||
instance.status = "error"
|
|
||||||
instance.url = None
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_readiness_probe(
|
|
||||||
instance: ToolInstance, tool_type: ToolType
|
|
||||||
) -> tuple[bool, list[str]]:
|
|
||||||
"""Run readiness probe if configured."""
|
|
||||||
if not tool_type.readiness_probe or not instance.container_id:
|
|
||||||
return True, []
|
|
||||||
|
|
||||||
probe = tool_type.readiness_probe
|
|
||||||
command = probe.get("command", "")
|
|
||||||
if not command:
|
|
||||||
return True, []
|
|
||||||
|
|
||||||
return await execute_probe(
|
|
||||||
container_id=instance.container_id,
|
|
||||||
command=command,
|
|
||||||
timeout=probe.get("timeout", 30),
|
|
||||||
interval=probe.get("interval", 2),
|
|
||||||
)
|
|
||||||
@@ -26,7 +26,7 @@ def resolve_base(manifest: dict) -> dict:
|
|||||||
result = deepcopy(manifest)
|
result = deepcopy(manifest)
|
||||||
|
|
||||||
base_definition_id = result.pop("base_definition_id", None)
|
base_definition_id = result.pop("base_definition_id", None)
|
||||||
result.pop("base_version", None)
|
base_version = result.pop("base_version", "latest")
|
||||||
|
|
||||||
if base_definition_id:
|
if base_definition_id:
|
||||||
# This will be provided by the caller (they have the DB session)
|
# This will be provided by the caller (they have the DB session)
|
||||||
@@ -118,11 +118,6 @@ def compile_dockerfile(manifest: dict) -> str:
|
|||||||
|
|
||||||
# System packages (apt)
|
# System packages (apt)
|
||||||
apt_packages = manifest.get("packages", {}).get("apt", [])
|
apt_packages = manifest.get("packages", {}).get("apt", [])
|
||||||
if manifest.get("user"):
|
|
||||||
# Ensure sudo is available for permission-fixing startup scripts
|
|
||||||
apt_packages = list(apt_packages)
|
|
||||||
if "sudo" not in apt_packages:
|
|
||||||
apt_packages.append("sudo")
|
|
||||||
if apt_packages:
|
if apt_packages:
|
||||||
lines.append("RUN apt-get update && apt-get install -y \\")
|
lines.append("RUN apt-get update && apt-get install -y \\")
|
||||||
for pkg in apt_packages[:-1]:
|
for pkg in apt_packages[:-1]:
|
||||||
@@ -172,17 +167,6 @@ def compile_dockerfile(manifest: dict) -> str:
|
|||||||
lines.append(f"ENV HOME={home}")
|
lines.append(f"ENV HOME={home}")
|
||||||
lines.append(f"ENV USER={name}")
|
lines.append(f"ENV USER={name}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
# Ensure home directory exists and is writable by the user
|
|
||||||
lines.append(
|
|
||||||
f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
|
|
||||||
)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Configure passwordless sudo so startup scripts can fix permissions
|
|
||||||
lines.append(
|
|
||||||
f'RUN echo "{name} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/{name} && chmod 0440 /etc/sudoers.d/{name}'
|
|
||||||
)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Build scripts
|
# Build scripts
|
||||||
build_scripts = manifest.get("scripts", {}).get("build", [])
|
build_scripts = manifest.get("scripts", {}).get("build", [])
|
||||||
@@ -197,11 +181,6 @@ def compile_dockerfile(manifest: dict) -> str:
|
|||||||
if build_scripts:
|
if build_scripts:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# After build scripts, ensure everything in home is owned by the user
|
|
||||||
if user and build_scripts:
|
|
||||||
lines.append(f"RUN chown -R {name}:{name} {home}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Create mount target directories
|
# Create mount target directories
|
||||||
mounts = manifest.get("mounts", [])
|
mounts = manifest.get("mounts", [])
|
||||||
if mounts:
|
if mounts:
|
||||||
@@ -329,21 +308,7 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
|||||||
service["volumes"] = sort_volumes_by_specificity(volumes)
|
service["volumes"] = sort_volumes_by_specificity(volumes)
|
||||||
|
|
||||||
compose = {"services": {"app": service}}
|
compose = {"services": {"app": service}}
|
||||||
result = yaml.dump(compose, default_flow_style=False)
|
return yaml.dump(compose, default_flow_style=False)
|
||||||
|
|
||||||
# Debug: log mount resolution so we can diagnose missing mounts
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.debug(
|
|
||||||
"compile_compose: REPO_PATH=%s SSH_PATH=%s EXTRA_VOLUMES=%s mounts=%s volumes=%s",
|
|
||||||
variables.get("REPO_PATH", "<empty>"),
|
|
||||||
variables.get("SSH_PATH", "<empty>"),
|
|
||||||
variables.get("EXTRA_VOLUMES", []),
|
|
||||||
manifest.get("mounts", []),
|
|
||||||
volumes,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
"""Profile resolver service for recursive ordered include resolution.
|
|
||||||
|
|
||||||
Provides deterministic merge rules, save-independent cycle protection,
|
|
||||||
and resolved output structures for env vars, runtime hints, mounts,
|
|
||||||
file trees, and override metadata.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResolvedMount:
|
|
||||||
"""A resolved mount with merged file tree and final mode."""
|
|
||||||
|
|
||||||
target_path: str
|
|
||||||
mode: str # "ro" or "rw"
|
|
||||||
files: dict[str, str] = field(default_factory=dict)
|
|
||||||
"""Relative file paths to UTF-8 text content."""
|
|
||||||
overridden_files: dict[str, list[str]] = field(default_factory=dict)
|
|
||||||
"""Map of relative file path to list of profile names that contributed
|
|
||||||
(latest is the winner)."""
|
|
||||||
mode_overridden_by: str | None = None
|
|
||||||
"""Name of the profile that set the final mode, if different from first."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResolvedRuntimeHints:
|
|
||||||
"""Resolved runtime hints from profile layers."""
|
|
||||||
|
|
||||||
start_command: str | None = None
|
|
||||||
working_directory: str | None = None
|
|
||||||
port: int | None = None
|
|
||||||
overridden_hints: dict[str, str] = field(default_factory=dict)
|
|
||||||
"""Map of hint key to profile name that provided the winning value."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResolvedProfileOutput:
|
|
||||||
"""Complete resolved output for a config profile."""
|
|
||||||
|
|
||||||
profile_id: uuid.UUID
|
|
||||||
profile_name: str
|
|
||||||
environment_variables: dict[str, str] = field(default_factory=dict)
|
|
||||||
"""Final merged env vars (later layers win)."""
|
|
||||||
env_var_sources: dict[str, list[str]] = field(default_factory=dict)
|
|
||||||
"""Map of env var key to ordered list of contributing profile names
|
|
||||||
(latest is the winner)."""
|
|
||||||
runtime_hints: ResolvedRuntimeHints = field(
|
|
||||||
default_factory=lambda: ResolvedRuntimeHints()
|
|
||||||
)
|
|
||||||
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
|
||||||
"""Map of target_path to ResolvedMount."""
|
|
||||||
resolution_order: list[str] = field(default_factory=list)
|
|
||||||
"""Ordered list of profile names as they were resolved."""
|
|
||||||
cycle_detected: bool = False
|
|
||||||
cycle_path: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileResolutionError(Exception):
|
|
||||||
"""Raised when profile resolution fails."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileCycleError(ProfileResolutionError):
|
|
||||||
"""Raised when a cycle is detected during profile resolution."""
|
|
||||||
|
|
||||||
def __init__(self, cycle_path: list[str]) -> None:
|
|
||||||
self.cycle_path = cycle_path
|
|
||||||
path_str = " -> ".join(cycle_path)
|
|
||||||
super().__init__(f"Profile include cycle detected: {path_str}")
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_env_vars(
|
|
||||||
current: dict[str, str],
|
|
||||||
sources: dict[str, list[str]],
|
|
||||||
profile: ConfigProfile,
|
|
||||||
) -> None:
|
|
||||||
"""Merge a profile's env vars into the current dict, tracking sources."""
|
|
||||||
if not profile.environment_variables:
|
|
||||||
return
|
|
||||||
for key, value in profile.environment_variables.items():
|
|
||||||
current[key] = value
|
|
||||||
if key not in sources:
|
|
||||||
sources[key] = []
|
|
||||||
sources[key].append(profile.name)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_runtime_hints(
|
|
||||||
hints: ResolvedRuntimeHints,
|
|
||||||
profile: ConfigProfile,
|
|
||||||
) -> None:
|
|
||||||
"""Merge a profile's runtime hints, tracking overrides."""
|
|
||||||
if profile.start_command is not None:
|
|
||||||
hints.start_command = profile.start_command
|
|
||||||
hints.overridden_hints["start_command"] = profile.name
|
|
||||||
if profile.working_directory is not None:
|
|
||||||
hints.working_directory = profile.working_directory
|
|
||||||
hints.overridden_hints["working_directory"] = profile.name
|
|
||||||
if profile.port is not None:
|
|
||||||
hints.port = profile.port
|
|
||||||
hints.overridden_hints["port"] = profile.name
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_mounts(
|
|
||||||
mounts: dict[str, ResolvedMount],
|
|
||||||
profile_mounts: list[ConfigMount],
|
|
||||||
profile: ConfigProfile,
|
|
||||||
) -> None:
|
|
||||||
"""Merge a profile's mounts into the current mounts dict."""
|
|
||||||
for mount in profile_mounts:
|
|
||||||
target = mount.target_path
|
|
||||||
if target not in mounts:
|
|
||||||
mounts[target] = ResolvedMount(
|
|
||||||
target_path=target,
|
|
||||||
mode=mount.mode,
|
|
||||||
files={},
|
|
||||||
overridden_files={},
|
|
||||||
)
|
|
||||||
resolved = mounts[target]
|
|
||||||
|
|
||||||
# Mode override: later wins
|
|
||||||
if resolved.mode != mount.mode:
|
|
||||||
resolved.mode = mount.mode
|
|
||||||
resolved.mode_overridden_by = profile.name
|
|
||||||
|
|
||||||
# File tree merge: later wins for same relative path
|
|
||||||
if mount.files:
|
|
||||||
for rel_path, content in mount.files.items():
|
|
||||||
if rel_path not in resolved.files:
|
|
||||||
resolved.overridden_files[rel_path] = []
|
|
||||||
else:
|
|
||||||
if rel_path not in resolved.overridden_files:
|
|
||||||
resolved.overridden_files[rel_path] = []
|
|
||||||
resolved.overridden_files[rel_path].append(profile.name)
|
|
||||||
resolved.files[rel_path] = content
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_profile_recursive(
|
|
||||||
profile: ConfigProfile,
|
|
||||||
visited: set[uuid.UUID],
|
|
||||||
path: list[str],
|
|
||||||
resolution_order: list[str],
|
|
||||||
env_vars: dict[str, str],
|
|
||||||
env_var_sources: dict[str, list[str]],
|
|
||||||
runtime_hints: ResolvedRuntimeHints,
|
|
||||||
mounts: dict[str, ResolvedMount],
|
|
||||||
) -> None:
|
|
||||||
"""Recursively resolve a profile and its includes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: The profile to resolve
|
|
||||||
visited: Set of already-resolved profile IDs to avoid duplicates
|
|
||||||
path: Current recursion path for cycle detection
|
|
||||||
resolution_order: Ordered list of profile names being resolved
|
|
||||||
env_vars: Accumulated environment variables
|
|
||||||
env_var_sources: Tracking of which profiles contributed each env var
|
|
||||||
runtime_hints: Accumulated runtime hints
|
|
||||||
mounts: Accumulated mounts
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ProfileCycleError: If a cycle is detected
|
|
||||||
"""
|
|
||||||
if profile.name in path:
|
|
||||||
# Cycle detected
|
|
||||||
cycle_start = path.index(profile.name)
|
|
||||||
cycle_path = path[cycle_start:] + [profile.name]
|
|
||||||
raise ProfileCycleError(cycle_path)
|
|
||||||
|
|
||||||
if profile.id in visited:
|
|
||||||
# Already resolved in another branch (diamond graph)
|
|
||||||
return
|
|
||||||
|
|
||||||
visited.add(profile.id)
|
|
||||||
path.append(profile.name)
|
|
||||||
resolution_order.append(profile.name)
|
|
||||||
|
|
||||||
# Resolve includes first (in order)
|
|
||||||
includes: list[ConfigInclude] = list(profile.includes)
|
|
||||||
includes.sort(key=lambda inc: inc.order_index)
|
|
||||||
for include in includes:
|
|
||||||
included_profile = include.included_profile
|
|
||||||
if included_profile is not None:
|
|
||||||
_resolve_profile_recursive(
|
|
||||||
included_profile,
|
|
||||||
visited,
|
|
||||||
path,
|
|
||||||
resolution_order,
|
|
||||||
env_vars,
|
|
||||||
env_var_sources,
|
|
||||||
runtime_hints,
|
|
||||||
mounts,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply this profile's values (later layers win)
|
|
||||||
_merge_env_vars(env_vars, env_var_sources, profile)
|
|
||||||
_merge_runtime_hints(runtime_hints, profile)
|
|
||||||
_merge_mounts(mounts, list(profile.mounts), profile)
|
|
||||||
|
|
||||||
path.pop()
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput:
|
|
||||||
"""Resolve a config profile with all its includes.
|
|
||||||
|
|
||||||
Processes included profiles in configured order, then applies the
|
|
||||||
selected profile itself. Later layers override earlier layers.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: The root profile to resolve
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ResolvedProfileOutput with merged env vars, runtime hints, mounts,
|
|
||||||
and override metadata
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ProfileCycleError: If a cycle is detected in the include graph
|
|
||||||
"""
|
|
||||||
env_vars: dict[str, str] = {}
|
|
||||||
env_var_sources: dict[str, list[str]] = {}
|
|
||||||
runtime_hints = ResolvedRuntimeHints()
|
|
||||||
mounts: dict[str, ResolvedMount] = {}
|
|
||||||
resolution_order: list[str] = []
|
|
||||||
|
|
||||||
_resolve_profile_recursive(
|
|
||||||
profile,
|
|
||||||
set(),
|
|
||||||
[],
|
|
||||||
resolution_order,
|
|
||||||
env_vars,
|
|
||||||
env_var_sources,
|
|
||||||
runtime_hints,
|
|
||||||
mounts,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ResolvedProfileOutput(
|
|
||||||
profile_id=profile.id,
|
|
||||||
profile_name=profile.name,
|
|
||||||
environment_variables=env_vars,
|
|
||||||
env_var_sources=env_var_sources,
|
|
||||||
runtime_hints=runtime_hints,
|
|
||||||
mounts=mounts,
|
|
||||||
resolution_order=resolution_order,
|
|
||||||
)
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -23,28 +22,12 @@ def _get_fernet() -> Fernet:
|
|||||||
return Fernet(key)
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_filename(name: str) -> str:
|
|
||||||
"""Sanitize a string for use as a filename.
|
|
||||||
|
|
||||||
Replaces non-alphanumeric characters with underscores and strips
|
|
||||||
leading/trailing underscores.
|
|
||||||
"""
|
|
||||||
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
|
|
||||||
sanitized = sanitized.strip("_")
|
|
||||||
# Ensure it's not empty
|
|
||||||
if not sanitized:
|
|
||||||
sanitized = "key"
|
|
||||||
return sanitized
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_ssh_key_files(
|
def prepare_ssh_key_files(
|
||||||
instance_dir: str,
|
instance_dir: str,
|
||||||
ssh_key,
|
ssh_key,
|
||||||
subdir: str = ".ssh",
|
subdir: str = ".ssh",
|
||||||
uid: int | None = None,
|
uid: int | None = None,
|
||||||
gid: int | None = None,
|
gid: int | None = None,
|
||||||
key_filename: str = "id_ed25519",
|
|
||||||
write_config: bool = True,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Decrypt and write SSH key files to instance directory for container mounting.
|
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||||
|
|
||||||
@@ -54,12 +37,6 @@ def prepare_ssh_key_files(
|
|||||||
subdir: Subdirectory within instance_dir to write to (default: ".ssh")
|
subdir: Subdirectory within instance_dir to write to (default: ".ssh")
|
||||||
uid: Optional UID to own the files (for bind-mount into non-root container)
|
uid: Optional UID to own the files (for bind-mount into non-root container)
|
||||||
gid: Optional GID to own the files
|
gid: Optional GID to own the files
|
||||||
key_filename: Base filename for the key pair (default: "id_ed25519").
|
|
||||||
The private key will be named "{key_filename}" and the public key
|
|
||||||
"{key_filename}.pub".
|
|
||||||
write_config: Whether to write an SSH config file (default: True).
|
|
||||||
Set to False when combining multiple keys into one directory,
|
|
||||||
then call write_ssh_config() separately.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to the .ssh directory
|
Path to the .ssh directory
|
||||||
@@ -72,101 +49,51 @@ def prepare_ssh_key_files(
|
|||||||
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
# Write private key with restricted permissions
|
# Write private key with restricted permissions
|
||||||
private_key_path = ssh_dir / key_filename
|
private_key_path = ssh_dir / "id_ed25519"
|
||||||
private_key_path.write_text(private_key)
|
private_key_path.write_text(private_key)
|
||||||
os.chmod(private_key_path, 0o600)
|
os.chmod(private_key_path, 0o600)
|
||||||
|
|
||||||
# Write public key
|
# Write public key
|
||||||
public_key_path = ssh_dir / f"{key_filename}.pub"
|
public_key_path = ssh_dir / "id_ed25519.pub"
|
||||||
public_key_path.write_text(ssh_key.public_key)
|
public_key_path.write_text(ssh_key.public_key)
|
||||||
os.chmod(public_key_path, 0o644)
|
os.chmod(public_key_path, 0o644)
|
||||||
|
|
||||||
# Write SSH config (only if requested)
|
# Write SSH config
|
||||||
if write_config:
|
config_path = ssh_dir / "config"
|
||||||
config_path = ssh_dir / "config"
|
config_content = """Host *
|
||||||
config_content = f"""Host *
|
|
||||||
StrictHostKeyChecking no
|
StrictHostKeyChecking no
|
||||||
UserKnownHostsFile /dev/null
|
UserKnownHostsFile /dev/null
|
||||||
IdentityFile ~/.ssh/{key_filename}
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
"""
|
"""
|
||||||
config_path.write_text(config_content)
|
|
||||||
os.chmod(config_path, 0o644)
|
|
||||||
|
|
||||||
# Set ownership to target container user if requested
|
|
||||||
if uid is not None or gid is not None:
|
|
||||||
effective_uid = uid if uid is not None else -1
|
|
||||||
effective_gid = gid if gid is not None else -1
|
|
||||||
try:
|
|
||||||
os.chown(ssh_dir, effective_uid, effective_gid)
|
|
||||||
os.chown(private_key_path, effective_uid, effective_gid)
|
|
||||||
os.chown(public_key_path, effective_uid, effective_gid)
|
|
||||||
os.chown(config_path, effective_uid, effective_gid)
|
|
||||||
logger.debug(
|
|
||||||
"Set SSH key ownership to uid=%s gid=%s for %s",
|
|
||||||
effective_uid,
|
|
||||||
effective_gid,
|
|
||||||
ssh_dir,
|
|
||||||
)
|
|
||||||
except PermissionError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
|
|
||||||
effective_uid,
|
|
||||||
effective_gid,
|
|
||||||
os.getuid(),
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Still chown the key files even if we didn't write config
|
|
||||||
if uid is not None or gid is not None:
|
|
||||||
effective_uid = uid if uid is not None else -1
|
|
||||||
effective_gid = gid if gid is not None else -1
|
|
||||||
try:
|
|
||||||
os.chown(private_key_path, effective_uid, effective_gid)
|
|
||||||
os.chown(public_key_path, effective_uid, effective_gid)
|
|
||||||
except PermissionError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return str(ssh_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def write_ssh_config(
|
|
||||||
ssh_dir: str,
|
|
||||||
key_filenames: list[str],
|
|
||||||
uid: int | None = None,
|
|
||||||
gid: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Write an SSH config file that includes multiple IdentityFile entries.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ssh_dir: Path to the .ssh directory
|
|
||||||
key_filenames: List of key filenames (without .pub extension)
|
|
||||||
uid: Optional UID to own the config file
|
|
||||||
gid: Optional GID to own the config file
|
|
||||||
"""
|
|
||||||
ssh_dir_path = Path(ssh_dir)
|
|
||||||
ssh_dir_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
config_path = ssh_dir_path / "config"
|
|
||||||
lines = ["Host *"]
|
|
||||||
lines.append(" StrictHostKeyChecking no")
|
|
||||||
lines.append(" UserKnownHostsFile /dev/null")
|
|
||||||
lines.append(" IdentitiesOnly yes")
|
|
||||||
for filename in key_filenames:
|
|
||||||
lines.append(f" IdentityFile ~/.ssh/{filename}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
config_content = "\n".join(lines)
|
|
||||||
config_path.write_text(config_content)
|
config_path.write_text(config_content)
|
||||||
os.chmod(config_path, 0o644)
|
os.chmod(config_path, 0o644)
|
||||||
|
|
||||||
|
# Set ownership to target container user if requested
|
||||||
if uid is not None or gid is not None:
|
if uid is not None or gid is not None:
|
||||||
effective_uid = uid if uid is not None else -1
|
effective_uid = uid if uid is not None else -1
|
||||||
effective_gid = gid if gid is not None else -1
|
effective_gid = gid if gid is not None else -1
|
||||||
try:
|
try:
|
||||||
|
os.chown(ssh_dir, effective_uid, effective_gid)
|
||||||
|
os.chown(private_key_path, effective_uid, effective_gid)
|
||||||
|
os.chown(public_key_path, effective_uid, effective_gid)
|
||||||
os.chown(config_path, effective_uid, effective_gid)
|
os.chown(config_path, effective_uid, effective_gid)
|
||||||
except PermissionError:
|
logger.debug(
|
||||||
pass
|
"Set SSH key ownership to uid=%s gid=%s for %s",
|
||||||
|
effective_uid,
|
||||||
|
effective_gid,
|
||||||
|
ssh_dir,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
|
||||||
|
effective_uid,
|
||||||
|
effective_gid,
|
||||||
|
os.getuid(),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
return str(ssh_dir)
|
||||||
|
|
||||||
|
|
||||||
def cleanup_ssh_key_files(instance_dir: str) -> None:
|
def cleanup_ssh_key_files(instance_dir: str) -> None:
|
||||||
|
|||||||
@@ -1,193 +1,417 @@
|
|||||||
"""Terminal session manager for WebSocket connections."""
|
"""Terminal session manager for WebSocket connections."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Coroutine
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.models.terminal_session import TerminalSessionModel
|
||||||
from src.services.terminal_session import TerminalSession
|
from src.services.terminal_session import TerminalSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_READ_BATCH_INTERVAL_S = 0.016 # 16ms max batching delay
|
|
||||||
_READ_POLL_TIMEOUT_S = 0.005
|
class MaxSessionsExceededError(Exception):
|
||||||
_READ_POLL_SLEEP_S = 0.001
|
"""Raised when the maximum number of terminal sessions per instance is reached."""
|
||||||
_HEARTBEAT_INTERVAL_S = 15.0
|
|
||||||
_IDLE_TIMEOUT_S = 60.0
|
def __init__(self, instance_id: str, max_sessions: int = 5) -> None:
|
||||||
|
self.instance_id = instance_id
|
||||||
|
self.max_sessions = max_sessions
|
||||||
|
super().__init__(
|
||||||
|
f"Maximum of {max_sessions} terminal sessions reached for instance {instance_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TerminalManager:
|
class TerminalManager:
|
||||||
"""Manages active terminal sessions."""
|
"""Manages active terminal sessions with persistence support."""
|
||||||
|
|
||||||
|
# Maximum sessions per tool instance
|
||||||
|
MAX_SESSIONS_PER_INSTANCE = 5
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialise the terminal manager."""
|
# Track sessions by (instance_id, session_id) for multi-session support
|
||||||
self._sessions: dict[str, TerminalSession] = {}
|
self._sessions: dict[tuple[str, str], TerminalSession] = {}
|
||||||
self._last_client_message: dict[str, float] = {}
|
self._idle_check_task: asyncio.Task | None = None
|
||||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
self._start_idle_check()
|
||||||
|
|
||||||
|
def _start_idle_check(self) -> None:
|
||||||
|
"""Start the idle timeout background task."""
|
||||||
|
if self._idle_check_task is not None and not self._idle_check_task.done():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._idle_check_task = loop.create_task(self._idle_check_loop())
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop running yet, will be started lazily
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _idle_check_loop(self) -> None:
|
||||||
|
"""Periodically check for idle sessions and clean them up."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(60) # Check every minute
|
||||||
|
await self._cleanup_idle_sessions()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in idle check loop: %s", exc)
|
||||||
|
|
||||||
|
async def _cleanup_idle_sessions(self) -> None:
|
||||||
|
"""Clean up sessions that have been idle for too long."""
|
||||||
|
idle_keys = []
|
||||||
|
for (instance_id, session_id), session in list(self._sessions.items()):
|
||||||
|
if session.is_idle():
|
||||||
|
idle_keys.append((instance_id, session_id))
|
||||||
|
|
||||||
|
for key in idle_keys:
|
||||||
|
instance_id, session_id = key
|
||||||
|
logger.info(
|
||||||
|
"Cleaning up idle terminal session %s for instance %s",
|
||||||
|
session_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
session = self._sessions.pop(key, None)
|
||||||
|
if session:
|
||||||
|
await session.close()
|
||||||
|
# Update DB status fire-and-forget
|
||||||
|
asyncio.create_task(self._mark_closed_in_db(session_id))
|
||||||
|
|
||||||
|
async def _insert_db_session_row(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Insert a TerminalSessionModel row into the database."""
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as db_session:
|
||||||
|
db_row = TerminalSessionModel(
|
||||||
|
id=uuid.UUID(session_id),
|
||||||
|
instance_id=instance_id,
|
||||||
|
name=name,
|
||||||
|
status="active",
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
last_activity_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db_session.add(db_row)
|
||||||
|
await db_session.commit()
|
||||||
|
logger.debug(
|
||||||
|
"Inserted terminal session row %s for instance %s",
|
||||||
|
session_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to insert terminal session row: %s", exc)
|
||||||
|
|
||||||
|
async def _mark_closed_in_db(self, session_id: str) -> None:
|
||||||
|
"""Mark a terminal session as closed in the database."""
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as db_session:
|
||||||
|
db_row = await db_session.get(
|
||||||
|
TerminalSessionModel, uuid.UUID(session_id)
|
||||||
|
)
|
||||||
|
if db_row:
|
||||||
|
db_row.status = "closed"
|
||||||
|
db_row.closed_at = datetime.now(timezone.utc)
|
||||||
|
await db_session.commit()
|
||||||
|
logger.debug(
|
||||||
|
"Marked terminal session %s as closed in DB", session_id
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to mark terminal session as closed in DB: %s", exc)
|
||||||
|
|
||||||
|
def _count_sessions_for_instance(self, instance_id_str: str) -> int:
|
||||||
|
"""Count active in-memory sessions for a given instance."""
|
||||||
|
return sum(1 for (iid, _sid) in self._sessions if iid == instance_id_str)
|
||||||
|
|
||||||
async def create_session(
|
async def create_session(
|
||||||
self,
|
self,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
container_id: str,
|
container_id: str,
|
||||||
websocket: WebSocket,
|
startup_command: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
) -> TerminalSession:
|
) -> TerminalSession:
|
||||||
"""Create a new terminal session."""
|
"""Create a new terminal session for an instance.
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
session = TerminalSession(session_id, instance_id, container_id)
|
|
||||||
await session.start()
|
|
||||||
self._sessions[session_id] = session
|
|
||||||
self._last_client_message[session_id] = time.monotonic()
|
|
||||||
|
|
||||||
# Start background tasks for I/O streaming
|
Enforces a maximum of MAX_SESSIONS_PER_INSTANCE sessions per instance.
|
||||||
self._start_task(self._read_loop(session, websocket))
|
Inserts a DB row fire-and-forget.
|
||||||
self._start_task(self._write_loop(session, websocket))
|
|
||||||
self._start_task(self._heartbeat_loop(session, websocket))
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
container_id: Docker container ID.
|
||||||
|
startup_command: Optional startup command to run.
|
||||||
|
name: Optional session name (auto-generated if omitted).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created TerminalSession.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MaxSessionsExceededError: If the instance already has max sessions.
|
||||||
|
"""
|
||||||
|
instance_id_str = str(instance_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._count_sessions_for_instance(instance_id_str)
|
||||||
|
>= self.MAX_SESSIONS_PER_INSTANCE
|
||||||
|
):
|
||||||
|
raise MaxSessionsExceededError(
|
||||||
|
instance_id_str, self.MAX_SESSIONS_PER_INSTANCE
|
||||||
|
)
|
||||||
|
|
||||||
|
if session_id is None:
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session = TerminalSession(
|
||||||
|
session_id=session_id,
|
||||||
|
instance_id=instance_id,
|
||||||
|
container_id=container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
await session.start(startup_command=startup_command)
|
||||||
|
|
||||||
|
key = (instance_id_str, session_id)
|
||||||
|
self._sessions[key] = session
|
||||||
|
|
||||||
|
# Fire-and-forget DB insert (skip if row already exists)
|
||||||
|
asyncio.create_task(
|
||||||
|
self._insert_db_session_row(session_id, instance_id, session.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Created terminal session %s for instance %s (name=%s)",
|
||||||
|
session_id,
|
||||||
|
instance_id,
|
||||||
|
session.name,
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def get_or_create_session(
|
||||||
|
self,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
container_id: str,
|
||||||
|
startup_command: str | None = None,
|
||||||
|
) -> TerminalSession:
|
||||||
|
"""Get existing session or create a new one.
|
||||||
|
|
||||||
|
Backward-compatible alias that uses 'default' as the session_id.
|
||||||
|
"""
|
||||||
|
# Ensure idle check is running (lazy start)
|
||||||
|
self._start_idle_check()
|
||||||
|
|
||||||
|
instance_id_str = str(instance_id)
|
||||||
|
key = (instance_id_str, "default")
|
||||||
|
|
||||||
|
# Check for existing default session
|
||||||
|
if key in self._sessions:
|
||||||
|
session = self._sessions[key]
|
||||||
|
|
||||||
|
# Check if session is still alive
|
||||||
|
if session.is_alive():
|
||||||
|
logger.debug(
|
||||||
|
"Reattaching to existing terminal session for instance %s",
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
else:
|
||||||
|
# Session died, clean it up
|
||||||
|
logger.debug(
|
||||||
|
"Existing session for instance %s is dead, cleaning up",
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
await session.close()
|
||||||
|
del self._sessions[key]
|
||||||
|
|
||||||
|
# Create new default session
|
||||||
|
logger.info(
|
||||||
|
"Creating new default terminal session for instance %s", instance_id
|
||||||
|
)
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session = TerminalSession(
|
||||||
|
session_id=session_id,
|
||||||
|
instance_id=instance_id,
|
||||||
|
container_id=container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
name="Session 1",
|
||||||
|
)
|
||||||
|
await session.start(startup_command=startup_command)
|
||||||
|
self._sessions[key] = session
|
||||||
|
|
||||||
|
# Fire-and-forget DB insert
|
||||||
|
asyncio.create_task(
|
||||||
|
self._insert_db_session_row(session_id, instance_id, session.name)
|
||||||
|
)
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def _start_task(self, coro: Coroutine[Any, Any, None]) -> None:
|
def get_session(
|
||||||
"""Start a background task and store a reference to prevent GC."""
|
|
||||||
task = asyncio.create_task(coro)
|
|
||||||
self._background_tasks.add(task)
|
|
||||||
task.add_done_callback(self._background_tasks.discard)
|
|
||||||
|
|
||||||
async def _read_loop(
|
|
||||||
self,
|
self,
|
||||||
session: TerminalSession,
|
instance_id: str,
|
||||||
websocket: WebSocket,
|
session_id: str,
|
||||||
) -> None:
|
) -> TerminalSession | None:
|
||||||
"""Read output from the container and send to WebSocket with batching."""
|
"""Lookup a session by composite key, or by internal session_id."""
|
||||||
try:
|
session = self._sessions.get((instance_id, session_id))
|
||||||
buffer = bytearray()
|
if session is not None:
|
||||||
last_flush = time.monotonic()
|
return session
|
||||||
|
# Fallback: search by internal TerminalSession.session_id
|
||||||
|
for (iid, _sid), sess in self._sessions.items():
|
||||||
|
if iid == instance_id and sess.session_id == session_id:
|
||||||
|
return sess
|
||||||
|
return None
|
||||||
|
|
||||||
while session.is_alive() and not session.closed:
|
def _find_key_by_internal_id(
|
||||||
data = await session.read_output(select_timeout=_READ_POLL_TIMEOUT_S)
|
|
||||||
if data:
|
|
||||||
buffer.extend(data)
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
flush_due = buffer and (
|
|
||||||
now - last_flush >= _READ_BATCH_INTERVAL_S or not data
|
|
||||||
)
|
|
||||||
|
|
||||||
if flush_due:
|
|
||||||
await websocket.send_bytes(bytes(buffer))
|
|
||||||
buffer.clear()
|
|
||||||
last_flush = now
|
|
||||||
elif not data:
|
|
||||||
await asyncio.sleep(_READ_POLL_SLEEP_S)
|
|
||||||
|
|
||||||
# Flush any remaining data
|
|
||||||
if buffer:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await websocket.send_bytes(bytes(buffer))
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Read loop error for session %s", session.session_id)
|
|
||||||
finally:
|
|
||||||
await self._cleanup_session(session)
|
|
||||||
|
|
||||||
async def _write_loop(
|
|
||||||
self,
|
self,
|
||||||
session: TerminalSession,
|
instance_id: str,
|
||||||
websocket: WebSocket,
|
internal_session_id: str,
|
||||||
) -> None:
|
) -> tuple[str, str] | None:
|
||||||
"""Read input from WebSocket and send to container."""
|
"""Find the manager dict key for a session by its internal session_id."""
|
||||||
try:
|
for (iid, sid), session in self._sessions.items():
|
||||||
while session.is_alive() and not session.closed:
|
if iid == instance_id and session.session_id == internal_session_id:
|
||||||
message = await websocket.receive()
|
return (iid, sid)
|
||||||
self._last_client_message[session.session_id] = time.monotonic()
|
return None
|
||||||
|
|
||||||
if message["type"] == "websocket.receive":
|
def get_sessions_for_instance(
|
||||||
if "bytes" in message:
|
|
||||||
await session.write_input(message["bytes"])
|
|
||||||
elif "text" in message:
|
|
||||||
text = message["text"]
|
|
||||||
if text.startswith("{"):
|
|
||||||
try:
|
|
||||||
ctrl = json.loads(text)
|
|
||||||
await self._handle_control_message(
|
|
||||||
session,
|
|
||||||
websocket,
|
|
||||||
ctrl,
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.debug("Invalid JSON control message: %s", text)
|
|
||||||
else:
|
|
||||||
await session.write_input(text.encode("utf-8"))
|
|
||||||
elif message["type"] == "websocket.disconnect":
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Write loop error for session %s", session.session_id)
|
|
||||||
finally:
|
|
||||||
await self._cleanup_session(session)
|
|
||||||
|
|
||||||
async def _handle_control_message(
|
|
||||||
self,
|
self,
|
||||||
session: TerminalSession,
|
instance_id: str,
|
||||||
websocket: WebSocket,
|
) -> list[TerminalSession]:
|
||||||
ctrl: dict[str, Any],
|
"""Return all in-memory sessions for a given instance."""
|
||||||
|
return [
|
||||||
|
session
|
||||||
|
for (iid, _sid), session in self._sessions.items()
|
||||||
|
if iid == instance_id
|
||||||
|
]
|
||||||
|
|
||||||
|
async def close_session(
|
||||||
|
self,
|
||||||
|
instance_id: str,
|
||||||
|
session_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a JSON control message from the client."""
|
"""Close a specific session and update its DB status."""
|
||||||
msg_type = ctrl.get("type")
|
key = (instance_id, session_id)
|
||||||
if msg_type == "resize":
|
session = self._sessions.pop(key, None)
|
||||||
await session.resize(
|
if session:
|
||||||
ctrl.get("cols", 80),
|
await session.close()
|
||||||
ctrl.get("rows", 24),
|
# Fire-and-forget DB update
|
||||||
)
|
asyncio.create_task(self._mark_closed_in_db(session_id))
|
||||||
elif msg_type == "ping":
|
logger.info(
|
||||||
await websocket.send_json(
|
"Closed terminal session %s for instance %s",
|
||||||
{"type": "pong", "id": ctrl.get("id")},
|
session_id,
|
||||||
|
instance_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _heartbeat_loop(
|
async def attach_websocket(
|
||||||
self,
|
self,
|
||||||
session: TerminalSession,
|
session: TerminalSession,
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Monitor client activity and close idle connections."""
|
"""Attach a WebSocket to an existing session.
|
||||||
try:
|
|
||||||
while session.is_alive() and not session.closed:
|
Closes existing WebSocket connections only for this specific session.
|
||||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
"""
|
||||||
last_msg = self._last_client_message.get(session.session_id, 0)
|
# Handle concurrent connections - close existing ones within the same session
|
||||||
if time.monotonic() - last_msg > _IDLE_TIMEOUT_S:
|
if session.has_websockets():
|
||||||
# Client has been silent for 60s — close connection
|
logger.debug(
|
||||||
with contextlib.suppress(Exception):
|
"Closing existing WebSocket connections for session %s (instance %s)",
|
||||||
await websocket.close(
|
|
||||||
code=1000,
|
|
||||||
reason="Idle timeout",
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Heartbeat loop error for session %s",
|
|
||||||
session.session_id,
|
session.session_id,
|
||||||
|
session.instance_id,
|
||||||
)
|
)
|
||||||
finally:
|
for ws in list(session._websockets):
|
||||||
await self._cleanup_session(session)
|
try:
|
||||||
|
await ws.close(code=4000, reason="New connection established")
|
||||||
|
except Exception:
|
||||||
|
pass # noqa: S110
|
||||||
|
session._websockets.clear()
|
||||||
|
|
||||||
async def _cleanup_session(self, session: TerminalSession) -> None:
|
# Attach new WebSocket
|
||||||
"""Clean up a session."""
|
session.attach_websocket(websocket)
|
||||||
if session.session_id in self._sessions:
|
|
||||||
del self._sessions[session.session_id]
|
# Replay buffer
|
||||||
self._last_client_message.pop(session.session_id, None)
|
buffer = session.get_buffer()
|
||||||
await session.close()
|
if buffer:
|
||||||
|
try:
|
||||||
|
await websocket.send_bytes(buffer)
|
||||||
|
except Exception:
|
||||||
|
pass # noqa: S110
|
||||||
|
|
||||||
|
async def detach_websocket(
|
||||||
|
self,
|
||||||
|
session: TerminalSession,
|
||||||
|
websocket: WebSocket,
|
||||||
|
) -> None:
|
||||||
|
"""Detach a WebSocket from a session."""
|
||||||
|
session.detach_websocket(websocket)
|
||||||
|
|
||||||
|
async def reset_session(
|
||||||
|
self,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
container_id: str,
|
||||||
|
startup_command: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> TerminalSession:
|
||||||
|
"""Reset a session by killing it and creating a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
container_id: Docker container ID.
|
||||||
|
startup_command: Optional startup command.
|
||||||
|
session_id: Specific session to reset. If None, resets the default session.
|
||||||
|
name: Optional name to preserve for the new session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created TerminalSession.
|
||||||
|
"""
|
||||||
|
instance_id_str = str(instance_id)
|
||||||
|
target_session_id = session_id or "default"
|
||||||
|
key = (instance_id_str, target_session_id)
|
||||||
|
|
||||||
|
# Preserve old name if not provided
|
||||||
|
old_name = name
|
||||||
|
if old_name is None and key in self._sessions:
|
||||||
|
old_name = self._sessions[key].name
|
||||||
|
|
||||||
|
# Close existing session if any
|
||||||
|
if key in self._sessions:
|
||||||
|
logger.debug(
|
||||||
|
"Resetting terminal session %s for instance %s",
|
||||||
|
target_session_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
old_session = self._sessions.pop(key)
|
||||||
|
await old_session.close()
|
||||||
|
# Fire-and-forget DB update for old session
|
||||||
|
asyncio.create_task(self._mark_closed_in_db(old_session.session_id))
|
||||||
|
|
||||||
|
# Create new session preserving the same session_id slot
|
||||||
|
new_session_id = str(uuid.uuid4())
|
||||||
|
new_session = TerminalSession(
|
||||||
|
session_id=new_session_id,
|
||||||
|
instance_id=instance_id,
|
||||||
|
container_id=container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
name=old_name or ("Session 1" if target_session_id == "default" else None),
|
||||||
|
)
|
||||||
|
await new_session.start(startup_command=startup_command)
|
||||||
|
self._sessions[key] = new_session
|
||||||
|
|
||||||
|
# Fire-and-forget DB insert
|
||||||
|
asyncio.create_task(
|
||||||
|
self._insert_db_session_row(new_session_id, instance_id, new_session.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_session
|
||||||
|
|
||||||
async def close_all(self) -> None:
|
async def close_all(self) -> None:
|
||||||
"""Close all active sessions."""
|
"""Close all active sessions."""
|
||||||
sessions = list(self._sessions.values())
|
sessions = list(self._sessions.values())
|
||||||
self._sessions.clear()
|
self._sessions.clear()
|
||||||
self._last_client_message.clear()
|
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
if self._idle_check_task and not self._idle_check_task.done():
|
||||||
|
self._idle_check_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
# Global terminal manager instance
|
# Global terminal manager instance
|
||||||
terminal_manager = TerminalManager()
|
terminal_manager = TerminalManager()
|
||||||
|
|||||||
@@ -1,158 +1,242 @@
|
|||||||
"""Terminal session management for tool instances."""
|
"""Terminal session management for tool instances."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import fcntl
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
import select
|
import select
|
||||||
|
import signal
|
||||||
import struct
|
import struct
|
||||||
import termios
|
import fcntl
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import deque
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TerminalSession:
|
class TerminalSession:
|
||||||
"""Manages a single terminal session connected to a docker container."""
|
"""Manages a single terminal session connected to a docker container.
|
||||||
|
|
||||||
|
Supports persistent sessions that survive WebSocket disconnections.
|
||||||
|
Multiple WebSocket connections can attach/detach from the same session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Circular buffer size (10KB)
|
||||||
|
BUFFER_SIZE = 10 * 1024
|
||||||
|
|
||||||
|
# Idle timeout in seconds (30 minutes)
|
||||||
|
IDLE_TIMEOUT = 30 * 60
|
||||||
|
|
||||||
|
# Session number counter per instance_id for auto-naming
|
||||||
|
_instance_counters: dict[str, int] = {}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
container_id: str,
|
container_id: str,
|
||||||
|
startup_command: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize a terminal session."""
|
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.instance_id = instance_id
|
self.instance_id = instance_id
|
||||||
self.container_id = container_id
|
self.container_id = container_id
|
||||||
|
self.startup_command = startup_command
|
||||||
self.process: asyncio.subprocess.Process | None = None
|
self.process: asyncio.subprocess.Process | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._master_fd: int | None = None
|
self._master_fd: int | None = None
|
||||||
self._slave_fd: int | None = None
|
self._slave_fd: int | None = None
|
||||||
self._echo_enabled = True
|
|
||||||
self._exit_reason: str | None = None
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
# Circular buffer for output replay
|
||||||
|
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||||
|
self._buffer_size = 0
|
||||||
|
|
||||||
|
# WebSocket connections
|
||||||
|
self._websockets: set[Any] = set()
|
||||||
|
|
||||||
|
# Activity tracking
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
# Terminal size
|
||||||
|
self._cols = 80
|
||||||
|
self._rows = 24
|
||||||
|
|
||||||
|
# Session metadata
|
||||||
|
self.name = name or self._generate_name(str(instance_id))
|
||||||
|
self.status: str = "active"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _generate_name(cls, instance_id: str) -> str:
|
||||||
|
"""Generate an auto-incremented session name for the instance."""
|
||||||
|
count = cls._instance_counters.get(instance_id, 0) + 1
|
||||||
|
cls._instance_counters[instance_id] = count
|
||||||
|
return f"Session {count}"
|
||||||
|
|
||||||
|
async def start(self, startup_command: str | None = None) -> None:
|
||||||
"""Start the docker exec process with a shell using a PTY."""
|
"""Start the docker exec process with a shell using a PTY."""
|
||||||
|
# Create a pseudo-terminal on the host
|
||||||
self._master_fd, self._slave_fd = pty.openpty()
|
self._master_fd, self._slave_fd = pty.openpty()
|
||||||
self._set_terminal_size(80, 24)
|
|
||||||
|
|
||||||
|
# Set the terminal size initially
|
||||||
|
self._set_terminal_size(self._cols, self._rows)
|
||||||
|
logger.debug(
|
||||||
|
f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the shell command
|
||||||
|
if startup_command:
|
||||||
|
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
||||||
|
logger.debug(
|
||||||
|
f"Using startup command for session {self.session_id}: {startup_command}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shell_cmd = "bash -il"
|
||||||
|
|
||||||
|
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||||
|
# Using -it because the slave fd IS a TTY
|
||||||
self.process = await asyncio.create_subprocess_exec(
|
self.process = await asyncio.create_subprocess_exec(
|
||||||
"docker",
|
"docker",
|
||||||
"exec",
|
"exec",
|
||||||
"-it",
|
"-it",
|
||||||
"-e",
|
"-e",
|
||||||
"TERM=xterm-256color",
|
"TERM=xterm",
|
||||||
self.container_id,
|
self.container_id,
|
||||||
"bash",
|
"bash",
|
||||||
"-il",
|
"-c",
|
||||||
|
shell_cmd,
|
||||||
stdin=self._slave_fd,
|
stdin=self._slave_fd,
|
||||||
stdout=self._slave_fd,
|
stdout=self._slave_fd,
|
||||||
stderr=self._slave_fd,
|
stderr=self._slave_fd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Close slave fd in parent process
|
||||||
os.close(self._slave_fd)
|
os.close(self._slave_fd)
|
||||||
self._slave_fd = None
|
self._slave_fd = None
|
||||||
self._echo_enabled = self._detect_echo_state()
|
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||||
"""Set the terminal size using TIOCSWINSZ."""
|
"""Set the terminal size using TIOCSWINSZ."""
|
||||||
if self._master_fd is None:
|
if self._master_fd is None:
|
||||||
|
logger.warning("Cannot resize: master_fd is None (session not started)")
|
||||||
return
|
return
|
||||||
tiocswinsz = 0x5414
|
# TIOCSWINSZ = 0x5414 on Linux
|
||||||
|
TIOCSWINSZ = 0x5414
|
||||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
fcntl.ioctl(self._master_fd, tiocswinsz, size)
|
|
||||||
|
|
||||||
def _detect_echo_state(self) -> bool:
|
|
||||||
"""Detect whether the PTY has echo enabled via termios."""
|
|
||||||
if self._master_fd is None:
|
|
||||||
return True
|
|
||||||
try:
|
try:
|
||||||
attrs = termios.tcgetattr(self._master_fd)
|
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||||
return bool(attrs[3] & termios.ECHO)
|
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||||
except OSError:
|
except (OSError, IOError) as e:
|
||||||
return True
|
logger.error(f"Failed to resize PTY: {e}")
|
||||||
|
|
||||||
async def check_echo_state(self) -> bool | None:
|
async def read_output(self) -> bytes:
|
||||||
"""Check if echo state changed. Returns new state if changed, None otherwise."""
|
"""Read output from the PTY master and store in buffer."""
|
||||||
current = self._detect_echo_state()
|
|
||||||
if current != self._echo_enabled:
|
|
||||||
self._echo_enabled = current
|
|
||||||
return current
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def echo_enabled(self) -> bool:
|
|
||||||
"""Return whether the PTY currently has echo enabled."""
|
|
||||||
return self._echo_enabled
|
|
||||||
|
|
||||||
@property
|
|
||||||
def closed(self) -> bool:
|
|
||||||
"""Return whether the session has been closed."""
|
|
||||||
return self._closed
|
|
||||||
|
|
||||||
async def read_output(self, select_timeout: float = 0.1) -> bytes:
|
|
||||||
"""Read output from the PTY master."""
|
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return b""
|
return b""
|
||||||
try:
|
try:
|
||||||
readable, _, _ = select.select(
|
# Use select to check if data is available
|
||||||
[self._master_fd],
|
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||||
[],
|
|
||||||
[],
|
|
||||||
select_timeout,
|
|
||||||
)
|
|
||||||
if readable:
|
if readable:
|
||||||
return os.read(self._master_fd, 8192)
|
data = os.read(self._master_fd, 4096)
|
||||||
|
if data:
|
||||||
|
self._add_to_buffer(data)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
return data
|
||||||
return b""
|
return b""
|
||||||
except (OSError, ValueError):
|
except (OSError, IOError, ValueError):
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
def _add_to_buffer(self, data: bytes) -> None:
|
||||||
|
"""Add data to circular buffer, maintaining size limit."""
|
||||||
|
self._output_buffer.append(data)
|
||||||
|
self._buffer_size += len(data)
|
||||||
|
|
||||||
|
# Trim if exceeds max size
|
||||||
|
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
||||||
|
removed = self._output_buffer.popleft()
|
||||||
|
self._buffer_size -= len(removed)
|
||||||
|
|
||||||
|
def get_buffer(self) -> bytes:
|
||||||
|
"""Get buffered output for replay."""
|
||||||
|
return b"".join(self._output_buffer)
|
||||||
|
|
||||||
async def write_input(self, data: bytes) -> None:
|
async def write_input(self, data: bytes) -> None:
|
||||||
"""Write input to the PTY master."""
|
"""Write input to the PTY master."""
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return
|
return
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.write(self._master_fd, data)
|
os.write(self._master_fd, data)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
except (OSError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
async def resize(self, cols: int, rows: int) -> None:
|
async def resize(self, cols: int, rows: int) -> None:
|
||||||
"""Resize the terminal."""
|
"""Resize the terminal."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
|
logger.warning("Cannot resize: session is closed")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Only resize if dimensions actually changed
|
||||||
|
if cols == self._cols and rows == self._rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._cols = cols
|
||||||
|
self._rows = rows
|
||||||
|
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||||
self._set_terminal_size(cols, rows)
|
self._set_terminal_size(cols, rows)
|
||||||
|
|
||||||
def get_exit_reason(self) -> str | None:
|
# Docker exec -it creates its own PTY inside the container,
|
||||||
"""Return the reason the session ended, if known."""
|
# so host PTY resize doesn't propagate to the container shell.
|
||||||
return self._exit_reason
|
# Send SIGWINCH to the docker exec process on the host.
|
||||||
|
# Docker exec forwards signals to the container process, which should
|
||||||
|
# cause the container's shell to re-read its terminal size.
|
||||||
|
if self.process and self.process.pid:
|
||||||
|
try:
|
||||||
|
os.kill(self.process.pid, signal.SIGWINCH)
|
||||||
|
logger.debug(
|
||||||
|
f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}"
|
||||||
|
)
|
||||||
|
except ProcessLookupError:
|
||||||
|
logger.warning(
|
||||||
|
f"docker exec process {self.process.pid} not found for session {self.session_id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send SIGWINCH: {e}")
|
||||||
|
|
||||||
|
async def reset(self) -> None:
|
||||||
|
"""Reset the session by killing the process and clearing state."""
|
||||||
|
self.status = "resetting"
|
||||||
|
await self.close()
|
||||||
|
self._closed = False
|
||||||
|
self._output_buffer.clear()
|
||||||
|
self._buffer_size = 0
|
||||||
|
self._websockets.clear()
|
||||||
|
self.process = None
|
||||||
|
self._master_fd = None
|
||||||
|
self._slave_fd = None
|
||||||
|
self.status = "active"
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the session and cleanup."""
|
"""Close the session and cleanup."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
self.status = "closed"
|
||||||
# Determine exit reason
|
|
||||||
if self.process is not None and self.process.returncode is not None:
|
|
||||||
if self.process.returncode == 0:
|
|
||||||
self._exit_reason = "process_exit"
|
|
||||||
else:
|
|
||||||
self._exit_reason = "process_exit"
|
|
||||||
else:
|
|
||||||
self._exit_reason = "timeout"
|
|
||||||
|
|
||||||
if self._master_fd is not None:
|
if self._master_fd is not None:
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.close(self._master_fd)
|
os.close(self._master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass # noqa: S110
|
||||||
self._master_fd = None
|
self._master_fd = None
|
||||||
|
|
||||||
if self.process is not None:
|
if self.process is not None:
|
||||||
try:
|
try:
|
||||||
self.process.kill()
|
self.process.kill()
|
||||||
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
||||||
except (TimeoutError, ProcessLookupError):
|
except (asyncio.TimeoutError, ProcessLookupError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def is_alive(self) -> bool:
|
def is_alive(self) -> bool:
|
||||||
@@ -160,3 +244,35 @@ class TerminalSession:
|
|||||||
if self.process is None:
|
if self.process is None:
|
||||||
return False
|
return False
|
||||||
return self.process.returncode is None
|
return self.process.returncode is None
|
||||||
|
|
||||||
|
def is_idle(self) -> bool:
|
||||||
|
"""Check if the session has been idle for too long."""
|
||||||
|
if self._websockets:
|
||||||
|
return False
|
||||||
|
return time.time() - self.last_activity > self.IDLE_TIMEOUT
|
||||||
|
|
||||||
|
def attach_websocket(self, websocket: Any) -> None:
|
||||||
|
"""Attach a WebSocket to this session."""
|
||||||
|
self._websockets.add(websocket)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
def detach_websocket(self, websocket: Any) -> None:
|
||||||
|
"""Detach a WebSocket from this session."""
|
||||||
|
self._websockets.discard(websocket)
|
||||||
|
|
||||||
|
def has_websockets(self) -> bool:
|
||||||
|
"""Check if any WebSockets are attached."""
|
||||||
|
return len(self._websockets) > 0
|
||||||
|
|
||||||
|
async def send_to_all(self, data: bytes) -> None:
|
||||||
|
"""Send data to all attached WebSockets."""
|
||||||
|
dead_sockets = set()
|
||||||
|
for ws in self._websockets:
|
||||||
|
try:
|
||||||
|
await ws.send_bytes(data)
|
||||||
|
except Exception:
|
||||||
|
dead_sockets.add(ws)
|
||||||
|
|
||||||
|
# Clean up dead sockets
|
||||||
|
for ws in dead_sockets:
|
||||||
|
self._websockets.discard(ws)
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
"""Clean tunnel service using cloudflared containers on the backend network.
|
|
||||||
|
|
||||||
Design:
|
|
||||||
- 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).
|
|
||||||
- This avoids host port conflicts and DNS resolution issues.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from src.services.docker 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_container_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_container_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
|
|
||||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
||||||
start_time = __import__("time").time()
|
|
||||||
url: str | None = None
|
|
||||||
combined_logs = ""
|
|
||||||
|
|
||||||
while __import__("time").time() - start_time < timeout:
|
|
||||||
stdout, stderr = _get_container_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_container_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_container_logs(tunnel_name)
|
|
||||||
combined_logs = stdout + "\n" + stderr
|
|
||||||
exit_code = _get_container_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,257 +0,0 @@
|
|||||||
"""Workspace lifecycle management service."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from src.models.workspace import Workspace
|
|
||||||
from src.services.git_service import GitService
|
|
||||||
from src.services.ssh_keys import _get_fernet
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SyncResult:
|
|
||||||
"""Result of a workspace sync operation."""
|
|
||||||
|
|
||||||
branch_deleted: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceHasInstancesError(Exception):
|
|
||||||
"""Raised when attempting to delete a workspace with running instances."""
|
|
||||||
|
|
||||||
def __init__(self, instances: list[dict]) -> None:
|
|
||||||
self.instances = instances
|
|
||||||
super().__init__(f"Workspace has {len(instances)} running tool instance(s)")
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceManager:
|
|
||||||
"""Manages workspace lifecycle: create, delete, sync, validate."""
|
|
||||||
|
|
||||||
BASE_PATH = "/data/working-copies"
|
|
||||||
|
|
||||||
def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
|
|
||||||
"""Return the filesystem path for a workspace."""
|
|
||||||
return os.path.join(self.BASE_PATH, str(repo_id), name)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self,
|
|
||||||
repo: GitRepository,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
name: str,
|
|
||||||
branch: str = "main",
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Workspace:
|
|
||||||
"""Clone repo to workspace path and create DB record.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo: The git repository to clone.
|
|
||||||
user_id: The owner user ID.
|
|
||||||
name: The workspace name (unique per repo).
|
|
||||||
branch: The branch to clone (default: "main").
|
|
||||||
session: Database session for loading SSH keys.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The created Workspace record.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If git clone fails.
|
|
||||||
"""
|
|
||||||
path = self._workspace_path(repo.id, name)
|
|
||||||
parent = os.path.dirname(path)
|
|
||||||
os.makedirs(parent, exist_ok=True)
|
|
||||||
# Ensure container users (various UIDs) can write to workspace dirs
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.chmod(parent, 0o777)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
|
|
||||||
)
|
|
||||||
|
|
||||||
if not repo.remote_url:
|
|
||||||
raise ValueError("Repository has no remote URL")
|
|
||||||
|
|
||||||
# Remove stale directory from previous failed/aborted clone
|
|
||||||
if os.path.exists(path):
|
|
||||||
logger.warning("Removing stale workspace directory: %s", path)
|
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
|
||||||
|
|
||||||
# Load SSH key if repo has one
|
|
||||||
ssh_key = None
|
|
||||||
if getattr(repo, "ssh_key_id", None) and session is not None:
|
|
||||||
from src.models.ssh_key import SSHKey
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
|
||||||
)
|
|
||||||
ssh_key_obj = result.scalar_one_or_none()
|
|
||||||
if ssh_key_obj:
|
|
||||||
fernet = _get_fernet()
|
|
||||||
ssh_key = fernet.decrypt(
|
|
||||||
ssh_key_obj.private_key_encrypted.encode()
|
|
||||||
).decode()
|
|
||||||
|
|
||||||
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
|
|
||||||
self._make_world_writable(path)
|
|
||||||
|
|
||||||
workspace = Workspace(
|
|
||||||
name=name,
|
|
||||||
repo_id=repo.id,
|
|
||||||
user_id=user_id,
|
|
||||||
branch=branch,
|
|
||||||
path=path,
|
|
||||||
status="ready",
|
|
||||||
last_sync_at=datetime.now(),
|
|
||||||
)
|
|
||||||
logger.info("Workspace created: %s", workspace.id)
|
|
||||||
return workspace
|
|
||||||
|
|
||||||
async def delete(
|
|
||||||
self,
|
|
||||||
workspace: Workspace,
|
|
||||||
force: bool = False,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Delete a workspace and all associated tool instances.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workspace: The workspace to delete.
|
|
||||||
force: If True, delete even if instances exist.
|
|
||||||
session: The database session (required for checking instances).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
WorkspaceHasInstancesError: If instances exist and force=False.
|
|
||||||
"""
|
|
||||||
if session is None:
|
|
||||||
raise ValueError("session is required for delete")
|
|
||||||
|
|
||||||
instances = await self._get_instances(workspace, session)
|
|
||||||
if instances and not force:
|
|
||||||
raise WorkspaceHasInstancesError(
|
|
||||||
[{"id": str(i.id), "name": i.name} for i in instances]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stop and delete all instances
|
|
||||||
for instance in instances:
|
|
||||||
await self._stop_and_delete_instance(instance)
|
|
||||||
|
|
||||||
# Delete directory
|
|
||||||
if os.path.exists(workspace.path):
|
|
||||||
shutil.rmtree(workspace.path, ignore_errors=True)
|
|
||||||
logger.info("Deleted workspace directory: %s", workspace.path)
|
|
||||||
|
|
||||||
# Delete record
|
|
||||||
await session.delete(workspace)
|
|
||||||
logger.info("Deleted workspace record: %s", workspace.id)
|
|
||||||
|
|
||||||
async def sync(
|
|
||||||
self, workspace: Workspace, session: AsyncSession | None = None
|
|
||||||
) -> SyncResult:
|
|
||||||
"""Sync a workspace with its remote.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workspace: The workspace to sync.
|
|
||||||
session: Database session for loading SSH keys.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SyncResult indicating whether the branch was deleted.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If git operations fail.
|
|
||||||
"""
|
|
||||||
logger.info("Syncing workspace: %s", workspace.id)
|
|
||||||
|
|
||||||
# Load SSH key if repo has one
|
|
||||||
ssh_key = None
|
|
||||||
if session is not None:
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.ssh_key import SSHKey
|
|
||||||
|
|
||||||
repo = await session.get(GitRepository, workspace.repo_id)
|
|
||||||
if repo and getattr(repo, "ssh_key_id", None):
|
|
||||||
result = await session.execute(
|
|
||||||
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
|
||||||
)
|
|
||||||
ssh_key_obj = result.scalar_one_or_none()
|
|
||||||
if ssh_key_obj:
|
|
||||||
fernet = _get_fernet()
|
|
||||||
ssh_key = fernet.decrypt(
|
|
||||||
ssh_key_obj.private_key_encrypted.encode()
|
|
||||||
).decode()
|
|
||||||
|
|
||||||
await GitService.fetch(workspace.path, ssh_key=ssh_key)
|
|
||||||
|
|
||||||
if not GitService.branch_exists_remotely(
|
|
||||||
workspace.path, workspace.branch, ssh_key=ssh_key
|
|
||||||
):
|
|
||||||
return SyncResult(branch_deleted=True)
|
|
||||||
|
|
||||||
await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key)
|
|
||||||
self._make_world_writable(workspace.path)
|
|
||||||
|
|
||||||
workspace.last_sync_at = datetime.now()
|
|
||||||
logger.info("Workspace synced: %s", workspace.id)
|
|
||||||
return SyncResult(branch_deleted=False)
|
|
||||||
|
|
||||||
def _make_world_writable(self, path: str) -> None:
|
|
||||||
"""Recursively make path readable/writable/traversable by any UID.
|
|
||||||
|
|
||||||
Directories get 777 (traversable). Files get rw for all while
|
|
||||||
preserving any existing execute bits.
|
|
||||||
"""
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.chmod(path, 0o777)
|
|
||||||
for root, dirs, files in os.walk(path):
|
|
||||||
for d in dirs:
|
|
||||||
dpath = os.path.join(root, d)
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.chmod(dpath, 0o777)
|
|
||||||
for f in files:
|
|
||||||
fpath = os.path.join(root, f)
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
mode = os.stat(fpath).st_mode
|
|
||||||
# Preserve execute bits, ensure read+write for all
|
|
||||||
new_mode = (mode & stat.S_IXUSR) | 0o666
|
|
||||||
if mode & stat.S_IXGRP:
|
|
||||||
new_mode |= stat.S_IXGRP
|
|
||||||
if mode & stat.S_IXOTH:
|
|
||||||
new_mode |= stat.S_IXOTH
|
|
||||||
os.chmod(fpath, new_mode)
|
|
||||||
|
|
||||||
async def _get_instances(
|
|
||||||
self,
|
|
||||||
workspace: Workspace,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> list[ToolInstance]:
|
|
||||||
"""Get all tool instances associated with this workspace."""
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
async def _stop_and_delete_instance(self, instance: ToolInstance) -> None:
|
|
||||||
"""Stop and delete a tool instance.
|
|
||||||
|
|
||||||
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
|
||||||
For now, this is a placeholder.
|
|
||||||
"""
|
|
||||||
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
|
|
||||||
@@ -60,12 +60,9 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
|
|
||||||
Returns structured data including commits, branches, and graph information.
|
Returns structured data including commits, branches, and graph information.
|
||||||
"""
|
"""
|
||||||
# Get list of branches (may fail for empty repos)
|
# Get list of branches
|
||||||
try:
|
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
||||||
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
||||||
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
|
||||||
except RuntimeError:
|
|
||||||
branches = []
|
|
||||||
|
|
||||||
# Build git log command - use NULL bytes as separators to avoid parsing issues
|
# Build git log command - use NULL bytes as separators to avoid parsing issues
|
||||||
log_args = [
|
log_args = [
|
||||||
@@ -79,16 +76,7 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
else:
|
else:
|
||||||
log_args.append("--all")
|
log_args.append("--all")
|
||||||
|
|
||||||
try:
|
log_output = _run_git_command(repo_path, log_args)
|
||||||
log_output = _run_git_command(repo_path, log_args)
|
|
||||||
except RuntimeError:
|
|
||||||
# Empty repo or no commits
|
|
||||||
return {
|
|
||||||
"commits": [],
|
|
||||||
"branches": branches,
|
|
||||||
"total_commits": 0,
|
|
||||||
"graph_data": {"nodes": [], "edges": []},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get branch info for each commit
|
# Get branch info for each commit
|
||||||
branch_map = _get_branch_map(repo_path)
|
branch_map = _get_branch_map(repo_path)
|
||||||
@@ -125,11 +113,8 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get total commit count
|
# Get total commit count
|
||||||
try:
|
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
||||||
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
||||||
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
|
||||||
except RuntimeError:
|
|
||||||
total_commits = 0
|
|
||||||
|
|
||||||
# Build graph data and generate graph symbols
|
# Build graph data and generate graph symbols
|
||||||
graph_data = _build_graph_data(commits)
|
graph_data = _build_graph_data(commits)
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
"""Integration tests for config profiles API."""
|
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -20,9 +17,7 @@ class TestConfigProfilesAPI:
|
|||||||
response = authenticated_client.get("/config-profiles")
|
response = authenticated_client.get("/config-profiles")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert isinstance(data, dict)
|
assert isinstance(data, list)
|
||||||
assert "profiles" in data
|
|
||||||
assert isinstance(data["profiles"], list)
|
|
||||||
|
|
||||||
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test creating a config profile."""
|
"""Test creating a config profile."""
|
||||||
@@ -31,48 +26,99 @@ class TestConfigProfilesAPI:
|
|||||||
json={
|
json={
|
||||||
"name": "test-profile",
|
"name": "test-profile",
|
||||||
"description": "Test profile",
|
"description": "Test profile",
|
||||||
|
"env_vars": {"VAR": "value"},
|
||||||
|
"runtime_hints": {"start_command": "npm start"},
|
||||||
|
"mounts": [{"target": "/app", "mode": "rw", "files": {}}],
|
||||||
|
"files": {"test.txt": "hello"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["name"] == "test-profile"
|
assert data["name"] == "test-profile"
|
||||||
assert data["description"] == "Test profile"
|
assert data["env_vars"] == {"VAR": "value"}
|
||||||
|
assert data["files"] == {"test.txt": "hello"}
|
||||||
|
assert data["mounts"][0]["target"] == "/app"
|
||||||
|
|
||||||
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test that duplicate profile names are rejected."""
|
"""Test that duplicate profile names are rejected."""
|
||||||
authenticated_client.post(
|
# Create first profile
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "duplicate-profile"},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "duplicate-profile"},
|
json={
|
||||||
|
"name": "duplicate-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
# Try to create second with same name
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
|
|
||||||
def test_create_config_profile_empty_name(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test that empty profile names are rejected."""
|
"""Test that profiles exceeding 10MB are rejected."""
|
||||||
|
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": " "},
|
json={
|
||||||
|
"name": "large-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {"large.txt": large_content},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 413
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_file_path(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid file paths are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {"../../../etc/passwd": "malicious"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_mount_target(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid mount targets are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-mount-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"mounts": [{"target": "relative/path", "mode": "rw", "files": {}}],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
|
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test getting a config profile by ID."""
|
"""Test getting a config profile by ID."""
|
||||||
|
# Create profile first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "get-test"},
|
json={
|
||||||
|
"name": "get-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
profile_id = create_response.json()["id"]
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Get it back
|
||||||
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["name"] == "get-test"
|
assert data["name"] == "get-test"
|
||||||
assert "includes" in data
|
|
||||||
assert "mounts" in data
|
|
||||||
|
|
||||||
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
|
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test getting a non-existent profile."""
|
"""Test getting a non-existent profile."""
|
||||||
@@ -81,381 +127,327 @@ class TestConfigProfilesAPI:
|
|||||||
|
|
||||||
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test updating a config profile."""
|
"""Test updating a config profile."""
|
||||||
|
# Create profile first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "update-test"},
|
json={
|
||||||
|
"name": "update-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
profile_id = create_response.json()["id"]
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update it
|
||||||
response = authenticated_client.put(
|
response = authenticated_client.put(
|
||||||
f"/config-profiles/{profile_id}",
|
f"/config-profiles/{profile_id}",
|
||||||
json={"name": "updated-name", "description": "updated desc"},
|
json={
|
||||||
|
"name": "updated-name",
|
||||||
|
"env_vars": {"NEW_VAR": "new_value"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["name"] == "updated-name"
|
assert data["name"] == "updated-name"
|
||||||
assert data["description"] == "updated desc"
|
assert data["env_vars"] == {"NEW_VAR": "new_value"}
|
||||||
|
|
||||||
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test deleting a config profile."""
|
"""Test deleting a config profile."""
|
||||||
|
# Create profile first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "delete-test"},
|
json={
|
||||||
|
"name": "delete-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
profile_id = create_response.json()["id"]
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Delete it
|
||||||
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
|
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
assert get_response.status_code == 404
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
def test_profile_access_check(self, authenticated_client: TestClient) -> None:
|
def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test that users can only access their own profiles."""
|
"""Test updating profile includes."""
|
||||||
# Create a profile
|
# Create base profile
|
||||||
|
base_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "base-profile",
|
||||||
|
"env_vars": {"BASE_VAR": "base_value"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
base_id = base_response.json()["id"]
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "child-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child_id = child_response.json()["id"]
|
||||||
|
|
||||||
|
# Update includes
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{child_id}/includes",
|
||||||
|
json={"includes": [base_id]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
print(f"Response data: {data}")
|
||||||
|
print(f"Includes: {data.get('includes', 'NO INCLUDES KEY')}")
|
||||||
|
assert len(data["includes"]) == 1, f"Expected 1 include, got {len(data.get('includes', []))}: {data.get('includes', [])}"
|
||||||
|
assert data["includes"][0]["included_profile_id"] == base_id
|
||||||
|
|
||||||
|
def test_update_profile_includes_cycle_detection(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that include cycles are detected."""
|
||||||
|
# Create profile A
|
||||||
|
a_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "profile-a",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
a_id = a_response.json()["id"]
|
||||||
|
|
||||||
|
# Create profile B
|
||||||
|
b_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "profile-b",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
b_id = b_response.json()["id"]
|
||||||
|
|
||||||
|
# Make B include A
|
||||||
|
authenticated_client.put(
|
||||||
|
f"/config-profiles/{b_id}/includes",
|
||||||
|
json={"includes": [a_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to make A include B (would create cycle)
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{a_id}/includes",
|
||||||
|
json={"includes": [b_id]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test previewing a resolved config profile."""
|
||||||
|
# Create base profile
|
||||||
|
base_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-base",
|
||||||
|
"env_vars": {"BASE_VAR": "base"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
base_id = base_response.json()["id"]
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-child",
|
||||||
|
"env_vars": {"CHILD_VAR": "child"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child_id = child_response.json()["id"]
|
||||||
|
|
||||||
|
# Make child include base
|
||||||
|
authenticated_client.put(
|
||||||
|
f"/config-profiles/{child_id}/includes",
|
||||||
|
json={"includes": [base_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preview child
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{child_id}/preview")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_name"] == "preview-child"
|
||||||
|
assert data["env_vars"]["BASE_VAR"] == "base"
|
||||||
|
assert data["env_vars"]["CHILD_VAR"] == "child"
|
||||||
|
assert len(data["included_profiles"]) == 1
|
||||||
|
|
||||||
|
def test_resolve_default_profile(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test resolving default profile for project/tool."""
|
||||||
|
# Create a global default profile (no project/tool scoping)
|
||||||
|
authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "default-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"is_default": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve default with random project/tool (should fall back to global)
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
tool_type_id = str(uuid.uuid4())
|
||||||
|
response = authenticated_client.get(
|
||||||
|
"/config-profiles/defaults/resolve",
|
||||||
|
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_name"] == "default-profile"
|
||||||
|
|
||||||
|
def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test resolving default profile when no profiles exist."""
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
tool_type_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
response = authenticated_client.get(
|
||||||
|
"/config-profiles/defaults/resolve",
|
||||||
|
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_id"] is None
|
||||||
|
|
||||||
|
def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test creating a config profile with git mounts."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "git-mount-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "git-mount-profile"
|
||||||
|
assert len(data["git_mounts"]) == 1
|
||||||
|
assert data["git_mounts"][0]["target_path"] == "/app"
|
||||||
|
assert data["git_mounts"][0]["branch"] == "main"
|
||||||
|
|
||||||
|
def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test updating git mounts on a config profile."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
# Create profile first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "access-test"},
|
json={
|
||||||
|
"name": "update-git-mounts",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
profile_id = create_response.json()["id"]
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
# The profile should be accessible
|
# Update with git mounts
|
||||||
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestConfigProfileIncludes:
|
|
||||||
"""Integration tests for config profile includes."""
|
|
||||||
|
|
||||||
def test_add_include_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test adding an include to a profile."""
|
|
||||||
# Create two profiles
|
|
||||||
profile1 = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "profile-1"},
|
|
||||||
).json()
|
|
||||||
profile2 = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "profile-2"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# Add include
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile1['id']}/includes",
|
|
||||||
json={"included_profile_id": profile2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["included_profile_id"] == profile2["id"]
|
|
||||||
assert data["included_profile_name"] == "profile-2"
|
|
||||||
|
|
||||||
def test_add_self_include_rejected(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that self-includes are rejected."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "self-include-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile['id']}/includes",
|
|
||||||
json={"included_profile_id": profile["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_add_include_cycle_rejected(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that circular includes are rejected."""
|
|
||||||
profile1 = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "cycle-1"},
|
|
||||||
).json()
|
|
||||||
profile2 = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "cycle-2"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# Add profile1 includes profile2
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile1['id']}/includes",
|
|
||||||
json={"included_profile_id": profile2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to add profile2 includes profile1 (creates cycle)
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile2['id']}/includes",
|
|
||||||
json={"included_profile_id": profile1["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_add_deep_cycle_rejected(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that deep circular includes are rejected."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "deep-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "deep-2"}
|
|
||||||
).json()
|
|
||||||
p3 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "deep-3"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# p1 -> p2 -> p3
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{p2['id']}/includes",
|
|
||||||
json={"included_profile_id": p3["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try p3 -> p1 (creates cycle)
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{p3['id']}/includes",
|
|
||||||
json={"included_profile_id": p1["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_add_duplicate_include_rejected(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that duplicate includes are rejected."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "dup-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "dup-2"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 1},
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
|
|
||||||
def test_list_includes(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test listing includes for a profile."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "list-inc-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "list-inc-2"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = authenticated_client.get(f"/config-profiles/{p1['id']}/includes")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data["includes"]) == 1
|
|
||||||
|
|
||||||
def test_update_include_order(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test updating include order index."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "order-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "order-2"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
inc = authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.put(
|
response = authenticated_client.put(
|
||||||
f"/config-profiles/{p1['id']}/includes/{inc['id']}",
|
f"/config-profiles/{profile_id}",
|
||||||
json={"order_index": 5},
|
json={
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "config",
|
||||||
|
"target_path": "/config",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["order_index"] == 5
|
|
||||||
|
|
||||||
def test_remove_include(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test removing an include."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "rem-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "rem-2"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
inc = authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.delete(
|
|
||||||
f"/config-profiles/{p1['id']}/includes/{inc['id']}"
|
|
||||||
)
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestConfigProfileMounts:
|
|
||||||
"""Integration tests for config profile mounts."""
|
|
||||||
|
|
||||||
def test_add_mount_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test adding a mount to a profile."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "mount-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
|
||||||
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}, "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["target_path"] == "/etc/config"
|
assert len(data["git_mounts"]) == 1
|
||||||
assert data["files"] == {"test.txt": "hello"}
|
assert data["git_mounts"][0]["source_path"] == "config"
|
||||||
|
|
||||||
def test_add_mount_relative_path_rejected(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
"""Test that relative mount paths are rejected."""
|
"""Test that invalid git mount source paths are rejected."""
|
||||||
profile = authenticated_client.post(
|
_project_id, repo_id = test_project_and_repo
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "rel-path-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
"/config-profiles",
|
||||||
json={"target_path": "etc/config", "files": {"test.txt": "hello"}},
|
json={
|
||||||
|
"name": "bad-git-mount",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "/absolute/path",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_add_target_path_traversal_rejected(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
"""Test that path traversal in mount paths is rejected."""
|
"""Test that git mount target paths with traversal are rejected."""
|
||||||
profile = authenticated_client.post(
|
_project_id, repo_id = test_project_and_repo
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "traversal-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
"/config-profiles",
|
||||||
json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}},
|
json={
|
||||||
|
"name": "bad-git-mount-target",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "../../../etc/passwd",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_add_duplicate_mount_rejected(self, authenticated_client: TestClient) -> None:
|
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
"""Test that duplicate mount paths are rejected."""
|
"""Test previewing a profile with git mounts."""
|
||||||
profile = authenticated_client.post(
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
# Create profile with git mounts
|
||||||
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "dup-mount-test"},
|
json={
|
||||||
).json()
|
"name": "preview-git-mounts",
|
||||||
|
"env_vars": {},
|
||||||
authenticated_client.post(
|
"files": {},
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
"git_mounts": [
|
||||||
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}},
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
response = authenticated_client.post(
|
# Preview
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
|
||||||
json={"target_path": "/etc/config", "files": {"test.txt": "world"}},
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
|
|
||||||
def test_update_mount(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test updating a mount."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "update-mount-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
mount = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
|
||||||
json={"target_path": "/old/path", "files": {"test.txt": "old"}},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.put(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts/{mount['id']}",
|
|
||||||
json={"target_path": "/new/path", "files": {"test.txt": "new"}, "order_index": 2},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["target_path"] == "/new/path"
|
assert len(data["git_mounts"]) == 1
|
||||||
assert data["files"] == {"test.txt": "new"}
|
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
|
||||||
assert data["order_index"] == 2
|
|
||||||
|
|
||||||
def test_remove_mount(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test removing a mount."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "rem-mount-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
mount = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
|
||||||
json={"target_path": "/tmp/test", "files": {"test.txt": "x"}},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.delete(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts/{mount['id']}"
|
|
||||||
)
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestConfigProfileDefaults:
|
|
||||||
"""Integration tests for default profile APIs."""
|
|
||||||
|
|
||||||
def test_get_default_profiles_empty(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting default profiles when none are set."""
|
|
||||||
response = authenticated_client.get("/config-profiles/defaults")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["default_profiles"] == {}
|
|
||||||
|
|
||||||
def test_set_default_profiles(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test setting default profiles."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "default-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.put(
|
|
||||||
"/config-profiles/defaults",
|
|
||||||
json={"default_profiles": {"code-server": profile["id"]}},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["default_profiles"]["code-server"] == profile["id"]
|
|
||||||
|
|
||||||
def test_set_default_profiles_invalid_profile(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test setting default profiles with invalid profile ID."""
|
|
||||||
response = authenticated_client.put(
|
|
||||||
"/config-profiles/defaults",
|
|
||||||
json={"default_profiles": {"code-server": str(uuid.uuid4())}},
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
def test_get_default_profile_for_tool_type(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting default profile for a specific tool type."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "tool-default-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
authenticated_client.put(
|
|
||||||
"/config-profiles/defaults",
|
|
||||||
json={"default_profiles": {"jupyter-notebook": profile["id"]}},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = authenticated_client.get("/config-profiles/defaults/jupyter-notebook")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["tool_type_id"] == "jupyter-notebook"
|
|
||||||
assert data["profile_id"] == profile["id"]
|
|
||||||
|
|
||||||
def test_get_default_profile_for_tool_type_not_set(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting default profile when not set."""
|
|
||||||
response = authenticated_client.get("/config-profiles/defaults/opencode")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["tool_type_id"] == "opencode"
|
|
||||||
assert data["profile_id"] is None
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
|
|||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
|
|||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str:
|
|||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
"""Integration tests for workspace API endpoints."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import uuid
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.workspace import Workspace
|
|
||||||
from src.services.workspace_manager import WorkspaceManager
|
|
||||||
|
|
||||||
|
|
||||||
def _get_user_id_from_client(client: TestClient) -> uuid.UUID:
|
|
||||||
"""Extract user ID from authenticated client session cookie."""
|
|
||||||
from src.auth.session import decode_session_cookie
|
|
||||||
from src.config import Settings
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
session_cookie = client.cookies.get("session")
|
|
||||||
if session_cookie:
|
|
||||||
session_data = decode_session_cookie(
|
|
||||||
settings=settings, cookie_value=session_cookie
|
|
||||||
)
|
|
||||||
if session_data:
|
|
||||||
return uuid.UUID(session_data["user_id"])
|
|
||||||
raise RuntimeError("Could not get user ID from authenticated client")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def test_repo(db_session: AsyncSession, authenticated_client: TestClient):
|
|
||||||
"""Create a test repository."""
|
|
||||||
user_id = _get_user_id_from_client(authenticated_client)
|
|
||||||
|
|
||||||
async def _create():
|
|
||||||
project = Project(name="Test Project", owner_id=user_id)
|
|
||||||
db_session.add(project)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
repo = GitRepository(
|
|
||||||
name="test-repo",
|
|
||||||
path="/tmp/test-repo",
|
|
||||||
remote_url="https://github.com/test/repo.git",
|
|
||||||
project_id=project.id,
|
|
||||||
owner_id=user_id,
|
|
||||||
)
|
|
||||||
db_session.add(repo)
|
|
||||||
await db_session.commit()
|
|
||||||
await db_session.refresh(repo)
|
|
||||||
return repo
|
|
||||||
|
|
||||||
return asyncio.run(_create())
|
|
||||||
|
|
||||||
|
|
||||||
class TestListWorkspaces:
|
|
||||||
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
|
|
||||||
|
|
||||||
def test_list_empty(
|
|
||||||
self, authenticated_client: TestClient, test_repo: GitRepository
|
|
||||||
):
|
|
||||||
"""Returns empty list when no workspaces exist."""
|
|
||||||
response = authenticated_client.get(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == []
|
|
||||||
|
|
||||||
def test_list_with_workspaces(
|
|
||||||
self,
|
|
||||||
authenticated_client: TestClient,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
test_repo: GitRepository,
|
|
||||||
):
|
|
||||||
"""Returns workspaces with instance counts."""
|
|
||||||
ws = Workspace(
|
|
||||||
name="dev",
|
|
||||||
repo_id=test_repo.id,
|
|
||||||
user_id=test_repo.owner_id,
|
|
||||||
branch="main",
|
|
||||||
path="/data/working-copies/test/dev",
|
|
||||||
)
|
|
||||||
db_session.add(ws)
|
|
||||||
|
|
||||||
async def _commit():
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
asyncio.run(_commit())
|
|
||||||
|
|
||||||
response = authenticated_client.get(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) == 1
|
|
||||||
assert data[0]["name"] == "dev"
|
|
||||||
assert data[0]["instance_count"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateWorkspace:
|
|
||||||
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
|
|
||||||
|
|
||||||
def test_create_success(
|
|
||||||
self, authenticated_client: TestClient, test_repo: GitRepository
|
|
||||||
):
|
|
||||||
"""Creates a workspace and clones the repo."""
|
|
||||||
mock_ws = Workspace(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
name="feature-branch",
|
|
||||||
repo_id=test_repo.id,
|
|
||||||
user_id=test_repo.owner_id,
|
|
||||||
branch="feature",
|
|
||||||
path="/data/working-copies/test/feature-branch",
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
WorkspaceManager, "create", return_value=mock_ws
|
|
||||||
) as mock_create:
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
|
||||||
json={"name": "feature-branch", "branch": "feature"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["name"] == "feature-branch"
|
|
||||||
assert data["branch"] == "feature"
|
|
||||||
mock_create.assert_called_once()
|
|
||||||
|
|
||||||
def test_create_missing_name(
|
|
||||||
self, authenticated_client: TestClient, test_repo: GitRepository
|
|
||||||
):
|
|
||||||
"""Returns 400 when name is missing."""
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
|
||||||
json={"branch": "main"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "name" in response.json()["detail"]
|
|
||||||
|
|
||||||
def test_create_duplicate_name(
|
|
||||||
self,
|
|
||||||
authenticated_client: TestClient,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
test_repo: GitRepository,
|
|
||||||
):
|
|
||||||
"""Returns 409 when workspace name already exists."""
|
|
||||||
ws = Workspace(
|
|
||||||
name="dev",
|
|
||||||
repo_id=test_repo.id,
|
|
||||||
user_id=test_repo.owner_id,
|
|
||||||
branch="main",
|
|
||||||
path="/data/working-copies/test/dev",
|
|
||||||
)
|
|
||||||
db_session.add(ws)
|
|
||||||
|
|
||||||
async def _commit():
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
asyncio.run(_commit())
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
WorkspaceManager, "create", side_effect=Exception("duplicate")
|
|
||||||
):
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
|
||||||
json={"name": "dev", "branch": "main"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteWorkspace:
|
|
||||||
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
|
|
||||||
|
|
||||||
def test_delete_without_instances(
|
|
||||||
self,
|
|
||||||
authenticated_client: TestClient,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
test_repo: GitRepository,
|
|
||||||
):
|
|
||||||
"""Deletes workspace when no instances exist."""
|
|
||||||
ws = Workspace(
|
|
||||||
name="dev",
|
|
||||||
repo_id=test_repo.id,
|
|
||||||
user_id=test_repo.owner_id,
|
|
||||||
branch="main",
|
|
||||||
path="/data/working-copies/test/dev",
|
|
||||||
)
|
|
||||||
db_session.add(ws)
|
|
||||||
|
|
||||||
async def _commit_refresh():
|
|
||||||
await db_session.commit()
|
|
||||||
await db_session.refresh(ws)
|
|
||||||
|
|
||||||
asyncio.run(_commit_refresh())
|
|
||||||
|
|
||||||
with patch.object(WorkspaceManager, "delete", return_value=None):
|
|
||||||
response = authenticated_client.delete(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["status"] == "deleted"
|
|
||||||
|
|
||||||
@pytest.mark.skip(
|
|
||||||
reason="Async fixture interaction with sync tests — endpoint logic verified manually"
|
|
||||||
)
|
|
||||||
def test_delete_with_instances_no_force(
|
|
||||||
self,
|
|
||||||
authenticated_client: TestClient,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
test_repo: GitRepository,
|
|
||||||
):
|
|
||||||
"""Returns 409 when workspace has instances and force=False."""
|
|
||||||
ws = Workspace(
|
|
||||||
name="dev",
|
|
||||||
repo_id=test_repo.id,
|
|
||||||
user_id=test_repo.owner_id,
|
|
||||||
branch="main",
|
|
||||||
path="/data/working-copies/test/dev",
|
|
||||||
)
|
|
||||||
db_session.add(ws)
|
|
||||||
|
|
||||||
tool_type = ToolType(
|
|
||||||
name="test-tool",
|
|
||||||
display_name="Test Tool",
|
|
||||||
default_port=8080,
|
|
||||||
category="dev",
|
|
||||||
)
|
|
||||||
db_session.add(tool_type)
|
|
||||||
|
|
||||||
async def _flush():
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
asyncio.run(_flush())
|
|
||||||
|
|
||||||
instance = ToolInstance(
|
|
||||||
name="test-instance",
|
|
||||||
display_name="Test Instance",
|
|
||||||
tool_type_id=tool_type.id,
|
|
||||||
repository_id=test_repo.id,
|
|
||||||
project_id=test_repo.project_id,
|
|
||||||
owner_id=test_repo.owner_id,
|
|
||||||
workspace_id=ws.id,
|
|
||||||
status="running",
|
|
||||||
)
|
|
||||||
db_session.add(instance)
|
|
||||||
|
|
||||||
async def _commit_refresh():
|
|
||||||
await db_session.commit()
|
|
||||||
await db_session.refresh(ws)
|
|
||||||
|
|
||||||
asyncio.run(_commit_refresh())
|
|
||||||
|
|
||||||
response = authenticated_client.delete(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}"
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
detail = response.json()["detail"]
|
|
||||||
assert detail["message"] == "Workspace has running tool instances"
|
|
||||||
assert len(detail["instances"]) == 1
|
|
||||||
|
|
||||||
def test_delete_with_instances_force(
|
|
||||||
self,
|
|
||||||
authenticated_client: TestClient,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
test_repo: GitRepository,
|
|
||||||
):
|
|
||||||
"""Deletes workspace when force=True even with instances."""
|
|
||||||
ws = Workspace(
|
|
||||||
name="dev",
|
|
||||||
repo_id=test_repo.id,
|
|
||||||
user_id=test_repo.owner_id,
|
|
||||||
branch="main",
|
|
||||||
path="/data/working-copies/test/dev",
|
|
||||||
)
|
|
||||||
db_session.add(ws)
|
|
||||||
|
|
||||||
async def _commit_refresh():
|
|
||||||
await db_session.commit()
|
|
||||||
await db_session.refresh(ws)
|
|
||||||
|
|
||||||
asyncio.run(_commit_refresh())
|
|
||||||
|
|
||||||
with patch.object(WorkspaceManager, "delete", return_value=None):
|
|
||||||
response = authenticated_client.delete(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}?force=true"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
class TestSyncWorkspace:
|
|
||||||
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
|
|
||||||
|
|
||||||
def test_sync_success(
|
|
||||||
self,
|
|
||||||
authenticated_client: TestClient,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
test_repo: GitRepository,
|
|
||||||
):
|
|
||||||
"""Sync succeeds and updates last_sync_at."""
|
|
||||||
ws = Workspace(
|
|
||||||
name="dev",
|
|
||||||
repo_id=test_repo.id,
|
|
||||||
user_id=test_repo.owner_id,
|
|
||||||
branch="main",
|
|
||||||
path="/data/working-copies/test/dev",
|
|
||||||
)
|
|
||||||
db_session.add(ws)
|
|
||||||
|
|
||||||
async def _commit_refresh():
|
|
||||||
await db_session.commit()
|
|
||||||
await db_session.refresh(ws)
|
|
||||||
|
|
||||||
asyncio.run(_commit_refresh())
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
WorkspaceManager, "sync", return_value=MagicMock(branch_deleted=False)
|
|
||||||
):
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}/sync"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["branch_deleted"] is False
|
|
||||||
assert data["pulled"] is True
|
|
||||||
|
|
||||||
def test_sync_branch_deleted(
|
|
||||||
self,
|
|
||||||
authenticated_client: TestClient,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
test_repo: GitRepository,
|
|
||||||
):
|
|
||||||
"""Returns 409 when branch was deleted from remote."""
|
|
||||||
ws = Workspace(
|
|
||||||
name="dev",
|
|
||||||
repo_id=test_repo.id,
|
|
||||||
user_id=test_repo.owner_id,
|
|
||||||
branch="feature-gone",
|
|
||||||
path="/data/working-copies/test/dev",
|
|
||||||
)
|
|
||||||
db_session.add(ws)
|
|
||||||
|
|
||||||
async def _commit_refresh():
|
|
||||||
await db_session.commit()
|
|
||||||
await db_session.refresh(ws)
|
|
||||||
|
|
||||||
asyncio.run(_commit_refresh())
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
WorkspaceManager, "sync", return_value=MagicMock(branch_deleted=True)
|
|
||||||
):
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}/sync"
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
detail = response.json()["detail"]
|
|
||||||
assert "deleted from remote" in detail["message"]
|
|
||||||
assert detail["branch_deleted"] is True
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""Unit tests for FileService."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.models.workspace import Workspace
|
|
||||||
from src.services.file_service import FileService
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_workspace():
|
|
||||||
"""Create a temporary workspace directory."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
ws = Workspace(
|
|
||||||
id="00000000-0000-0000-0000-000000000001",
|
|
||||||
name="test-ws",
|
|
||||||
repo_id="00000000-0000-0000-0000-000000000002",
|
|
||||||
user_id="00000000-0000-0000-0000-000000000003",
|
|
||||||
branch="main",
|
|
||||||
path=tmpdir,
|
|
||||||
)
|
|
||||||
yield ws
|
|
||||||
|
|
||||||
|
|
||||||
class TestFileService:
|
|
||||||
"""Tests for FileService."""
|
|
||||||
|
|
||||||
def test_list_directory_empty(self, temp_workspace: Workspace):
|
|
||||||
"""Returns empty list for empty directory."""
|
|
||||||
service = FileService()
|
|
||||||
entries = service.list_directory(temp_workspace)
|
|
||||||
assert entries == []
|
|
||||||
|
|
||||||
def test_list_directory_with_files(self, temp_workspace: Workspace):
|
|
||||||
"""Returns entries sorted (dirs first, then files)."""
|
|
||||||
# Create files and dirs
|
|
||||||
os.makedirs(os.path.join(temp_workspace.path, "src"))
|
|
||||||
with open(os.path.join(temp_workspace.path, "README.md"), "w") as f:
|
|
||||||
f.write("# Test")
|
|
||||||
with open(os.path.join(temp_workspace.path, "main.py"), "w") as f:
|
|
||||||
f.write("print('hello')")
|
|
||||||
|
|
||||||
service = FileService()
|
|
||||||
entries = service.list_directory(temp_workspace)
|
|
||||||
|
|
||||||
assert len(entries) == 3
|
|
||||||
assert entries[0].name == "src" and entries[0].type == "directory"
|
|
||||||
assert entries[1].name == "main.py" and entries[1].type == "file"
|
|
||||||
assert entries[2].name == "README.md" and entries[2].type == "file"
|
|
||||||
|
|
||||||
def test_read_file(self, temp_workspace: Workspace):
|
|
||||||
"""Reads text file content."""
|
|
||||||
with open(os.path.join(temp_workspace.path, "test.txt"), "w") as f:
|
|
||||||
f.write("hello world")
|
|
||||||
|
|
||||||
service = FileService()
|
|
||||||
content = service.read_file(temp_workspace, "test.txt")
|
|
||||||
assert content == "hello world"
|
|
||||||
|
|
||||||
def test_read_binary_file_rejected(self, temp_workspace: Workspace):
|
|
||||||
"""Rejects binary files."""
|
|
||||||
with open(os.path.join(temp_workspace.path, "binary.bin"), "wb") as f:
|
|
||||||
f.write(b"\x00\x01\x02")
|
|
||||||
|
|
||||||
service = FileService()
|
|
||||||
with pytest.raises(ValueError, match="Binary"):
|
|
||||||
service.read_file(temp_workspace, "binary.bin")
|
|
||||||
|
|
||||||
def test_write_file(self, temp_workspace: Workspace):
|
|
||||||
"""Writes file to workspace."""
|
|
||||||
service = FileService()
|
|
||||||
service.write_file(temp_workspace, "nested/file.txt", "content")
|
|
||||||
|
|
||||||
assert os.path.exists(os.path.join(temp_workspace.path, "nested", "file.txt"))
|
|
||||||
with open(os.path.join(temp_workspace.path, "nested", "file.txt")) as f:
|
|
||||||
assert f.read() == "content"
|
|
||||||
|
|
||||||
def test_path_escapes_workspace(self, temp_workspace: Workspace):
|
|
||||||
"""Rejects paths that escape workspace directory."""
|
|
||||||
service = FileService()
|
|
||||||
with pytest.raises(ValueError, match="escapes"):
|
|
||||||
service.list_directory(temp_workspace, "../outside")
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"""Unit tests for GitService."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.services.git_service import GitService
|
|
||||||
|
|
||||||
|
|
||||||
class TestGitServiceClone:
|
|
||||||
"""Tests for GitService.clone."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_clone_success(self):
|
|
||||||
"""Clone succeeds when git returns 0."""
|
|
||||||
mock_proc = AsyncMock()
|
|
||||||
mock_proc.returncode = 0
|
|
||||||
mock_proc.communicate.return_value = (b"", b"")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"asyncio.create_subprocess_exec", return_value=mock_proc
|
|
||||||
) as mock_exec:
|
|
||||||
await GitService.clone(
|
|
||||||
"https://github.com/test/repo.git", "main", "/tmp/ws"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_exec.assert_called_once_with(
|
|
||||||
"git",
|
|
||||||
"clone",
|
|
||||||
"--branch",
|
|
||||||
"main",
|
|
||||||
"--single-branch",
|
|
||||||
"https://github.com/test/repo.git",
|
|
||||||
"/tmp/ws",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_clone_failure(self):
|
|
||||||
"""Clone raises RuntimeError when git fails."""
|
|
||||||
mock_proc = AsyncMock()
|
|
||||||
mock_proc.returncode = 1
|
|
||||||
mock_proc.communicate.return_value = (b"", b"fatal: repository not found")
|
|
||||||
|
|
||||||
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
|
|
||||||
with pytest.raises(RuntimeError, match="Git clone failed"):
|
|
||||||
await GitService.clone("https://bad/url.git", "main", "/tmp/ws")
|
|
||||||
|
|
||||||
|
|
||||||
class TestGitServiceFetch:
|
|
||||||
"""Tests for GitService.fetch."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_success(self):
|
|
||||||
"""Fetch succeeds when git returns 0."""
|
|
||||||
mock_proc = AsyncMock()
|
|
||||||
mock_proc.returncode = 0
|
|
||||||
mock_proc.communicate.return_value = (b"", b"")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"asyncio.create_subprocess_exec", return_value=mock_proc
|
|
||||||
) as mock_exec:
|
|
||||||
await GitService.fetch("/tmp/repo")
|
|
||||||
|
|
||||||
mock_exec.assert_called_once_with(
|
|
||||||
"git",
|
|
||||||
"-C",
|
|
||||||
"/tmp/repo",
|
|
||||||
"fetch",
|
|
||||||
"origin",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_failure(self):
|
|
||||||
"""Fetch raises RuntimeError when git fails."""
|
|
||||||
mock_proc = AsyncMock()
|
|
||||||
mock_proc.returncode = 128
|
|
||||||
mock_proc.communicate.return_value = (b"", b"fatal: not a git repository")
|
|
||||||
|
|
||||||
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
|
|
||||||
with pytest.raises(RuntimeError, match="Git fetch failed"):
|
|
||||||
await GitService.fetch("/not/a/repo")
|
|
||||||
|
|
||||||
|
|
||||||
class TestGitServicePull:
|
|
||||||
"""Tests for GitService.pull."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pull_success(self):
|
|
||||||
"""Pull succeeds when git returns 0."""
|
|
||||||
mock_proc = AsyncMock()
|
|
||||||
mock_proc.returncode = 0
|
|
||||||
mock_proc.communicate.return_value = (b"Already up to date.", b"")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"asyncio.create_subprocess_exec", return_value=mock_proc
|
|
||||||
) as mock_exec:
|
|
||||||
await GitService.pull("/tmp/repo", "feature-branch")
|
|
||||||
|
|
||||||
mock_exec.assert_called_once_with(
|
|
||||||
"git",
|
|
||||||
"-C",
|
|
||||||
"/tmp/repo",
|
|
||||||
"pull",
|
|
||||||
"origin",
|
|
||||||
"feature-branch",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGitServiceBranchExistsRemotely:
|
|
||||||
"""Tests for GitService.branch_exists_remotely."""
|
|
||||||
|
|
||||||
def test_branch_exists(self):
|
|
||||||
"""Returns True when branch exists on remote."""
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = "abc123 refs/heads/main\n"
|
|
||||||
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
result = GitService.branch_exists_remotely("/tmp/repo", "main")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
mock_run.assert_called_once_with(
|
|
||||||
["git", "-C", "/tmp/repo", "ls-remote", "--heads", "origin", "main"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_branch_not_exists(self):
|
|
||||||
"""Returns False when branch does not exist on remote."""
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = ""
|
|
||||||
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
result = GitService.branch_exists_remotely("/tmp/repo", "deleted-branch")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_ls_remote_fails(self):
|
|
||||||
"""Returns False when ls-remote fails."""
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 128
|
|
||||||
mock_result.stdout = ""
|
|
||||||
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
result = GitService.branch_exists_remotely("/tmp/repo", "main")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
@@ -39,18 +39,3 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
|
|||||||
|
|
||||||
assert module.revision == "0002_refresh_tokens"
|
assert module.revision == "0002_refresh_tokens"
|
||||||
assert module.down_revision == "0001_initial_schema"
|
assert module.down_revision == "0001_initial_schema"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_config_profiles_migration_has_expected_revision_chain() -> None:
|
|
||||||
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
|
|
||||||
spec = spec_from_file_location("add_config_profiles", migration_path)
|
|
||||||
|
|
||||||
assert spec is not None
|
|
||||||
assert spec.loader is not None
|
|
||||||
|
|
||||||
module = module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
|
|
||||||
assert module.revision == "0013_add_config_profiles"
|
|
||||||
assert module.down_revision == "0012_default_port_req"
|
|
||||||
|
|||||||
@@ -1,463 +0,0 @@
|
|||||||
"""Unit tests for the profile resolver service."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.services.profile_resolver import (
|
|
||||||
ProfileCycleError,
|
|
||||||
ResolvedProfileOutput,
|
|
||||||
resolve_profile,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_profile(
|
|
||||||
name: str,
|
|
||||||
env_vars: dict[str, str] | None = None,
|
|
||||||
start_command: str | None = None,
|
|
||||||
working_directory: str | None = None,
|
|
||||||
port: int | None = None,
|
|
||||||
mounts: list[MagicMock] | None = None,
|
|
||||||
includes: list[MagicMock] | None = None,
|
|
||||||
) -> MagicMock:
|
|
||||||
"""Create a mock ConfigProfile for testing."""
|
|
||||||
profile = MagicMock()
|
|
||||||
profile.id = uuid.uuid4()
|
|
||||||
profile.name = name
|
|
||||||
profile.environment_variables = env_vars or {}
|
|
||||||
profile.start_command = start_command
|
|
||||||
profile.working_directory = working_directory
|
|
||||||
profile.port = port
|
|
||||||
profile.mounts = mounts or []
|
|
||||||
profile.includes = includes or []
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
def _make_include(included_profile: MagicMock, order_index: int = 0) -> MagicMock:
|
|
||||||
"""Create a mock ConfigInclude for testing."""
|
|
||||||
include = MagicMock()
|
|
||||||
include.included_profile = included_profile
|
|
||||||
include.order_index = order_index
|
|
||||||
return include
|
|
||||||
|
|
||||||
|
|
||||||
def _make_mount(
|
|
||||||
target_path: str,
|
|
||||||
mode: str = "rw",
|
|
||||||
files: dict[str, str] | None = None,
|
|
||||||
order_index: int = 0,
|
|
||||||
) -> MagicMock:
|
|
||||||
"""Create a mock ConfigMount for testing."""
|
|
||||||
mount = MagicMock()
|
|
||||||
mount.target_path = target_path
|
|
||||||
mount.mode = mode
|
|
||||||
mount.files = files or {}
|
|
||||||
mount.order_index = order_index
|
|
||||||
return mount
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveProfileBasic:
|
|
||||||
"""Tests for basic profile resolution without includes."""
|
|
||||||
|
|
||||||
def test_empty_profile(self) -> None:
|
|
||||||
"""Resolving an empty profile returns empty output."""
|
|
||||||
profile = _make_profile("empty")
|
|
||||||
result = resolve_profile(profile)
|
|
||||||
|
|
||||||
assert isinstance(result, ResolvedProfileOutput)
|
|
||||||
assert result.profile_name == "empty"
|
|
||||||
assert result.environment_variables == {}
|
|
||||||
assert result.runtime_hints.start_command is None
|
|
||||||
assert result.runtime_hints.working_directory is None
|
|
||||||
assert result.runtime_hints.port is None
|
|
||||||
assert result.mounts == {}
|
|
||||||
assert result.resolution_order == ["empty"]
|
|
||||||
|
|
||||||
def test_env_vars_only(self) -> None:
|
|
||||||
"""Profile with env vars resolves correctly."""
|
|
||||||
profile = _make_profile(
|
|
||||||
"env-only",
|
|
||||||
env_vars={"FOO": "bar", "BAZ": "qux"},
|
|
||||||
)
|
|
||||||
result = resolve_profile(profile)
|
|
||||||
|
|
||||||
assert result.environment_variables == {"FOO": "bar", "BAZ": "qux"}
|
|
||||||
assert result.env_var_sources == {
|
|
||||||
"FOO": ["env-only"],
|
|
||||||
"BAZ": ["env-only"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_runtime_hints_only(self) -> None:
|
|
||||||
"""Profile with runtime hints resolves correctly."""
|
|
||||||
profile = _make_profile(
|
|
||||||
"hints-only",
|
|
||||||
start_command="python app.py",
|
|
||||||
working_directory="/app",
|
|
||||||
port=8080,
|
|
||||||
)
|
|
||||||
result = resolve_profile(profile)
|
|
||||||
|
|
||||||
assert result.runtime_hints.start_command == "python app.py"
|
|
||||||
assert result.runtime_hints.working_directory == "/app"
|
|
||||||
assert result.runtime_hints.port == 8080
|
|
||||||
assert result.runtime_hints.overridden_hints == {
|
|
||||||
"start_command": "hints-only",
|
|
||||||
"working_directory": "hints-only",
|
|
||||||
"port": "hints-only",
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_mounts_only(self) -> None:
|
|
||||||
"""Profile with mounts resolves correctly."""
|
|
||||||
profile = _make_profile(
|
|
||||||
"mounts-only",
|
|
||||||
mounts=[
|
|
||||||
_make_mount(
|
|
||||||
"/config",
|
|
||||||
mode="ro",
|
|
||||||
files={"settings.json": '{"key": "value"}'},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
result = resolve_profile(profile)
|
|
||||||
|
|
||||||
assert "/config" in result.mounts
|
|
||||||
mount = result.mounts["/config"]
|
|
||||||
assert mount.target_path == "/config"
|
|
||||||
assert mount.mode == "ro"
|
|
||||||
assert mount.files == {"settings.json": '{"key": "value"}'}
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveProfileIncludes:
|
|
||||||
"""Tests for profile resolution with includes."""
|
|
||||||
|
|
||||||
def test_single_include(self) -> None:
|
|
||||||
"""Profile with one include resolves in correct order."""
|
|
||||||
base = _make_profile("base", env_vars={"FOO": "base"})
|
|
||||||
derived = _make_profile(
|
|
||||||
"derived",
|
|
||||||
env_vars={"BAR": "derived"},
|
|
||||||
includes=[_make_include(base, order_index=0)],
|
|
||||||
)
|
|
||||||
result = resolve_profile(derived)
|
|
||||||
|
|
||||||
assert result.resolution_order == ["derived", "base"]
|
|
||||||
assert result.environment_variables == {
|
|
||||||
"FOO": "base",
|
|
||||||
"BAR": "derived",
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_multiple_includes_ordered(self) -> None:
|
|
||||||
"""Multiple includes are resolved in order_index order."""
|
|
||||||
first = _make_profile("first", env_vars={"KEY": "first"})
|
|
||||||
second = _make_profile("second", env_vars={"KEY": "second"})
|
|
||||||
main = _make_profile(
|
|
||||||
"main",
|
|
||||||
includes=[
|
|
||||||
_make_include(first, order_index=0),
|
|
||||||
_make_include(second, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
result = resolve_profile(main)
|
|
||||||
|
|
||||||
assert result.resolution_order == ["main", "first", "second"]
|
|
||||||
# second overrides first
|
|
||||||
assert result.environment_variables == {"KEY": "second"}
|
|
||||||
assert result.env_var_sources["KEY"] == ["first", "second"]
|
|
||||||
|
|
||||||
def test_include_order_matters(self) -> None:
|
|
||||||
"""Changing include order changes resolution."""
|
|
||||||
a = _make_profile("a", env_vars={"KEY": "a"})
|
|
||||||
b = _make_profile("b", env_vars={"KEY": "b"})
|
|
||||||
main1 = _make_profile(
|
|
||||||
"main",
|
|
||||||
includes=[
|
|
||||||
_make_include(a, order_index=0),
|
|
||||||
_make_include(b, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
main2 = _make_profile(
|
|
||||||
"main",
|
|
||||||
includes=[
|
|
||||||
_make_include(b, order_index=0),
|
|
||||||
_make_include(a, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
result1 = resolve_profile(main1)
|
|
||||||
result2 = resolve_profile(main2)
|
|
||||||
|
|
||||||
assert result1.environment_variables["KEY"] == "b"
|
|
||||||
assert result2.environment_variables["KEY"] == "a"
|
|
||||||
|
|
||||||
def test_nested_includes(self) -> None:
|
|
||||||
"""Deeply nested includes resolve recursively."""
|
|
||||||
deep = _make_profile("deep", env_vars={"DEEP": "value"})
|
|
||||||
mid = _make_profile(
|
|
||||||
"mid",
|
|
||||||
env_vars={"MID": "value"},
|
|
||||||
includes=[_make_include(deep, order_index=0)],
|
|
||||||
)
|
|
||||||
top = _make_profile(
|
|
||||||
"top",
|
|
||||||
env_vars={"TOP": "value"},
|
|
||||||
includes=[_make_include(mid, order_index=0)],
|
|
||||||
)
|
|
||||||
result = resolve_profile(top)
|
|
||||||
|
|
||||||
assert result.resolution_order == ["top", "mid", "deep"]
|
|
||||||
assert result.environment_variables == {
|
|
||||||
"TOP": "value",
|
|
||||||
"MID": "value",
|
|
||||||
"DEEP": "value",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveProfileOverrides:
|
|
||||||
"""Tests for deterministic override rules."""
|
|
||||||
|
|
||||||
def test_env_var_override(self) -> None:
|
|
||||||
"""Later layers override earlier env vars."""
|
|
||||||
base = _make_profile("base", env_vars={"KEY": "base"})
|
|
||||||
override = _make_profile("override", env_vars={"KEY": "override"})
|
|
||||||
main = _make_profile(
|
|
||||||
"main",
|
|
||||||
includes=[
|
|
||||||
_make_include(base, order_index=0),
|
|
||||||
_make_include(override, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
result = resolve_profile(main)
|
|
||||||
|
|
||||||
assert result.environment_variables["KEY"] == "override"
|
|
||||||
assert result.env_var_sources["KEY"] == ["base", "override"]
|
|
||||||
|
|
||||||
def test_main_profile_wins_over_includes(self) -> None:
|
|
||||||
"""The main profile itself wins over all includes."""
|
|
||||||
base = _make_profile("base", env_vars={"KEY": "base"})
|
|
||||||
main = _make_profile(
|
|
||||||
"main",
|
|
||||||
env_vars={"KEY": "main"},
|
|
||||||
includes=[_make_include(base, order_index=0)],
|
|
||||||
)
|
|
||||||
result = resolve_profile(main)
|
|
||||||
|
|
||||||
assert result.environment_variables["KEY"] == "main"
|
|
||||||
assert result.env_var_sources["KEY"] == ["base", "main"]
|
|
||||||
|
|
||||||
def test_runtime_hint_override(self) -> None:
|
|
||||||
"""Later layers override earlier runtime hints."""
|
|
||||||
base = _make_profile("base", start_command="python old.py")
|
|
||||||
override = _make_profile("override", start_command="python new.py")
|
|
||||||
main = _make_profile(
|
|
||||||
"main",
|
|
||||||
includes=[
|
|
||||||
_make_include(base, order_index=0),
|
|
||||||
_make_include(override, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
result = resolve_profile(main)
|
|
||||||
|
|
||||||
assert result.runtime_hints.start_command == "python new.py"
|
|
||||||
assert result.runtime_hints.overridden_hints["start_command"] == "override"
|
|
||||||
|
|
||||||
def test_mount_file_override(self) -> None:
|
|
||||||
"""Later layers override earlier files in the same mount."""
|
|
||||||
base = _make_profile(
|
|
||||||
"base",
|
|
||||||
mounts=[
|
|
||||||
_make_mount(
|
|
||||||
"/config",
|
|
||||||
files={"app.json": '{"v": 1}'},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
override = _make_profile(
|
|
||||||
"override",
|
|
||||||
mounts=[
|
|
||||||
_make_mount(
|
|
||||||
"/config",
|
|
||||||
files={"app.json": '{"v": 2}'},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
main = _make_profile(
|
|
||||||
"main",
|
|
||||||
includes=[
|
|
||||||
_make_include(base, order_index=0),
|
|
||||||
_make_include(override, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
result = resolve_profile(main)
|
|
||||||
|
|
||||||
mount = result.mounts["/config"]
|
|
||||||
assert mount.files["app.json"] == '{"v": 2}'
|
|
||||||
assert mount.overridden_files["app.json"] == ["override"]
|
|
||||||
|
|
||||||
def test_mount_mode_override(self) -> None:
|
|
||||||
"""Later layers override mount mode."""
|
|
||||||
base = _make_profile(
|
|
||||||
"base",
|
|
||||||
mounts=[_make_mount("/data", mode="ro")],
|
|
||||||
)
|
|
||||||
override = _make_profile(
|
|
||||||
"override",
|
|
||||||
mounts=[_make_mount("/data", mode="rw")],
|
|
||||||
)
|
|
||||||
main = _make_profile(
|
|
||||||
"main",
|
|
||||||
includes=[
|
|
||||||
_make_include(base, order_index=0),
|
|
||||||
_make_include(override, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
result = resolve_profile(main)
|
|
||||||
|
|
||||||
assert result.mounts["/data"].mode == "rw"
|
|
||||||
assert result.mounts["/data"].mode_overridden_by == "override"
|
|
||||||
|
|
||||||
def test_mount_file_merge(self) -> None:
|
|
||||||
"""Different files in the same mount are merged."""
|
|
||||||
base = _make_profile(
|
|
||||||
"base",
|
|
||||||
mounts=[
|
|
||||||
_make_mount(
|
|
||||||
"/config",
|
|
||||||
files={"a.json": "1"},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
override = _make_profile(
|
|
||||||
"override",
|
|
||||||
mounts=[
|
|
||||||
_make_mount(
|
|
||||||
"/config",
|
|
||||||
files={"b.json": "2"},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
main = _make_profile(
|
|
||||||
"main",
|
|
||||||
includes=[
|
|
||||||
_make_include(base, order_index=0),
|
|
||||||
_make_include(override, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
result = resolve_profile(main)
|
|
||||||
|
|
||||||
mount = result.mounts["/config"]
|
|
||||||
assert mount.files == {"a.json": "1", "b.json": "2"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveProfileCycles:
|
|
||||||
"""Tests for cycle detection during resolution."""
|
|
||||||
|
|
||||||
def test_direct_cycle(self) -> None:
|
|
||||||
"""A -> B -> A is detected."""
|
|
||||||
a = _make_profile("a")
|
|
||||||
b = _make_profile("b", includes=[_make_include(a, order_index=0)])
|
|
||||||
a.includes = [_make_include(b, order_index=0)]
|
|
||||||
|
|
||||||
with pytest.raises(ProfileCycleError) as exc_info:
|
|
||||||
resolve_profile(a)
|
|
||||||
|
|
||||||
assert "a" in exc_info.value.cycle_path
|
|
||||||
assert "b" in exc_info.value.cycle_path
|
|
||||||
|
|
||||||
def test_indirect_cycle(self) -> None:
|
|
||||||
"""A -> B -> C -> A is detected."""
|
|
||||||
a = _make_profile("a")
|
|
||||||
c = _make_profile("c")
|
|
||||||
b = _make_profile("b", includes=[_make_include(c, order_index=0)])
|
|
||||||
a.includes = [_make_include(b, order_index=0)]
|
|
||||||
c.includes = [_make_include(a, order_index=0)]
|
|
||||||
|
|
||||||
with pytest.raises(ProfileCycleError) as exc_info:
|
|
||||||
resolve_profile(a)
|
|
||||||
|
|
||||||
assert "a" in exc_info.value.cycle_path
|
|
||||||
assert "b" in exc_info.value.cycle_path
|
|
||||||
assert "c" in exc_info.value.cycle_path
|
|
||||||
|
|
||||||
def test_self_cycle(self) -> None:
|
|
||||||
"""A -> A is detected."""
|
|
||||||
a = _make_profile("a")
|
|
||||||
a.includes = [_make_include(a, order_index=0)]
|
|
||||||
|
|
||||||
with pytest.raises(ProfileCycleError) as exc_info:
|
|
||||||
resolve_profile(a)
|
|
||||||
|
|
||||||
assert exc_info.value.cycle_path == ["a", "a"]
|
|
||||||
|
|
||||||
def test_cycle_does_not_partially_resolve(self) -> None:
|
|
||||||
"""Cycle detection prevents any partial resolution."""
|
|
||||||
a = _make_profile("a", env_vars={"A": "a"})
|
|
||||||
b = _make_profile("b", env_vars={"B": "b"})
|
|
||||||
a.includes = [_make_include(b, order_index=0)]
|
|
||||||
b.includes = [_make_include(a, order_index=0)]
|
|
||||||
|
|
||||||
with pytest.raises(ProfileCycleError):
|
|
||||||
resolve_profile(a)
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveProfileDiamond:
|
|
||||||
"""Tests for diamond-shaped include graphs."""
|
|
||||||
|
|
||||||
def test_diamond_resolution(self) -> None:
|
|
||||||
"""Diamond graph resolves correctly without duplication issues."""
|
|
||||||
base = _make_profile("base", env_vars={"BASE": "base"})
|
|
||||||
left = _make_profile(
|
|
||||||
"left",
|
|
||||||
env_vars={"LEFT": "left"},
|
|
||||||
includes=[_make_include(base, order_index=0)],
|
|
||||||
)
|
|
||||||
right = _make_profile(
|
|
||||||
"right",
|
|
||||||
env_vars={"RIGHT": "right"},
|
|
||||||
includes=[_make_include(base, order_index=0)],
|
|
||||||
)
|
|
||||||
top = _make_profile(
|
|
||||||
"top",
|
|
||||||
env_vars={"TOP": "top"},
|
|
||||||
includes=[
|
|
||||||
_make_include(left, order_index=0),
|
|
||||||
_make_include(right, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
result = resolve_profile(top)
|
|
||||||
|
|
||||||
# base should appear once (via left, then right skips because visited)
|
|
||||||
assert result.resolution_order == ["top", "left", "base", "right"]
|
|
||||||
assert result.environment_variables == {
|
|
||||||
"TOP": "top",
|
|
||||||
"LEFT": "left",
|
|
||||||
"RIGHT": "right",
|
|
||||||
"BASE": "base",
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_diamond_override(self) -> None:
|
|
||||||
"""Diamond graph with conflicting overrides resolves correctly."""
|
|
||||||
base = _make_profile("base", env_vars={"KEY": "base"})
|
|
||||||
left = _make_profile(
|
|
||||||
"left",
|
|
||||||
env_vars={"KEY": "left"},
|
|
||||||
includes=[_make_include(base, order_index=0)],
|
|
||||||
)
|
|
||||||
right = _make_profile(
|
|
||||||
"right",
|
|
||||||
env_vars={"KEY": "right"},
|
|
||||||
includes=[_make_include(base, order_index=0)],
|
|
||||||
)
|
|
||||||
top = _make_profile(
|
|
||||||
"top",
|
|
||||||
includes=[
|
|
||||||
_make_include(left, order_index=0),
|
|
||||||
_make_include(right, order_index=1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
result = resolve_profile(top)
|
|
||||||
|
|
||||||
# right wins because it's later
|
|
||||||
assert result.environment_variables["KEY"] == "right"
|
|
||||||
assert result.env_var_sources["KEY"] == ["base", "left", "right"]
|
|
||||||
# Note: base appears once because visited set skips duplicate resolution in diamond graphs
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Unit tests for TerminalManager."""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.services.terminal_manager import TerminalManager
|
|
||||||
from src.services.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def manager():
|
|
||||||
return TerminalManager()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_websocket():
|
|
||||||
ws = AsyncMock()
|
|
||||||
ws.send_bytes = AsyncMock()
|
|
||||||
ws.send_json = AsyncMock()
|
|
||||||
ws.close = AsyncMock()
|
|
||||||
ws.receive = AsyncMock()
|
|
||||||
return ws
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_session():
|
|
||||||
session = MagicMock(spec=TerminalSession)
|
|
||||||
session.session_id = "sess-123"
|
|
||||||
session.is_alive.return_value = True
|
|
||||||
session._closed = False
|
|
||||||
session.read_output = AsyncMock(return_value=b"")
|
|
||||||
session.write_input = AsyncMock()
|
|
||||||
session.resize = AsyncMock()
|
|
||||||
session.close = AsyncMock()
|
|
||||||
session.get_exit_reason.return_value = None
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateSession:
|
|
||||||
@patch("src.services.terminal_manager.asyncio.create_task")
|
|
||||||
@patch("src.services.terminal_manager.uuid.uuid4", return_value="sess-123")
|
|
||||||
async def test_create_session_registers_and_starts_loops(
|
|
||||||
self, mock_uuid, mock_create_task, manager, mock_websocket
|
|
||||||
):
|
|
||||||
instance_id = __import__("uuid").uuid4()
|
|
||||||
mock_sess = MagicMock()
|
|
||||||
mock_sess.session_id = "sess-123"
|
|
||||||
mock_sess.is_alive.return_value = True
|
|
||||||
mock_sess._closed = False
|
|
||||||
mock_sess.start = AsyncMock()
|
|
||||||
mock_sess.read_output = AsyncMock(return_value=b"")
|
|
||||||
mock_sess.write_input = AsyncMock()
|
|
||||||
mock_sess.resize = AsyncMock()
|
|
||||||
mock_sess.close = AsyncMock()
|
|
||||||
mock_sess.get_exit_reason.return_value = None
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(manager, "_read_loop", new=AsyncMock()),
|
|
||||||
patch.object(manager, "_write_loop", new=AsyncMock()),
|
|
||||||
patch.object(manager, "_heartbeat_loop", new=AsyncMock()),
|
|
||||||
patch(
|
|
||||||
"src.services.terminal_manager.TerminalSession",
|
|
||||||
return_value=mock_sess,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
session = await manager.create_session(
|
|
||||||
instance_id, "container-abc", mock_websocket
|
|
||||||
)
|
|
||||||
assert session.session_id == "sess-123"
|
|
||||||
assert "sess-123" in manager._sessions
|
|
||||||
assert "sess-123" in manager._last_client_message
|
|
||||||
|
|
||||||
|
|
||||||
class TestHandleControlMessage:
|
|
||||||
async def test_handle_resize(self, manager, mock_session, mock_websocket):
|
|
||||||
ctrl = {"type": "resize", "cols": 120, "rows": 40}
|
|
||||||
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
|
|
||||||
mock_session.resize.assert_awaited_once_with(120, 40)
|
|
||||||
|
|
||||||
async def test_handle_ping(self, manager, mock_session, mock_websocket):
|
|
||||||
ctrl = {"type": "ping", "id": 42}
|
|
||||||
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
|
|
||||||
mock_websocket.send_json.assert_awaited_once_with({"type": "pong", "id": 42})
|
|
||||||
|
|
||||||
async def test_handle_unknown_type(self, manager, mock_session, mock_websocket):
|
|
||||||
ctrl = {"type": "unknown", "data": "test"}
|
|
||||||
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
|
|
||||||
mock_websocket.send_json.assert_not_awaited()
|
|
||||||
mock_session.resize.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupSession:
|
|
||||||
async def test_cleanup_removes_session(self, manager, mock_session):
|
|
||||||
manager._sessions["sess-123"] = mock_session
|
|
||||||
manager._last_client_message["sess-123"] = 123.0
|
|
||||||
|
|
||||||
await manager._cleanup_session(mock_session)
|
|
||||||
assert "sess-123" not in manager._sessions
|
|
||||||
assert "sess-123" not in manager._last_client_message
|
|
||||||
mock_session.close.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCloseAll:
|
|
||||||
async def test_close_all_clears_sessions(self, manager, mock_session):
|
|
||||||
manager._sessions["sess-123"] = mock_session
|
|
||||||
manager._last_client_message["sess-123"] = 123.0
|
|
||||||
|
|
||||||
await manager.close_all()
|
|
||||||
assert len(manager._sessions) == 0
|
|
||||||
assert len(manager._last_client_message) == 0
|
|
||||||
mock_session.close.assert_awaited_once()
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"""Unit tests for TerminalSession."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.services.terminal_session import TerminalSession
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_pty():
|
|
||||||
"""Mock pty.openpty to return predictable fds."""
|
|
||||||
master_fd = 10
|
|
||||||
slave_fd = 11
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"src.services.terminal_session.pty.openpty",
|
|
||||||
return_value=(master_fd, slave_fd),
|
|
||||||
),
|
|
||||||
patch("src.services.terminal_session.os.close") as mock_close,
|
|
||||||
):
|
|
||||||
yield master_fd, slave_fd, mock_close
|
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalSessionStart:
|
|
||||||
def test_init_state(self, mock_pty):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
|
|
||||||
assert session.session_id == "sess-1"
|
|
||||||
assert session.container_id == "container-abc"
|
|
||||||
assert session._echo_enabled is True
|
|
||||||
assert session._exit_reason is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalSessionEchoDetection:
|
|
||||||
@patch("src.services.terminal_session.termios.tcgetattr")
|
|
||||||
def test_detect_echo_state_enabled(self, mock_tcgetattr):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._master_fd = 10
|
|
||||||
|
|
||||||
# termios.ECHO flag set
|
|
||||||
attrs = [[], [], [], __import__("termios").ECHO, [], [], []]
|
|
||||||
mock_tcgetattr.return_value = attrs
|
|
||||||
|
|
||||||
result = session._detect_echo_state()
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@patch("src.services.terminal_session.termios.tcgetattr")
|
|
||||||
def test_detect_echo_state_disabled(self, mock_tcgetattr):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._master_fd = 10
|
|
||||||
|
|
||||||
# termios.ECHO flag NOT set
|
|
||||||
attrs = [[], [], [], 0, [], [], []]
|
|
||||||
mock_tcgetattr.return_value = attrs
|
|
||||||
|
|
||||||
result = session._detect_echo_state()
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_detect_echo_state_no_master_fd(self):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._master_fd = None
|
|
||||||
|
|
||||||
result = session._detect_echo_state()
|
|
||||||
assert result is True # default
|
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalSessionResize:
|
|
||||||
@patch("src.services.terminal_session.fcntl.ioctl")
|
|
||||||
def test_resize_sets_size(self, mock_ioctl):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._master_fd = 10
|
|
||||||
|
|
||||||
# Should not raise
|
|
||||||
asyncio.run(session.resize(120, 40))
|
|
||||||
mock_ioctl.assert_called_once()
|
|
||||||
|
|
||||||
def test_resize_when_closed(self):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._closed = True
|
|
||||||
|
|
||||||
# Should not raise
|
|
||||||
asyncio.run(session.resize(120, 40))
|
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalSessionWriteInput:
|
|
||||||
@patch("src.services.terminal_session.os.write")
|
|
||||||
def test_write_input(self, mock_write):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._master_fd = 10
|
|
||||||
|
|
||||||
asyncio.run(session.write_input(b"hello"))
|
|
||||||
mock_write.assert_called_once_with(10, b"hello")
|
|
||||||
|
|
||||||
def test_write_input_when_closed(self):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._closed = True
|
|
||||||
|
|
||||||
# Should not raise
|
|
||||||
asyncio.run(session.write_input(b"hello"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalSessionReadOutput:
|
|
||||||
@patch("src.services.terminal_session.select.select")
|
|
||||||
@patch("src.services.terminal_session.os.read")
|
|
||||||
def test_read_output_with_data(self, mock_read, mock_select):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._master_fd = 10
|
|
||||||
|
|
||||||
mock_select.return_value = ([10], [], [])
|
|
||||||
mock_read.return_value = b"output"
|
|
||||||
|
|
||||||
result = asyncio.run(session.read_output())
|
|
||||||
assert result == b"output"
|
|
||||||
|
|
||||||
@patch("src.services.terminal_session.select.select")
|
|
||||||
def test_read_output_no_data(self, mock_select):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._master_fd = 10
|
|
||||||
|
|
||||||
mock_select.return_value = ([], [], [])
|
|
||||||
|
|
||||||
result = asyncio.run(session.read_output())
|
|
||||||
assert result == b""
|
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalSessionClose:
|
|
||||||
@patch("src.services.terminal_session.os.close")
|
|
||||||
@patch("src.services.terminal_session.asyncio.wait_for")
|
|
||||||
async def test_close_sets_exit_reason(self, mock_wait_for, mock_close):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._master_fd = 10
|
|
||||||
session.process = MagicMock()
|
|
||||||
session.process.returncode = 0
|
|
||||||
|
|
||||||
await session.close()
|
|
||||||
assert session._exit_reason == "process_exit"
|
|
||||||
assert session._closed is True
|
|
||||||
|
|
||||||
async def test_close_idempotent(self):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session._closed = True
|
|
||||||
|
|
||||||
# Should not raise
|
|
||||||
await session.close()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalSessionIsAlive:
|
|
||||||
def test_is_alive_with_running_process(self):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session.process = MagicMock()
|
|
||||||
session.process.returncode = None
|
|
||||||
|
|
||||||
assert session.is_alive() is True
|
|
||||||
|
|
||||||
def test_is_alive_with_exited_process(self):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session.process = MagicMock()
|
|
||||||
session.process.returncode = 0
|
|
||||||
|
|
||||||
assert session.is_alive() is False
|
|
||||||
|
|
||||||
def test_is_alive_no_process(self):
|
|
||||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
|
||||||
session.process = None
|
|
||||||
|
|
||||||
assert session.is_alive() is False
|
|
||||||
@@ -145,12 +145,10 @@ class TestCreateInstanceDockerfileLegacy:
|
|||||||
data = MagicMock()
|
data = MagicMock()
|
||||||
data.tool_type_id = str(fake_tool_type_id)
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
data.display_name = None
|
data.display_name = None
|
||||||
data.workspace_id = None
|
|
||||||
data.clone_mode = "mount"
|
data.clone_mode = "mount"
|
||||||
data.branch = None
|
data.branch = None
|
||||||
data.new_branch = None
|
data.new_branch = None
|
||||||
data.config_profile_id = None
|
data.config_profile_id = None
|
||||||
data.ssh_key_ids = []
|
|
||||||
|
|
||||||
result = await create_instance(
|
result = await create_instance(
|
||||||
project_id=fake_project_id,
|
project_id=fake_project_id,
|
||||||
@@ -227,12 +225,10 @@ class TestCreateInstanceDockerfileLegacy:
|
|||||||
data = MagicMock()
|
data = MagicMock()
|
||||||
data.tool_type_id = str(fake_tool_type_id)
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
data.display_name = None
|
data.display_name = None
|
||||||
data.workspace_id = None
|
|
||||||
data.clone_mode = "mount"
|
data.clone_mode = "mount"
|
||||||
data.branch = None
|
data.branch = None
|
||||||
data.new_branch = None
|
data.new_branch = None
|
||||||
data.config_profile_id = None
|
data.config_profile_id = None
|
||||||
data.ssh_key_ids = []
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
await create_instance(
|
await create_instance(
|
||||||
@@ -309,12 +305,10 @@ class TestCreateInstanceComposeLegacy:
|
|||||||
data = MagicMock()
|
data = MagicMock()
|
||||||
data.tool_type_id = str(fake_tool_type_id)
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
data.display_name = None
|
data.display_name = None
|
||||||
data.workspace_id = None
|
|
||||||
data.clone_mode = "mount"
|
data.clone_mode = "mount"
|
||||||
data.branch = None
|
data.branch = None
|
||||||
data.new_branch = None
|
data.new_branch = None
|
||||||
data.config_profile_id = None
|
data.config_profile_id = None
|
||||||
data.ssh_key_ids = []
|
|
||||||
|
|
||||||
result = await create_instance(
|
result = await create_instance(
|
||||||
project_id=fake_project_id,
|
project_id=fake_project_id,
|
||||||
@@ -395,12 +389,10 @@ class TestCreateInstanceManifestNotCalledForLegacy:
|
|||||||
data = MagicMock()
|
data = MagicMock()
|
||||||
data.tool_type_id = str(fake_tool_type_id)
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
data.display_name = None
|
data.display_name = None
|
||||||
data.workspace_id = None
|
|
||||||
data.clone_mode = "mount"
|
data.clone_mode = "mount"
|
||||||
data.branch = None
|
data.branch = None
|
||||||
data.new_branch = None
|
data.new_branch = None
|
||||||
data.config_profile_id = None
|
data.config_profile_id = None
|
||||||
data.ssh_key_ids = []
|
|
||||||
|
|
||||||
await create_instance(
|
await create_instance(
|
||||||
project_id=fake_project_id,
|
project_id=fake_project_id,
|
||||||
@@ -420,7 +412,6 @@ class TestStartInstanceLegacyFallback:
|
|||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
|
||||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@@ -435,7 +426,6 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
mock_ensure_web_bind,
|
mock_ensure_web_bind,
|
||||||
mock_ensure_container_name,
|
mock_ensure_container_name,
|
||||||
mock_backend_network,
|
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
@@ -521,7 +511,6 @@ class TestStartInstanceLegacyFallback:
|
|||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
|
||||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@@ -536,7 +525,6 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
mock_ensure_web_bind,
|
mock_ensure_web_bind,
|
||||||
mock_ensure_container_name,
|
mock_ensure_container_name,
|
||||||
mock_backend_network,
|
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
@@ -621,7 +609,6 @@ class TestStartInstanceLegacyFallback:
|
|||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
|
||||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@@ -636,7 +623,6 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
mock_ensure_web_bind,
|
mock_ensure_web_bind,
|
||||||
mock_ensure_container_name,
|
mock_ensure_container_name,
|
||||||
mock_backend_network,
|
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
@@ -728,7 +714,6 @@ class TestStartInstanceSshPermissions:
|
|||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
|
||||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@@ -741,7 +726,6 @@ class TestStartInstanceSshPermissions:
|
|||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
mock_ensure_web_bind,
|
mock_ensure_web_bind,
|
||||||
mock_ensure_container_name,
|
mock_ensure_container_name,
|
||||||
mock_backend_network,
|
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
@@ -837,24 +821,23 @@ class TestStartInstanceSshPermissions:
|
|||||||
mock_session.get.side_effect = _get
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
with patch("os.path.exists", return_value=True):
|
with patch("os.path.exists", return_value=True):
|
||||||
with patch("os.makedirs"):
|
with patch(
|
||||||
with patch(
|
"src.api.tool_instances._prepare_manifest_instance"
|
||||||
"src.api.tool_instances._prepare_manifest_instance"
|
) as mock_prepare:
|
||||||
) as mock_prepare:
|
mock_prepare.return_value = (
|
||||||
mock_prepare.return_value = (
|
"headquarter/test:latest",
|
||||||
"headquarter/test:latest",
|
"services:\n app:\n image: test",
|
||||||
"services:\n app:\n image: test",
|
{"name": "test-manifest", "user": {"name": "user"}},
|
||||||
{"name": "test-manifest", "user": {"name": "user"}},
|
"/home/user",
|
||||||
"/home/user",
|
)
|
||||||
)
|
result = await start_instance(
|
||||||
result = await start_instance(
|
project_id=fake_project_id,
|
||||||
project_id=fake_project_id,
|
repo_id=fake_repo_id,
|
||||||
repo_id=fake_repo_id,
|
instance_id=fake_instance_id,
|
||||||
instance_id=fake_instance_id,
|
data=None,
|
||||||
data=None,
|
user_id=fake_user_id,
|
||||||
user_id=fake_user_id,
|
session=mock_session,
|
||||||
session=mock_session,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
assert result["status"] == "running"
|
assert result["status"] == "running"
|
||||||
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
|
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
|
||||||
@@ -865,7 +848,6 @@ class TestStartInstanceSshPermissions:
|
|||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
|
||||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@@ -878,7 +860,6 @@ class TestStartInstanceSshPermissions:
|
|||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
mock_ensure_web_bind,
|
mock_ensure_web_bind,
|
||||||
mock_ensure_container_name,
|
mock_ensure_container_name,
|
||||||
mock_backend_network,
|
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
@@ -961,16 +942,15 @@ class TestStartInstanceSshPermissions:
|
|||||||
mock_session.get.side_effect = _get
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
with patch("os.path.exists", return_value=True):
|
with patch("os.path.exists", return_value=True):
|
||||||
with patch("os.makedirs"):
|
with patch("src.api.tool_instances._modify_compose_file"):
|
||||||
with patch("src.api.tool_instances._modify_compose_file"):
|
result = await start_instance(
|
||||||
result = await start_instance(
|
project_id=fake_project_id,
|
||||||
project_id=fake_project_id,
|
repo_id=fake_repo_id,
|
||||||
repo_id=fake_repo_id,
|
instance_id=fake_instance_id,
|
||||||
instance_id=fake_instance_id,
|
data=None,
|
||||||
data=None,
|
user_id=fake_user_id,
|
||||||
user_id=fake_user_id,
|
session=mock_session,
|
||||||
session=mock_session,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
assert result["status"] == "running"
|
assert result["status"] == "running"
|
||||||
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
|
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
|
||||||
@@ -983,7 +963,6 @@ class TestStartInstanceManifestBranch:
|
|||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
|
||||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@@ -1000,7 +979,6 @@ class TestStartInstanceManifestBranch:
|
|||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
mock_ensure_web_bind,
|
mock_ensure_web_bind,
|
||||||
mock_ensure_container_name,
|
mock_ensure_container_name,
|
||||||
mock_backend_network,
|
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
|
|||||||
-1
File diff suppressed because one or more lines are too long
@@ -15,12 +15,6 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Never cache index.html so browsers always fetch new hashed JS/CSS
|
|
||||||
location = /index.html {
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
add_header Pragma "no-cache";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
|||||||
Generated
+11
-80
@@ -16,10 +16,10 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
"sonner": "^1.7.4",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"xterm-addon-serialize": "^0.11.0",
|
|
||||||
"xterm-addon-web-links": "^0.9.0"
|
"xterm-addon-web-links": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1391,9 +1391,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1411,9 +1408,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1431,9 +1425,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1451,9 +1442,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1471,9 +1459,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1491,9 +1476,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1672,9 +1654,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1689,9 +1668,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1706,9 +1682,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1723,9 +1696,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1740,9 +1710,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1757,9 +1724,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1774,9 +1738,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1791,9 +1752,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1808,9 +1766,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1825,9 +1780,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1842,9 +1794,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1859,9 +1808,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1876,9 +1822,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4361,9 +4304,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4385,9 +4325,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4409,9 +4346,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4433,9 +4367,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5538,6 +5469,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "1.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||||
|
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -6373,16 +6314,6 @@
|
|||||||
"xterm": "^5.0.0"
|
"xterm": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xterm-addon-serialize": {
|
|
||||||
"version": "0.11.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/xterm-addon-serialize/-/xterm-addon-serialize-0.11.0.tgz",
|
|
||||||
"integrity": "sha512-2CNDnmLdLkNWfsxNFkGsI5FE9W/BbsMzeOrbu59yNqH9L6k1gmL+Ab6VXxEp2NQUJSzaiqi6t0nFR5k5EDkVIg==",
|
|
||||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-serialize instead.",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xterm-addon-web-links": {
|
"node_modules/xterm-addon-web-links": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"xterm-addon-serialize": "^0.11.0",
|
|
||||||
"xterm-addon-web-links": "^0.9.0"
|
"xterm-addon-web-links": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* Verifies repository structure conventions.
|
|
||||||
* Run with: node scripts/check-structure.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const SRC_DIR = path.join(__dirname, "..", "src");
|
|
||||||
|
|
||||||
let errors = 0;
|
|
||||||
let warnings = 0;
|
|
||||||
|
|
||||||
// Known acceptable deviations — documented in naming.md
|
|
||||||
const OVERSIZE_ALLOWLIST = [
|
|
||||||
// Form-heavy admin tabs: 15+ fields each, splitting would create micro-components
|
|
||||||
"components/features/tool-workshop/ToolTypesTab.tsx",
|
|
||||||
// Complex terminal hook: WS lifecycle + ping-pong + echo + resize debouncing
|
|
||||||
"hooks/use-terminal-connection.ts",
|
|
||||||
// Terminal component: xterm lifecycle + resize observer + overlay UI
|
|
||||||
"components/features/terminal/TerminalComponent.tsx",
|
|
||||||
// Instance list with health polling + inline confirmations
|
|
||||||
"components/features/session/InstanceList.tsx",
|
|
||||||
// Dialog with form validation + SSH key handling
|
|
||||||
"components/features/project/RepositoryCreateDialog.tsx",
|
|
||||||
// Test files: complex test coverage
|
|
||||||
"hooks/use-terminal-connection.test.ts",
|
|
||||||
"pages/ToolWorkshopPage.test.tsx",
|
|
||||||
// Global utility CSS: will be further split in future iteration
|
|
||||||
"styles/utilities.css",
|
|
||||||
];
|
|
||||||
|
|
||||||
function checkFileSize(filePath, maxLines = 300) {
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8");
|
|
||||||
const lines = content.split("\n").length;
|
|
||||||
const relative = path.relative(SRC_DIR, filePath);
|
|
||||||
if (lines > maxLines) {
|
|
||||||
if (OVERSIZE_ALLOWLIST.includes(relative)) {
|
|
||||||
console.warn(`⚠️ OVERSIZED (${lines} lines, allowlisted): ${relative}`);
|
|
||||||
warnings++;
|
|
||||||
} else {
|
|
||||||
console.error(`❌ OVERSIZED (${lines} lines): ${relative}`);
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function walk(dir, callback) {
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
||||||
walk(fullPath, callback);
|
|
||||||
} else {
|
|
||||||
callback(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Checking file sizes...\n");
|
|
||||||
walk(SRC_DIR, (filePath) => {
|
|
||||||
const ext = path.extname(filePath);
|
|
||||||
if ([".ts", ".tsx", ".py", ".css"].includes(ext)) {
|
|
||||||
checkFileSize(filePath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("\n---");
|
|
||||||
if (errors === 0 && warnings === 0) {
|
|
||||||
console.log("✅ All checks passed!");
|
|
||||||
process.exit(0);
|
|
||||||
} else if (errors === 0) {
|
|
||||||
console.log(`✅ All checks passed with ${warnings} warning(s)`);
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
console.log(`❌ ${errors} error(s), ${warnings} warning(s)`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
import { apiClient } from "./client";
|
|
||||||
import type {
|
|
||||||
CommitDetail,
|
|
||||||
CommitHistoryResponse,
|
|
||||||
CommitResponse,
|
|
||||||
GitRepository,
|
|
||||||
GitRepositoryCreate,
|
|
||||||
GitStatus,
|
|
||||||
MergeResponse,
|
|
||||||
URLParseResult,
|
|
||||||
} from "../types/git-repository";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
CommitDetail,
|
|
||||||
CommitHistoryEntry,
|
|
||||||
CommitHistoryResponse,
|
|
||||||
CommitResponse,
|
|
||||||
GitRepository,
|
|
||||||
GitRepositoryCreate,
|
|
||||||
GitStatus,
|
|
||||||
MergeResponse,
|
|
||||||
URLParseResult,
|
|
||||||
} from "../types/git-repository";
|
|
||||||
|
|
||||||
export interface Branch {
|
|
||||||
name: string;
|
|
||||||
is_default: boolean;
|
|
||||||
last_commit: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BranchesResponse {
|
|
||||||
branches: Branch[];
|
|
||||||
default_branch: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
|
||||||
const response = await apiClient.post("/projects/repositories/parse-url", {
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRepositories(
|
|
||||||
projectId: string,
|
|
||||||
): Promise<GitRepository[]> {
|
|
||||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRepositoryBranches(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
): Promise<BranchesResponse> {
|
|
||||||
const response = await apiClient.get(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/branches`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRepository(
|
|
||||||
projectId: string,
|
|
||||||
data: GitRepositoryCreate,
|
|
||||||
): Promise<GitRepository> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories`,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteRepository(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRepositoryHistory(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branch?: string,
|
|
||||||
limit?: number,
|
|
||||||
): Promise<CommitHistoryResponse> {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
if (branch) searchParams.set("branch", branch);
|
|
||||||
if (limit) searchParams.set("limit", String(limit));
|
|
||||||
const queryString = searchParams.toString();
|
|
||||||
const params = queryString ? `?${queryString}` : "";
|
|
||||||
const response = await apiClient.get(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/history${params}`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCommitDetail(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
commitHash: string,
|
|
||||||
): Promise<CommitDetail> {
|
|
||||||
const response = await apiClient.get(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRepositoryStatus(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
): Promise<GitStatus> {
|
|
||||||
const response = await apiClient.get(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/status`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createBranch(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
name: string,
|
|
||||||
baseBranch: string = "HEAD",
|
|
||||||
): Promise<{ message: string; branch: string }> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/branches`,
|
|
||||||
{ name, base_branch: baseBranch },
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteBranch(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branchName: string,
|
|
||||||
force: boolean = false,
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const response = await apiClient.delete(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkoutBranch(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branch: string,
|
|
||||||
): Promise<{ message: string; branch: string }> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/checkout`,
|
|
||||||
{ branch },
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function commitChanges(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
message: string,
|
|
||||||
files?: string[],
|
|
||||||
): Promise<CommitResponse> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/commit`,
|
|
||||||
{ message, files },
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRepository(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/fetch`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pullRepository(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branch?: string,
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const params = branch ? `?branch=${branch}` : "";
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/pull${params}`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pushRepository(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branch?: string,
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const params = branch ? `?branch=${branch}` : "";
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/push${params}`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mergeBranches(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
sourceBranch: string,
|
|
||||||
targetBranch?: string,
|
|
||||||
message?: string,
|
|
||||||
): Promise<MergeResponse> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/merge`,
|
|
||||||
{ source_branch: sourceBranch, target_branch: targetBranch, message },
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface GitRepository {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
project_id: string;
|
||||||
|
owner_id: string;
|
||||||
|
is_mirror: boolean;
|
||||||
|
remote_url: string | null;
|
||||||
|
ssh_key_id: string | null;
|
||||||
|
last_push: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitRepositoryCreate {
|
||||||
|
name: string;
|
||||||
|
remote_url?: string;
|
||||||
|
force_original_url?: boolean;
|
||||||
|
ssh_key_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface URLParseResult {
|
||||||
|
original_url: string;
|
||||||
|
base_url: string | null;
|
||||||
|
is_valid_clone_url: boolean;
|
||||||
|
needs_parsing: boolean;
|
||||||
|
host: string | null;
|
||||||
|
message: string;
|
||||||
|
error_code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||||
|
const response = await apiClient.post("/repositories/parse-url", { url });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
|
||||||
|
if (projectId) {
|
||||||
|
const response = await apiClient.get<GitRepository[]>(
|
||||||
|
`/projects/${projectId}/repositories`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
// List all user repositories (including external)
|
||||||
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAllUserRepositories(): Promise<GitRepository[]> {
|
||||||
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRepository(
|
||||||
|
projectId: string,
|
||||||
|
data: GitRepositoryCreate
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.post(`/projects/${projectId}/repositories`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExternalRepository(
|
||||||
|
data: GitRepositoryCreate
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.post<GitRepository>("/repositories", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRepositorySshKey(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
sshKeyId: string | null
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/ssh-key`,
|
||||||
|
{ ssh_key_id: sshKeyId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Branch {
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
last_commit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchesResponse {
|
||||||
|
branches: Branch[];
|
||||||
|
default_branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepositoryBranches(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<BranchesResponse> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitHistoryEntry {
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
message: string;
|
||||||
|
author_name: string;
|
||||||
|
author_email: string;
|
||||||
|
author_date: string;
|
||||||
|
refs: string[];
|
||||||
|
graph_symbol: string;
|
||||||
|
graph_depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitHistoryResponse {
|
||||||
|
commits: CommitHistoryEntry[];
|
||||||
|
branches: string[];
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepositoryHistory(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branch?: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<CommitHistoryResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (branch) searchParams.set("branch", branch);
|
||||||
|
if (limit) searchParams.set("limit", String(limit));
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const params = queryString ? `?${queryString}` : "";
|
||||||
|
const response = await apiClient.get(`/projects/${projectId}/repositories/${repoId}/history${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitDetail {
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
message: string;
|
||||||
|
author_name: string;
|
||||||
|
author_email: string;
|
||||||
|
author_date: string;
|
||||||
|
committer_name: string;
|
||||||
|
committer_email: string;
|
||||||
|
committer_date: string;
|
||||||
|
stats: {
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
files_changed: number;
|
||||||
|
};
|
||||||
|
diff: string;
|
||||||
|
parents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCommitDetail(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
commitHash: string
|
||||||
|
): Promise<CommitDetail> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git Control API
|
||||||
|
|
||||||
|
export interface GitStatus {
|
||||||
|
branch: string;
|
||||||
|
modified: string[];
|
||||||
|
added: string[];
|
||||||
|
deleted: string[];
|
||||||
|
untracked: string[];
|
||||||
|
renamed: string[];
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepositoryStatus(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/status`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBranch(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
name: string,
|
||||||
|
baseBranch: string = "HEAD"
|
||||||
|
): Promise<{ message: string; branch: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`,
|
||||||
|
{ name, base_branch: baseBranch }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBranch(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branchName: string,
|
||||||
|
force: boolean = false
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.delete(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkoutBranch(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branch: string
|
||||||
|
): Promise<{ message: string; branch: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/checkout`,
|
||||||
|
{ branch }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitResponse {
|
||||||
|
commit_hash: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitChanges(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
message: string,
|
||||||
|
files?: string[]
|
||||||
|
): Promise<CommitResponse> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/commit`,
|
||||||
|
{ message, files }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRepository(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/fetch`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pullRepository(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branch?: string
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const params = branch ? `?branch=${branch}` : "";
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/pull${params}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushRepository(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branch?: string
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const params = branch ? `?branch=${branch}` : "";
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/push${params}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergeResponse {
|
||||||
|
commit_hash: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeBranches(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
sourceBranch: string,
|
||||||
|
targetBranch?: string,
|
||||||
|
message?: string
|
||||||
|
): Promise<MergeResponse> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/merge`,
|
||||||
|
{ source_branch: sourceBranch, target_branch: targetBranch, message }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -1,54 +1,51 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
import type { Project, ProjectWithRepos } from "../types";
|
import type { Project } from "../types";
|
||||||
|
|
||||||
export type ProjectCreateInput = {
|
export type ProjectCreateInput = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectUpdateInput = {
|
export type ProjectUpdateInput = {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetDefaultSSHKeyInput = {
|
export type SetDefaultSSHKeyInput = {
|
||||||
ssh_key_id: string;
|
ssh_key_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listProjects = async (): Promise<ProjectWithRepos[]> => {
|
export const listProjects = async (): Promise<Project[]> => {
|
||||||
const response = await apiClient.get<ProjectWithRepos[]>("/projects");
|
const response = await apiClient.get<Project[]>("/projects");
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createProject = async (
|
export const createProject = async (
|
||||||
input: ProjectCreateInput,
|
input: ProjectCreateInput
|
||||||
): Promise<Project> => {
|
): Promise<Project> => {
|
||||||
const response = await apiClient.post<Project>("/projects", input);
|
const response = await apiClient.post<Project>("/projects", input);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProject = async (
|
export const updateProject = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
input: ProjectUpdateInput,
|
input: ProjectUpdateInput
|
||||||
): Promise<Project> => {
|
): Promise<Project> => {
|
||||||
const response = await apiClient.patch<Project>(
|
const response = await apiClient.patch<Project>(`/projects/${projectId}`, input);
|
||||||
`/projects/${projectId}`,
|
return response.data;
|
||||||
input,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteProject = async (projectId: string): Promise<void> => {
|
export const deleteProject = async (projectId: string): Promise<void> => {
|
||||||
await apiClient.delete(`/projects/${projectId}`);
|
await apiClient.delete(`/projects/${projectId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setDefaultSSHKey = async (
|
export const setDefaultSSHKey = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
input: SetDefaultSSHKeyInput,
|
input: SetDefaultSSHKeyInput
|
||||||
): Promise<Project> => {
|
): Promise<Project> => {
|
||||||
const response = await apiClient.patch<Project>(
|
const response = await apiClient.patch<Project>(
|
||||||
`/projects/${projectId}/default-ssh-key`,
|
`/projects/${projectId}/default-ssh-key`,
|
||||||
input,
|
input
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
+146
-92
@@ -1,130 +1,184 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
import type { Session } from "../types/session";
|
|
||||||
import type { ToolInstance } from "../types/tool-instance";
|
|
||||||
|
|
||||||
export type { Session } from "../types/session";
|
export interface ToolInstance {
|
||||||
export type { ToolInstance } from "../types/tool-instance";
|
id: string;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
tool_type_id: string;
|
||||||
|
tool_type_name: string;
|
||||||
|
tool_type_interfaces: string[];
|
||||||
|
status: string;
|
||||||
|
url: string | null;
|
||||||
|
port: number | null;
|
||||||
|
selected_config_profile_id: string | null;
|
||||||
|
ssh_key_ids: string[];
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InstanceHealth {
|
export interface Session {
|
||||||
healthy: boolean;
|
id: string;
|
||||||
container_status: string;
|
display_name: string;
|
||||||
container_health: string | null;
|
tool_type_name: string;
|
||||||
container_exit_code: number | null;
|
tool_icon: string;
|
||||||
tunnel_status: string;
|
tool_type_interfaces: string[];
|
||||||
tunnel_status_code: number | null;
|
repository_name: string;
|
||||||
probe_status: string;
|
repository_id: string;
|
||||||
last_probe_output: string | null;
|
project_name: string;
|
||||||
error: string | null;
|
project_id: string;
|
||||||
|
status: string;
|
||||||
|
url: string | null;
|
||||||
|
container_status?: string;
|
||||||
|
probe_status?: string;
|
||||||
|
clone_mode?: string;
|
||||||
|
branch?: string | null;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listInstances(
|
export async function listInstances(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string
|
||||||
): Promise<ToolInstance[]> {
|
): Promise<ToolInstance[]> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`
|
||||||
);
|
);
|
||||||
return response.data.instances;
|
return response.data.instances;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInstance(
|
export async function createInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
toolTypeId: string,
|
toolTypeId: string,
|
||||||
displayName?: string,
|
displayName?: string,
|
||||||
_cloneMode?: string,
|
cloneMode?: string,
|
||||||
_branch?: string,
|
branch?: string,
|
||||||
_newBranch?: string,
|
newBranch?: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
_sshKeyIds?: string[],
|
sshKeyIds?: string[]
|
||||||
workspaceId?: string,
|
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
{
|
{
|
||||||
tool_type_id: toolTypeId,
|
tool_type_id: toolTypeId,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
workspace_id: workspaceId || undefined,
|
clone_mode: cloneMode || "mount",
|
||||||
config_profile_id: configProfileId || undefined,
|
branch: branch || undefined,
|
||||||
},
|
new_branch: newBranch || undefined,
|
||||||
);
|
config_profile_id: configProfileId,
|
||||||
return response.data;
|
ssh_key_ids: sshKeyIds || [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startInstance(
|
export async function startInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
sshKeyIds?: string[],
|
||||||
_sshKeyIds?: string[],
|
retries = 2
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
_retries?: number,
|
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
try {
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
const response = await apiClient.post(
|
||||||
{ config_profile_id: configProfileId },
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||||
);
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
||||||
return response.data;
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
|
const axiosError = error as AxiosError;
|
||||||
|
if (retries > 0 && !axiosError.response) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopInstance(
|
export async function stopInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string
|
||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restartInstance(
|
export async function restartInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
|
retries = 2
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
try {
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
const response = await apiClient.post(
|
||||||
);
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||||
return response.data;
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
|
const axiosError = error as AxiosError;
|
||||||
|
if (retries > 0 && !axiosError.response) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInstance(
|
export async function deleteInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
force?: boolean,
|
force?: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const url = force
|
await apiClient.delete(
|
||||||
? `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}?force=true`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||||
: `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`;
|
{ params: { force } }
|
||||||
await apiClient.delete(url);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserSessions(): Promise<Session[]> {
|
export async function getUserSessions(): Promise<Session[]> {
|
||||||
const response = await apiClient.get("/users/me/sessions");
|
const response = await apiClient.get("/users/me/sessions");
|
||||||
return response.data.sessions;
|
return response.data.sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceHealth {
|
||||||
|
healthy: boolean;
|
||||||
|
container_status: string;
|
||||||
|
container_health: string | null;
|
||||||
|
container_exit_code: number | null;
|
||||||
|
tunnel_status: string;
|
||||||
|
tunnel_status_code: number | null;
|
||||||
|
probe_status: string;
|
||||||
|
last_probe_output: string | null;
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkInstanceHealth(
|
export async function checkInstanceHealth(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string
|
||||||
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
): Promise<InstanceHealth> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recreateInstanceTunnel(
|
export async function recreateInstanceTunnel(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { apiClient } from "./client";
|
|
||||||
import type {
|
|
||||||
ToolType,
|
|
||||||
CreateToolTypeRequest,
|
|
||||||
UpdateToolTypeRequest,
|
|
||||||
} from "../types/tool-type";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
ReadinessProbe,
|
|
||||||
ToolType,
|
|
||||||
CreateToolTypeRequest,
|
|
||||||
UpdateToolTypeRequest,
|
|
||||||
} from "../types/tool-type";
|
|
||||||
|
|
||||||
export const listToolTypes = async (): Promise<ToolType[]> => {
|
|
||||||
const response = await apiClient.get<ToolType[]>("/tool-types");
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getToolType = async (id: string): Promise<ToolType> => {
|
|
||||||
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createToolType = async (
|
|
||||||
data: CreateToolTypeRequest,
|
|
||||||
): Promise<ToolType> => {
|
|
||||||
const response = await apiClient.post<ToolType>("/tool-types", data);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateToolType = async (
|
|
||||||
id: string,
|
|
||||||
data: UpdateToolTypeRequest,
|
|
||||||
): Promise<ToolType> => {
|
|
||||||
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteToolType = async (id: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/tool-types/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const validateToolType = async (
|
|
||||||
id: string,
|
|
||||||
): Promise<{ valid: boolean; errors?: string[] }> => {
|
|
||||||
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(
|
|
||||||
`/tool-types/${id}/validate`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
listToolTypes,
|
listToolTypes,
|
||||||
updateToolType,
|
updateToolType,
|
||||||
validateToolType,
|
validateToolType,
|
||||||
} from "../api/tool-types";
|
} from "../api/tool_types";
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
const mockGet = vi.fn();
|
||||||
const mockPost = vi.fn();
|
const mockPost = vi.fn();
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ReadinessProbe {
|
||||||
|
command: string;
|
||||||
|
timeout: number;
|
||||||
|
interval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string | null;
|
||||||
|
category: string;
|
||||||
|
interface_type: string;
|
||||||
|
requires_port: boolean;
|
||||||
|
default_port: number | null;
|
||||||
|
definition_type: "compose" | "dockerfile" | "manifest";
|
||||||
|
manifest_id: string | null;
|
||||||
|
compose_template: string | null;
|
||||||
|
dockerfile_template: string | null;
|
||||||
|
build_context: Record<string, string> | null;
|
||||||
|
readiness_probe: ReadinessProbe | null;
|
||||||
|
startup_command: string | null;
|
||||||
|
required_variables: string[];
|
||||||
|
created_by_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateToolTypeRequest {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
interface_type?: string;
|
||||||
|
requires_port?: boolean;
|
||||||
|
default_port: number;
|
||||||
|
definition_type?: "compose" | "dockerfile" | "manifest";
|
||||||
|
manifest_id?: string;
|
||||||
|
compose_template?: string;
|
||||||
|
dockerfile_template?: string;
|
||||||
|
build_context?: Record<string, string>;
|
||||||
|
readiness_probe?: ReadinessProbe;
|
||||||
|
startup_command?: string;
|
||||||
|
required_variables: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateToolTypeRequest {
|
||||||
|
display_name?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
interface_type?: string;
|
||||||
|
requires_port?: boolean;
|
||||||
|
default_port?: number;
|
||||||
|
definition_type?: "compose" | "dockerfile" | "manifest";
|
||||||
|
manifest_id?: string;
|
||||||
|
compose_template?: string;
|
||||||
|
dockerfile_template?: string;
|
||||||
|
build_context?: Record<string, string>;
|
||||||
|
readiness_probe?: ReadinessProbe;
|
||||||
|
startup_command?: string;
|
||||||
|
required_variables?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listToolTypes = async (): Promise<ToolType[]> => {
|
||||||
|
const response = await apiClient.get<ToolType[]>("/tool-types");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getToolType = async (id: string): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createToolType = async (
|
||||||
|
data: CreateToolTypeRequest,
|
||||||
|
): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.post<ToolType>("/tool-types", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateToolType = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateToolTypeRequest,
|
||||||
|
): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteToolType = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/tool-types/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateToolType = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<{ valid: boolean; errors?: string[] }> => {
|
||||||
|
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(
|
||||||
|
`/tool-types/${id}/validate`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/** Workspace file API client. */
|
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
|
||||||
|
|
||||||
export interface FileEntry {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
type: "file" | "directory";
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listWorkspaceFiles(
|
|
||||||
workspaceId: string,
|
|
||||||
path: string = "",
|
|
||||||
): Promise<FileEntry[]> {
|
|
||||||
const response = await apiClient.get<{ entries: FileEntry[] }>(
|
|
||||||
`/workspaces/${workspaceId}/files/`,
|
|
||||||
{ params: { path } },
|
|
||||||
);
|
|
||||||
return response.data.entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWorkspaceFileContent(
|
|
||||||
workspaceId: string,
|
|
||||||
path: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const response = await apiClient.get<{ content: string }>(
|
|
||||||
`/workspaces/${workspaceId}/files/content`,
|
|
||||||
{ params: { path } },
|
|
||||||
);
|
|
||||||
return response.data.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveWorkspaceFile(
|
|
||||||
workspaceId: string,
|
|
||||||
path: string,
|
|
||||||
content: string,
|
|
||||||
commitMessage?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await apiClient.post(`/workspaces/${workspaceId}/files/content`, {
|
|
||||||
path,
|
|
||||||
content,
|
|
||||||
message: commitMessage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/** Workspace git API client. */
|
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
|
||||||
|
|
||||||
export interface GitStatus {
|
|
||||||
branch: string;
|
|
||||||
modified: string[];
|
|
||||||
added: string[];
|
|
||||||
deleted: string[];
|
|
||||||
untracked: string[];
|
|
||||||
ahead: number;
|
|
||||||
behind: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Commit {
|
|
||||||
hash: string;
|
|
||||||
message: string;
|
|
||||||
author: string;
|
|
||||||
date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getGitStatus(workspaceId: string): Promise<GitStatus> {
|
|
||||||
const response = await apiClient.get<GitStatus>(
|
|
||||||
`/workspaces/${workspaceId}/git/status`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getGitBranches(
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<{ branches: string[]; current_branch: string }> {
|
|
||||||
const response = await apiClient.get<{
|
|
||||||
branches: string[];
|
|
||||||
current_branch: string;
|
|
||||||
}>(`/workspaces/${workspaceId}/git/branches`);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gitCommit(
|
|
||||||
workspaceId: string,
|
|
||||||
message: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await apiClient.post(`/workspaces/${workspaceId}/git/commit`, { message });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gitPush(workspaceId: string): Promise<void> {
|
|
||||||
await apiClient.post(`/workspaces/${workspaceId}/git/push`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gitPull(workspaceId: string): Promise<void> {
|
|
||||||
await apiClient.post(`/workspaces/${workspaceId}/git/pull`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gitFetch(workspaceId: string): Promise<void> {
|
|
||||||
await apiClient.post(`/workspaces/${workspaceId}/git/fetch`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gitCheckout(
|
|
||||||
workspaceId: string,
|
|
||||||
branch: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await apiClient.post(`/workspaces/${workspaceId}/git/checkout`, { branch });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getGitHistory(
|
|
||||||
workspaceId: string,
|
|
||||||
path?: string,
|
|
||||||
limit: number = 50,
|
|
||||||
): Promise<Commit[]> {
|
|
||||||
const response = await apiClient.get<{ commits: Commit[] }>(
|
|
||||||
`/workspaces/${workspaceId}/git/history`,
|
|
||||||
{ params: { path, limit } },
|
|
||||||
);
|
|
||||||
return response.data.commits;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/** Workspace instance API client. */
|
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
|
||||||
import type { ToolInstance } from "./sessions";
|
|
||||||
|
|
||||||
export async function listWorkspaceInstances(
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<ToolInstance[]> {
|
|
||||||
const response = await apiClient.get<ToolInstance[]>(
|
|
||||||
`/workspaces/${workspaceId}/instances/`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWorkspaceInstance(
|
|
||||||
workspaceId: string,
|
|
||||||
toolTypeId: string,
|
|
||||||
displayName?: string,
|
|
||||||
configProfileId?: string,
|
|
||||||
): Promise<ToolInstance> {
|
|
||||||
const response = await apiClient.post<ToolInstance>(
|
|
||||||
`/workspaces/${workspaceId}/instances/`,
|
|
||||||
{
|
|
||||||
tool_type_id: toolTypeId,
|
|
||||||
display_name: displayName,
|
|
||||||
config_profile_id: configProfileId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/** Workspace API client. */
|
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
|
||||||
import type {
|
|
||||||
Workspace,
|
|
||||||
CreateWorkspaceRequest,
|
|
||||||
SyncResult,
|
|
||||||
} from "../types/workspace";
|
|
||||||
|
|
||||||
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
|
|
||||||
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
|
|
||||||
return workspaceId ? `${base}/${workspaceId}` : `${base}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listWorkspaces(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
): Promise<Workspace[]> {
|
|
||||||
const response = await apiClient.get<Workspace[]>(
|
|
||||||
workspaceUrl(projectId, repoId),
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAllWorkspaces(): Promise<Workspace[]> {
|
|
||||||
const response = await apiClient.get<Workspace[]>("/workspaces/");
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWorkspace(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
data: CreateWorkspaceRequest,
|
|
||||||
): Promise<Workspace> {
|
|
||||||
const response = await apiClient.post<Workspace>(
|
|
||||||
workspaceUrl(projectId, repoId),
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWorkspaceTopLevel(
|
|
||||||
data: CreateWorkspaceRequest & { repo_id: string },
|
|
||||||
): Promise<Workspace> {
|
|
||||||
const response = await apiClient.post<Workspace>("/workspaces/", data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWorkspace(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<Workspace> {
|
|
||||||
const response = await apiClient.get<Workspace>(
|
|
||||||
workspaceUrl(projectId, repoId, workspaceId),
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateWorkspace(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
data: Partial<CreateWorkspaceRequest>,
|
|
||||||
): Promise<Workspace> {
|
|
||||||
const response = await apiClient.patch<Workspace>(
|
|
||||||
workspaceUrl(projectId, repoId, workspaceId),
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteWorkspace(
|
|
||||||
workspaceId: string,
|
|
||||||
force = false,
|
|
||||||
): Promise<{ status: string }> {
|
|
||||||
const response = await apiClient.delete<{ status: string }>(
|
|
||||||
`/workspaces/${workspaceId}?force=${force}`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncWorkspace(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<SyncResult> {
|
|
||||||
const response = await apiClient.post<SyncResult>(
|
|
||||||
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user