Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e99e7f197 | |||
| e46b4f9249 | |||
| 5deee8c65c | |||
| 314ba3aee4 | |||
| 18e4a89573 | |||
| 29943ac239 | |||
| 22474cdba5 | |||
| 0c839e8c6f | |||
| c63cf7db50 | |||
| d9d2b91384 | |||
| d6ea5fb1fd | |||
| 1883825b18 | |||
| bc71fd6fac | |||
| 28aa9ccf5a | |||
| 44dd80cb58 | |||
| 23485833d8 | |||
| e23dcdf4e1 | |||
| f05ac55875 | |||
| bcefeb4163 | |||
| 33d08faf70 | |||
| 8a58c61278 | |||
| 8231e750d9 | |||
| 6ce645d210 | |||
| 89ca9f10c7 | |||
| baabd1fa62 | |||
| f14fc37e75 | |||
| e07938098a | |||
| 943b9db5c7 | |||
| a4604d6a9a | |||
| 18204628cc | |||
| 93b415c53e | |||
| e7adfb462b | |||
| ed1d6528c6 | |||
| c4be7163d6 | |||
| 13f55fff47 | |||
| 0ec20b9c23 | |||
| 4c11163bff | |||
| adda76a2ff | |||
| 47962ed476 | |||
| 6a0c9bd669 | |||
| 76fbf0a755 | |||
| 187193fa6e | |||
| cd9c9539a2 | |||
| 555517c144 | |||
| fc1554140f | |||
| bc5e80c954 | |||
| ab79080f0b | |||
| 4c216dd1ca | |||
| a37a3122f9 |
@@ -49,3 +49,5 @@ apps/web/dist/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/.stoneforge/.worktrees/
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0014_merge_heads"
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0015_single_interface"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""add pi agent tool type
|
||||
|
||||
Revision ID: 20260527_160017_add_pi_agent
|
||||
Revises: f3d2dc90ba3a
|
||||
Create Date: 2026-05-27T16:00:17
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import uuid
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260527_160017_add_pi_agent"
|
||||
down_revision: Union[str, None] = "2026_05_27_external_repos"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if pi-agent already exists
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
|
||||
).fetchone()
|
||||
|
||||
if result is None:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO tool_types (
|
||||
id, name, display_name, description, category,
|
||||
interface_type, requires_port, default_port,
|
||||
definition_type, compose_template, dockerfile_template, required_variables,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
:id, :name, :display_name, :description, :category,
|
||||
:interface_type, :requires_port, :default_port,
|
||||
:definition_type, :compose_template, :dockerfile_template, :required_variables,
|
||||
now(), now()
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"id": PI_AGENT_ID,
|
||||
"name": "pi-agent",
|
||||
"display_name": "Pi Agent",
|
||||
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
|
||||
"category": "development",
|
||||
"interface_type": "terminal",
|
||||
"requires_port": False,
|
||||
"default_port": 0,
|
||||
"definition_type": "dockerfile",
|
||||
"compose_template": """services:
|
||||
app:
|
||||
build: .
|
||||
stdin_open: true
|
||||
tty: true
|
||||
volumes:
|
||||
- ${REPO_PATH}:/workspace
|
||||
working_dir: /workspace
|
||||
command: /bin/bash""",
|
||||
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install base dependencies
|
||||
RUN apt-get update && apt-get install -y \\
|
||||
curl \\
|
||||
wget \\
|
||||
git \\
|
||||
neovim \\
|
||||
ranger \\
|
||||
tmux \\
|
||||
htop \\
|
||||
tree \\
|
||||
jq \\
|
||||
ca-certificates \\
|
||||
python3 \\
|
||||
python3-pip \\
|
||||
build-essential \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js (required for Pi)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||
&& apt-get install -y nodejs \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Pi Coding Agent globally
|
||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -s /bin/bash user
|
||||
WORKDIR /home/user
|
||||
|
||||
# Set up git
|
||||
RUN git config --global init.defaultBranch main \\
|
||||
&& git config --global user.email "dev@headquarter.local" \\
|
||||
&& git config --global user.name "Developer"
|
||||
|
||||
# Create default tmux config
|
||||
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||
|
||||
# Create default ranger config
|
||||
RUN mkdir -p /home/user/.config/ranger \\
|
||||
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||
|
||||
# Set up Pi config directory
|
||||
RUN mkdir -p /home/user/.pi/agent
|
||||
|
||||
USER user
|
||||
|
||||
# Default to bash (Pi is invoked manually via `pi` command)
|
||||
CMD ["/bin/bash"]""",
|
||||
"required_variables": json.dumps(["REPO_PATH"]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add_git_mounts_to_config_profiles
|
||||
|
||||
Revision ID: 2026_05_26_add_git_mounts
|
||||
Revises: f3d2dc90ba3a
|
||||
Create Date: 2026-05-26 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_26_add_git_mounts"
|
||||
down_revision: Union[str, Sequence[str], None] = "2026_05_24_220141"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("git_mounts", sa.JSON(), nullable=True, default=list),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("config_profiles", "git_mounts")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""make_project_id_nullable_in_git_repositories
|
||||
|
||||
Revision ID: 2026_05_27_external_repos
|
||||
Revises: 2026_05_26_add_git_mounts
|
||||
Create Date: 2026-05-27 08:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_27_external_repos"
|
||||
down_revision: Union[str, Sequence[str], None] = "2026_05_26_add_git_mounts"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Expand alembic_version version_num to avoid truncation errors
|
||||
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(64)")
|
||||
|
||||
# Make project_id nullable to allow external repositories
|
||||
op.alter_column(
|
||||
"git_repositories",
|
||||
"project_id",
|
||||
existing_type=sa.UUID(),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"git_repositories",
|
||||
"project_id",
|
||||
existing_type=sa.UUID(),
|
||||
nullable=False,
|
||||
)
|
||||
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(32)")
|
||||
@@ -0,0 +1,381 @@
|
||||
"""add tool definition manifests
|
||||
|
||||
Revision ID: 2026_05_28_add_tool_definition_manifests
|
||||
Revises: 20260527_160017_add_pi_agent
|
||||
Create Date: 2026-05-28T11:00:00
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_28_add_tool_definition_manifests"
|
||||
down_revision: Union[str, None] = "20260527_160017_add_pi_agent"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
BASE_UBUNTU_ID = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||
PI_AGENT_MANIFEST_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── Create tool_definition_manifests table ───────────────────────
|
||||
op.create_table(
|
||||
"tool_definition_manifests",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("name", sa.String(64), nullable=False),
|
||||
sa.Column("display_name", sa.String(128), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("category", sa.String(64), nullable=True),
|
||||
sa.Column("interface_type", sa.String(16), nullable=False),
|
||||
sa.Column("base_image", sa.String(256), nullable=True),
|
||||
sa.Column("base_definition_id", sa.UUID(), nullable=True),
|
||||
sa.Column(
|
||||
"base_version", sa.String(32), nullable=False, server_default="latest"
|
||||
),
|
||||
sa.Column("manifest", sa.JSON(), nullable=False),
|
||||
sa.Column("dockerfile_cache", sa.Text(), nullable=True),
|
||||
sa.Column("compose_cache", sa.Text(), nullable=True),
|
||||
sa.Column("version", sa.String(32), nullable=False, server_default="v1"),
|
||||
sa.Column("is_base", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("created_by_id", sa.UUID(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["base_definition_id"], ["tool_definition_manifests.id"]
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by_id"], ["users.id"]),
|
||||
sa.CheckConstraint(
|
||||
"(base_image IS NOT NULL) OR (base_definition_id IS NOT NULL)",
|
||||
name="ck_tool_definition_manifests_base_required",
|
||||
),
|
||||
)
|
||||
|
||||
# ── Add columns to tool_types ────────────────────────────────────
|
||||
# Check if manifest_id exists before adding
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'tool_types' AND column_name = 'manifest_id'
|
||||
""")
|
||||
)
|
||||
if not result.fetchone():
|
||||
op.add_column("tool_types", sa.Column("manifest_id", sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_tool_types_manifest_id",
|
||||
"tool_types",
|
||||
"tool_definition_manifests",
|
||||
["manifest_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Update definition_type to allow 'legacy' and 'manifest'
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT constraint_name FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'chk_definition_type'
|
||||
""")
|
||||
)
|
||||
if result.fetchone():
|
||||
op.drop_constraint("chk_definition_type", "tool_types", type_="check")
|
||||
|
||||
op.execute("ALTER TABLE tool_types ALTER COLUMN definition_type TYPE VARCHAR(16)")
|
||||
op.execute(
|
||||
"ALTER TABLE tool_types ALTER COLUMN definition_type SET DEFAULT 'legacy'"
|
||||
)
|
||||
|
||||
# ── Add columns to tool_instances ────────────────────────────────
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'tool_instances' AND column_name = 'manifest_compiled_at'
|
||||
""")
|
||||
)
|
||||
if not result.fetchone():
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column(
|
||||
"manifest_compiled_at", sa.TIMESTAMP(timezone=True), nullable=True
|
||||
),
|
||||
)
|
||||
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'tool_instances' AND column_name = 'image_tag'
|
||||
""")
|
||||
)
|
||||
if not result.fetchone():
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("image_tag", sa.String(256), nullable=True),
|
||||
)
|
||||
|
||||
# ── Data migration: create base definition + pi-agent manifest ───
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO tool_definition_manifests
|
||||
(id, name, display_name, description, interface_type, base_image,
|
||||
manifest, is_base, version, created_at, updated_at)
|
||||
VALUES
|
||||
(:base_id, 'ubuntu-24.04-dev', 'Ubuntu 24.04 Dev Base',
|
||||
'Base development environment with build tools', 'terminal',
|
||||
'ubuntu:24.04', :base_manifest, true, 'v1', now(), now())
|
||||
"""
|
||||
),
|
||||
{
|
||||
"base_id": BASE_UBUNTU_ID,
|
||||
"base_manifest": json.dumps(
|
||||
{
|
||||
"name": "ubuntu-24.04-dev",
|
||||
"display_name": "Ubuntu 24.04 Dev Base",
|
||||
"interface_type": "terminal",
|
||||
"base_image": "ubuntu:24.04",
|
||||
"packages": {
|
||||
"apt": [
|
||||
"curl",
|
||||
"wget",
|
||||
"git",
|
||||
"build-essential",
|
||||
"ca-certificates",
|
||||
"python3",
|
||||
"python3-pip",
|
||||
]
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"uid": 1000,
|
||||
"gid": 1000,
|
||||
"create_home": True,
|
||||
"shell": "/bin/bash",
|
||||
},
|
||||
"env": {"DEBIAN_FRONTEND": "noninteractive"},
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO tool_definition_manifests
|
||||
(id, name, display_name, description, category, interface_type,
|
||||
base_definition_id, base_version, manifest, version, created_at, updated_at)
|
||||
VALUES
|
||||
(:manifest_id, 'pi-agent', 'Pi Agent',
|
||||
'Terminal-based coding harness with nvim, ranger, tmux',
|
||||
'development', 'terminal', :base_id, 'v1', :manifest, 'v1',
|
||||
now(), now())
|
||||
"""
|
||||
),
|
||||
{
|
||||
"manifest_id": PI_AGENT_MANIFEST_ID,
|
||||
"base_id": BASE_UBUNTU_ID,
|
||||
"manifest": json.dumps(
|
||||
{
|
||||
"name": "pi-agent",
|
||||
"display_name": "Pi Agent",
|
||||
"description": "Terminal-based coding harness",
|
||||
"category": "development",
|
||||
"interface_type": "terminal",
|
||||
"base_definition_id": str(BASE_UBUNTU_ID),
|
||||
"base_version": "v1",
|
||||
"packages": {
|
||||
"apt": [
|
||||
"neovim",
|
||||
"ranger",
|
||||
"tmux",
|
||||
"htop",
|
||||
"tree",
|
||||
"jq",
|
||||
],
|
||||
"node": {"version": "20"},
|
||||
"npm_global": ["@earendil-works/pi-coding-agent"],
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"uid": 1001,
|
||||
"gid": 1001,
|
||||
"create_home": True,
|
||||
"shell": "/bin/bash",
|
||||
},
|
||||
"env": {"DEBIAN_FRONTEND": "noninteractive"},
|
||||
"scripts": {
|
||||
"build": [
|
||||
"git config --global init.defaultBranch main && git config --global user.email 'dev@headquarter.local' && git config --global user.name 'Developer'",
|
||||
"mkdir -p /home/user/.config/ranger && echo 'set preview_files true' > /home/user/.config/ranger/rc.conf",
|
||||
],
|
||||
"startup": [
|
||||
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi",
|
||||
],
|
||||
},
|
||||
"mounts": [
|
||||
{
|
||||
"name": "workspace",
|
||||
"target": "/workspace",
|
||||
"source_type": "repo",
|
||||
"writable": True,
|
||||
"owner": "user",
|
||||
},
|
||||
{
|
||||
"name": "ssh_keys",
|
||||
"target": "/home/user/.ssh",
|
||||
"source_type": "ssh_key",
|
||||
"mode": "0700",
|
||||
"file_mode": "0600",
|
||||
"readonly": True,
|
||||
},
|
||||
{
|
||||
"name": "pi_state",
|
||||
"target": "/tmp/.pi/agents",
|
||||
"source_type": "instance",
|
||||
"writable": True,
|
||||
},
|
||||
{
|
||||
"name": "pi_config",
|
||||
"target": "/home/user/.pi",
|
||||
"source_type": "git_mount",
|
||||
"git_mount_ref": "dotfiles",
|
||||
"writable": True,
|
||||
"owner": "user",
|
||||
},
|
||||
],
|
||||
"runtime": {
|
||||
"command": ["/bin/bash"],
|
||||
"stdin_open": True,
|
||||
"tty": True,
|
||||
"working_dir": "/workspace",
|
||||
},
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# ── Update existing pi-agent tool_type ───────────────────────────
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE tool_types
|
||||
SET manifest_id = :manifest_id,
|
||||
definition_type = 'manifest',
|
||||
dockerfile_template = NULL,
|
||||
compose_template = NULL
|
||||
WHERE name = 'pi-agent'
|
||||
"""
|
||||
),
|
||||
{"manifest_id": PI_AGENT_MANIFEST_ID},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Restore pi-agent templates if manifest_id column exists
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'tool_types' AND column_name = 'manifest_id'
|
||||
""")
|
||||
)
|
||||
has_manifest_id = result.fetchone() is not None
|
||||
|
||||
if has_manifest_id:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE tool_types
|
||||
SET manifest_id = NULL,
|
||||
definition_type = 'dockerfile',
|
||||
dockerfile_template = :dockerfile,
|
||||
compose_template = :compose
|
||||
WHERE name = 'pi-agent'
|
||||
"""
|
||||
),
|
||||
{
|
||||
"dockerfile": """# Pi Coding Agent - Terminal-based coding harness
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \\
|
||||
curl wget git neovim ranger tmux htop tree jq \\
|
||||
ca-certificates python3 python3-pip build-essential \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||
&& apt-get install -y nodejs \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||
|
||||
RUN useradd -m -s /bin/bash user
|
||||
WORKDIR /home/user
|
||||
|
||||
RUN git config --global init.defaultBranch main \\
|
||||
&& git config --global user.email "dev@headquarter.local" \\
|
||||
&& git config --global user.name "Developer"
|
||||
|
||||
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||
|
||||
RUN mkdir -p /home/user/.config/ranger \\
|
||||
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||
|
||||
RUN mkdir -p /home/user/.pi/agent
|
||||
|
||||
USER user
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
""",
|
||||
"compose": """services:
|
||||
app:
|
||||
build: .
|
||||
stdin_open: true
|
||||
tty: true
|
||||
volumes:
|
||||
- ${REPO_PATH}:/workspace
|
||||
working_dir: /workspace
|
||||
command: /bin/bash""",
|
||||
},
|
||||
)
|
||||
|
||||
# Drop columns conditionally
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'tool_instances' AND column_name = 'image_tag'
|
||||
""")
|
||||
)
|
||||
if result.fetchone():
|
||||
op.drop_column("tool_instances", "image_tag")
|
||||
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'tool_instances' AND column_name = 'manifest_compiled_at'
|
||||
""")
|
||||
)
|
||||
if result.fetchone():
|
||||
op.drop_column("tool_instances", "manifest_compiled_at")
|
||||
|
||||
if has_manifest_id:
|
||||
op.drop_constraint(
|
||||
"fk_tool_types_manifest_id", "tool_types", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("tool_types", "manifest_id")
|
||||
|
||||
op.drop_table("tool_definition_manifests")
|
||||
@@ -5,8 +5,6 @@ Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
|
||||
Create Date: 2026-05-24 18:00:43.990361
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ Create Date: 2026-05-24 10:43:14.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f3d2dc90ba3a"
|
||||
|
||||
+10
-10
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
)
|
||||
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||
response = RedirectResponse(location)
|
||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
||||
@@ -63,7 +63,7 @@ async def callback(
|
||||
auth_next: str | None = Cookie(default="/"),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> RedirectResponse:
|
||||
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||
|
||||
if auth_state is None or auth_state != state:
|
||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||
@@ -71,7 +71,7 @@ async def callback(
|
||||
|
||||
settings = Settings()
|
||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
@@ -92,7 +92,7 @@ async def callback(
|
||||
access_token=token_payload["access_token"],
|
||||
client=client,
|
||||
)
|
||||
logger.info("User info fetched successfully")
|
||||
logger.debug("User info fetched successfully")
|
||||
except Exception as exc:
|
||||
logger.error("User info fetch failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
||||
@@ -100,19 +100,19 @@ async def callback(
|
||||
authentik_id = str(user_info.get("sub", ""))
|
||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||
name = str(user_info.get("name", email))
|
||||
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||
|
||||
try:
|
||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||
if user is None:
|
||||
logger.info("Creating new user: authentik_id=%s", authentik_id)
|
||||
logger.debug("Creating new user: authentik_id=%s", authentik_id)
|
||||
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
logger.info("New user created: id=%s", user.id)
|
||||
else:
|
||||
logger.info("Existing user found: id=%s, updating info", user.id)
|
||||
logger.debug("Existing user found: id=%s, updating info", user.id)
|
||||
user.email = email
|
||||
user.name = name
|
||||
await session.commit()
|
||||
@@ -165,20 +165,20 @@ async def me(
|
||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, Any]:
|
||||
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||
|
||||
if not session_cookie:
|
||||
logger.warning("Auth /me: missing session cookie")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||
|
||||
settings = Settings()
|
||||
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
||||
|
||||
try:
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
user_id = payload["user_id"]
|
||||
logger.info("Auth /me: decoded session for user_id=%s", user_id)
|
||||
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
|
||||
except ValueError as exc:
|
||||
logger.warning("Auth /me: invalid session: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||
|
||||
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.config_folder import ConfigFolder
|
||||
|
||||
@@ -15,9 +16,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
||||
|
||||
MAX_FOLDER_SIZE_MB = 10
|
||||
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||
|
||||
|
||||
class ConfigFolderCreate(BaseModel):
|
||||
name: str = Field(description="Folder name (unique per user)")
|
||||
@@ -28,24 +26,12 @@ class ConfigFolderCreate(BaseModel):
|
||||
@field_validator("mount_path")
|
||||
@classmethod
|
||||
def validate_mount_path(cls, v: str) -> str:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return v
|
||||
return _validate_mount_path(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
total_size = 0
|
||||
for path, content in v.items():
|
||||
# Check for path traversal
|
||||
if ".." in path or path.startswith("/"):
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
total_size += len(content.encode("utf-8"))
|
||||
|
||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||
|
||||
return v
|
||||
return _validate_files(v)
|
||||
|
||||
|
||||
class ConfigFolderUpdate(BaseModel):
|
||||
@@ -58,29 +44,12 @@ class ConfigFolderUpdate(BaseModel):
|
||||
@field_validator("mount_path")
|
||||
@classmethod
|
||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return v
|
||||
return _validate_mount_path(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
total_size = 0
|
||||
for path, content in v.items():
|
||||
# Check for path traversal
|
||||
if ".." in path or path.startswith("/"):
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
total_size += len(content.encode("utf-8"))
|
||||
|
||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||
|
||||
return v
|
||||
return _validate_files(v)
|
||||
|
||||
|
||||
class ProjectOverrideCreate(BaseModel):
|
||||
@@ -90,11 +59,7 @@ class ProjectOverrideCreate(BaseModel):
|
||||
@field_validator("mount_path")
|
||||
@classmethod
|
||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return v
|
||||
return _validate_mount_path(v)
|
||||
|
||||
|
||||
class ConfigFolderResponse(BaseModel):
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.shared_validators import validate_env_vars as _validate_env_vars
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.project import Project
|
||||
@@ -55,6 +56,36 @@ def _calculate_profile_size(data: dict) -> int:
|
||||
return total
|
||||
|
||||
|
||||
class GitMountItem(BaseModel):
|
||||
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
||||
target_path: str = Field(description="Absolute path inside container")
|
||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||
|
||||
@field_validator("remote_url")
|
||||
@classmethod
|
||||
def validate_remote_url(cls, v: str) -> str:
|
||||
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||
return v
|
||||
|
||||
@field_validator("source_path")
|
||||
@classmethod
|
||||
def validate_source_path(cls, v: str) -> str:
|
||||
if v.startswith("/"):
|
||||
raise ValueError("source_path must be relative (no leading /)")
|
||||
if ".." in v:
|
||||
raise ValueError("source_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
@field_validator("target_path")
|
||||
@classmethod
|
||||
def validate_target_path(cls, v: str) -> str:
|
||||
if ".." in v:
|
||||
raise ValueError("target_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
|
||||
class MountItem(BaseModel):
|
||||
target: str = Field(description="Absolute mount target path")
|
||||
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||
@@ -97,6 +128,7 @@ class ConfigProfileCreate(BaseModel):
|
||||
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||
git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts")
|
||||
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@@ -120,9 +152,10 @@ class ConfigProfileCreate(BaseModel):
|
||||
@field_validator("env_vars")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict) -> dict:
|
||||
if not isinstance(v, dict):
|
||||
result = _validate_env_vars(v)
|
||||
if result is None:
|
||||
raise ValueError("env_vars must be a JSON object")
|
||||
return v
|
||||
return result
|
||||
|
||||
@field_validator("runtime_hints")
|
||||
@classmethod
|
||||
@@ -148,6 +181,7 @@ class ConfigProfileUpdate(BaseModel):
|
||||
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
||||
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
|
||||
git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts")
|
||||
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@@ -191,6 +225,7 @@ class ConfigProfileResponse(BaseModel):
|
||||
runtime_hints: dict
|
||||
mounts: list
|
||||
files: dict
|
||||
git_mounts: list
|
||||
is_default: bool
|
||||
includes: list[dict]
|
||||
created_at: str
|
||||
@@ -225,6 +260,32 @@ async def _check_access(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
||||
|
||||
|
||||
async def _validate_git_mounts(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
git_mounts: list[dict],
|
||||
project_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate git mount URLs.
|
||||
|
||||
Simply checks that remote_url looks like a valid git URL.
|
||||
Actual clone validation happens at instance startup time.
|
||||
"""
|
||||
for mount in git_mounts:
|
||||
remote_url = mount.get("remote_url")
|
||||
if not remote_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Git mount missing remote_url",
|
||||
)
|
||||
|
||||
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid git URL: {remote_url}",
|
||||
)
|
||||
|
||||
|
||||
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
@@ -236,6 +297,7 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc
|
||||
"env_vars": profile.env_vars or {},
|
||||
"runtime_hints": profile.runtime_hints or {},
|
||||
"mounts": profile.mounts or [],
|
||||
"git_mounts": profile.git_mounts or [],
|
||||
"files": profile.files or {},
|
||||
"is_default": profile.is_default,
|
||||
"includes": [
|
||||
@@ -319,6 +381,11 @@ async def create_config_profile(
|
||||
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||
|
||||
# Validate git mounts reference existing repositories
|
||||
if data.git_mounts:
|
||||
git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts]
|
||||
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
||||
|
||||
# Check size
|
||||
size = _calculate_profile_size(data.model_dump())
|
||||
@@ -337,6 +404,7 @@ async def create_config_profile(
|
||||
env_vars=data.env_vars,
|
||||
runtime_hints=data.runtime_hints,
|
||||
mounts=[m.model_dump() for m in data.mounts],
|
||||
git_mounts=[m.model_dump() for m in data.git_mounts],
|
||||
files=data.files,
|
||||
is_default=data.is_default,
|
||||
)
|
||||
@@ -351,7 +419,7 @@ async def create_config_profile(
|
||||
)
|
||||
profile = result.scalar_one()
|
||||
|
||||
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
|
||||
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||
return _profile_to_response(profile)
|
||||
|
||||
|
||||
@@ -413,6 +481,14 @@ async def update_config_profile(
|
||||
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||
)
|
||||
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||
|
||||
# Validate git mounts reference existing repositories
|
||||
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||
git_mounts_data = [
|
||||
m.model_dump() if hasattr(m, "model_dump") else m
|
||||
for m in update_data["git_mounts"]
|
||||
]
|
||||
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
|
||||
|
||||
# Check size
|
||||
current_data = _profile_to_response(profile)
|
||||
@@ -430,6 +506,8 @@ async def update_config_profile(
|
||||
value = uuid.UUID(value) if value else None
|
||||
elif field_name == "mounts" and value is not None:
|
||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||
elif field_name == "git_mounts" and value is not None:
|
||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||
setattr(profile, field_name, value)
|
||||
|
||||
await session.commit()
|
||||
@@ -442,7 +520,7 @@ async def update_config_profile(
|
||||
)
|
||||
profile = result.scalar_one()
|
||||
|
||||
logger.info("Updated config profile %s", profile.id)
|
||||
logger.debug("Updated config profile %s", profile.id)
|
||||
return _profile_to_response(profile)
|
||||
|
||||
|
||||
@@ -462,7 +540,7 @@ async def delete_config_profile(
|
||||
await session.delete(profile)
|
||||
await session.commit()
|
||||
|
||||
logger.info("Deleted config profile %s", profile_id)
|
||||
logger.debug("Deleted config profile %s", profile_id)
|
||||
return None
|
||||
|
||||
|
||||
@@ -547,7 +625,7 @@ async def update_profile_includes(
|
||||
)
|
||||
direct_includes = inc_result.scalars().all()
|
||||
|
||||
logger.info("Updated includes for config profile %s", profile.id)
|
||||
logger.debug("Updated includes for config profile %s", profile.id)
|
||||
return _profile_to_response(profile, list(direct_includes))
|
||||
|
||||
|
||||
|
||||
@@ -10,12 +10,10 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||
from src.config import Settings
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
from src.utils.git_files import (
|
||||
commit_file,
|
||||
get_file_content,
|
||||
@@ -42,40 +40,6 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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,
|
||||
user_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> Project:
|
||||
"""Fetch a project and verify ownership.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
The project if found and owned by the user.
|
||||
|
||||
Raises:
|
||||
HTTPException: If project not found or user is not the owner.
|
||||
"""
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||
if project.owner_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||
return project
|
||||
|
||||
|
||||
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
||||
"""Generate the filesystem path for a repository.
|
||||
|
||||
@@ -256,7 +220,7 @@ class GitRepositoryResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
path: str
|
||||
project_id: uuid.UUID
|
||||
project_id: uuid.UUID | None
|
||||
owner_id: uuid.UUID
|
||||
is_mirror: bool
|
||||
remote_url: str | None
|
||||
@@ -266,6 +230,156 @@ class GitRepositoryResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@router.get(
|
||||
"/repositories",
|
||||
response_model=list[GitRepositoryResponse],
|
||||
summary="List all user repositories",
|
||||
description="List all git repositories owned by the user, including external repositories not tied to any project.",
|
||||
)
|
||||
async def list_user_repositories(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[GitRepository]:
|
||||
"""List all repositories owned by the user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
List of all repositories owned by the user.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.owner_id == user_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/repositories/parse-url",
|
||||
response_model=URLParseResponse,
|
||||
summary="Parse a git URL",
|
||||
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
||||
)
|
||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
||||
|
||||
Args:
|
||||
data: Request containing the URL to parse.
|
||||
|
||||
Returns:
|
||||
Parsed URL information including whether it needs parsing and suggested corrections.
|
||||
"""
|
||||
result = parse_git_url(data.url)
|
||||
return URLParseResponse(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/repositories",
|
||||
response_model=GitRepositoryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create an external repository",
|
||||
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
|
||||
)
|
||||
async def create_external_repository(
|
||||
data: GitRepositoryCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> GitRepository:
|
||||
"""Create a new external git repository.
|
||||
|
||||
External repositories are not tied to any project and can be used
|
||||
across all projects for config profile git mounts.
|
||||
|
||||
Args:
|
||||
data: Repository creation data including name and optional remote URL.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
The newly created external repository.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
|
||||
# Check for duplicate name (external repos only)
|
||||
existing = await session.execute(
|
||||
select(GitRepository).where(
|
||||
GitRepository.project_id.is_(None),
|
||||
GitRepository.owner_id == user_id,
|
||||
GitRepository.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
|
||||
# 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"]
|
||||
|
||||
# Validate SSH key if provided
|
||||
ssh_key_id = None
|
||||
ssh_key = None
|
||||
if data.ssh_key_id:
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
if ssh_key.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
||||
|
||||
if remote_url:
|
||||
_preflight_remote_repository(remote_url, ssh_key)
|
||||
|
||||
# Create external repo with no project
|
||||
repo = GitRepository(
|
||||
name=data.name,
|
||||
path="", # Will be set after clone
|
||||
project_id=None,
|
||||
owner_id=user_id,
|
||||
remote_url=remote_url,
|
||||
ssh_key_id=ssh_key_id,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.flush()
|
||||
|
||||
# Set path and optionally clone
|
||||
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
|
||||
repo.path = repo_path
|
||||
|
||||
if remote_url:
|
||||
try:
|
||||
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
repo.is_mirror = False
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
||||
else:
|
||||
# Initialize empty repo
|
||||
os.makedirs(repo_path, exist_ok=True)
|
||||
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
|
||||
repo.is_mirror = False
|
||||
|
||||
await session.commit()
|
||||
return repo
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories",
|
||||
response_model=list[GitRepositoryResponse],
|
||||
@@ -335,25 +449,6 @@ async def delete_repository(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/repositories/parse-url",
|
||||
response_model=URLParseResponse,
|
||||
summary="Parse a git URL",
|
||||
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
||||
)
|
||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
||||
|
||||
Args:
|
||||
data: Request containing the URL to parse.
|
||||
|
||||
Returns:
|
||||
Parsed URL information including whether it needs parsing and suggested corrections.
|
||||
"""
|
||||
result = parse_git_url(data.url)
|
||||
return URLParseResponse(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories",
|
||||
response_model=GitRepositoryResponse,
|
||||
|
||||
@@ -4,11 +4,10 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
|
||||
@@ -7,23 +7,14 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
@@ -132,32 +123,6 @@ async def get_project(
|
||||
return await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
|
||||
async def _get_owned_project(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> Project:
|
||||
"""Fetch a project and verify ownership.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
The project if found and owned by the user.
|
||||
|
||||
Raises:
|
||||
HTTPException: If project not found or user is not the owner.
|
||||
"""
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||
if project.owner_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||
return project
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{project_id}",
|
||||
response_model=ProjectResponse,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Shared Pydantic validators for API schemas."""
|
||||
|
||||
|
||||
MAX_FOLDER_SIZE_MB = 10
|
||||
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||
|
||||
|
||||
def validate_mount_path(v: str | None) -> str | None:
|
||||
"""Validate that a mount path is absolute (starts with /).
|
||||
|
||||
Args:
|
||||
v: Mount path string or None.
|
||||
|
||||
Returns:
|
||||
The validated path, or None if input was None.
|
||||
|
||||
Raises:
|
||||
ValueError: If path is not absolute.
|
||||
"""
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return v
|
||||
|
||||
|
||||
def validate_files(v: dict | None, max_size_bytes: int = MAX_FOLDER_SIZE_BYTES) -> dict | None:
|
||||
"""Validate file dict for path traversal and size limits.
|
||||
|
||||
Args:
|
||||
v: Dict of {path: content} or None.
|
||||
max_size_bytes: Maximum total size in bytes.
|
||||
|
||||
Returns:
|
||||
The validated dict, or None if input was None.
|
||||
|
||||
Raises:
|
||||
ValueError: If path traversal detected or size limit exceeded.
|
||||
"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
total_size = 0
|
||||
for path, content in v.items():
|
||||
# Check for path traversal
|
||||
if ".." in path or path.startswith("/"):
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
total_size += len(content.encode("utf-8"))
|
||||
|
||||
if total_size > max_size_bytes:
|
||||
raise ValueError(f"Total folder size exceeds {max_size_bytes // (1024 * 1024)}MB limit")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
def validate_env_vars(v: dict | None) -> dict | None:
|
||||
"""Validate that environment variables is a JSON object.
|
||||
|
||||
Args:
|
||||
v: Dict of env vars or None.
|
||||
|
||||
Returns:
|
||||
The validated dict, or None if input was None.
|
||||
|
||||
Raises:
|
||||
ValueError: If not a dict.
|
||||
"""
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("environment_variables must be a JSON object")
|
||||
return v
|
||||
|
||||
|
||||
def validate_volumes(v: list | None) -> list | None:
|
||||
"""Validate volume mounts list.
|
||||
|
||||
Args:
|
||||
v: List of volume dicts or None.
|
||||
|
||||
Returns:
|
||||
The validated list, or None if input was None.
|
||||
|
||||
Raises:
|
||||
ValueError: If not a list or missing required fields.
|
||||
"""
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("volumes must be a JSON array")
|
||||
for i, vol in enumerate(v):
|
||||
if not isinstance(vol, dict):
|
||||
raise ValueError(f"Volume at index {i} must be an object")
|
||||
if "source" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||
if "target" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||
return v
|
||||
@@ -10,22 +10,13 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.config import Settings
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
"""Generate a valid Fernet key from the session secret."""
|
||||
import base64
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_db_session
|
||||
@@ -44,9 +44,9 @@ async def terminal_websocket(
|
||||
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", instance_id)
|
||||
await websocket.accept()
|
||||
logger.info("Terminal WebSocket accepted for instance %s", instance_id)
|
||||
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||
|
||||
try:
|
||||
# Parse instance_id
|
||||
@@ -80,13 +80,13 @@ async def terminal_websocket(
|
||||
await websocket.close(code=4004, reason="Instance not running")
|
||||
return
|
||||
|
||||
logger.info("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||
|
||||
# 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.info("Using startup command for instance %s: %s", instance_id, startup_command)
|
||||
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
|
||||
|
||||
# Get or create terminal session
|
||||
try:
|
||||
@@ -95,15 +95,15 @@ async def terminal_websocket(
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
)
|
||||
logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||
|
||||
# Attach WebSocket to session
|
||||
await terminal_manager.attach_websocket(session, websocket)
|
||||
logger.info("WebSocket attached to session for instance %s", instance_id)
|
||||
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||
|
||||
# Send connected status
|
||||
await websocket.send_json({"type": "status", "status": "connected"})
|
||||
logger.info("Sent connected status for instance %s", instance_id)
|
||||
logger.debug("Sent connected status for instance %s", instance_id)
|
||||
|
||||
# Use mutable session reference so loops can survive reset
|
||||
session_ref = SessionRef(session)
|
||||
@@ -112,7 +112,7 @@ async def terminal_websocket(
|
||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||
logger.info("Started terminal loops for instance %s", instance_id)
|
||||
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(
|
||||
@@ -120,7 +120,7 @@ async def terminal_websocket(
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
logger.info("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
||||
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
||||
|
||||
# Cancel remaining tasks
|
||||
for task in pending:
|
||||
@@ -134,7 +134,7 @@ async def terminal_websocket(
|
||||
try:
|
||||
if 'session' in locals():
|
||||
await terminal_manager.detach_websocket(session, websocket)
|
||||
logger.info("WebSocket detached from session for instance %s", instance_id)
|
||||
logger.debug("WebSocket detached from session for instance %s", instance_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -183,11 +183,11 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
||||
if msg_type == "resize":
|
||||
cols = ctrl.get("cols", 80)
|
||||
rows = ctrl.get("rows", 24)
|
||||
logger.info(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
||||
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
||||
await session.resize(cols, rows)
|
||||
elif msg_type == "reset":
|
||||
# Reset terminal session
|
||||
logger.info("Resetting terminal session for instance %s", session.instance_id)
|
||||
logger.debug("Resetting terminal session for instance %s", session.instance_id)
|
||||
await websocket.send_json({"type": "status", "status": "resetting"})
|
||||
|
||||
# Reset the session
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tool configuration API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -8,12 +7,11 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_type import ToolType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||
|
||||
|
||||
@@ -42,27 +40,12 @@ class ToolConfigCreate(BaseModel):
|
||||
@field_validator("environment_variables")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("environment_variables must be a JSON object")
|
||||
return v
|
||||
return _validate_env_vars(v)
|
||||
|
||||
@field_validator("volumes")
|
||||
@classmethod
|
||||
def validate_volumes(cls, v: list | None) -> list | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("volumes must be a JSON array")
|
||||
for i, vol in enumerate(v):
|
||||
if not isinstance(vol, dict):
|
||||
raise ValueError(f"Volume at index {i} must be an object")
|
||||
if "source" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||
if "target" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||
return v
|
||||
return _validate_volumes(v)
|
||||
|
||||
|
||||
class ToolConfigUpdate(BaseModel):
|
||||
@@ -88,27 +71,12 @@ class ToolConfigUpdate(BaseModel):
|
||||
@field_validator("environment_variables")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("environment_variables must be a JSON object")
|
||||
return v
|
||||
return _validate_env_vars(v)
|
||||
|
||||
@field_validator("volumes")
|
||||
@classmethod
|
||||
def validate_volumes(cls, v: list | None) -> list | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("volumes must be a JSON array")
|
||||
for i, vol in enumerate(v):
|
||||
if not isinstance(vol, dict):
|
||||
raise ValueError(f"Volume at index {i} must be an object")
|
||||
if "source" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||
if "target" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||
return v
|
||||
return _validate_volumes(v)
|
||||
|
||||
|
||||
class ToolConfigResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Tool definition API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
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_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.manifest_compiler import (
|
||||
compile_compose,
|
||||
compile_dockerfile,
|
||||
compile_entrypoint,
|
||||
compute_image_tag,
|
||||
deep_merge,
|
||||
resolve_base,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/tool-definitions", tags=["tool-definitions"])
|
||||
|
||||
|
||||
class CreateToolDefinitionRequest(BaseModel):
|
||||
"""Request body for creating a tool definition manifest."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
name: str = Field(description="Unique identifier (kebab-case)")
|
||||
display_name: str = Field(description="Human-readable name")
|
||||
description: str | None = Field(default=None)
|
||||
category: str = Field(default="development")
|
||||
interface_type: str = Field(default="terminal", description="web or terminal")
|
||||
base_image: str | None = Field(default=None, description="Direct base image")
|
||||
base_definition_id: str | None = Field(
|
||||
default=None, description="Reference to a base definition"
|
||||
)
|
||||
base_version: str = Field(default="latest")
|
||||
manifest: dict = Field(description="The full manifest JSON")
|
||||
|
||||
|
||||
class UpdateToolDefinitionRequest(BaseModel):
|
||||
"""Request body for updating a tool definition manifest."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
display_name: str | None = Field(default=None)
|
||||
description: str | None = Field(default=None)
|
||||
category: str | None = Field(default=None)
|
||||
manifest: dict | None = Field(default=None)
|
||||
base_version: str | None = Field(default=None)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
summary="Create tool definition",
|
||||
description="Create a new tool definition manifest.",
|
||||
)
|
||||
async def create_tool_definition(
|
||||
data: CreateToolDefinitionRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a new tool definition manifest.
|
||||
|
||||
Args:
|
||||
data: Manifest data.
|
||||
user_id: Authenticated user ID.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with created definition details.
|
||||
"""
|
||||
# Validate base reference
|
||||
if not data.base_image and not data.base_definition_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Either base_image or base_definition_id is required",
|
||||
)
|
||||
|
||||
base_def_id = None
|
||||
if data.base_definition_id:
|
||||
try:
|
||||
base_def_id = uuid.UUID(data.base_definition_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid base_definition_id: {data.base_definition_id}",
|
||||
)
|
||||
|
||||
base_def = await session.get(ToolDefinitionManifest, base_def_id)
|
||||
if not base_def:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Base definition not found: {data.base_definition_id}",
|
||||
)
|
||||
if not base_def.is_base:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Referenced definition is not a base definition",
|
||||
)
|
||||
|
||||
# Check name uniqueness
|
||||
existing = await session.execute(
|
||||
select(ToolDefinitionManifest).where(ToolDefinitionManifest.name == data.name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Tool definition '{data.name}' already exists",
|
||||
)
|
||||
|
||||
definition = ToolDefinitionManifest(
|
||||
name=data.name,
|
||||
display_name=data.display_name,
|
||||
description=data.description,
|
||||
category=data.category,
|
||||
interface_type=data.interface_type,
|
||||
base_image=data.base_image,
|
||||
base_definition_id=base_def_id,
|
||||
base_version=data.base_version,
|
||||
manifest=data.manifest,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
session.add(definition)
|
||||
await session.commit()
|
||||
await session.refresh(definition)
|
||||
|
||||
logger.info("Created tool definition %s (%s)", definition.id, definition.name)
|
||||
|
||||
return {
|
||||
"id": str(definition.id),
|
||||
"name": definition.name,
|
||||
"display_name": definition.display_name,
|
||||
"description": definition.description,
|
||||
"category": definition.category,
|
||||
"interface_type": definition.interface_type,
|
||||
"base_image": definition.base_image,
|
||||
"base_definition_id": str(definition.base_definition_id)
|
||||
if definition.base_definition_id
|
||||
else None,
|
||||
"base_version": definition.base_version,
|
||||
"manifest": definition.manifest,
|
||||
"is_base": definition.is_base,
|
||||
"created_at": definition.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="List tool definitions",
|
||||
description="List all tool definition manifests.",
|
||||
)
|
||||
async def list_tool_definitions(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
include_bases: bool = True,
|
||||
) -> dict:
|
||||
"""List all tool definition manifests.
|
||||
|
||||
Args:
|
||||
user_id: Authenticated user ID.
|
||||
session: Database session.
|
||||
include_bases: Whether to include base definitions.
|
||||
|
||||
Returns:
|
||||
Dictionary containing list of definitions.
|
||||
"""
|
||||
query = select(ToolDefinitionManifest)
|
||||
if not include_bases:
|
||||
query = query.where(ToolDefinitionManifest.is_base == False)
|
||||
|
||||
result = await session.execute(
|
||||
query.order_by(ToolDefinitionManifest.created_at.desc())
|
||||
)
|
||||
definitions = result.scalars().all()
|
||||
|
||||
return {
|
||||
"definitions": [
|
||||
{
|
||||
"id": str(d.id),
|
||||
"name": d.name,
|
||||
"display_name": d.display_name,
|
||||
"description": d.description,
|
||||
"category": d.category,
|
||||
"interface_type": d.interface_type,
|
||||
"is_base": d.is_base,
|
||||
"base_image": d.base_image,
|
||||
"base_definition_id": str(d.base_definition_id)
|
||||
if d.base_definition_id
|
||||
else None,
|
||||
"base_version": d.base_version,
|
||||
"version": d.version,
|
||||
"created_at": d.created_at.isoformat(),
|
||||
}
|
||||
for d in definitions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{definition_id}",
|
||||
summary="Get tool definition",
|
||||
description="Get a specific tool definition manifest.",
|
||||
)
|
||||
async def get_tool_definition(
|
||||
definition_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get a specific tool definition manifest.
|
||||
|
||||
Args:
|
||||
definition_id: UUID of the definition.
|
||||
user_id: Authenticated user ID.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with definition details.
|
||||
"""
|
||||
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||
if not definition:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool definition not found: {definition_id}",
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(definition.id),
|
||||
"name": definition.name,
|
||||
"display_name": definition.display_name,
|
||||
"description": definition.description,
|
||||
"category": definition.category,
|
||||
"interface_type": definition.interface_type,
|
||||
"base_image": definition.base_image,
|
||||
"base_definition_id": str(definition.base_definition_id)
|
||||
if definition.base_definition_id
|
||||
else None,
|
||||
"base_version": definition.base_version,
|
||||
"manifest": definition.manifest,
|
||||
"dockerfile_cache": definition.dockerfile_cache,
|
||||
"compose_cache": definition.compose_cache,
|
||||
"version": definition.version,
|
||||
"is_base": definition.is_base,
|
||||
"created_at": definition.created_at.isoformat(),
|
||||
"updated_at": definition.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{definition_id}",
|
||||
summary="Update tool definition",
|
||||
description="Update a tool definition manifest.",
|
||||
)
|
||||
async def update_tool_definition(
|
||||
definition_id: uuid.UUID,
|
||||
data: UpdateToolDefinitionRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a tool definition manifest.
|
||||
|
||||
Args:
|
||||
definition_id: UUID of the definition.
|
||||
data: Update data.
|
||||
user_id: Authenticated user ID.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with updated definition details.
|
||||
"""
|
||||
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||
if not definition:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool definition not found: {definition_id}",
|
||||
)
|
||||
|
||||
if data.display_name is not None:
|
||||
definition.display_name = data.display_name
|
||||
if data.description is not None:
|
||||
definition.description = data.description
|
||||
if data.category is not None:
|
||||
definition.category = data.category
|
||||
if data.manifest is not None:
|
||||
definition.manifest = data.manifest
|
||||
if data.base_version is not None:
|
||||
definition.base_version = data.base_version
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(definition)
|
||||
|
||||
logger.info("Updated tool definition %s (%s)", definition.id, definition.name)
|
||||
|
||||
return {
|
||||
"id": str(definition.id),
|
||||
"name": definition.name,
|
||||
"display_name": definition.display_name,
|
||||
"manifest": definition.manifest,
|
||||
"updated_at": definition.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{definition_id}",
|
||||
summary="Delete tool definition",
|
||||
description="Delete a tool definition manifest.",
|
||||
)
|
||||
async def delete_tool_definition(
|
||||
definition_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Delete a tool definition manifest.
|
||||
|
||||
Args:
|
||||
definition_id: UUID of the definition.
|
||||
user_id: Authenticated user ID.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with deletion status.
|
||||
"""
|
||||
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||
if not definition:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool definition not found: {definition_id}",
|
||||
)
|
||||
|
||||
# Check if any tool types reference this manifest
|
||||
result = await session.execute(
|
||||
select(ToolType).where(ToolType.manifest_id == definition_id)
|
||||
)
|
||||
referencing = result.scalars().all()
|
||||
if referencing:
|
||||
tool_names = ", ".join(t.name for t in referencing)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Cannot delete: referenced by tool types: {tool_names}",
|
||||
)
|
||||
|
||||
await session.delete(definition)
|
||||
await session.commit()
|
||||
|
||||
logger.info("Deleted tool definition %s (%s)", definition.id, definition.name)
|
||||
|
||||
return {"status": "deleted", "id": str(definition_id)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{definition_id}/compile",
|
||||
summary="Compile tool definition",
|
||||
description="Compile a manifest to Dockerfile + Compose preview without building.",
|
||||
)
|
||||
async def compile_tool_definition(
|
||||
definition_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Compile a manifest to Dockerfile + Compose preview.
|
||||
|
||||
Args:
|
||||
definition_id: UUID of the definition.
|
||||
user_id: Authenticated user ID.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with compiled Dockerfile, Compose, and image tag.
|
||||
"""
|
||||
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||
if not definition:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool definition not found: {definition_id}",
|
||||
)
|
||||
|
||||
manifest = dict(definition.manifest)
|
||||
|
||||
# Resolve base if referenced
|
||||
if definition.base_definition_id:
|
||||
base_def = await session.get(
|
||||
ToolDefinitionManifest, definition.base_definition_id
|
||||
)
|
||||
if base_def:
|
||||
base_manifest = dict(base_def.manifest)
|
||||
manifest = resolve_base(deep_merge(base_manifest, manifest))
|
||||
|
||||
# Compile
|
||||
dockerfile = compile_dockerfile(manifest)
|
||||
entrypoint = compile_entrypoint(manifest)
|
||||
image_tag = compute_image_tag(definition.name, manifest)
|
||||
|
||||
# Dummy compose with placeholder variables
|
||||
dummy_vars = {
|
||||
"IMAGE_TAG": image_tag,
|
||||
"INSTANCE_NAME": f"{definition.name}-preview",
|
||||
"INSTANCE_DIR": "/data/instances/preview",
|
||||
"REPO_PATH": "/data/repos/preview",
|
||||
"SSH_PATH": "/data/instances/preview/.ssh",
|
||||
"TOOL_PORT": "8080",
|
||||
"EXTRA_ENV": {},
|
||||
"EXTRA_VOLUMES": [],
|
||||
}
|
||||
compose = compile_compose(manifest, dummy_vars)
|
||||
|
||||
# Update cache
|
||||
definition.dockerfile_cache = dockerfile
|
||||
definition.compose_cache = compose
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"id": str(definition.id),
|
||||
"name": definition.name,
|
||||
"dockerfile": dockerfile,
|
||||
"entrypoint": entrypoint,
|
||||
"compose": compose,
|
||||
"image_tag": image_tag,
|
||||
}
|
||||
+885
-225
File diff suppressed because it is too large
Load Diff
+31
-131
@@ -1,33 +1,23 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
def _sanitize_template_vars(template: str) -> str:
|
||||
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
||||
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, 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.user import User
|
||||
|
||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
"""Fetch a user by ID or raise 401 if not found."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
return user
|
||||
|
||||
|
||||
async def _require_admin(user: User) -> None:
|
||||
"""Check if user has admin privileges.
|
||||
|
||||
@@ -72,24 +62,7 @@ class ToolTypeCreate(BaseModel):
|
||||
if v is None:
|
||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||
|
||||
# Replace template variables with dummy values before YAML validation
|
||||
# to avoid YAML parsing errors with {{VAR}} syntax
|
||||
sanitized = _sanitize_template_vars(v)
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
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")
|
||||
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@@ -156,29 +129,11 @@ class ToolTypeCreate(BaseModel):
|
||||
# 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:
|
||||
sanitized = _sanitize_template_vars(self.compose_template)
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
except yaml.YAMLError:
|
||||
parsed = validate_compose_yaml(self.compose_template)
|
||||
except ValueError:
|
||||
return self
|
||||
|
||||
port_str = str(self.default_port)
|
||||
port_exposed = False
|
||||
|
||||
if isinstance(parsed, dict) and "services" in parsed:
|
||||
for service_name, service_config in parsed["services"].items():
|
||||
if isinstance(service_config, dict) and "ports" in service_config:
|
||||
for port_mapping in service_config["ports"]:
|
||||
if isinstance(port_mapping, str):
|
||||
if port_str in port_mapping:
|
||||
port_exposed = True
|
||||
break
|
||||
elif isinstance(port_mapping, int) and port_mapping == self.default_port:
|
||||
port_exposed = True
|
||||
break
|
||||
if port_exposed:
|
||||
break
|
||||
|
||||
if not port_exposed:
|
||||
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
|
||||
@@ -222,29 +177,13 @@ class ToolTypeUpdate(BaseModel):
|
||||
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
|
||||
|
||||
# Replace template variables with dummy values before YAML validation
|
||||
sanitized = _sanitize_template_vars(v)
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
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")
|
||||
|
||||
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@@ -442,54 +381,29 @@ async def update_tool_type(
|
||||
template = update_data.get("compose_template", tool_type.compose_template)
|
||||
if template:
|
||||
try:
|
||||
sanitized = _sanitize_template_vars(template)
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
except yaml.YAMLError:
|
||||
parsed = None
|
||||
|
||||
if parsed and isinstance(parsed, dict) and "services" in parsed:
|
||||
port_str = str(new_port)
|
||||
port_exposed = False
|
||||
for service_config in parsed["services"].values():
|
||||
if isinstance(service_config, dict) and "ports" in service_config:
|
||||
for port_mapping in service_config["ports"]:
|
||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||
port_exposed = True
|
||||
break
|
||||
elif isinstance(port_mapping, int) and port_mapping == new_port:
|
||||
port_exposed = True
|
||||
break
|
||||
if port_exposed:
|
||||
break
|
||||
|
||||
if not port_exposed:
|
||||
parsed = validate_compose_yaml(template)
|
||||
if not check_port_exposed(parsed, new_port):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
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
|
||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||
if definition_type == "compose":
|
||||
if "required_variables" in update_data and "compose_template" in update_data:
|
||||
template = update_data["compose_template"]
|
||||
for var in update_data["required_variables"]:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Required variable '{var}' not found in compose template"
|
||||
)
|
||||
validate_required_variables(
|
||||
update_data["compose_template"], update_data["required_variables"]
|
||||
)
|
||||
elif "required_variables" in update_data:
|
||||
template = tool_type.compose_template
|
||||
if template:
|
||||
for var in update_data["required_variables"]:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Required variable '{var}' not found in compose template"
|
||||
)
|
||||
validate_required_variables(template, update_data["required_variables"])
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(tool_type, field, value)
|
||||
@@ -534,16 +448,9 @@ async def validate_tool_type_template(
|
||||
errors.append("Compose template is required")
|
||||
else:
|
||||
try:
|
||||
sanitized = _sanitize_template_vars(data.compose_template)
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
if not isinstance(parsed, dict):
|
||||
errors.append("Compose template must be a YAML mapping")
|
||||
elif "services" not in parsed:
|
||||
errors.append("Compose template must contain 'services' key")
|
||||
elif not parsed["services"]:
|
||||
errors.append("Compose template must define at least one service")
|
||||
except yaml.YAMLError as e:
|
||||
errors.append(f"Invalid YAML: {e}")
|
||||
validate_compose_yaml(data.compose_template)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
elif data.definition_type == "dockerfile":
|
||||
if not data.dockerfile_template:
|
||||
@@ -592,16 +499,9 @@ async def validate_tool_type(
|
||||
errors.append("Compose template is empty")
|
||||
else:
|
||||
try:
|
||||
sanitized = _sanitize_template_vars(tool_type.compose_template)
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
if not isinstance(parsed, dict):
|
||||
errors.append("Compose template must be a YAML mapping")
|
||||
elif "services" not in parsed:
|
||||
errors.append("Compose template must contain 'services' key")
|
||||
elif not parsed["services"]:
|
||||
errors.append("Compose template must define at least one service")
|
||||
except yaml.YAMLError as e:
|
||||
errors.append(f"Invalid YAML: {e}")
|
||||
validate_compose_yaml(tool_type.compose_template)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
elif tool_type.definition_type == "dockerfile":
|
||||
if not tool_type.dockerfile_template:
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Shared validation utilities for tool types."""
|
||||
|
||||
import re
|
||||
|
||||
import yaml
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
def sanitize_template_vars(template: str) -> str:
|
||||
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
||||
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
||||
|
||||
|
||||
def validate_compose_yaml(template: str) -> dict:
|
||||
"""Validate and parse a compose template.
|
||||
|
||||
Args:
|
||||
template: Raw compose template string.
|
||||
|
||||
Returns:
|
||||
Parsed YAML dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If YAML is invalid or missing required keys.
|
||||
"""
|
||||
sanitized = sanitize_template_vars(template)
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
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 parsed
|
||||
|
||||
|
||||
def check_port_exposed(parsed: dict, port: int) -> bool:
|
||||
"""Check if a port is exposed in a parsed compose template.
|
||||
|
||||
Args:
|
||||
parsed: Parsed compose YAML dict.
|
||||
port: Port number to check.
|
||||
|
||||
Returns:
|
||||
True if port is exposed in any service.
|
||||
"""
|
||||
port_str = str(port)
|
||||
|
||||
if not isinstance(parsed, dict) or "services" not in parsed:
|
||||
return 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:
|
||||
return True
|
||||
elif isinstance(port_mapping, int) and port_mapping == port:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def validate_required_variables(template: str, variables: list[str]) -> None:
|
||||
"""Validate that all required variables exist in the template.
|
||||
|
||||
Args:
|
||||
template: Compose template string.
|
||||
variables: List of required variable names.
|
||||
|
||||
Raises:
|
||||
HTTPException: If any variable is not found in the template.
|
||||
"""
|
||||
for var in 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",
|
||||
)
|
||||
@@ -1,28 +1,19 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.user import User
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||
|
||||
|
||||
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_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
||||
"""Get or create user config record.
|
||||
|
||||
@@ -111,11 +102,11 @@ async def update_user_config(
|
||||
|
||||
# Merge updates
|
||||
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
|
||||
config.config = {**config.config, **update_data}
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
logger.info("Updated config: %s", config.config)
|
||||
logger.debug("Updated config: %s", config.config)
|
||||
return UserConfigResponse.model_validate(config.config)
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
@@ -16,14 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.auth.session import decode_session_cookie
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.project import Project
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@@ -47,3 +48,39 @@ async def get_current_user(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
return user
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
"""Fetch a user by ID or raise 401 if not found."""
|
||||
user = await session.get(User, user_id)
|
||||
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,
|
||||
user_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> "Project":
|
||||
"""Fetch a project and verify ownership.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
The project if found and owned by the user.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found, 403 if user is not the owner.
|
||||
"""
|
||||
from src.models.project import Project
|
||||
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||
if project.owner_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||
return project
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.dashboard import router as dashboard_router
|
||||
@@ -20,6 +18,7 @@ from src.api.instance_proxy import router as instance_proxy_router
|
||||
from src.api.config_folders import router as config_folders_router
|
||||
from src.api.config_profiles import router as config_profiles_router
|
||||
from src.api.tool_configs import router as tool_configs_router
|
||||
from src.api.tool_definitions import router as tool_definitions_router
|
||||
from src.api.tool_instances import router as tool_instances_router
|
||||
from src.api.tool_instances import sessions_router
|
||||
from src.api.tool_types import router as tool_types_router
|
||||
@@ -68,7 +67,9 @@ def _sanitize_validation_errors(errors):
|
||||
"type": error.get("type"),
|
||||
"loc": error.get("loc"),
|
||||
"msg": error.get("msg"),
|
||||
"input": str(error.get("input")) if error.get("input") is not None else None,
|
||||
"input": str(error.get("input"))
|
||||
if error.get("input") is not None
|
||||
else None,
|
||||
}
|
||||
# Convert ctx to safe format
|
||||
ctx = error.get("ctx")
|
||||
@@ -112,10 +113,12 @@ async def on_startup():
|
||||
if not db_ready:
|
||||
logger.error("Database initialization failed. Shutting down.")
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("Startup complete.")
|
||||
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(dashboard_router)
|
||||
@@ -125,6 +128,7 @@ app.include_router(ssh_keys_router)
|
||||
app.include_router(git_repositories_router)
|
||||
app.include_router(user_config_router)
|
||||
app.include_router(tool_types_router)
|
||||
app.include_router(tool_definitions_router)
|
||||
app.include_router(config_folders_router)
|
||||
app.include_router(config_profiles_router)
|
||||
app.include_router(tool_instances_router)
|
||||
|
||||
@@ -4,9 +4,23 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
__all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ConfigFolder",
|
||||
"ConfigProfile",
|
||||
"ConfigProfileInclude",
|
||||
"GitRepository",
|
||||
"Project",
|
||||
"SSHKey",
|
||||
"ToolDefinitionManifest",
|
||||
"ToolInstance",
|
||||
"ToolType",
|
||||
"User",
|
||||
"UserConfig",
|
||||
]
|
||||
|
||||
@@ -39,6 +39,9 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
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()
|
||||
|
||||
@@ -19,7 +19,7 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
path: Mapped[str] = mapped_column(String(1024))
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
|
||||
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tool Definition Manifest model."""
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class ToolDefinitionManifest(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""A declarative manifest that compiles to Dockerfile + Compose.
|
||||
|
||||
Can be either:
|
||||
- A base definition (is_base=True) with a FROM image and common packages
|
||||
- A tool definition (is_base=False) that references a base + adds specifics
|
||||
"""
|
||||
|
||||
__tablename__ = "tool_definition_manifests"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
interface_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
# Base: either a direct image or a reference to another manifest
|
||||
base_image: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
base_definition_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(),
|
||||
ForeignKey("tool_definition_manifests.id"),
|
||||
nullable=True,
|
||||
)
|
||||
base_version: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="latest"
|
||||
)
|
||||
|
||||
# The full manifest JSON
|
||||
manifest: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
|
||||
# Caches for quick inspection
|
||||
dockerfile_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
compose_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Versioning
|
||||
version: Mapped[str] = mapped_column(String(32), nullable=False, default="v1")
|
||||
is_base: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
created_by: Mapped["User | None"] = relationship(
|
||||
foreign_keys=[created_by_id],
|
||||
)
|
||||
base_definition: Mapped["ToolDefinitionManifest | None"] = relationship(
|
||||
remote_side="ToolDefinitionManifest.id",
|
||||
foreign_keys=[base_definition_id],
|
||||
)
|
||||
@@ -33,42 +33,26 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="pending"
|
||||
)
|
||||
container_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
container_name: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
compose_path: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True
|
||||
)
|
||||
url: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True
|
||||
)
|
||||
public_url: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True
|
||||
)
|
||||
tunnel_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
port: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
|
||||
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
container_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
compose_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
public_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
tunnel_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
probe_result: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True
|
||||
)
|
||||
clone_mode: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="mount"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@@ -18,12 +19,19 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||
interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
|
||||
interface_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="web"
|
||||
)
|
||||
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||
definition_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="compose"
|
||||
) # "compose" or "dockerfile"
|
||||
String(16), nullable=False, default="legacy"
|
||||
) # "legacy" | "manifest"
|
||||
manifest_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(),
|
||||
ForeignKey("tool_definition_manifests.id"),
|
||||
nullable=True,
|
||||
)
|
||||
compose_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
dockerfile_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
build_context: Mapped[dict | None] = mapped_column(
|
||||
@@ -31,11 +39,16 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
)
|
||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
required_variables: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
manifest: Mapped["ToolDefinitionManifest | None"] = relationship(
|
||||
foreign_keys=[manifest_id],
|
||||
)
|
||||
created_by: Mapped["User | None"] = relationship()
|
||||
|
||||
@@ -42,7 +42,7 @@ def clone_repository(
|
||||
str(clone_path),
|
||||
]
|
||||
|
||||
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||
logger.debug("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
@@ -55,7 +55,7 @@ def clone_repository(
|
||||
logger.error("Git clone failed: %s", result.stderr)
|
||||
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
||||
|
||||
logger.info("Successfully cloned repository into %s", clone_path)
|
||||
logger.debug("Successfully cloned repository into %s", clone_path)
|
||||
return str(clone_path)
|
||||
|
||||
|
||||
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
|
||||
if clone_path.exists():
|
||||
import shutil
|
||||
shutil.rmtree(clone_path)
|
||||
logger.info("Removed clone directory: %s", clone_path)
|
||||
logger.debug("Removed clone directory: %s", clone_path)
|
||||
|
||||
@@ -48,6 +48,7 @@ class ResolvedProfile:
|
||||
env_vars: dict[str, str] = field(default_factory=dict)
|
||||
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
||||
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
||||
files: dict[str, str] = field(default_factory=dict)
|
||||
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||
@@ -168,6 +169,28 @@ def _merge_mounts(
|
||||
return result
|
||||
|
||||
|
||||
def _merge_git_mounts(
|
||||
base: list[dict[str, Any]],
|
||||
overlay: list[dict[str, Any]],
|
||||
source_name: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge git mounts from included profiles.
|
||||
|
||||
Later mounts override earlier ones with the same remote_url + target_path combo.
|
||||
"""
|
||||
result = list(base)
|
||||
# Build lookup by (remote_url, target_path)
|
||||
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
||||
for mount in overlay:
|
||||
key = (mount["remote_url"], mount["target_path"])
|
||||
if key in seen:
|
||||
result[seen[key]] = dict(mount)
|
||||
else:
|
||||
seen[key] = len(result)
|
||||
result.append(dict(mount))
|
||||
return result
|
||||
|
||||
|
||||
async def _resolve_profile_recursive(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
@@ -244,6 +267,9 @@ async def _resolve_profile_recursive(
|
||||
result.mount_overrides,
|
||||
included.profile_name,
|
||||
)
|
||||
result.git_mounts = _merge_git_mounts(
|
||||
result.git_mounts, included.git_mounts, included.profile_name
|
||||
)
|
||||
|
||||
# Apply the profile's own settings (selected profile overrides includes)
|
||||
result.env_vars = _merge_env_vars(
|
||||
@@ -270,7 +296,11 @@ async def _resolve_profile_recursive(
|
||||
result.mount_overrides,
|
||||
profile.name,
|
||||
)
|
||||
|
||||
result.git_mounts = _merge_git_mounts(
|
||||
result.git_mounts,
|
||||
profile.git_mounts or [],
|
||||
profile.name,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -449,5 +479,6 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||
"files": resolved.file_overrides,
|
||||
"mounts": resolved.mount_overrides,
|
||||
},
|
||||
"git_mounts": resolved.git_mounts,
|
||||
"included_profiles": resolved.included_profiles,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Docker service for managing tool instances."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -35,6 +37,7 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
|
||||
"""
|
||||
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)
|
||||
@@ -87,7 +90,7 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
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)
|
||||
|
||||
@@ -109,7 +112,7 @@ def execute_compose_command(
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
@@ -136,14 +139,17 @@ def execute_compose_command(
|
||||
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", "-q", "--filter", f"name={instance_name}"],
|
||||
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name.lower()}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
@@ -156,14 +162,25 @@ def get_container_id(instance_name: str) -> str | 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", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
||||
[
|
||||
"docker",
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
f"name={instance_name.lower()}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
@@ -173,7 +190,9 @@ def get_container_name(instance_name: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
||||
def connect_container_to_network(
|
||||
container_name: str, network_name: str = "backend"
|
||||
) -> bool:
|
||||
"""Connect a Docker container to an existing network.
|
||||
|
||||
Args:
|
||||
@@ -198,12 +217,14 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
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",
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||
container_id,
|
||||
],
|
||||
@@ -213,12 +234,12 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -238,13 +259,12 @@ def wait_for_container_running(
|
||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||
and 'waited_seconds' (float)
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
info = get_container_status(container_id)
|
||||
|
||||
|
||||
if info["status"] == "running":
|
||||
return {
|
||||
"success": True,
|
||||
@@ -252,7 +272,7 @@ def wait_for_container_running(
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
if info["status"] == "exited":
|
||||
return {
|
||||
"success": False,
|
||||
@@ -260,7 +280,7 @@ def wait_for_container_running(
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
if info["status"] == "not_found":
|
||||
return {
|
||||
"success": False,
|
||||
@@ -268,9 +288,9 @@ def wait_for_container_running(
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
# Timeout reached
|
||||
info = get_container_status(container_id)
|
||||
return {
|
||||
@@ -322,11 +342,6 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import re
|
||||
|
||||
|
||||
def start_cloudflared_tunnel(
|
||||
container_name: str, port: int, timeout: int = 30
|
||||
) -> dict[str, str]:
|
||||
@@ -344,8 +359,6 @@ def start_cloudflared_tunnel(
|
||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -354,18 +367,29 @@ def start_cloudflared_tunnel(
|
||||
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}"],
|
||||
[
|
||||
"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())
|
||||
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)
|
||||
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)
|
||||
@@ -381,9 +405,15 @@ def start_cloudflared_tunnel(
|
||||
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()
|
||||
@@ -410,7 +440,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
|
||||
Args:
|
||||
pid: Process ID of the cloudflared tunnel
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
|
||||
try:
|
||||
@@ -455,14 +484,23 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"--max-time", str(timeout), url],
|
||||
[
|
||||
"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",
|
||||
@@ -495,7 +533,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
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"]):
|
||||
if any(
|
||||
err in error_str
|
||||
for err in [
|
||||
"connection refused",
|
||||
"econnrefused",
|
||||
"could not resolve",
|
||||
"nodename",
|
||||
]
|
||||
):
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
|
||||
@@ -18,13 +18,12 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Write Dockerfile
|
||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||
dockerfile_path.write_text(dockerfile)
|
||||
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
||||
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
|
||||
|
||||
# Write build context files
|
||||
if build_context:
|
||||
@@ -39,10 +38,10 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
logger.info("Wrote build context file: %s", full_path)
|
||||
logger.debug("Wrote build context file: %s", full_path)
|
||||
|
||||
# Build image
|
||||
logger.info("Building Docker image with tag: %s", tag)
|
||||
logger.debug("Building Docker image with tag: %s", tag)
|
||||
cmd = [
|
||||
"docker", "build",
|
||||
"-t", tag,
|
||||
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
||||
text=True,
|
||||
timeout=300, # 5 minute timeout for builds
|
||||
)
|
||||
logger.info("Docker build completed: returncode=%d", result.returncode)
|
||||
logger.debug("Docker build completed: returncode=%d", result.returncode)
|
||||
if result.returncode != 0:
|
||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Manifest compiler: transforms ToolDefinitionManifest into Dockerfile + Compose."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shlex
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def resolve_base(manifest: dict) -> dict:
|
||||
"""Merge a base definition into a tool manifest.
|
||||
|
||||
If the manifest has base_definition_id, the base manifest is loaded
|
||||
and merged. Tool-specific values override base values.
|
||||
|
||||
Args:
|
||||
manifest: The tool manifest JSON (may reference a base)
|
||||
|
||||
Returns:
|
||||
A fully resolved manifest with base values merged in.
|
||||
"""
|
||||
result = deepcopy(manifest)
|
||||
|
||||
base_definition_id = result.pop("base_definition_id", None)
|
||||
base_version = result.pop("base_version", "latest")
|
||||
|
||||
if base_definition_id:
|
||||
# This will be provided by the caller (they have the DB session)
|
||||
# For now, we assume the manifest has been pre-resolved
|
||||
# or the caller provides the base manifest separately.
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def deep_merge(base: dict, override: dict) -> dict:
|
||||
"""Deep merge two manifests. Arrays are concatenated; dicts are merged.
|
||||
|
||||
Args:
|
||||
base: The base manifest.
|
||||
override: The tool-specific overrides.
|
||||
|
||||
Returns:
|
||||
Merged manifest.
|
||||
"""
|
||||
merged = deepcopy(base)
|
||||
|
||||
for key, value in override.items():
|
||||
if key == "mounts" and isinstance(value, list):
|
||||
# Concatenate mount arrays
|
||||
existing = merged.get("mounts", [])
|
||||
merged["mounts"] = existing + deepcopy(value)
|
||||
elif key == "scripts" and isinstance(value, dict):
|
||||
# Merge script categories
|
||||
if "scripts" not in merged:
|
||||
merged["scripts"] = {}
|
||||
for script_key, script_value in value.items():
|
||||
existing = merged["scripts"].get(script_key, [])
|
||||
merged["scripts"][script_key] = existing + deepcopy(script_value)
|
||||
elif key == "packages" and isinstance(value, dict):
|
||||
# Union package arrays
|
||||
if "packages" not in merged:
|
||||
merged["packages"] = {}
|
||||
for pkg_key, pkg_value in value.items():
|
||||
if (
|
||||
pkg_key in merged["packages"]
|
||||
and isinstance(merged["packages"][pkg_key], list)
|
||||
and isinstance(pkg_value, list)
|
||||
):
|
||||
merged["packages"][pkg_key] = merged["packages"][
|
||||
pkg_key
|
||||
] + deepcopy(pkg_value)
|
||||
else:
|
||||
merged["packages"][pkg_key] = deepcopy(pkg_value)
|
||||
elif key == "env" and isinstance(value, dict):
|
||||
# Dict merge: override wins on key conflict
|
||||
if "env" not in merged:
|
||||
merged["env"] = {}
|
||||
merged["env"].update(deepcopy(value))
|
||||
elif (
|
||||
isinstance(value, dict) and key in merged and isinstance(merged[key], dict)
|
||||
):
|
||||
# Generic dict merge
|
||||
merged[key] = {**merged[key], **deepcopy(value)}
|
||||
else:
|
||||
# Override entirely
|
||||
merged[key] = deepcopy(value)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def compile_dockerfile(manifest: dict) -> str:
|
||||
"""Compile a resolved manifest into a Dockerfile string.
|
||||
|
||||
Args:
|
||||
manifest: Fully resolved manifest JSON.
|
||||
|
||||
Returns:
|
||||
Dockerfile content.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# FROM
|
||||
base_image = manifest.get("base_image", "ubuntu:24.04")
|
||||
lines.append(f"FROM {base_image}")
|
||||
lines.append("")
|
||||
|
||||
# Build-time environment
|
||||
env = manifest.get("env", {})
|
||||
for key, value in env.items():
|
||||
lines.append(f"ENV {key}={shlex.quote(value)}")
|
||||
if env:
|
||||
lines.append("")
|
||||
|
||||
# System packages (apt)
|
||||
apt_packages = manifest.get("packages", {}).get("apt", [])
|
||||
if apt_packages:
|
||||
lines.append("RUN apt-get update && apt-get install -y \\\\")
|
||||
for pkg in apt_packages[:-1]:
|
||||
lines.append(f" {pkg} \\\\")
|
||||
lines.append(f" {apt_packages[-1]} \\\\")
|
||||
lines.append(" && rm -rf /var/lib/apt/lists/*")
|
||||
lines.append("")
|
||||
|
||||
# Node.js
|
||||
node = manifest.get("packages", {}).get("node")
|
||||
if node:
|
||||
version = node.get("version", "20")
|
||||
lines.append(
|
||||
f"RUN curl -fsSL https://deb.nodesource.com/setup_{version}.x | bash - && \\\\"
|
||||
)
|
||||
lines.append(" apt-get install -y nodejs && \\\\")
|
||||
lines.append(" rm -rf /var/lib/apt/lists/*")
|
||||
lines.append("")
|
||||
|
||||
# NPM global packages
|
||||
npm_packages = manifest.get("packages", {}).get("npm_global", [])
|
||||
if npm_packages:
|
||||
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
|
||||
lines.append(f"RUN npm install -g {pkg_list}")
|
||||
lines.append("")
|
||||
|
||||
# Pip packages
|
||||
pip_packages = manifest.get("packages", {}).get("pip", [])
|
||||
if pip_packages:
|
||||
pkg_list = " ".join(shlex.quote(p) for p in pip_packages)
|
||||
lines.append(f"RUN pip install {pkg_list}")
|
||||
lines.append("")
|
||||
|
||||
# User creation
|
||||
user = manifest.get("user")
|
||||
if user:
|
||||
name = user["name"]
|
||||
uid = user["uid"]
|
||||
gid = user["gid"]
|
||||
create_home = "-m " if user.get("create_home", True) else ""
|
||||
shell = user.get("shell", "/bin/bash")
|
||||
lines.append(f"RUN groupadd -g {gid} {name} && \\\\")
|
||||
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
|
||||
lines.append("")
|
||||
|
||||
# Build scripts
|
||||
build_scripts = manifest.get("scripts", {}).get("build", [])
|
||||
for script in build_scripts:
|
||||
# Normalize multi-line scripts into single RUN command
|
||||
stripped_lines = [
|
||||
line.strip() for line in script.strip().split("\n") if line.strip()
|
||||
]
|
||||
if stripped_lines:
|
||||
normalized = " && ".join(stripped_lines)
|
||||
lines.append(f"RUN {normalized}")
|
||||
if build_scripts:
|
||||
lines.append("")
|
||||
|
||||
# Create mount target directories
|
||||
mounts = manifest.get("mounts", [])
|
||||
if mounts:
|
||||
dirs = [mount["target"] for mount in mounts]
|
||||
dir_str = " ".join(dirs)
|
||||
lines.append(f"RUN mkdir -p {dir_str}")
|
||||
if user:
|
||||
lines.append(f"RUN chown -R {user['name']}:{user['name']} {dir_str}")
|
||||
lines.append("")
|
||||
|
||||
# Entrypoint for startup scripts
|
||||
startup_scripts = manifest.get("scripts", {}).get("startup", [])
|
||||
if startup_scripts:
|
||||
lines.append(
|
||||
"COPY .headquarter/entrypoint.sh /usr/local/bin/headquarter-entrypoint"
|
||||
)
|
||||
lines.append("RUN chmod +x /usr/local/bin/headquarter-entrypoint")
|
||||
lines.append("")
|
||||
|
||||
# Switch to runtime user
|
||||
if user:
|
||||
lines.append(f"USER {user['name']}")
|
||||
lines.append(f"WORKDIR /home/{user['name']}")
|
||||
lines.append("")
|
||||
|
||||
# Entrypoint and CMD
|
||||
runtime = manifest.get("runtime", {})
|
||||
if startup_scripts:
|
||||
lines.append('ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]')
|
||||
|
||||
command = runtime.get("command", ["/bin/bash"])
|
||||
cmd_json = json.dumps(command)
|
||||
lines.append(f"CMD {cmd_json}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def compile_entrypoint(manifest: dict) -> str:
|
||||
"""Generate the startup entrypoint script from startup scripts.
|
||||
|
||||
Args:
|
||||
manifest: Fully resolved manifest JSON.
|
||||
|
||||
Returns:
|
||||
Shell script content.
|
||||
"""
|
||||
lines = ["#!/bin/bash", "set -e", ""]
|
||||
|
||||
startup_scripts = manifest.get("scripts", {}).get("startup", [])
|
||||
for script in startup_scripts:
|
||||
lines.append(script)
|
||||
lines.append("")
|
||||
|
||||
lines.append('exec "$@"')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||
"""Compile a resolved manifest into a Docker Compose string.
|
||||
|
||||
Args:
|
||||
manifest: Fully resolved manifest JSON.
|
||||
variables: Resolved values: IMAGE_TAG, INSTANCE_NAME, REPO_PATH, etc.
|
||||
|
||||
Returns:
|
||||
Docker Compose YAML content.
|
||||
"""
|
||||
runtime = manifest.get("runtime", {})
|
||||
user = manifest.get("user")
|
||||
interface_type = manifest["interface_type"]
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": variables["IMAGE_TAG"],
|
||||
"container_name": variables["INSTANCE_NAME"],
|
||||
"restart": "unless-stopped",
|
||||
}
|
||||
|
||||
# Terminal-specific fields
|
||||
if runtime.get("stdin_open", False):
|
||||
service["stdin_open"] = True
|
||||
if runtime.get("tty", False):
|
||||
service["tty"] = True
|
||||
if runtime.get("working_dir"):
|
||||
service["working_dir"] = runtime["working_dir"]
|
||||
|
||||
# User override
|
||||
if user:
|
||||
service["user"] = f"{user['uid']}:{user['gid']}"
|
||||
|
||||
# Ports for web tools
|
||||
default_port = manifest.get("default_port")
|
||||
if interface_type == "web" and default_port:
|
||||
service["ports"] = [f"{variables['TOOL_PORT']}:{default_port}"]
|
||||
|
||||
# Environment
|
||||
env = manifest.get("env", {})
|
||||
if env:
|
||||
service["environment"] = dict(env)
|
||||
|
||||
# Merge extra env from config
|
||||
extra_env = variables.get("EXTRA_ENV", {})
|
||||
if extra_env:
|
||||
if "environment" not in service:
|
||||
service["environment"] = {}
|
||||
service["environment"].update(extra_env)
|
||||
|
||||
# Volumes from mount schema
|
||||
volumes = []
|
||||
for mount in manifest.get("mounts", []):
|
||||
source = resolve_mount_source(mount, variables)
|
||||
if not source:
|
||||
continue
|
||||
target = mount["target"]
|
||||
readonly = ":ro" if mount.get("readonly", False) else ""
|
||||
volumes.append(f"{source}:{target}{readonly}")
|
||||
|
||||
# Append extra volumes from tool config / config profile
|
||||
for vol in variables.get("EXTRA_VOLUMES", []):
|
||||
vol_str = f"{vol['source']}:{vol['target']}"
|
||||
if vol.get("readonly"):
|
||||
vol_str += ":ro"
|
||||
volumes.append(vol_str)
|
||||
|
||||
if volumes:
|
||||
service["volumes"] = volumes
|
||||
|
||||
compose = {"services": {"app": service}}
|
||||
return yaml.dump(compose, default_flow_style=False)
|
||||
|
||||
|
||||
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
||||
"""Resolve a mount's source_type to an actual host path.
|
||||
|
||||
Args:
|
||||
mount: Mount definition from manifest.
|
||||
variables: Resolved variables dict.
|
||||
|
||||
Returns:
|
||||
Host path string, or empty string if unresolved.
|
||||
"""
|
||||
source_type = mount.get("source_type", "host_path")
|
||||
|
||||
if source_type == "repo":
|
||||
return variables.get("REPO_PATH", "")
|
||||
elif source_type == "ssh_key":
|
||||
return variables.get("SSH_PATH", "")
|
||||
elif source_type == "instance":
|
||||
instance_dir = variables.get("INSTANCE_DIR", "")
|
||||
mount_name = mount.get("name", "unknown")
|
||||
return f"{instance_dir}/mounts/{mount_name}"
|
||||
elif source_type == "git_mount":
|
||||
ref = mount.get("git_mount_ref", "default")
|
||||
return variables.get(f"GIT_MOUNT_{ref}", "")
|
||||
elif source_type == "host_path":
|
||||
return mount.get("source", "")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
||||
"""Compute a deterministic image tag from manifest content.
|
||||
|
||||
Args:
|
||||
tool_name: Human-readable tool name.
|
||||
manifest: Fully resolved manifest JSON.
|
||||
|
||||
Returns:
|
||||
Docker image tag string.
|
||||
"""
|
||||
# Canonicalize: sort keys, stable JSON
|
||||
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
|
||||
hash_suffix = hashlib.sha256(canonical.encode()).hexdigest()[:8]
|
||||
safe_name = tool_name.lower().replace(" ", "-").replace("_", "-")
|
||||
return f"headquarter/{safe_name}-{hash_suffix}:latest"
|
||||
|
||||
|
||||
def merge_with_config(
|
||||
manifest: dict, tool_configs: list[dict], profile: dict | None = None
|
||||
) -> dict:
|
||||
"""Merge ToolConfig and ConfigProfile overrides into a manifest.
|
||||
|
||||
Args:
|
||||
manifest: Base manifest from tool definition.
|
||||
tool_configs: List of ToolConfig records.
|
||||
profile: Resolved ConfigProfile (optional).
|
||||
|
||||
Returns:
|
||||
Manifest with overrides applied.
|
||||
"""
|
||||
result = deepcopy(manifest)
|
||||
|
||||
# Apply ToolConfigs
|
||||
extra_env: dict[str, str] = {}
|
||||
extra_volumes: list[dict] = []
|
||||
|
||||
for config in tool_configs:
|
||||
if config.get("config_type") == "env":
|
||||
extra_env[config["key"]] = config["value"]
|
||||
elif config.get("config_type") == "file" and config.get("file_path"):
|
||||
# Files are handled outside the manifest (written to instance dir)
|
||||
pass
|
||||
if config.get("port_override"):
|
||||
result["default_port"] = config["port_override"]
|
||||
if config.get("start_command"):
|
||||
result["runtime"] = result.get("runtime", {})
|
||||
result["runtime"]["command"] = config["start_command"].split()
|
||||
if config.get("working_directory"):
|
||||
result["runtime"] = result.get("runtime", {})
|
||||
result["runtime"]["working_dir"] = config["working_directory"]
|
||||
if config.get("environment_variables"):
|
||||
extra_env.update(config["environment_variables"])
|
||||
if config.get("volumes"):
|
||||
extra_volumes.extend(config["volumes"])
|
||||
|
||||
# Apply ConfigProfile
|
||||
if profile:
|
||||
if profile.get("environment_variables"):
|
||||
extra_env.update(profile["environment_variables"])
|
||||
if profile.get("mounts"):
|
||||
extra_volumes.extend(profile["mounts"])
|
||||
# Profile hints override everything
|
||||
hints = profile.get("hints", {})
|
||||
if hints.get("start_command"):
|
||||
result["runtime"] = result.get("runtime", {})
|
||||
result["runtime"]["command"] = hints["start_command"].split()
|
||||
if hints.get("working_directory"):
|
||||
result["runtime"] = result.get("runtime", {})
|
||||
result["runtime"]["working_dir"] = hints["working_directory"]
|
||||
if hints.get("port_override"):
|
||||
result["default_port"] = hints["port_override"]
|
||||
|
||||
# Store merged extras for the compose compiler
|
||||
result["_extra_env"] = extra_env
|
||||
result["_extra_volumes"] = extra_volumes
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Permission fixer: applies mount permission policies post-start."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def apply_mount_permissions(
|
||||
container_id: str,
|
||||
mounts: list[dict],
|
||||
timeout: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Apply permission policies to mounted directories in a running container.
|
||||
|
||||
Runs `chown`, `chmod`, and file-mode fixes for each mount that declares
|
||||
an owner, mode, or file_mode. Requires the container to have a root user.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
mounts: List of mount definitions from the manifest.
|
||||
timeout: Max seconds per docker exec command.
|
||||
|
||||
Returns:
|
||||
List of result dicts: [{mount_name, success, error}]
|
||||
"""
|
||||
results = []
|
||||
|
||||
for mount in mounts:
|
||||
name = mount.get("name", "unknown")
|
||||
target = mount["target"]
|
||||
owner = mount.get("owner")
|
||||
mode = mount.get("mode")
|
||||
file_mode = mount.get("file_mode")
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"mount_name": name,
|
||||
"success": True,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# Skip if no permission policy defined
|
||||
if not owner and not mode and not file_mode:
|
||||
results.append(result)
|
||||
continue
|
||||
|
||||
try:
|
||||
if owner:
|
||||
_run_in_container(
|
||||
container_id,
|
||||
["chown", "-R", f"{owner}:{owner}", target],
|
||||
timeout,
|
||||
)
|
||||
logger.debug(
|
||||
"Applied owner %s to %s in container %s",
|
||||
owner,
|
||||
target,
|
||||
container_id,
|
||||
)
|
||||
|
||||
if mode and result["success"]:
|
||||
_run_in_container(
|
||||
container_id,
|
||||
["chmod", mode, target],
|
||||
timeout,
|
||||
)
|
||||
logger.debug(
|
||||
"Applied mode %s to %s in container %s",
|
||||
mode,
|
||||
target,
|
||||
container_id,
|
||||
)
|
||||
|
||||
if file_mode and result["success"]:
|
||||
_run_in_container(
|
||||
container_id,
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
f"find {target} -type f -exec chmod {file_mode} {{}} +",
|
||||
],
|
||||
timeout,
|
||||
)
|
||||
logger.debug(
|
||||
"Applied file_mode %s to files in %s in container %s",
|
||||
file_mode,
|
||||
target,
|
||||
container_id,
|
||||
)
|
||||
|
||||
except PermissionFixError as exc:
|
||||
result["success"] = False
|
||||
result["error"] = str(exc)
|
||||
logger.warning(
|
||||
"Permission fix failed for mount %s (target=%s): %s",
|
||||
name,
|
||||
target,
|
||||
exc,
|
||||
)
|
||||
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class PermissionFixError(Exception):
|
||||
"""Raised when a permission fix command fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _run_in_container(
|
||||
container_id: str,
|
||||
command: list[str],
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Run a command inside a container as root.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
command: Command + args to execute.
|
||||
timeout: Max seconds to wait.
|
||||
|
||||
Raises:
|
||||
PermissionFixError: If the command fails or times out.
|
||||
"""
|
||||
cmd = ["docker", "exec", "--user", "root", container_id] + command
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise PermissionFixError(
|
||||
f"Command timed out after {timeout}s: {' '.join(command)}"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise PermissionFixError(f"Docker command not found: {' '.join(command)}")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise PermissionFixError(
|
||||
f"Command failed (rc={result.returncode}): {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def check_root_user_available(container_id: str, timeout: int = 5) -> bool:
|
||||
"""Check if the container has a root user we can exec as.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
timeout: Max seconds to wait.
|
||||
|
||||
Returns:
|
||||
True if root user exists and is usable.
|
||||
"""
|
||||
try:
|
||||
_run_in_container(container_id, ["id", "root"], timeout)
|
||||
return True
|
||||
except PermissionFixError:
|
||||
return False
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
@@ -72,11 +71,11 @@ class TerminalManager:
|
||||
|
||||
# Check if session is still alive
|
||||
if session.is_alive():
|
||||
logger.info("Reattaching to existing terminal session for instance %s", instance_id)
|
||||
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
|
||||
return session
|
||||
else:
|
||||
# Session died, clean it up
|
||||
logger.info("Existing session for instance %s is dead, cleaning up", instance_id)
|
||||
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
|
||||
await session.close()
|
||||
del self._sessions[instance_id_str]
|
||||
|
||||
@@ -97,7 +96,7 @@ class TerminalManager:
|
||||
"""Attach a WebSocket to an existing session."""
|
||||
# Handle concurrent connections - close existing ones
|
||||
if session.has_websockets():
|
||||
logger.info("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||
for ws in list(session._websockets):
|
||||
try:
|
||||
await ws.close(code=4000, reason="New connection established")
|
||||
@@ -135,7 +134,7 @@ class TerminalManager:
|
||||
|
||||
# Close existing session if any
|
||||
if instance_id_str in self._sessions:
|
||||
logger.info("Resetting terminal session for instance %s", instance_id)
|
||||
logger.debug("Resetting terminal session for instance %s", instance_id)
|
||||
old_session = self._sessions.pop(instance_id_str)
|
||||
await old_session.close()
|
||||
|
||||
|
||||
@@ -60,12 +60,12 @@ class TerminalSession:
|
||||
|
||||
# Set the terminal size initially
|
||||
self._set_terminal_size(self._cols, self._rows)
|
||||
logger.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{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.info(f"Using startup command for session {self.session_id}: {startup_command}")
|
||||
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
|
||||
else:
|
||||
shell_cmd = "bash -il"
|
||||
|
||||
@@ -102,7 +102,7 @@ class TerminalSession:
|
||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||
logger.info(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||
except (OSError, IOError) as e:
|
||||
logger.error(f"Failed to resize PTY: {e}")
|
||||
|
||||
@@ -159,7 +159,7 @@ class TerminalSession:
|
||||
|
||||
self._cols = cols
|
||||
self._rows = rows
|
||||
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||
self._set_terminal_size(cols, rows)
|
||||
|
||||
# Docker exec -it creates its own PTY inside the container,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _run_git_command(repo_path: str, *args: str) -> str:
|
||||
|
||||
@@ -8,16 +8,14 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Set test environment BEFORE importing app modules
|
||||
os.environ["APP_ENV"] = "testing"
|
||||
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
from src.config import Settings, build_database_url
|
||||
from src.config import Settings
|
||||
from src.models.base import Base
|
||||
from src.main import app
|
||||
from src.auth.dependencies import get_db_session
|
||||
@@ -133,6 +131,65 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project_and_repo(authenticated_client) -> tuple[str, str]:
|
||||
"""Create a project and repository directly in the database."""
|
||||
import uuid
|
||||
from src.models.project import Project
|
||||
from src.models.git_repository import GitRepository
|
||||
|
||||
project_id = uuid.uuid4()
|
||||
repo_id = uuid.uuid4()
|
||||
user_id = None
|
||||
|
||||
# Get user ID from session
|
||||
async def get_user_id():
|
||||
nonlocal user_id
|
||||
from src.auth.session import decode_session_cookie
|
||||
settings = Settings()
|
||||
session_cookie = authenticated_client.cookies.get("session")
|
||||
if session_cookie:
|
||||
session = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
if session:
|
||||
user_id = uuid.UUID(session["user_id"])
|
||||
|
||||
asyncio.run(get_user_id())
|
||||
|
||||
if not user_id:
|
||||
raise RuntimeError("Could not get user ID from authenticated client")
|
||||
|
||||
async def create_project_and_repo():
|
||||
override_fn = app.dependency_overrides.get(get_db_session)
|
||||
if override_fn:
|
||||
gen = override_fn()
|
||||
session = await gen.asend(None)
|
||||
try:
|
||||
project = Project(
|
||||
id=project_id,
|
||||
name="test-project",
|
||||
description="Test project",
|
||||
owner_id=user_id,
|
||||
)
|
||||
session.add(project)
|
||||
|
||||
repo = GitRepository(
|
||||
id=repo_id,
|
||||
name="test-repo",
|
||||
path="/tmp/test-repo",
|
||||
project_id=project_id,
|
||||
owner_id=user_id,
|
||||
remote_url="https://github.com/test/repo.git",
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
finally:
|
||||
await gen.aclose()
|
||||
|
||||
asyncio.run(create_project_and_repo())
|
||||
|
||||
return str(project_id), str(repo_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||
"""Provide an authenticated test client with an admin user."""
|
||||
|
||||
@@ -320,3 +320,134 @@ class TestConfigProfilesAPI:
|
||||
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(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "update-git-mounts",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
# Update with git mounts
|
||||
response = authenticated_client.put(
|
||||
f"/config-profiles/{profile_id}",
|
||||
json={
|
||||
"git_mounts": [
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": "config",
|
||||
"target_path": "/config",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["git_mounts"]) == 1
|
||||
assert data["git_mounts"][0]["source_path"] == "config"
|
||||
|
||||
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test that invalid git mount source paths are rejected."""
|
||||
_project_id, repo_id = test_project_and_repo
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
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
|
||||
|
||||
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test that git mount target paths with traversal are rejected."""
|
||||
_project_id, repo_id = test_project_and_repo
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
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
|
||||
|
||||
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test previewing a profile with git mounts."""
|
||||
_project_id, repo_id = test_project_and_repo
|
||||
|
||||
# Create profile with git mounts
|
||||
create_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "preview-git-mounts",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
"git_mounts": [
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
# Preview
|
||||
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["git_mounts"]) == 1
|
||||
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
|
||||
|
||||
@@ -82,28 +82,6 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
|
||||
assert UserConfig.user.property.mapper.class_ is User
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
|
||||
columns = RefreshToken.__table__.columns
|
||||
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
|
||||
|
||||
assert set(columns.keys()) == {
|
||||
"id",
|
||||
"user_id",
|
||||
"token_hash",
|
||||
"expires_at",
|
||||
"revoked_at",
|
||||
"user_agent",
|
||||
"ip_address",
|
||||
"created_at",
|
||||
}
|
||||
assert columns["token_hash"].unique is True
|
||||
assert columns["revoked_at"].nullable is True
|
||||
assert user_fk.target_fullname == "users.id"
|
||||
assert RefreshToken.user.property.mapper.class_ is User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -221,7 +220,7 @@ class TestToolTypesAPIExtended:
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
_ = response.json()
|
||||
|
||||
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
|
||||
"""Test creating a tool type with startup_command."""
|
||||
|
||||
@@ -6,13 +6,13 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.services.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
ConfigProfileNotFoundError,
|
||||
ResolvedProfile,
|
||||
check_include_cycle,
|
||||
resolve_profile,
|
||||
_merge_env_vars,
|
||||
_merge_files,
|
||||
_merge_mounts,
|
||||
_merge_runtime_hints,
|
||||
_merge_git_mounts,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ class TestMergeFunctions:
|
||||
|
||||
def test_merge_mounts_basic(self) -> None:
|
||||
"""Test basic mount merging."""
|
||||
from src.services.config_profile_resolver import ResolvedMount
|
||||
result = _merge_mounts(
|
||||
{},
|
||||
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
||||
@@ -97,6 +96,39 @@ class TestMergeFunctions:
|
||||
assert result["/app"].mode == "ro"
|
||||
assert overrides == {"/app": "source"}
|
||||
|
||||
def test_merge_git_mounts_basic(self) -> None:
|
||||
"""Test basic git mount merging."""
|
||||
result = _merge_git_mounts(
|
||||
[],
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||
assert result[0]["target_path"] == "/app"
|
||||
|
||||
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
||||
"""Test that git mounts with same repo+target override."""
|
||||
result = _merge_git_mounts(
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["source_path"] == "src"
|
||||
assert result[0]["branch"] == "dev"
|
||||
|
||||
def test_merge_git_mounts_different_targets(self) -> None:
|
||||
"""Test that git mounts with different targets are preserved."""
|
||||
result = _merge_git_mounts(
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 2
|
||||
targets = {m["target_path"] for m in result}
|
||||
assert targets == {"/app", "/config"}
|
||||
|
||||
|
||||
class TestResolveProfile:
|
||||
"""Unit tests for profile resolution."""
|
||||
@@ -250,6 +282,76 @@ class TestResolveProfile:
|
||||
with pytest.raises(ConfigProfileCycleError):
|
||||
await resolve_profile(db_session, profile_a.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
|
||||
"""Test resolving a profile with git mounts."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
profile = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="with-git-mounts",
|
||||
env_vars={},
|
||||
files={},
|
||||
git_mounts=[
|
||||
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||
],
|
||||
)
|
||||
db_session.add(profile)
|
||||
await db_session.commit()
|
||||
|
||||
result = await resolve_profile(db_session, profile.id)
|
||||
assert len(result.git_mounts) == 1
|
||||
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||
assert result.git_mounts[0]["target_path"] == "/app"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
|
||||
"""Test resolving a profile that includes another with git mounts."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
# Create base profile with git mount
|
||||
base = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="base",
|
||||
env_vars={},
|
||||
files={},
|
||||
git_mounts=[
|
||||
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||
],
|
||||
)
|
||||
db_session.add(base)
|
||||
|
||||
# Create child profile with its own git mount
|
||||
child = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="child",
|
||||
env_vars={},
|
||||
files={},
|
||||
git_mounts=[
|
||||
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
|
||||
],
|
||||
)
|
||||
db_session.add(child)
|
||||
await db_session.commit()
|
||||
|
||||
# Create include relationship
|
||||
include = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
profile_id=child.id,
|
||||
included_profile_id=base.id,
|
||||
order_index=0,
|
||||
)
|
||||
db_session.add(include)
|
||||
await db_session.commit()
|
||||
|
||||
result = await resolve_profile(db_session, child.id)
|
||||
assert len(result.git_mounts) == 2
|
||||
targets = {m["target_path"] for m in result.git_mounts}
|
||||
assert targets == {"/app", "/config"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
|
||||
"""Test resolving a non-existent profile."""
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Unit tests for docker service utilities."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.services.docker import get_container_id, get_container_name
|
||||
|
||||
|
||||
class TestGetContainerId:
|
||||
"""Tests for get_container_id."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_lowercases_name_for_filter(self, mock_run) -> None:
|
||||
"""Docker ps name filter is case-sensitive; we must lowercase."""
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="abc123\n")
|
||||
|
||||
result = get_container_id("MyContainer-ABC")
|
||||
|
||||
assert result == "abc123"
|
||||
call_args = mock_run.call_args[0][0]
|
||||
# The filter must use lowercase
|
||||
assert "name=mycontainer-abc" in call_args
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_returns_none_when_not_found(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="")
|
||||
|
||||
result = get_container_id("missing")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetContainerName:
|
||||
"""Tests for get_container_name."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_lowercases_name_for_filter(self, mock_run) -> None:
|
||||
"""Docker ps name filter is case-sensitive; we must lowercase."""
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="mycontainer-abc\n")
|
||||
|
||||
result = get_container_name("MyContainer-ABC")
|
||||
|
||||
assert result == "mycontainer-abc"
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "name=mycontainer-abc" in call_args
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_returns_none_when_not_found(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="")
|
||||
|
||||
result = get_container_name("missing")
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Unit tests for git mount resolution in tool instances."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.tool_instances import (
|
||||
_checkout_branch,
|
||||
_expand_glob_source,
|
||||
_resolve_single_git_mount,
|
||||
)
|
||||
|
||||
|
||||
class TestExpandGlobSource:
|
||||
"""Unit tests for glob pattern expansion."""
|
||||
|
||||
def test_no_glob_single_file(self, tmp_path: Path) -> None:
|
||||
"""Test non-glob path returns single file."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
result = _expand_glob_source(str(test_file), str(tmp_path))
|
||||
assert len(result) == 1
|
||||
assert result[0] == str(test_file)
|
||||
|
||||
def test_no_glob_missing_file(self, tmp_path: Path) -> None:
|
||||
"""Test non-glob missing file returns empty list."""
|
||||
missing_file = tmp_path / "missing.txt"
|
||||
|
||||
result = _expand_glob_source(str(missing_file), str(tmp_path))
|
||||
assert len(result) == 0
|
||||
|
||||
def test_glob_pattern(self, tmp_path: Path) -> None:
|
||||
"""Test glob pattern matches files."""
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.txt").write_text("content2")
|
||||
(tmp_path / "other.py").write_text("code")
|
||||
|
||||
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||
assert len(result) == 2
|
||||
assert all(f.endswith(".txt") for f in result)
|
||||
|
||||
def test_glob_recursive(self, tmp_path: Path) -> None:
|
||||
"""Test recursive glob pattern."""
|
||||
subdir = tmp_path / "subdir"
|
||||
subdir.mkdir()
|
||||
(subdir / "nested.txt").write_text("content")
|
||||
|
||||
result = _expand_glob_source(str(tmp_path / "**" / "*.txt"), str(tmp_path))
|
||||
assert len(result) == 1
|
||||
assert "nested.txt" in result[0]
|
||||
|
||||
def test_glob_limit_enforced(self, tmp_path: Path) -> None:
|
||||
"""Test that glob matches are limited to prevent abuse."""
|
||||
# Create more than 100 files
|
||||
for i in range(105):
|
||||
(tmp_path / f"file{i}.txt").write_text("content")
|
||||
|
||||
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||
assert len(result) == 100 # MAX_GLOB_MATCHES limit
|
||||
|
||||
def test_glob_escapes_repo(self, tmp_path: Path) -> None:
|
||||
"""Test that glob results outside repo are filtered."""
|
||||
other_dir = tmp_path.parent / "other"
|
||||
other_dir.mkdir(exist_ok=True)
|
||||
(other_dir / "outside.txt").write_text("content")
|
||||
|
||||
result = _expand_glob_source(str(tmp_path.parent / "*" / "*.txt"), str(tmp_path))
|
||||
# Should only include files within tmp_path, not other_dir
|
||||
assert all(r.startswith(str(tmp_path)) for r in result)
|
||||
|
||||
|
||||
class TestCheckoutBranch:
|
||||
"""Unit tests for branch checkout."""
|
||||
|
||||
def test_checkout_existing_branch(self, tmp_path: Path) -> None:
|
||||
"""Test checking out an existing branch."""
|
||||
# Initialize git repo
|
||||
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||
(tmp_path / "file.txt").write_text("content")
|
||||
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||
os.system(f"cd {tmp_path} && git branch feature")
|
||||
|
||||
_checkout_branch(str(tmp_path), "feature")
|
||||
|
||||
# Verify we're on feature branch
|
||||
result = os.popen(f"cd {tmp_path} && git branch --show-current").read().strip()
|
||||
assert result == "feature"
|
||||
|
||||
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
|
||||
"""Test checking out a non-existent branch returns False."""
|
||||
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||
(tmp_path / "file.txt").write_text("content")
|
||||
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||
|
||||
result = _checkout_branch(str(tmp_path), "nonexistent")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestResolveSingleGitMount:
|
||||
"""Unit tests for resolving a single git mount."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_missing_remote_url(self, db_session) -> None:
|
||||
"""Test that missing remote_url returns empty list."""
|
||||
git_mount = {
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
}
|
||||
|
||||
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_missing_target_path(self, db_session) -> None:
|
||||
"""Test that missing target path returns empty list."""
|
||||
git_mount = {
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
}
|
||||
|
||||
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||
assert result == []
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for git URL parsing utilities."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Unit tests for the manifest compiler."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.manifest_compiler import (
|
||||
compile_compose,
|
||||
compile_dockerfile,
|
||||
compile_entrypoint,
|
||||
compute_image_tag,
|
||||
deep_merge,
|
||||
merge_with_config,
|
||||
resolve_base,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveBase:
|
||||
"""Tests for resolve_base."""
|
||||
|
||||
def test_returns_manifest_unchanged_when_no_base(self) -> None:
|
||||
manifest = {"name": "test", "base_image": "ubuntu:24.04"}
|
||||
result = resolve_base(manifest)
|
||||
assert result["name"] == "test"
|
||||
assert "base_definition_id" not in result
|
||||
|
||||
|
||||
class TestDeepMerge:
|
||||
"""Tests for deep_merge."""
|
||||
|
||||
def test_packages_are_unioned(self) -> None:
|
||||
base = {"packages": {"apt": ["curl", "git"]}}
|
||||
override = {"packages": {"apt": ["neovim"]}}
|
||||
result = deep_merge(base, override)
|
||||
assert result["packages"]["apt"] == ["curl", "git", "neovim"]
|
||||
|
||||
def test_node_version_overrides(self) -> None:
|
||||
base = {"packages": {"node": {"version": "18"}}}
|
||||
override = {"packages": {"node": {"version": "20"}}}
|
||||
result = deep_merge(base, override)
|
||||
assert result["packages"]["node"]["version"] == "20"
|
||||
|
||||
def test_env_is_merged_with_override_winning(self) -> None:
|
||||
base = {"env": {"FOO": "base", "BAR": "base"}}
|
||||
override = {"env": {"FOO": "override"}}
|
||||
result = deep_merge(base, override)
|
||||
assert result["env"]["FOO"] == "override"
|
||||
assert result["env"]["BAR"] == "base"
|
||||
|
||||
def test_build_scripts_are_concatenated(self) -> None:
|
||||
base = {"scripts": {"build": ["echo base"]}}
|
||||
override = {"scripts": {"build": ["echo override"]}}
|
||||
result = deep_merge(base, override)
|
||||
assert result["scripts"]["build"] == ["echo base", "echo override"]
|
||||
|
||||
def test_mounts_are_concatenated(self) -> None:
|
||||
base = {"mounts": [{"name": "base-mount", "target": "/base"}]}
|
||||
override = {"mounts": [{"name": "tool-mount", "target": "/tool"}]}
|
||||
result = deep_merge(base, override)
|
||||
assert len(result["mounts"]) == 2
|
||||
|
||||
def test_user_is_overridden_entirely(self) -> None:
|
||||
base = {"user": {"name": "base", "uid": 1000}}
|
||||
override = {"user": {"name": "tool", "uid": 1001}}
|
||||
result = deep_merge(base, override)
|
||||
assert result["user"]["name"] == "tool"
|
||||
assert result["user"]["uid"] == 1001
|
||||
|
||||
|
||||
class TestCompileDockerfile:
|
||||
"""Tests for compile_dockerfile."""
|
||||
|
||||
def test_includes_from(self) -> None:
|
||||
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert "FROM ubuntu:24.04" in df
|
||||
|
||||
def test_installs_apt_packages(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"name": "test",
|
||||
"packages": {"apt": ["curl", "git"]},
|
||||
}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert "apt-get install -y" in df
|
||||
assert "curl" in df
|
||||
assert "git" in df
|
||||
assert "rm -rf /var/lib/apt/lists/*" in df
|
||||
|
||||
def test_installs_node(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"name": "test",
|
||||
"packages": {"node": {"version": "20"}},
|
||||
}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert "nodesource.com/setup_20.x" in df
|
||||
|
||||
def test_installs_npm_global(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"name": "test",
|
||||
"packages": {"npm_global": ["@scope/pkg"]},
|
||||
}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert "npm install -g @scope/pkg" in df
|
||||
|
||||
def test_creates_user(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"name": "test",
|
||||
"user": {"name": "dev", "uid": 1001, "gid": 1001},
|
||||
}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert "groupadd -g 1001 dev" in df
|
||||
assert "useradd -u 1001 -g 1001" in df
|
||||
assert "USER dev" in df
|
||||
|
||||
def test_build_scripts_as_run_commands(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"name": "test",
|
||||
"scripts": {"build": ["echo hello", "echo world"]},
|
||||
}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert "RUN echo hello" in df
|
||||
assert "RUN echo world" in df
|
||||
|
||||
def test_creates_mount_directories(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"name": "test",
|
||||
"user": {"name": "dev", "uid": 1001, "gid": 1001},
|
||||
"mounts": [
|
||||
{"name": "ws", "target": "/workspace"},
|
||||
{"name": "cfg", "target": "/config"},
|
||||
],
|
||||
}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert "mkdir -p /workspace /config" in df
|
||||
assert "chown -R dev:dev /workspace /config" in df
|
||||
|
||||
def test_entrypoint_for_startup_scripts(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"name": "test",
|
||||
"scripts": {"startup": ["echo start"]},
|
||||
}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert 'ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]' in df
|
||||
|
||||
def test_cmd_from_runtime(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"name": "test",
|
||||
"runtime": {"command": ["/bin/bash", "-il"]},
|
||||
}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert 'CMD ["/bin/bash", "-il"]' in df
|
||||
|
||||
def test_default_cmd_when_no_runtime(self) -> None:
|
||||
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
|
||||
df = compile_dockerfile(manifest)
|
||||
assert 'CMD ["/bin/bash"]' in df
|
||||
|
||||
|
||||
class TestCompileEntrypoint:
|
||||
"""Tests for compile_entrypoint."""
|
||||
|
||||
def test_includes_shebang_and_set_e(self) -> None:
|
||||
manifest = {"scripts": {"startup": ["echo hello"]}}
|
||||
ep = compile_entrypoint(manifest)
|
||||
assert "#!/bin/bash" in ep
|
||||
assert "set -e" in ep
|
||||
|
||||
def test_includes_startup_scripts(self) -> None:
|
||||
manifest = {"scripts": {"startup": ["echo hello", "echo world"]}}
|
||||
ep = compile_entrypoint(manifest)
|
||||
assert "echo hello" in ep
|
||||
assert "echo world" in ep
|
||||
|
||||
def test_ends_with_exec(self) -> None:
|
||||
manifest: dict = {"scripts": {"startup": []}}
|
||||
ep = compile_entrypoint(manifest)
|
||||
assert 'exec "$@"' in ep
|
||||
|
||||
|
||||
class TestCompileCompose:
|
||||
"""Tests for compile_compose."""
|
||||
|
||||
def test_includes_image_and_container_name(self) -> None:
|
||||
manifest = {"name": "test", "interface_type": "terminal"}
|
||||
vars_dict = {"IMAGE_TAG": "test:v1", "INSTANCE_NAME": "test-1"}
|
||||
compose = compile_compose(manifest, vars_dict)
|
||||
assert "image: test:v1" in compose
|
||||
assert "container_name: test-1" in compose
|
||||
|
||||
def test_terminal_fields(self) -> None:
|
||||
manifest = {
|
||||
"name": "test",
|
||||
"interface_type": "terminal",
|
||||
"runtime": {"stdin_open": True, "tty": True, "working_dir": "/workspace"},
|
||||
}
|
||||
compose = compile_compose(manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n"})
|
||||
assert "stdin_open: true" in compose
|
||||
assert "tty: true" in compose
|
||||
assert "working_dir: /workspace" in compose
|
||||
|
||||
def test_web_ports(self) -> None:
|
||||
manifest = {
|
||||
"name": "test",
|
||||
"interface_type": "web",
|
||||
"default_port": 8080,
|
||||
}
|
||||
compose = compile_compose(
|
||||
manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n", "TOOL_PORT": "3000"}
|
||||
)
|
||||
assert "3000:8080" in compose
|
||||
|
||||
def test_user_override(self) -> None:
|
||||
manifest = {
|
||||
"name": "test",
|
||||
"interface_type": "terminal",
|
||||
"user": {"uid": 1001, "gid": 1001},
|
||||
}
|
||||
compose = compile_compose(manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n"})
|
||||
assert "user: 1001:1001" in compose
|
||||
|
||||
def test_mounts_resolved(self) -> None:
|
||||
manifest = {
|
||||
"name": "test",
|
||||
"interface_type": "terminal",
|
||||
"mounts": [
|
||||
{"name": "ws", "target": "/workspace", "source_type": "repo"},
|
||||
{
|
||||
"name": "ssh",
|
||||
"target": "/home/user/.ssh",
|
||||
"source_type": "ssh_key",
|
||||
"readonly": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
compose = compile_compose(
|
||||
manifest,
|
||||
{
|
||||
"IMAGE_TAG": "t",
|
||||
"INSTANCE_NAME": "n",
|
||||
"REPO_PATH": "/repos/myrepo",
|
||||
"SSH_PATH": "/keys/ssh",
|
||||
},
|
||||
)
|
||||
assert "/repos/myrepo:/workspace" in compose
|
||||
assert "/keys/ssh:/home/user/.ssh:ro" in compose
|
||||
|
||||
def test_extra_volumes_appended(self) -> None:
|
||||
manifest = {"name": "test", "interface_type": "terminal"}
|
||||
compose = compile_compose(
|
||||
manifest,
|
||||
{
|
||||
"IMAGE_TAG": "t",
|
||||
"INSTANCE_NAME": "n",
|
||||
"EXTRA_VOLUMES": [{"source": "/host/x", "target": "/container/x"}],
|
||||
},
|
||||
)
|
||||
assert "/host/x:/container/x" in compose
|
||||
|
||||
|
||||
class TestComputeImageTag:
|
||||
"""Tests for compute_image_tag."""
|
||||
|
||||
def test_is_deterministic(self) -> None:
|
||||
manifest = {"name": "test", "packages": {"apt": ["curl"]}}
|
||||
tag1 = compute_image_tag("My Tool", manifest)
|
||||
tag2 = compute_image_tag("My Tool", manifest)
|
||||
assert tag1 == tag2
|
||||
|
||||
def test_changes_with_content(self) -> None:
|
||||
manifest1 = {"name": "test", "packages": {"apt": ["curl"]}}
|
||||
manifest2 = {"name": "test", "packages": {"apt": ["wget"]}}
|
||||
tag1 = compute_image_tag("test", manifest1)
|
||||
tag2 = compute_image_tag("test", manifest2)
|
||||
assert tag1 != tag2
|
||||
|
||||
def test_lowercases_name(self) -> None:
|
||||
manifest = {"name": "test"}
|
||||
tag = compute_image_tag("My Tool", manifest)
|
||||
assert "my-tool" in tag
|
||||
|
||||
def test_valid_docker_reference(self) -> None:
|
||||
manifest = {"name": "test"}
|
||||
tag = compute_image_tag("test", manifest)
|
||||
assert tag.startswith("headquarter/test-")
|
||||
assert tag.endswith(":latest")
|
||||
|
||||
|
||||
class TestMergeWithConfig:
|
||||
"""Tests for merge_with_config."""
|
||||
|
||||
def test_applies_tool_config_env(self) -> None:
|
||||
manifest = {"name": "test"}
|
||||
configs = [
|
||||
{"config_type": "env", "key": "FOO", "value": "bar"},
|
||||
]
|
||||
result = merge_with_config(manifest, configs)
|
||||
assert result["_extra_env"]["FOO"] == "bar"
|
||||
|
||||
def test_applies_port_override(self) -> None:
|
||||
manifest = {"name": "test", "default_port": 8080}
|
||||
configs = [{"port_override": 3000}]
|
||||
result = merge_with_config(manifest, configs)
|
||||
assert result["default_port"] == 3000
|
||||
|
||||
def test_applies_start_command(self) -> None:
|
||||
manifest = {"name": "test", "runtime": {"command": ["/bin/bash"]}}
|
||||
configs = [{"start_command": "/bin/sh"}]
|
||||
result = merge_with_config(manifest, configs)
|
||||
assert result["runtime"]["command"] == ["/bin/sh"]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Unit tests for the permission fixer."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.permission_fixer import (
|
||||
PermissionFixError,
|
||||
apply_mount_permissions,
|
||||
check_root_user_available,
|
||||
_run_in_container,
|
||||
)
|
||||
|
||||
|
||||
class TestApplyMountPermissions:
|
||||
"""Tests for apply_mount_permissions."""
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
def test_applies_chown_when_owner_declared(self, mock_run) -> None:
|
||||
mounts = [
|
||||
{"name": "workspace", "target": "/workspace", "owner": "user"},
|
||||
]
|
||||
results = apply_mount_permissions("abc123", mounts)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["mount_name"] == "workspace"
|
||||
assert results[0]["success"] is True
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args[0]
|
||||
assert args[0] == "abc123"
|
||||
assert args[1] == ["chown", "-R", "user:user", "/workspace"]
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
def test_applies_chmod_when_mode_declared(self, mock_run) -> None:
|
||||
mounts = [
|
||||
{"name": "ssh", "target": "/home/user/.ssh", "mode": "0700"},
|
||||
]
|
||||
results = apply_mount_permissions("abc123", mounts)
|
||||
|
||||
assert results[0]["success"] is True
|
||||
# Only chmod called (no owner, so no chown)
|
||||
assert mock_run.call_count == 1
|
||||
chmod_call = mock_run.call_args_list[0]
|
||||
assert chmod_call[0][1] == ["chmod", "0700", "/home/user/.ssh"]
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
def test_applies_file_mode_when_declared(self, mock_run) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"name": "ssh",
|
||||
"target": "/home/user/.ssh",
|
||||
"file_mode": "0600",
|
||||
},
|
||||
]
|
||||
results = apply_mount_permissions("abc123", mounts)
|
||||
|
||||
assert results[0]["success"] is True
|
||||
# Only file_mode called (no owner, no mode)
|
||||
assert mock_run.call_count == 1
|
||||
file_mode_call = mock_run.call_args_list[0]
|
||||
assert file_mode_call[0][1][0] == "sh"
|
||||
assert (
|
||||
"find /home/user/.ssh -type f -exec chmod 0600" in file_mode_call[0][1][2]
|
||||
)
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
def test_skips_mount_with_no_policy(self, mock_run) -> None:
|
||||
mounts = [
|
||||
{"name": "workspace", "target": "/workspace", "writable": True},
|
||||
]
|
||||
results = apply_mount_permissions("abc123", mounts)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["success"] is True
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
def test_reports_failure_on_command_error(self, mock_run) -> None:
|
||||
mock_run.side_effect = PermissionFixError("chown failed")
|
||||
|
||||
mounts = [
|
||||
{"name": "workspace", "target": "/workspace", "owner": "user"},
|
||||
]
|
||||
results = apply_mount_permissions("abc123", mounts)
|
||||
|
||||
assert results[0]["success"] is False
|
||||
assert "chown failed" in results[0]["error"]
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
def test_stops_on_first_failure(self, mock_run) -> None:
|
||||
"""If chown fails, chmod and file_mode should not run."""
|
||||
mock_run.side_effect = PermissionFixError("chown failed")
|
||||
|
||||
mounts = [
|
||||
{
|
||||
"name": "workspace",
|
||||
"target": "/workspace",
|
||||
"owner": "user",
|
||||
"mode": "0755",
|
||||
"file_mode": "0644",
|
||||
},
|
||||
]
|
||||
results = apply_mount_permissions("abc123", mounts)
|
||||
|
||||
assert results[0]["success"] is False
|
||||
assert mock_run.call_count == 1 # Only chown attempted
|
||||
|
||||
|
||||
class TestRunInContainer:
|
||||
"""Tests for _run_in_container."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_success(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
_run_in_container("abc123", ["echo", "hello"], 10)
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd == ["docker", "exec", "--user", "root", "abc123", "echo", "hello"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_failure_raises(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="permission denied")
|
||||
with pytest.raises(PermissionFixError, match="permission denied"):
|
||||
_run_in_container("abc123", ["chown", "x"], 10)
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_timeout_raises(self, mock_run) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker"], timeout=10)
|
||||
with pytest.raises(PermissionFixError, match="timed out"):
|
||||
_run_in_container("abc123", ["chown", "x"], 10)
|
||||
|
||||
|
||||
class TestCheckRootUserAvailable:
|
||||
"""Tests for check_root_user_available."""
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
def test_returns_true_when_root_exists(self, mock_run) -> None:
|
||||
assert check_root_user_available("abc123") is True
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
def test_returns_false_when_root_missing(self, mock_run) -> None:
|
||||
mock_run.side_effect = PermissionFixError("no such user")
|
||||
assert check_root_user_available("abc123") is False
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Unit tests for readiness probe service."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.readiness_probe import execute_probe
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.tool_instances import CreateInstanceRequest
|
||||
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
"""Unit tests for legacy tool instance fallback paths.
|
||||
|
||||
Verifies that tool types with definition_type "dockerfile", "compose",
|
||||
and "legacy" continue to use the original startup flow after the
|
||||
manifest-based flow was introduced.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.tool_instances import create_instance, start_instance
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_user_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_project_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_repo_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_tool_type_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_instance_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session(fake_user_id, fake_project_id, fake_repo_id, fake_tool_type_id):
|
||||
"""Return an async SQLAlchemy session with basic mocks."""
|
||||
session = AsyncMock()
|
||||
|
||||
user = User(id=fake_user_id, email="test@example.com")
|
||||
project = MagicMock()
|
||||
project.id = fake_project_id
|
||||
repo = GitRepository(
|
||||
id=fake_repo_id,
|
||||
project_id=fake_project_id,
|
||||
name="test-repo",
|
||||
path="/data/repos/test-repo",
|
||||
remote_url=None,
|
||||
ssh_key_id=None,
|
||||
)
|
||||
|
||||
async def _get(model, pk):
|
||||
if model is User and pk == fake_user_id:
|
||||
return user
|
||||
if model is GitRepository and pk == fake_repo_id:
|
||||
return repo
|
||||
return None
|
||||
|
||||
def _add(instance):
|
||||
if getattr(instance, "created_at", None) is None:
|
||||
instance.created_at = datetime.now()
|
||||
if getattr(instance, "updated_at", None) is None:
|
||||
instance.updated_at = datetime.now()
|
||||
|
||||
session.get.side_effect = _get
|
||||
session.add = MagicMock(side_effect=_add)
|
||||
session.execute.return_value = MagicMock(scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))))
|
||||
return session
|
||||
|
||||
|
||||
class TestCreateInstanceDockerfileLegacy:
|
||||
"""Legacy dockerfile definition type in create_instance."""
|
||||
|
||||
@patch("src.api.tool_instances.ensure_instance_directory")
|
||||
@patch("src.api.tool_instances.find_free_port")
|
||||
@patch("src.api.tool_instances.build_image")
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
async def test_builds_from_dockerfile_template(
|
||||
self,
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_write_compose,
|
||||
mock_build_image,
|
||||
mock_find_port,
|
||||
mock_ensure_dir,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
fake_repo_id,
|
||||
fake_tool_type_id,
|
||||
) -> None:
|
||||
"""When definition_type is 'dockerfile', build_image is called with the template."""
|
||||
mock_get_user.return_value = AsyncMock()
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_find_port.return_value = 12345
|
||||
mock_ensure_dir.return_value = "/data/instances/test-instance"
|
||||
mock_build_image.return_value = (0, "built", "")
|
||||
|
||||
tool_type = ToolType(
|
||||
id=fake_tool_type_id,
|
||||
name="legacy-df-tool",
|
||||
display_name="Legacy DF Tool",
|
||||
default_port=8080,
|
||||
definition_type="dockerfile",
|
||||
dockerfile_template="FROM python:3.11\nRUN echo hi",
|
||||
compose_template=None,
|
||||
)
|
||||
repo = GitRepository(
|
||||
id=fake_repo_id,
|
||||
project_id=fake_project_id,
|
||||
name="test-repo",
|
||||
path="/data/repos/test-repo",
|
||||
remote_url=None,
|
||||
ssh_key_id=None,
|
||||
)
|
||||
|
||||
async def _get(model, pk):
|
||||
if model is ToolType and pk == fake_tool_type_id:
|
||||
return tool_type
|
||||
if model is GitRepository and pk == fake_repo_id:
|
||||
return repo
|
||||
if model is User and pk == fake_user_id:
|
||||
return User(id=fake_user_id, email="test@example.com")
|
||||
return None
|
||||
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
data = MagicMock()
|
||||
data.tool_type_id = str(fake_tool_type_id)
|
||||
data.display_name = None
|
||||
data.clone_mode = "mount"
|
||||
data.branch = None
|
||||
data.new_branch = None
|
||||
data.config_profile_id = None
|
||||
|
||||
result = await create_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
data=data,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert result["status"] == "pending"
|
||||
mock_build_image.assert_called_once()
|
||||
call_kwargs = mock_build_image.call_args.kwargs
|
||||
assert call_kwargs["dockerfile"] == "FROM python:3.11\nRUN echo hi"
|
||||
mock_write_compose.assert_called_once()
|
||||
|
||||
@patch("src.api.tool_instances.ensure_instance_directory")
|
||||
@patch("src.api.tool_instances.find_free_port")
|
||||
@patch("src.api.tool_instances.build_image")
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
async def test_dockerfile_build_failure_raises_500(
|
||||
self,
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_write_compose,
|
||||
mock_build_image,
|
||||
mock_find_port,
|
||||
mock_ensure_dir,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
fake_repo_id,
|
||||
fake_tool_type_id,
|
||||
) -> None:
|
||||
"""Failed dockerfile build should raise HTTP 500."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_get_user.return_value = AsyncMock()
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_find_port.return_value = 12345
|
||||
mock_ensure_dir.return_value = "/data/instances/test-instance"
|
||||
mock_build_image.return_value = (1, "", "build failed")
|
||||
|
||||
tool_type = ToolType(
|
||||
id=fake_tool_type_id,
|
||||
name="legacy-df-tool",
|
||||
display_name="Legacy DF Tool",
|
||||
default_port=8080,
|
||||
definition_type="dockerfile",
|
||||
dockerfile_template="FROM invalid",
|
||||
compose_template=None,
|
||||
)
|
||||
repo = GitRepository(
|
||||
id=fake_repo_id,
|
||||
project_id=fake_project_id,
|
||||
name="test-repo",
|
||||
path="/data/repos/test-repo",
|
||||
remote_url=None,
|
||||
ssh_key_id=None,
|
||||
)
|
||||
|
||||
async def _get(model, pk):
|
||||
if model is ToolType and pk == fake_tool_type_id:
|
||||
return tool_type
|
||||
if model is GitRepository and pk == fake_repo_id:
|
||||
return repo
|
||||
if model is User and pk == fake_user_id:
|
||||
return User(id=fake_user_id, email="test@example.com")
|
||||
return None
|
||||
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
data = MagicMock()
|
||||
data.tool_type_id = str(fake_tool_type_id)
|
||||
data.display_name = None
|
||||
data.clone_mode = "mount"
|
||||
data.branch = None
|
||||
data.new_branch = None
|
||||
data.config_profile_id = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
data=data,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "Failed to build Docker image" in exc_info.value.detail
|
||||
|
||||
|
||||
class TestCreateInstanceComposeLegacy:
|
||||
"""Legacy compose definition type in create_instance."""
|
||||
|
||||
@patch("src.api.tool_instances.ensure_instance_directory")
|
||||
@patch("src.api.tool_instances.find_free_port")
|
||||
@patch("src.api.tool_instances.render_compose_template")
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
async def test_renders_compose_template(
|
||||
self,
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_write_compose,
|
||||
mock_render_compose,
|
||||
mock_find_port,
|
||||
mock_ensure_dir,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
fake_repo_id,
|
||||
fake_tool_type_id,
|
||||
) -> None:
|
||||
"""When definition_type is 'compose', render_compose_template is called."""
|
||||
mock_get_user.return_value = AsyncMock()
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_find_port.return_value = 12345
|
||||
mock_ensure_dir.return_value = "/data/instances/test-instance"
|
||||
mock_render_compose.return_value = "services:\n app:\n image: nginx"
|
||||
|
||||
tool_type = ToolType(
|
||||
id=fake_tool_type_id,
|
||||
name="legacy-compose-tool",
|
||||
display_name="Legacy Compose Tool",
|
||||
default_port=80,
|
||||
definition_type="compose",
|
||||
dockerfile_template=None,
|
||||
compose_template="services:\n app:\n image: nginx\n ports:\n - ${TOOL_PORT}:80",
|
||||
)
|
||||
repo = GitRepository(
|
||||
id=fake_repo_id,
|
||||
project_id=fake_project_id,
|
||||
name="test-repo",
|
||||
path="/data/repos/test-repo",
|
||||
remote_url=None,
|
||||
ssh_key_id=None,
|
||||
)
|
||||
|
||||
async def _get(model, pk):
|
||||
if model is ToolType and pk == fake_tool_type_id:
|
||||
return tool_type
|
||||
if model is GitRepository and pk == fake_repo_id:
|
||||
return repo
|
||||
if model is User and pk == fake_user_id:
|
||||
return User(id=fake_user_id, email="test@example.com")
|
||||
return None
|
||||
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
data = MagicMock()
|
||||
data.tool_type_id = str(fake_tool_type_id)
|
||||
data.display_name = None
|
||||
data.clone_mode = "mount"
|
||||
data.branch = None
|
||||
data.new_branch = None
|
||||
data.config_profile_id = None
|
||||
|
||||
result = await create_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
data=data,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert result["status"] == "pending"
|
||||
mock_render_compose.assert_called_once()
|
||||
args = mock_render_compose.call_args[0]
|
||||
assert "services:" in args[0]
|
||||
mock_write_compose.assert_called_once()
|
||||
|
||||
|
||||
class TestCreateInstanceManifestNotCalledForLegacy:
|
||||
"""Ensure manifest compiler is never invoked for legacy types."""
|
||||
|
||||
@patch("src.api.tool_instances.ensure_instance_directory")
|
||||
@patch("src.api.tool_instances.find_free_port")
|
||||
@patch("src.api.tool_instances.build_image")
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
async def test_dockerfile_does_not_call_manifest_compiler(
|
||||
self,
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_write_compose,
|
||||
mock_build_image,
|
||||
mock_find_port,
|
||||
mock_ensure_dir,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
fake_repo_id,
|
||||
fake_tool_type_id,
|
||||
) -> None:
|
||||
"""Legacy dockerfile type must not trigger manifest compilation."""
|
||||
mock_get_user.return_value = AsyncMock()
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_find_port.return_value = 12345
|
||||
mock_ensure_dir.return_value = "/data/instances/test-instance"
|
||||
mock_build_image.return_value = (0, "built", "")
|
||||
|
||||
tool_type = ToolType(
|
||||
id=fake_tool_type_id,
|
||||
name="legacy-df",
|
||||
display_name="Legacy",
|
||||
default_port=8080,
|
||||
definition_type="dockerfile",
|
||||
dockerfile_template="FROM alpine",
|
||||
compose_template=None,
|
||||
)
|
||||
repo = GitRepository(
|
||||
id=fake_repo_id,
|
||||
project_id=fake_project_id,
|
||||
name="test-repo",
|
||||
path="/data/repos/test-repo",
|
||||
remote_url=None,
|
||||
ssh_key_id=None,
|
||||
)
|
||||
|
||||
async def _get(model, pk):
|
||||
if model is ToolType and pk == fake_tool_type_id:
|
||||
return tool_type
|
||||
if model is GitRepository and pk == fake_repo_id:
|
||||
return repo
|
||||
if model is User and pk == fake_user_id:
|
||||
return User(id=fake_user_id, email="test@example.com")
|
||||
return None
|
||||
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
data = MagicMock()
|
||||
data.tool_type_id = str(fake_tool_type_id)
|
||||
data.display_name = None
|
||||
data.clone_mode = "mount"
|
||||
data.branch = None
|
||||
data.new_branch = None
|
||||
data.config_profile_id = None
|
||||
|
||||
await create_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
data=data,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
mock_prepare_manifest.assert_not_called()
|
||||
|
||||
|
||||
class TestStartInstanceLegacyFallback:
|
||||
"""Legacy paths in start_instance must NOT call manifest compiler."""
|
||||
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
async def test_legacy_type_skips_manifest_flow(
|
||||
self,
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
fake_repo_id,
|
||||
fake_instance_id,
|
||||
fake_tool_type_id,
|
||||
) -> None:
|
||||
"""When definition_type is 'legacy', start_instance uses old flow."""
|
||||
mock_get_user.return_value = AsyncMock()
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
||||
|
||||
instance = ToolInstance(
|
||||
id=fake_instance_id,
|
||||
name="legacy-instance",
|
||||
repository_id=fake_repo_id,
|
||||
tool_type_id=fake_tool_type_id,
|
||||
compose_path="/data/instances/legacy-instance/docker-compose.yml",
|
||||
status="stopped",
|
||||
clone_mode="mount",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
tool_type = ToolType(
|
||||
id=fake_tool_type_id,
|
||||
name="legacy-tool",
|
||||
display_name="Legacy Tool",
|
||||
default_port=8080,
|
||||
definition_type="legacy",
|
||||
manifest_id=None,
|
||||
dockerfile_template=None,
|
||||
compose_template="services:\n app:\n image: nginx",
|
||||
)
|
||||
repo = GitRepository(
|
||||
id=fake_repo_id,
|
||||
project_id=fake_project_id,
|
||||
name="test-repo",
|
||||
path="/data/repos/test-repo",
|
||||
remote_url=None,
|
||||
ssh_key_id=None,
|
||||
)
|
||||
|
||||
async def _get(model, pk):
|
||||
if model is ToolInstance and pk == fake_instance_id:
|
||||
return instance
|
||||
if model is ToolType and pk == fake_tool_type_id:
|
||||
return tool_type
|
||||
if model is GitRepository and pk == fake_repo_id:
|
||||
return repo
|
||||
if model is User and pk == fake_user_id:
|
||||
return User(id=fake_user_id, email="test@example.com")
|
||||
return None
|
||||
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
# Ensure compose file exists so the check passes
|
||||
with patch("os.path.exists", return_value=True):
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert result["status"] == "running"
|
||||
mock_prepare_manifest.assert_not_called()
|
||||
mock_execute_compose.assert_called_once()
|
||||
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
async def test_compose_type_skips_manifest_flow(
|
||||
self,
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
fake_repo_id,
|
||||
fake_instance_id,
|
||||
fake_tool_type_id,
|
||||
) -> None:
|
||||
"""When definition_type is 'compose', start_instance uses old flow."""
|
||||
mock_get_user.return_value = AsyncMock()
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
||||
|
||||
instance = ToolInstance(
|
||||
id=fake_instance_id,
|
||||
name="compose-instance",
|
||||
repository_id=fake_repo_id,
|
||||
tool_type_id=fake_tool_type_id,
|
||||
compose_path="/data/instances/compose-instance/docker-compose.yml",
|
||||
status="stopped",
|
||||
clone_mode="mount",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
tool_type = ToolType(
|
||||
id=fake_tool_type_id,
|
||||
name="compose-tool",
|
||||
display_name="Compose Tool",
|
||||
default_port=8080,
|
||||
definition_type="compose",
|
||||
manifest_id=None,
|
||||
dockerfile_template=None,
|
||||
compose_template="services:\n app:\n image: nginx",
|
||||
)
|
||||
repo = GitRepository(
|
||||
id=fake_repo_id,
|
||||
project_id=fake_project_id,
|
||||
name="test-repo",
|
||||
path="/data/repos/test-repo",
|
||||
remote_url=None,
|
||||
ssh_key_id=None,
|
||||
)
|
||||
|
||||
async def _get(model, pk):
|
||||
if model is ToolInstance and pk == fake_instance_id:
|
||||
return instance
|
||||
if model is ToolType and pk == fake_tool_type_id:
|
||||
return tool_type
|
||||
if model is GitRepository and pk == fake_repo_id:
|
||||
return repo
|
||||
if model is User and pk == fake_user_id:
|
||||
return User(id=fake_user_id, email="test@example.com")
|
||||
return None
|
||||
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert result["status"] == "running"
|
||||
mock_prepare_manifest.assert_not_called()
|
||||
mock_execute_compose.assert_called_once()
|
||||
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
async def test_dockerfile_type_skips_manifest_flow(
|
||||
self,
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
fake_repo_id,
|
||||
fake_instance_id,
|
||||
fake_tool_type_id,
|
||||
) -> None:
|
||||
"""When definition_type is 'dockerfile', start_instance uses old flow."""
|
||||
mock_get_user.return_value = AsyncMock()
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
||||
|
||||
instance = ToolInstance(
|
||||
id=fake_instance_id,
|
||||
name="df-instance",
|
||||
repository_id=fake_repo_id,
|
||||
tool_type_id=fake_tool_type_id,
|
||||
compose_path="/data/instances/df-instance/docker-compose.yml",
|
||||
status="stopped",
|
||||
clone_mode="mount",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
tool_type = ToolType(
|
||||
id=fake_tool_type_id,
|
||||
name="df-tool",
|
||||
display_name="Dockerfile Tool",
|
||||
default_port=8080,
|
||||
definition_type="dockerfile",
|
||||
manifest_id=None,
|
||||
dockerfile_template="FROM alpine",
|
||||
compose_template=None,
|
||||
)
|
||||
repo = GitRepository(
|
||||
id=fake_repo_id,
|
||||
project_id=fake_project_id,
|
||||
name="test-repo",
|
||||
path="/data/repos/test-repo",
|
||||
remote_url=None,
|
||||
ssh_key_id=None,
|
||||
)
|
||||
|
||||
async def _get(model, pk):
|
||||
if model is ToolInstance and pk == fake_instance_id:
|
||||
return instance
|
||||
if model is ToolType and pk == fake_tool_type_id:
|
||||
return tool_type
|
||||
if model is GitRepository and pk == fake_repo_id:
|
||||
return repo
|
||||
if model is User and pk == fake_user_id:
|
||||
return User(id=fake_user_id, email="test@example.com")
|
||||
return None
|
||||
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert result["status"] == "running"
|
||||
mock_prepare_manifest.assert_not_called()
|
||||
mock_execute_compose.assert_called_once()
|
||||
|
||||
|
||||
class TestStartInstanceManifestBranch:
|
||||
"""Manifest branch is taken ONLY when definition_type == 'manifest'."""
|
||||
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
async def test_manifest_type_calls_compiler(
|
||||
self,
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_write_compose,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
fake_repo_id,
|
||||
fake_instance_id,
|
||||
fake_tool_type_id,
|
||||
) -> None:
|
||||
"""When definition_type is 'manifest' and manifest_id is set, compiler runs."""
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
|
||||
manifest_id = uuid.uuid4()
|
||||
|
||||
mock_get_user.return_value = AsyncMock()
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
||||
mock_prepare_manifest.return_value = (
|
||||
"headquarter/test:latest",
|
||||
"services:\n app:\n image: test",
|
||||
{"name": "test-manifest"},
|
||||
)
|
||||
|
||||
instance = ToolInstance(
|
||||
id=fake_instance_id,
|
||||
name="manifest-instance",
|
||||
repository_id=fake_repo_id,
|
||||
tool_type_id=fake_tool_type_id,
|
||||
compose_path="/data/instances/manifest-instance/docker-compose.yml",
|
||||
status="stopped",
|
||||
clone_mode="mount",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
tool_type = ToolType(
|
||||
id=fake_tool_type_id,
|
||||
name="manifest-tool",
|
||||
display_name="Manifest Tool",
|
||||
default_port=8080,
|
||||
definition_type="manifest",
|
||||
manifest_id=manifest_id,
|
||||
dockerfile_template=None,
|
||||
compose_template=None,
|
||||
)
|
||||
repo = GitRepository(
|
||||
id=fake_repo_id,
|
||||
project_id=fake_project_id,
|
||||
name="test-repo",
|
||||
path="/data/repos/test-repo",
|
||||
remote_url=None,
|
||||
ssh_key_id=None,
|
||||
)
|
||||
manifest_def = ToolDefinitionManifest(
|
||||
id=manifest_id,
|
||||
name="test-manifest",
|
||||
display_name="Test Manifest",
|
||||
interface_type="web",
|
||||
manifest={"base_image": "alpine"},
|
||||
)
|
||||
|
||||
async def _get(model, pk):
|
||||
if model is ToolInstance and pk == fake_instance_id:
|
||||
return instance
|
||||
if model is ToolType and pk == fake_tool_type_id:
|
||||
return tool_type
|
||||
if model is GitRepository and pk == fake_repo_id:
|
||||
return repo
|
||||
if model is User and pk == fake_user_id:
|
||||
return User(id=fake_user_id, email="test@example.com")
|
||||
if model is ToolDefinitionManifest and pk == manifest_id:
|
||||
return manifest_def
|
||||
return None
|
||||
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert result["status"] == "running"
|
||||
mock_prepare_manifest.assert_called_once()
|
||||
mock_execute_compose.assert_called_once()
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import axios from "axios";
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -19,7 +19,7 @@ const MAX_RETRIES = 2;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
// Track retry count per request
|
||||
const retryCount = new WeakMap<any, number>();
|
||||
const retryCount = new WeakMap<AxiosRequestConfig, number>();
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ConfigProfile {
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ConfigProfileMount[];
|
||||
git_mounts: GitMount[];
|
||||
files: Record<string, string>;
|
||||
is_default: boolean;
|
||||
includes: ConfigProfileInclude[];
|
||||
@@ -23,6 +24,13 @@ export interface ConfigProfileMount {
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GitMount {
|
||||
remote_url: string;
|
||||
source_path: string;
|
||||
target_path: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface ConfigProfileInclude {
|
||||
id: string;
|
||||
included_profile_id: string;
|
||||
@@ -35,6 +43,7 @@ export interface ResolvedProfile {
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ResolvedMount[];
|
||||
git_mounts: GitMount[];
|
||||
files: Record<string, string>;
|
||||
overrides: {
|
||||
env_vars: Record<string, string>;
|
||||
@@ -60,6 +69,7 @@ export interface CreateConfigProfileRequest {
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
}
|
||||
@@ -72,6 +82,7 @@ export interface UpdateConfigProfileRequest {
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
@@ -31,12 +31,24 @@ export interface URLParseResult {
|
||||
}
|
||||
|
||||
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||
const response = await apiClient.post("/projects/repositories/parse-url", { url });
|
||||
const response = await apiClient.post("/repositories/parse-url", { url });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -48,6 +60,13 @@ export async function createRepository(
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ToolInstance {
|
||||
@@ -80,9 +81,10 @@ export async function startInstance(
|
||||
{ config_profile_id: configProfileId }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||
if (retries > 0 && !error.response) {
|
||||
const axiosError = error as AxiosError;
|
||||
if (retries > 0 && !axiosError.response) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||
}
|
||||
@@ -114,9 +116,10 @@ export async function restartInstance(
|
||||
{ config_profile_id: configProfileId }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||
if (retries > 0 && !error.response) {
|
||||
const axiosError = error as AxiosError;
|
||||
if (retries > 0 && !axiosError.response) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ToolDefinitionManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
interface_type: string;
|
||||
base_image: string | null;
|
||||
base_definition_id: string | null;
|
||||
base_version: string;
|
||||
manifest: Record<string, unknown>;
|
||||
dockerfile_cache: string | null;
|
||||
compose_cache: string | null;
|
||||
version: string;
|
||||
is_base: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateToolDefinitionRequest {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
interface_type?: string;
|
||||
base_image?: string;
|
||||
base_definition_id?: string;
|
||||
base_version?: string;
|
||||
manifest: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateToolDefinitionRequest {
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
manifest?: Record<string, unknown>;
|
||||
base_version?: string;
|
||||
}
|
||||
|
||||
export interface CompileResult {
|
||||
id: string;
|
||||
name: string;
|
||||
dockerfile: string;
|
||||
entrypoint: string;
|
||||
compose: string;
|
||||
image_tag: string;
|
||||
}
|
||||
|
||||
export const listToolDefinitions = async (
|
||||
includeBases = true,
|
||||
): Promise<ToolDefinitionManifest[]> => {
|
||||
const response = await apiClient.get<{
|
||||
definitions: ToolDefinitionManifest[];
|
||||
}>("/tool-definitions", {
|
||||
params: { include_bases: includeBases },
|
||||
});
|
||||
return response.data.definitions;
|
||||
};
|
||||
|
||||
export const getToolDefinition = async (
|
||||
id: string,
|
||||
): Promise<ToolDefinitionManifest> => {
|
||||
const response = await apiClient.get<ToolDefinitionManifest>(
|
||||
`/tool-definitions/${id}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createToolDefinition = async (
|
||||
data: CreateToolDefinitionRequest,
|
||||
): Promise<ToolDefinitionManifest> => {
|
||||
const response = await apiClient.post<ToolDefinitionManifest>(
|
||||
"/tool-definitions",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateToolDefinition = async (
|
||||
id: string,
|
||||
data: UpdateToolDefinitionRequest,
|
||||
): Promise<ToolDefinitionManifest> => {
|
||||
const response = await apiClient.put<ToolDefinitionManifest>(
|
||||
`/tool-definitions/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteToolDefinition = async (
|
||||
id: string,
|
||||
): Promise<{ status: string; id: string }> => {
|
||||
const response = await apiClient.delete<{ status: string; id: string }>(
|
||||
`/tool-definitions/${id}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const compileToolDefinition = async (
|
||||
id: string,
|
||||
): Promise<CompileResult> => {
|
||||
const response = await apiClient.post<CompileResult>(
|
||||
`/tool-definitions/${id}/compile`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,90 +1,102 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ReadinessProbe {
|
||||
command: string;
|
||||
timeout: number;
|
||||
interval: number;
|
||||
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';
|
||||
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;
|
||||
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';
|
||||
compose_template?: string;
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
startup_command?: string;
|
||||
required_variables: string[];
|
||||
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';
|
||||
compose_template?: string;
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
startup_command?: string;
|
||||
required_variables?: 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 const listToolTypes = async (): Promise<ToolType[]> => {
|
||||
const response = await apiClient.get<ToolType[]>("/tool-types");
|
||||
return response.data;
|
||||
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;
|
||||
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 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 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}`);
|
||||
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;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const LoadingState = ({ message = "Loading..." }: LoadingStateProps) => (
|
||||
<p className="muted">{message}</p>
|
||||
);
|
||||
|
||||
interface ErrorStateProps {
|
||||
message?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export const ErrorState = ({ message = "Failed to load", onRetry }: ErrorStateProps) => (
|
||||
<div className="card stack">
|
||||
<p>{message}</p>
|
||||
{onRetry && (
|
||||
<button className="secondary-button" onClick={onRetry} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface EmptyStateProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const EmptyState = ({ message }: EmptyStateProps) => (
|
||||
<p className="muted">{message}</p>
|
||||
);
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import type { GitMount } from "../api/config_profiles";
|
||||
|
||||
interface GitMountEditorProps {
|
||||
mounts: GitMount[];
|
||||
onChange: (mounts: GitMount[]) => void;
|
||||
}
|
||||
|
||||
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [newMount, setNewMount] = useState<GitMount>({
|
||||
remote_url: "",
|
||||
source_path: ".",
|
||||
target_path: "",
|
||||
branch: "",
|
||||
});
|
||||
|
||||
const handleAdd = (mount: GitMount) => {
|
||||
onChange([...mounts, mount]);
|
||||
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||
};
|
||||
|
||||
const handleUpdate = (index: number, updated: GitMount) => {
|
||||
const updatedMounts = [...mounts];
|
||||
updatedMounts[index] = updated;
|
||||
onChange(updatedMounts);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(mounts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const validatePath = (path: string, isTarget: boolean): string | null => {
|
||||
if (!path) return isTarget ? "Target path is required" : null;
|
||||
if (path.includes("..")) return "Path cannot contain ..";
|
||||
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateUrl = (url: string): string | null => {
|
||||
if (!url) return "Git URL is required";
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
|
||||
return "Must be a valid git URL (https://, git@, or ssh://)";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="git-mount-editor">
|
||||
<h4 className="section-subtitle">Git Mounts</h4>
|
||||
|
||||
{mounts.length > 0 && (
|
||||
<div className="git-mount-list">
|
||||
{mounts.map((mount, index) => (
|
||||
<div key={index} className="git-mount-item">
|
||||
{editingIndex === index ? (
|
||||
<GitMountForm
|
||||
mount={mount}
|
||||
onSave={(updated) => handleUpdate(index, updated)}
|
||||
onCancel={() => setEditingIndex(null)}
|
||||
validatePath={validatePath}
|
||||
validateUrl={validateUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="git-mount-display">
|
||||
<div className="git-mount-info">
|
||||
<span className="git-mount-repo">{mount.remote_url}</span>
|
||||
<span className="git-mount-paths">
|
||||
{mount.source_path || "."} → {mount.target_path}
|
||||
</span>
|
||||
{mount.branch && (
|
||||
<span className="git-mount-branch">@{mount.branch}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="git-mount-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => setEditingIndex(index)}
|
||||
title="Edit"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={() => handleRemove(index)}
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="git-mount-add">
|
||||
<h5>Add Git Mount</h5>
|
||||
<GitMountForm
|
||||
mount={newMount}
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
|
||||
validatePath={validatePath}
|
||||
validateUrl={validateUrl}
|
||||
isNew
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GitMountFormProps {
|
||||
mount: GitMount;
|
||||
onSave: (mount: GitMount) => void;
|
||||
onCancel: () => void;
|
||||
validatePath: (path: string, isTarget: boolean) => string | null;
|
||||
validateUrl: (url: string) => string | null;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
|
||||
const [form, setForm] = useState<GitMount>({ ...mount });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleChange = (field: keyof GitMount, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
const urlError = validateUrl(form.remote_url);
|
||||
if (urlError) newErrors.remote_url = urlError;
|
||||
|
||||
const sourceError = validatePath(form.source_path || ".", false);
|
||||
if (sourceError) newErrors.source_path = sourceError;
|
||||
|
||||
const targetError = validatePath(form.target_path, true);
|
||||
if (targetError) newErrors.target_path = targetError;
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(form);
|
||||
if (isNew) {
|
||||
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="git-mount-form">
|
||||
<div className="form-row">
|
||||
<label>Git URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.remote_url}
|
||||
onChange={(e) => handleChange("remote_url", e.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={errors.remote_url ? "error" : ""}
|
||||
/>
|
||||
<span className="hint">Repository URL (HTTPS or SSH)</span>
|
||||
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Source Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.source_path || "."}
|
||||
onChange={(e) => handleChange("source_path", e.target.value)}
|
||||
placeholder="e.g., . or configs/*.json"
|
||||
className={errors.source_path ? "error" : ""}
|
||||
/>
|
||||
<span className="hint">Relative path in repo (supports glob patterns)</span>
|
||||
{errors.source_path && <span className="error-text">{errors.source_path}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Target Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.target_path}
|
||||
onChange={(e) => handleChange("target_path", e.target.value)}
|
||||
placeholder="e.g., /app/config"
|
||||
className={errors.target_path ? "error" : ""}
|
||||
/>
|
||||
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
|
||||
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Branch (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.branch || ""}
|
||||
onChange={(e) => handleChange("branch", e.target.value)}
|
||||
placeholder="e.g., main or v1.0"
|
||||
/>
|
||||
<span className="hint">Branch or tag to checkout</span>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="primary-button" onClick={handleSubmit}>
|
||||
{isNew ? "Add" : "Save"}
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -150,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
|
||||
const sizeValue = sizeMap[size];
|
||||
|
||||
if (!IconComponent) {
|
||||
console.warn(`Icon "${name}" not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
import {
|
||||
compileToolDefinition,
|
||||
type ToolDefinitionManifest,
|
||||
} from "../api/tool_definitions";
|
||||
|
||||
interface PackageEntry {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MountEntry {
|
||||
name: string;
|
||||
target: string;
|
||||
source_type: string;
|
||||
writable: boolean;
|
||||
owner: string;
|
||||
mode: string;
|
||||
file_mode: string;
|
||||
readonly: boolean;
|
||||
git_mount_ref: string;
|
||||
}
|
||||
|
||||
interface ManifestEditorProps {
|
||||
manifest: Record<string, unknown> | null;
|
||||
baseDefinitions: ToolDefinitionManifest[];
|
||||
onChange: (manifest: Record<string, unknown>) => void;
|
||||
definitionId?: string | null;
|
||||
}
|
||||
|
||||
export const ManifestEditor = ({
|
||||
manifest,
|
||||
baseDefinitions,
|
||||
onChange,
|
||||
definitionId,
|
||||
}: ManifestEditorProps) => {
|
||||
const [baseImage, setBaseImage] = useState("");
|
||||
const [baseDefinitionId, setBaseDefinitionId] = useState("");
|
||||
const [aptPackages, setAptPackages] = useState<PackageEntry[]>([]);
|
||||
const [npmPackages, setNpmPackages] = useState<PackageEntry[]>([]);
|
||||
const [pipPackages, setPipPackages] = useState<PackageEntry[]>([]);
|
||||
const [nodeVersion, setNodeVersion] = useState("");
|
||||
const [userName, setUserName] = useState("user");
|
||||
const [userUid, setUserUid] = useState("1000");
|
||||
const [userGid, setUserGid] = useState("1000");
|
||||
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
|
||||
const [buildScripts, setBuildScripts] = useState<string[]>([""]);
|
||||
const [startupScripts, setStartupScripts] = useState<string[]>([""]);
|
||||
const [mounts, setMounts] = useState<MountEntry[]>([]);
|
||||
const [command, setCommand] = useState<string[]>([""]);
|
||||
const [workingDir, setWorkingDir] = useState("/workspace");
|
||||
const [stdinOpen, setStdinOpen] = useState(true);
|
||||
const [tty, setTty] = useState(true);
|
||||
|
||||
const [preview, setPreview] = useState<{
|
||||
dockerfile: string;
|
||||
compose: string;
|
||||
entrypoint: string;
|
||||
} | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
// Load manifest into form
|
||||
useEffect(() => {
|
||||
if (!manifest) return;
|
||||
|
||||
const pkgs = (manifest.packages as Record<string, unknown>) || {};
|
||||
setBaseImage((manifest.base_image as string) || "");
|
||||
setBaseDefinitionId((manifest.base_definition_id as string) || "");
|
||||
setAptPackages(((pkgs.apt as string[]) || []).map((p) => ({ name: p })));
|
||||
setNpmPackages(
|
||||
((pkgs.npm_global as string[]) || []).map((p) => ({ name: p })),
|
||||
);
|
||||
setPipPackages(((pkgs.pip as string[]) || []).map((p) => ({ name: p })));
|
||||
setNodeVersion((pkgs.node as Record<string, string>)?.version || "");
|
||||
|
||||
const user = (manifest.user as Record<string, unknown>) || {};
|
||||
setUserName((user.name as string) || "user");
|
||||
setUserUid(String(user.uid || "1000"));
|
||||
setUserGid(String(user.gid || "1000"));
|
||||
|
||||
const env = (manifest.env as Record<string, string>) || {};
|
||||
setEnvVars(Object.entries(env).map(([key, value]) => ({ key, value })));
|
||||
|
||||
const scripts = (manifest.scripts as Record<string, string[]>) || {};
|
||||
setBuildScripts((scripts.build || []).length > 0 ? scripts.build : [""]);
|
||||
setStartupScripts(
|
||||
(scripts.startup || []).length > 0 ? scripts.startup : [""],
|
||||
);
|
||||
|
||||
const mts = (manifest.mounts as MountEntry[]) || [];
|
||||
setMounts(mts);
|
||||
|
||||
const runtime = (manifest.runtime as Record<string, unknown>) || {};
|
||||
setCommand((runtime.command as string[]) || [""]);
|
||||
setWorkingDir((runtime.working_dir as string) || "/workspace");
|
||||
setStdinOpen((runtime.stdin_open as boolean) ?? true);
|
||||
setTty((runtime.tty as boolean) ?? true);
|
||||
}, [manifest]);
|
||||
|
||||
// Build manifest from form state
|
||||
const buildManifest = useCallback((): Record<string, unknown> => {
|
||||
const packages: Record<string, unknown> = {};
|
||||
const apt = aptPackages.map((p) => p.name).filter(Boolean);
|
||||
if (apt.length) packages.apt = apt;
|
||||
const npm = npmPackages.map((p) => p.name).filter(Boolean);
|
||||
if (npm.length) packages.npm_global = npm;
|
||||
const pip = pipPackages.map((p) => p.name).filter(Boolean);
|
||||
if (pip.length) packages.pip = pip;
|
||||
if (nodeVersion) packages.node = { version: nodeVersion };
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
envVars.forEach(({ key, value }) => {
|
||||
if (key) env[key] = value;
|
||||
});
|
||||
|
||||
const scripts: Record<string, string[]> = {};
|
||||
const build = buildScripts.filter(Boolean);
|
||||
if (build.length) scripts.build = build;
|
||||
const startup = startupScripts.filter(Boolean);
|
||||
if (startup.length) scripts.startup = startup;
|
||||
|
||||
const mts = mounts.filter((m) => m.name && m.target);
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
packages,
|
||||
user: {
|
||||
name: userName,
|
||||
uid: parseInt(userUid) || 1000,
|
||||
gid: parseInt(userGid) || 1000,
|
||||
create_home: true,
|
||||
shell: "/bin/bash",
|
||||
},
|
||||
env,
|
||||
scripts,
|
||||
mounts: mts,
|
||||
runtime: {
|
||||
command:
|
||||
command.filter(Boolean).length > 0
|
||||
? command.filter(Boolean)
|
||||
: ["/bin/bash"],
|
||||
stdin_open: stdinOpen,
|
||||
tty: tty,
|
||||
working_dir: workingDir,
|
||||
},
|
||||
};
|
||||
|
||||
if (baseImage) result.base_image = baseImage;
|
||||
if (baseDefinitionId) result.base_definition_id = baseDefinitionId;
|
||||
|
||||
return result;
|
||||
}, [
|
||||
aptPackages,
|
||||
npmPackages,
|
||||
pipPackages,
|
||||
nodeVersion,
|
||||
userName,
|
||||
userUid,
|
||||
userGid,
|
||||
envVars,
|
||||
buildScripts,
|
||||
startupScripts,
|
||||
mounts,
|
||||
command,
|
||||
workingDir,
|
||||
stdinOpen,
|
||||
tty,
|
||||
baseImage,
|
||||
baseDefinitionId,
|
||||
]);
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
const m = buildManifest();
|
||||
onChange(m);
|
||||
}, [buildManifest, onChange]);
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!definitionId) {
|
||||
setPreviewError("Save the tool definition first to preview");
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
setPreviewError(null);
|
||||
try {
|
||||
const result = await compileToolDefinition(definitionId);
|
||||
setPreview({
|
||||
dockerfile: result.dockerfile,
|
||||
compose: result.compose,
|
||||
entrypoint: result.entrypoint,
|
||||
});
|
||||
} catch (err) {
|
||||
setPreviewError(extractErrorMessage(err));
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAptPackage = () => setAptPackages([...aptPackages, { name: "" }]);
|
||||
const removeAptPackage = (idx: number) =>
|
||||
setAptPackages(aptPackages.filter((_, i) => i !== idx));
|
||||
const updateAptPackage = (idx: number, name: string) => {
|
||||
const copy = [...aptPackages];
|
||||
copy[idx] = { name };
|
||||
setAptPackages(copy);
|
||||
};
|
||||
|
||||
const addNpmPackage = () => setNpmPackages([...npmPackages, { name: "" }]);
|
||||
const removeNpmPackage = (idx: number) =>
|
||||
setNpmPackages(npmPackages.filter((_, i) => i !== idx));
|
||||
const updateNpmPackage = (idx: number, name: string) => {
|
||||
const copy = [...npmPackages];
|
||||
copy[idx] = { name };
|
||||
setNpmPackages(copy);
|
||||
};
|
||||
|
||||
const addPipPackage = () => setPipPackages([...pipPackages, { name: "" }]);
|
||||
const removePipPackage = (idx: number) =>
|
||||
setPipPackages(pipPackages.filter((_, i) => i !== idx));
|
||||
const updatePipPackage = (idx: number, name: string) => {
|
||||
const copy = [...pipPackages];
|
||||
copy[idx] = { name };
|
||||
setPipPackages(copy);
|
||||
};
|
||||
|
||||
const addEnvVar = () => setEnvVars([...envVars, { key: "", value: "" }]);
|
||||
const removeEnvVar = (idx: number) =>
|
||||
setEnvVars(envVars.filter((_, i) => i !== idx));
|
||||
const updateEnvVar = (idx: number, field: "key" | "value", val: string) => {
|
||||
const copy = [...envVars];
|
||||
copy[idx] = { ...copy[idx], [field]: val };
|
||||
setEnvVars(copy);
|
||||
};
|
||||
|
||||
const addBuildScript = () => setBuildScripts([...buildScripts, ""]);
|
||||
const removeBuildScript = (idx: number) =>
|
||||
setBuildScripts(buildScripts.filter((_, i) => i !== idx));
|
||||
const updateBuildScript = (idx: number, val: string) => {
|
||||
const copy = [...buildScripts];
|
||||
copy[idx] = val;
|
||||
setBuildScripts(copy);
|
||||
};
|
||||
|
||||
const addStartupScript = () => setStartupScripts([...startupScripts, ""]);
|
||||
const removeStartupScript = (idx: number) =>
|
||||
setStartupScripts(startupScripts.filter((_, i) => i !== idx));
|
||||
const updateStartupScript = (idx: number, val: string) => {
|
||||
const copy = [...startupScripts];
|
||||
copy[idx] = val;
|
||||
setStartupScripts(copy);
|
||||
};
|
||||
|
||||
const addMount = () =>
|
||||
setMounts([
|
||||
...mounts,
|
||||
{
|
||||
name: "",
|
||||
target: "",
|
||||
source_type: "repo",
|
||||
writable: true,
|
||||
owner: "",
|
||||
mode: "",
|
||||
file_mode: "",
|
||||
readonly: false,
|
||||
git_mount_ref: "",
|
||||
},
|
||||
]);
|
||||
const removeMount = (idx: number) =>
|
||||
setMounts(mounts.filter((_, i) => i !== idx));
|
||||
const updateMount = (idx: number, field: keyof MountEntry, val: unknown) => {
|
||||
const copy = [...mounts];
|
||||
copy[idx] = { ...copy[idx], [field]: val };
|
||||
setMounts(copy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: "1.5rem" }}>
|
||||
{/* Base Image */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Base Image</h4>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Base Definition</label>
|
||||
<select
|
||||
value={baseDefinitionId}
|
||||
onChange={(e) => {
|
||||
setBaseDefinitionId(e.target.value);
|
||||
setBaseImage("");
|
||||
}}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Custom image...</option>
|
||||
{baseDefinitions.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.display_name} ({b.version})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Custom Base Image</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseImage}
|
||||
onChange={(e) => {
|
||||
setBaseImage(e.target.value);
|
||||
setBaseDefinitionId("");
|
||||
}}
|
||||
placeholder="e.g., ubuntu:24.04"
|
||||
className="form-input"
|
||||
disabled={!!baseDefinitionId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packages */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Packages</h4>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Node.js Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nodeVersion}
|
||||
onChange={(e) => setNodeVersion(e.target.value)}
|
||||
placeholder="e.g., 20"
|
||||
className="form-input"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>APT Packages</label>
|
||||
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
{aptPackages.map((pkg, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={pkg.name}
|
||||
onChange={(e) => updateAptPackage(idx, e.target.value)}
|
||||
placeholder="e.g., neovim"
|
||||
className="form-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAptPackage(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAptPackage}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add APT Package
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>NPM Global Packages</label>
|
||||
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
{npmPackages.map((pkg, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={pkg.name}
|
||||
onChange={(e) => updateNpmPackage(idx, e.target.value)}
|
||||
placeholder="e.g., @scope/pkg"
|
||||
className="form-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeNpmPackage(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addNpmPackage}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add NPM Package
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Pip Packages</label>
|
||||
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
{pipPackages.map((pkg, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={pkg.name}
|
||||
onChange={(e) => updatePipPackage(idx, e.target.value)}
|
||||
placeholder="e.g., requests"
|
||||
className="form-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePipPackage(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPipPackage}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Pip Package
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime User */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Runtime User</h4>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>User Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>UID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={userUid}
|
||||
onChange={(e) => setUserUid(e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>GID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={userGid}
|
||||
onChange={(e) => setUserGid(e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Environment Variables</h4>
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{envVars.map((ev, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={ev.key}
|
||||
onChange={(e) => updateEnvVar(idx, "key", e.target.value)}
|
||||
placeholder="KEY"
|
||||
className="form-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={ev.value}
|
||||
onChange={(e) => updateEnvVar(idx, "value", e.target.value)}
|
||||
placeholder="value"
|
||||
className="form-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvVar(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEnvVar}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Env Var
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Scripts */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Build Scripts (run during docker build)</h4>
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{buildScripts.map((script, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
>
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={(e) => updateBuildScript(idx, e.target.value)}
|
||||
placeholder="git config --global user.email 'dev@example.com'"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8125rem",
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeBuildScript(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addBuildScript}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Build Script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Startup Scripts */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>
|
||||
Startup Scripts (run when container starts)
|
||||
</h4>
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{startupScripts.map((script, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
>
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={(e) => updateStartupScript(idx, e.target.value)}
|
||||
placeholder="chown -R user:user /workspace"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8125rem",
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStartupScript(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addStartupScript}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Startup Script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mounts */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Mount Schema</h4>
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
{mounts.map((mount, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="stack"
|
||||
style={{
|
||||
gap: "0.5rem",
|
||||
padding: "0.75rem",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.name}
|
||||
onChange={(e) => updateMount(idx, "name", e.target.value)}
|
||||
placeholder="Name (e.g., workspace)"
|
||||
className="form-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.target}
|
||||
onChange={(e) => updateMount(idx, "target", e.target.value)}
|
||||
placeholder="Target (e.g., /workspace)"
|
||||
className="form-input"
|
||||
/>
|
||||
<select
|
||||
value={mount.source_type}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "source_type", e.target.value)
|
||||
}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="repo">Repository</option>
|
||||
<option value="ssh_key">SSH Key</option>
|
||||
<option value="instance">Instance</option>
|
||||
<option value="git_mount">Git Mount</option>
|
||||
<option value="host_path">Host Path</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMount(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mount.writable}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "writable", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Writable
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mount.readonly}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "readonly", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Read-only
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.owner}
|
||||
onChange={(e) => updateMount(idx, "owner", e.target.value)}
|
||||
placeholder="Owner (e.g., user)"
|
||||
className="form-input"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.mode}
|
||||
onChange={(e) => updateMount(idx, "mode", e.target.value)}
|
||||
placeholder="Mode (e.g., 0755)"
|
||||
className="form-input"
|
||||
style={{ width: "100px" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.file_mode}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "file_mode", e.target.value)
|
||||
}
|
||||
placeholder="File mode (e.g., 0644)"
|
||||
className="form-input"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
{mount.source_type === "git_mount" && (
|
||||
<input
|
||||
type="text"
|
||||
value={mount.git_mount_ref}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "git_mount_ref", e.target.value)
|
||||
}
|
||||
placeholder="Git mount ref"
|
||||
className="form-input"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMount}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Mount
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Runtime</h4>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Command</label>
|
||||
<input
|
||||
type="text"
|
||||
value={command.join(" ")}
|
||||
onChange={(e) => setCommand(e.target.value.split(" "))}
|
||||
placeholder="/bin/bash"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Working Directory</label>
|
||||
<input
|
||||
type="text"
|
||||
value={workingDir}
|
||||
onChange={(e) => setWorkingDir(e.target.value)}
|
||||
placeholder="/workspace"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stdinOpen}
|
||||
onChange={(e) => setStdinOpen(e.target.checked)}
|
||||
/>
|
||||
stdin_open
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tty}
|
||||
onChange={(e) => setTty(e.target.checked)}
|
||||
/>
|
||||
tty
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: 0 }}>Live Preview</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
{previewLoading ? "Compiling..." : "Preview"}
|
||||
</button>
|
||||
</div>
|
||||
{previewError && <p className="text-error">{previewError}</p>}
|
||||
{preview && (
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
<div>
|
||||
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
Dockerfile
|
||||
</label>
|
||||
<pre
|
||||
style={{
|
||||
background: "var(--code-bg, #1e1e1e)",
|
||||
color: "var(--code-fg, #d4d4d4)",
|
||||
padding: "1rem",
|
||||
borderRadius: "0.375rem",
|
||||
overflow: "auto",
|
||||
fontSize: "0.8125rem",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
>
|
||||
{preview.dockerfile}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
Compose
|
||||
</label>
|
||||
<pre
|
||||
style={{
|
||||
background: "var(--code-bg, #1e1e1e)",
|
||||
color: "var(--code-fg, #d4d4d4)",
|
||||
padding: "1rem",
|
||||
borderRadius: "0.375rem",
|
||||
overflow: "auto",
|
||||
fontSize: "0.8125rem",
|
||||
maxHeight: "200px",
|
||||
}}
|
||||
>
|
||||
{preview.compose}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface FormField {
|
||||
name: string;
|
||||
|
||||
@@ -22,8 +22,6 @@ interface MobileListViewProps {
|
||||
export const MobileListView: React.FC<MobileListViewProps> = ({
|
||||
items,
|
||||
onItemClick,
|
||||
onItemDelete,
|
||||
onItemDuplicate,
|
||||
emptyMessage = "No items found",
|
||||
searchPlaceholder = "Search...",
|
||||
onSearch,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type AsyncStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
interface UseAsyncDataResult<T> {
|
||||
data: T | null;
|
||||
status: AsyncStatus;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export function useAsyncData<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
deps: React.DependencyList = []
|
||||
): UseAsyncDataResult<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [status, setStatus] = useState<AsyncStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetcher();
|
||||
setData(result);
|
||||
setStatus("ready");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load data");
|
||||
setStatus("error");
|
||||
}
|
||||
}, deps);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { data, status, error, reload };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
stopInstance,
|
||||
deleteInstance,
|
||||
startInstance,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import type { Session } from "../api/sessions";
|
||||
|
||||
interface UseInstanceActionsOptions {
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseInstanceActionsReturn {
|
||||
loadingSessionId: string | null;
|
||||
dirtyDeleteSession: Session | null;
|
||||
dirtyDeleteFiles: string[];
|
||||
handleOpen: (session: Session) => void;
|
||||
handleStart: (session: Session) => Promise<void>;
|
||||
handleStop: (session: Session) => Promise<void>;
|
||||
handleDelete: (session: Session) => Promise<void>;
|
||||
handleForceDelete: (session: Session) => Promise<void>;
|
||||
handleRecreateTunnel: (session: Session) => Promise<void>;
|
||||
clearDirtyDelete: () => void;
|
||||
}
|
||||
|
||||
export function useInstanceActions(
|
||||
options: UseInstanceActionsOptions
|
||||
): UseInstanceActionsReturn {
|
||||
const { onRefresh } = options;
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||
|
||||
const handleOpen = useCallback((session: Session) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||
window.location.href = `/instances/${session.id}/terminal`;
|
||||
return;
|
||||
}
|
||||
window.location.href = `/projects/${session.project_id}`;
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await startInstance(session.project_id, session.repository_id, session.id);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const handleStop = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { detail?: { changed_files?: string[] } } };
|
||||
};
|
||||
if (axiosError.response?.status === 409) {
|
||||
const detail = axiosError.response.data?.detail;
|
||||
if (detail?.changed_files) {
|
||||
setDirtyDeleteSession(session);
|
||||
setDirtyDeleteFiles(detail.changed_files);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const handleForceDelete = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const handleRecreateTunnel = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const clearDirtyDelete = useCallback(() => {
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
import { MobileListView } from "../components/mobile-list-view";
|
||||
import { MobileDetailView } from "../components/mobile-detail-view";
|
||||
import { MobileEditView } from "../components/mobile-edit-view";
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { Project } from "../types";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { GitMountEditor } from "../components/git-mount-editor";
|
||||
|
||||
type Status = "loading" | "ready" | "error";
|
||||
type MobileView = "list" | "detail" | "edit";
|
||||
@@ -45,6 +48,7 @@ export const ConfigProfilesPage = () => {
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
});
|
||||
@@ -65,6 +69,7 @@ export const ConfigProfilesPage = () => {
|
||||
setProfiles(profs || []);
|
||||
setProjects(projs || []);
|
||||
setToolTypes(types || []);
|
||||
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
@@ -82,6 +87,7 @@ export const ConfigProfilesPage = () => {
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
});
|
||||
@@ -100,6 +106,7 @@ export const ConfigProfilesPage = () => {
|
||||
env_vars: profile.env_vars,
|
||||
runtime_hints: profile.runtime_hints,
|
||||
mounts: profile.mounts,
|
||||
git_mounts: profile.git_mounts || [],
|
||||
files: profile.files,
|
||||
is_default: profile.is_default,
|
||||
});
|
||||
@@ -127,16 +134,6 @@ export const ConfigProfilesPage = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const extractErrorMessage = (err: unknown): string => {
|
||||
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
|
||||
const detail = axiosError?.response?.data?.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
|
||||
}
|
||||
return "Failed to save";
|
||||
};
|
||||
|
||||
// Include management functions
|
||||
const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id);
|
||||
|
||||
@@ -417,7 +414,7 @@ export const ConfigProfilesPage = () => {
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p>Loading Config Profiles...</p>
|
||||
<LoadingState message="Loading Config Profiles..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -425,10 +422,7 @@ export const ConfigProfilesPage = () => {
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p className="text-error">Failed to load Config Profiles.</p>
|
||||
<button onClick={loadData}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load Config Profiles." onRetry={loadData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1250,6 +1244,13 @@ export const ConfigProfilesPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<GitMountEditor
|
||||
mounts={formData.git_mounts || []}
|
||||
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
|
||||
<button type="submit" disabled={saveStatus === "saving"}>
|
||||
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||
|
||||
@@ -2,15 +2,16 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||
import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { Icon } from "../components/icon";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -31,7 +32,6 @@ export const HomePage = () => {
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
@@ -58,6 +58,15 @@ export const HomePage = () => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const {
|
||||
loadingSessionId: actionBusy,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
} = useInstanceActions({ onRefresh: loadHome });
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
@@ -130,64 +139,6 @@ export const HomePage = () => {
|
||||
await loadHome();
|
||||
};
|
||||
|
||||
const handleOpen = (session: SessionView) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
};
|
||||
|
||||
const handleStop = async (session: SessionView) => {
|
||||
if (actionBusy === session.id) return;
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (session: SessionView) => {
|
||||
if (actionBusy === session.id) return;
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
} catch {
|
||||
// error - session remains in state
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: SessionView) => {
|
||||
if (actionBusy === session.id) return;
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async (session: SessionView) => {
|
||||
if (actionBusy === session.id) return;
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await startInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
@@ -202,17 +153,9 @@ export const HomePage = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Unable to load your workspace overview.</p>
|
||||
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
|
||||
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
@@ -260,7 +203,7 @@ export const HomePage = () => {
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="muted">No projects yet.</p>
|
||||
<EmptyState message="No projects yet." />
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories";
|
||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const GitHistoryPage = () => {
|
||||
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [commits, setCommits] = useState<CommitHistoryEntry[]>([]);
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>("");
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (!projectId || !repoId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
||||
setCommits(data.commits);
|
||||
setBranches(data.branches);
|
||||
if (data.branches.length > 0 && !selectedBranch) {
|
||||
setSelectedBranch(data.branches[0]);
|
||||
}
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, repoId, selectedBranch]);
|
||||
const { data: historyData, status, reload } = useAsyncData<CommitHistoryResponse>(
|
||||
async () => {
|
||||
if (!projectId || !repoId) return { commits: [], branches: [], tags: [] };
|
||||
return await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
||||
},
|
||||
[projectId, repoId, selectedBranch]
|
||||
);
|
||||
|
||||
// Auto-select first branch when data loads
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
}, [loadHistory]);
|
||||
if (historyData?.branches.length && !selectedBranch) {
|
||||
setSelectedBranch(historyData.branches[0]);
|
||||
}
|
||||
}, [historyData?.branches, selectedBranch]);
|
||||
|
||||
const handleCommitClick = async (hash: string) => {
|
||||
if (!projectId || !repoId) return;
|
||||
@@ -61,7 +55,7 @@ export const GitHistoryPage = () => {
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p className="muted">Loading commit history...</p>
|
||||
<LoadingState message="Loading commit history..." />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -69,15 +63,14 @@ export const GitHistoryPage = () => {
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load commit history</p>
|
||||
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load commit history" onRetry={reload} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const commits = historyData?.commits ?? [];
|
||||
const branches = historyData?.branches ?? [];
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -6,48 +6,38 @@ import {
|
||||
listRepositories,
|
||||
} from "../api/git_repositories";
|
||||
import type { GitRepository } from "../api/git_repositories";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
||||
|
||||
type RepoStatus = "loading" | "ready" | "error";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const GitRepositoriesPage = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
const { data: repositories, status, reload } = useAsyncData<GitRepository[]>(
|
||||
async () => {
|
||||
if (!projectId) return [];
|
||||
return await listRepositories(projectId);
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadRepositories();
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && repositories.length === 0;
|
||||
const safeRepositories = repositories ?? [];
|
||||
const isEmpty = status === "ready" && safeRepositories.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -59,23 +49,15 @@ export const GitRepositoriesPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading repositories..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Failed to load repositories" onRetry={reload} />}
|
||||
|
||||
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
|
||||
{isEmpty && <EmptyState message="No repositories yet. Create your first repository above." />}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
{status === "ready" && safeRepositories.length > 0 && (
|
||||
<div className="repository-list">
|
||||
{repositories.map((repo) => (
|
||||
{safeRepositories.map((repo) => (
|
||||
<article className="card repository-card" key={repo.id}>
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
@@ -131,7 +113,7 @@ export const GitRepositoriesPage = () => {
|
||||
open={showCreate}
|
||||
title="Create Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
onCreated={reload}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAuth } from "../state/auth";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { UserProfile } from "../api/profile";
|
||||
|
||||
type ProfileStatus = "loading" | "ready" | "error" | "saving";
|
||||
|
||||
export const ProfilePage = () => {
|
||||
const { refreshSession } = useAuth();
|
||||
const [status, setStatus] = useState<ProfileStatus>("loading");
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
|
||||
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getProfile();
|
||||
setProfile(data);
|
||||
setName(data.name);
|
||||
setEmail(data.email);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setProfile(null);
|
||||
setStatus("error");
|
||||
// Sync loaded profile into form fields
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setName(profile.name);
|
||||
setEmail(profile.email);
|
||||
setDisplayStatus("ready");
|
||||
setError(null);
|
||||
}
|
||||
}, []);
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
}, [loadProfile]);
|
||||
if (loadStatus === "error") {
|
||||
setDisplayStatus("error");
|
||||
}
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
@@ -45,16 +44,15 @@ export const ProfilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("saving");
|
||||
setDisplayStatus("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateProfile({ name: name.trim(), email: email.trim() });
|
||||
setProfile(updated);
|
||||
await updateProfile({ name: name.trim(), email: email.trim() });
|
||||
await refreshSession();
|
||||
setStatus("ready");
|
||||
setDisplayStatus("ready");
|
||||
} catch {
|
||||
setError("Failed to update profile");
|
||||
setStatus("ready");
|
||||
setDisplayStatus("ready");
|
||||
}
|
||||
}, [name, email, refreshSession]);
|
||||
|
||||
@@ -73,19 +71,19 @@ export const ProfilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("saving");
|
||||
setDisplayStatus("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await uploadAvatar(file);
|
||||
setProfile(updated);
|
||||
await uploadAvatar(file);
|
||||
await refreshSession();
|
||||
setStatus("ready");
|
||||
reload();
|
||||
setDisplayStatus("ready");
|
||||
} catch {
|
||||
setError("Failed to upload avatar");
|
||||
setStatus("ready");
|
||||
setDisplayStatus("ready");
|
||||
}
|
||||
},
|
||||
[refreshSession]
|
||||
[refreshSession, reload]
|
||||
);
|
||||
|
||||
const avatarUrl = profile?.avatar_url ?? null;
|
||||
@@ -94,19 +92,11 @@ export const ProfilePage = () => {
|
||||
<section className="stack">
|
||||
<h1>Profile</h1>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading profile...</p>}
|
||||
{displayStatus === "loading" && <LoadingState message="Loading profile..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load profile</p>
|
||||
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
|
||||
|
||||
{(status === "ready" || status === "saving") && profile && (
|
||||
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
|
||||
<div className="card stack">
|
||||
<div className="profile-avatar-section">
|
||||
<div className="avatar-preview">
|
||||
@@ -118,11 +108,11 @@ export const ProfilePage = () => {
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
{status === "saving" ? (
|
||||
{displayStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Uploading...
|
||||
@@ -146,7 +136,7 @@ export const ProfilePage = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-name">Name</label>
|
||||
<input
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
id="profile-name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
type="text"
|
||||
@@ -157,7 +147,7 @@ export const ProfilePage = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-email">Email</label>
|
||||
<input
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
id="profile-email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
@@ -170,11 +160,11 @@ export const ProfilePage = () => {
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
onClick={() => void handleSave()}
|
||||
type="button"
|
||||
>
|
||||
{status === "saving" ? (
|
||||
{displayStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -10,15 +10,15 @@ import {
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { Project } from "../types";
|
||||
|
||||
type ProjectsStatus = "loading" | "ready" | "error";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [formName, setFormName] = useState("");
|
||||
@@ -26,21 +26,7 @@ export const ProjectsPage = () => {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setProjects([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, [loadProjects]);
|
||||
const safeProjects = projects ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
@@ -88,7 +74,7 @@ export const ProjectsPage = () => {
|
||||
await updateProject(editingProject.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
await loadProjects();
|
||||
reload();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
}
|
||||
@@ -98,13 +84,13 @@ export const ProjectsPage = () => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadProjects();
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && projects.length === 0;
|
||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -116,23 +102,15 @@ export const ProjectsPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading projects...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load projects</p>
|
||||
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Failed to load projects" onRetry={reload} />}
|
||||
|
||||
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
|
||||
{isEmpty && <EmptyState message="No projects yet. Create your first project above." />}
|
||||
|
||||
{status === "ready" && projects.length > 0 && (
|
||||
{status === "ready" && safeProjects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{projects.map((project) => (
|
||||
{safeProjects.map((project) => (
|
||||
<article className="card project-card" key={project.id}>
|
||||
<div className="project-info">
|
||||
<h3>{project.name}</h3>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
|
||||
@@ -164,26 +165,16 @@ export const RepoWorkspace = () => {
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="muted">Loading repositories...</p>
|
||||
<LoadingState message="Loading repositories..." />
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadRepositories()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
|
||||
)}
|
||||
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<p>No repositories in this project yet.</p>
|
||||
<EmptyState message="No repositories in this project yet." />
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/settings/repositories`}
|
||||
@@ -486,7 +477,7 @@ const FileBrowser = ({
|
||||
</button>
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<p className="muted">No files in this repository yet.</p>
|
||||
<EmptyState message="No files in this repository yet." />
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||
|
||||
+18
-117
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { Project } from "../types";
|
||||
@@ -7,24 +6,20 @@ import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import {
|
||||
getUserSessions,
|
||||
type Session,
|
||||
deleteInstance,
|
||||
stopInstance,
|
||||
startInstance,
|
||||
checkInstanceHealth,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
import { Icon } from "../components/icon";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { SessionCard } from "../components/session-card";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
import type { InstanceHealth } from "../api/sessions";
|
||||
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const SessionsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||
@@ -34,11 +29,7 @@ export const SessionsPage = () => {
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
|
||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
@@ -83,7 +74,18 @@ export const SessionsPage = () => {
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
|
||||
const {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
} = useInstanceActions({ onRefresh: loadSessions });
|
||||
|
||||
// Poll health every 30 seconds for active web-enabled instances
|
||||
useEffect(() => {
|
||||
@@ -154,116 +156,15 @@ export const SessionsPage = () => {
|
||||
await loadSessions();
|
||||
};
|
||||
|
||||
const handleStop = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
// Remove from local state immediately
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
} catch (error) {
|
||||
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
||||
if (axiosError.response?.status === 409) {
|
||||
const detail = axiosError.response.data?.detail;
|
||||
if (detail?.changed_files) {
|
||||
setDirtyDeleteSession(session);
|
||||
setDirtyDeleteFiles(detail.changed_files);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceDelete = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id
|
||||
);
|
||||
// Refresh sessions to get new URL
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await startInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (session: Session) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, '_blank', 'noopener,noreferrer');
|
||||
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
} else {
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading sessions...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load sessions</p>
|
||||
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Failed to load sessions" onRetry={() => void loadSessions()} />}
|
||||
|
||||
{status === "ready" && (
|
||||
<>
|
||||
@@ -311,7 +212,7 @@ export const SessionsPage = () => {
|
||||
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
{dirtyDeleteSession && (
|
||||
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
|
||||
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Uncommitted Changes</h3>
|
||||
<p>
|
||||
@@ -330,7 +231,7 @@ export const SessionsPage = () => {
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setDirtyDeleteSession(null)}
|
||||
onClick={clearDirtyDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
type SettingsStatus = "loading" | "ready" | "error";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
@@ -26,7 +26,7 @@ type SettingsOutletContext = {
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||
const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
|
||||
const [config, setConfig] = useState<UserConfig>({
|
||||
theme: "system",
|
||||
default_editor: null,
|
||||
@@ -36,19 +36,12 @@ export const SettingsPage = () => {
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserConfig();
|
||||
setConfig(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync loaded config into local editable state
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
}, [loadConfig]);
|
||||
if (loadedConfig) {
|
||||
setConfig(loadedConfig);
|
||||
}
|
||||
}, [loadedConfig]);
|
||||
|
||||
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -79,17 +72,13 @@ export const SettingsPage = () => {
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <section className="stack"><p className="muted">Loading settings...</p></section>;
|
||||
return <section className="stack"><LoadingState message="Loading settings..." /></section>;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load settings</p>
|
||||
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load settings" onRetry={reload} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||
@@ -17,23 +17,9 @@ export const SSHKeysPage = () => {
|
||||
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, []);
|
||||
|
||||
async function loadKeys() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await listSSHKeys();
|
||||
setKeys(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Failed to load SSH keys");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const safeKeys = keys ?? [];
|
||||
|
||||
async function handleGenerate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -45,7 +31,7 @@ export const SSHKeysPage = () => {
|
||||
setNewKeyName("");
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setError("Failed to generate SSH key");
|
||||
setMutationError("Failed to generate SSH key");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
@@ -58,7 +44,7 @@ export const SSHKeysPage = () => {
|
||||
await deleteSSHKey(keyId);
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setError("Failed to delete SSH key");
|
||||
setMutationError("Failed to delete SSH key");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +60,9 @@ export const SSHKeysPage = () => {
|
||||
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||
setError(null);
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setError("Failed to sign payload");
|
||||
setMutationError("Failed to sign payload");
|
||||
} finally {
|
||||
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
@@ -94,15 +80,15 @@ export const SSHKeysPage = () => {
|
||||
signature: signature.trim(),
|
||||
});
|
||||
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||
setError(null);
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setError("Failed to verify signature");
|
||||
setMutationError("Failed to verify signature");
|
||||
} finally {
|
||||
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -116,7 +102,7 @@ export const SSHKeysPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{mutationError && <div className="error">{mutationError}</div>}
|
||||
|
||||
<form onSubmit={handleGenerate} className="stack">
|
||||
<div className="form-group">
|
||||
@@ -145,11 +131,13 @@ export const SSHKeysPage = () => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
|
||||
|
||||
<div className="keys-list">
|
||||
{keys.length === 0 ? (
|
||||
<p className="muted">No SSH keys yet. Generate one above.</p>
|
||||
{safeKeys.length === 0 ? (
|
||||
<EmptyState message="No SSH keys yet. Generate one above." />
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
safeKeys.map((key) => (
|
||||
<div key={key.id} className="key-card">
|
||||
<div className="key-header">
|
||||
<h3>{key.name}</h3>
|
||||
|
||||
+1966
-1471
File diff suppressed because it is too large
Load Diff
+236
-23
@@ -2633,26 +2633,17 @@ a.nav-item,
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--space-2);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-container .xterm {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
/* xterm.js manages its own positioning and sizing */
|
||||
|
||||
.terminal-container canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Ensure xterm fills container */
|
||||
.terminal-container .xterm-viewport {
|
||||
width: 100% !important;
|
||||
}
|
||||
/* xterm.js manages its own viewport dimensions - do not override */
|
||||
|
||||
/* Mobile terminal container - no padding to maximize space */
|
||||
.terminal-wrapper.mobile .terminal-container {
|
||||
@@ -3264,18 +3255,9 @@ a.nav-item,
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* xterm fills container */
|
||||
.terminal-wrapper.mobile .terminal-container .xterm {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* xterm.js manages its own sizing */
|
||||
|
||||
/* Enable scrolling in mobile terminal */
|
||||
.terminal-wrapper.mobile .terminal-container .xterm-viewport {
|
||||
overflow-y: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
/* xterm.js manages its own scrolling and viewport dimensions */
|
||||
|
||||
/* Special Keys Strip */
|
||||
.special-keys-strip {
|
||||
@@ -3646,6 +3628,87 @@ a.nav-item,
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Mobile Bottom Sheet */
|
||||
.mobile-bottom-sheet-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet {
|
||||
background: var(--panel);
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slide-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 16px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-content {
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-item:active {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-item.active {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Mobile Page Header */
|
||||
.mobile-page-header {
|
||||
display: flex;
|
||||
@@ -3943,3 +4006,153 @@ a.nav-item,
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Git Mount Editor Styles */
|
||||
.git-mount-editor {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.git-mount-editor .section-subtitle {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.git-mount-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.git-mount-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.git-mount-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.git-mount-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-mount-repo {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-mount-paths {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.git-mount-branch {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.git-mount-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-mount-add {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.git-mount-add h5 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-mount-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-mount-form .form-row input,
|
||||
.git-mount-form .form-row select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row input.error,
|
||||
.git-mount-form .form-row select.error {
|
||||
border-color: #cd3131;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row .hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-mount-form .form-row .error-text {
|
||||
font-size: 0.75rem;
|
||||
color: #cd3131;
|
||||
}
|
||||
|
||||
.git-mount-form .form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.new-repo-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.new-repo-form input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.new-repo-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const extractErrorMessage = (err: unknown): string => {
|
||||
const axiosError = err as { response?: { data?: { detail?: string | Array<{ msg?: string }> } } };
|
||||
const detail = axiosError?.response?.data?.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
|
||||
}
|
||||
return "Failed to save";
|
||||
};
|
||||
@@ -33,6 +33,7 @@ All responses are JSON. Error responses follow this format:
|
||||
- [Auth](auth.md) - Authentication endpoints
|
||||
- [Projects](projects.md) - Project management
|
||||
- [Repositories](repositories.md) - Git repositories and file operations
|
||||
- [Config Profiles](config-profiles.md) - Config profile management with git mounts
|
||||
- [Users](users.md) - User management and settings
|
||||
- [Tool Types](tool-types.md) - Tool type management
|
||||
- [SSH Keys](ssh-keys.md) - SSH key management
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Config Profiles
|
||||
|
||||
## Overview
|
||||
|
||||
Config profiles allow users to define reusable configuration sets for tool instances. Profiles can include environment variables, files, mounts, and git repository mounts. They support profile includes for composition and can be scoped to specific projects or tool types.
|
||||
|
||||
## Git Mounts
|
||||
|
||||
Git mounts allow you to mount files or directories from git repositories into tool instances at startup.
|
||||
|
||||
### Git Mount Object
|
||||
|
||||
```json
|
||||
{
|
||||
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"source_path": ".",
|
||||
"target_path": "/app/config",
|
||||
"branch": "main"
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `repo_id` | string (UUID) | Yes | ID of the git repository to mount from |
|
||||
| `source_path` | string | No | Path within the repository (default: "."). Supports glob patterns like "*.json" or "configs/**" |
|
||||
| `target_path` | string | Yes | Absolute path inside the container where files will be mounted |
|
||||
| `branch` | string | No | Branch or tag to checkout before mounting (default: current branch) |
|
||||
|
||||
### Path Validation
|
||||
|
||||
- `source_path`: Must be relative (no leading `/`). Cannot contain `..` (path traversal)
|
||||
- `target_path`: Must be absolute (starts with `/`). Cannot contain `..`
|
||||
|
||||
### Glob Patterns
|
||||
|
||||
The `source_path` supports standard glob patterns:
|
||||
|
||||
- `*.json` - Match all JSON files in root
|
||||
- `configs/**` - Match all files in configs directory recursively
|
||||
- `src/*.py` - Match all Python files in src directory
|
||||
- `.` - Mount entire repository (default)
|
||||
|
||||
**Limits:**
|
||||
- Maximum 100 matches per glob pattern
|
||||
- Only matches within the repository boundary
|
||||
|
||||
### Branch Behavior
|
||||
|
||||
When a `branch` is specified:
|
||||
|
||||
1. System attempts to checkout the branch in the existing clone
|
||||
2. If branch doesn't exist locally, attempts to fetch from remote and checkout
|
||||
3. If checkout fails, logs warning and continues with current branch
|
||||
4. No branch specified: uses current checked-out branch
|
||||
|
||||
**Auto-clone:** If repository is not cloned locally, the system will automatically clone it using the repository's configured SSH key.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List Config Profiles
|
||||
|
||||
```
|
||||
GET /config-profiles
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
- `project_id` (optional): Filter by project compatibility
|
||||
- `tool_type_id` (optional): Filter by tool type compatibility
|
||||
|
||||
Response includes `git_mounts` array in each profile.
|
||||
|
||||
### Create Config Profile
|
||||
|
||||
```
|
||||
POST /config-profiles
|
||||
```
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"name": "My Profile",
|
||||
"git_mounts": [
|
||||
{
|
||||
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"source_path": "configs/*.json",
|
||||
"target_path": "/app/config",
|
||||
"branch": "main"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
- All referenced repositories must exist
|
||||
- Repositories must belong to the same project (if profile has project_id)
|
||||
- source_path and target_path must pass path validation
|
||||
|
||||
### Update Config Profile
|
||||
|
||||
```
|
||||
PUT /config-profiles/{id}
|
||||
```
|
||||
|
||||
Same request body as create. Partial updates supported (omit fields to keep current values).
|
||||
|
||||
### Preview Resolved Profile
|
||||
|
||||
```
|
||||
GET /config-profiles/{id}/preview
|
||||
```
|
||||
|
||||
Returns the fully resolved profile with all includes merged. Git mounts from included profiles are merged with override rules (later profiles override earlier ones with same repo_id + target_path combo).
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"profile_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"profile_name": "My Profile",
|
||||
"env_vars": {},
|
||||
"runtime_hints": {},
|
||||
"mounts": [],
|
||||
"git_mounts": [
|
||||
{
|
||||
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"source_path": "configs/*.json",
|
||||
"target_path": "/app/config",
|
||||
"branch": "main"
|
||||
}
|
||||
],
|
||||
"files": {},
|
||||
"overrides": {
|
||||
"env_vars": {},
|
||||
"runtime_hints": {},
|
||||
"files": {},
|
||||
"mounts": {}
|
||||
},
|
||||
"included_profiles": []
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Git mount errors during instance startup are non-blocking:
|
||||
- Missing repository: Mount skipped, warning logged
|
||||
- Clone failure: Mount skipped, warning logged
|
||||
- Invalid paths: Mount skipped, warning logged
|
||||
- Branch checkout failure: Falls back to current branch, warning logged
|
||||
|
||||
Instance startup continues normally even if some git mounts fail.
|
||||
|
||||
## Profile Resolution
|
||||
|
||||
When a profile includes other profiles, git mounts are merged:
|
||||
- Same `repo_id` + `target_path` combo: later profile overrides
|
||||
- Different combos: both are kept
|
||||
- Branch conflicts: later profile wins
|
||||
|
||||
Example:
|
||||
```
|
||||
Base Profile: git_mounts = [{repo_a, /app, main}]
|
||||
Included Profile: git_mounts = [{repo_a, /app, develop}, {repo_b, /data}]
|
||||
Resolved: git_mounts = [{repo_a, /app, develop}, {repo_b, /data}]
|
||||
```
|
||||
@@ -8,6 +8,73 @@ All endpoints require authentication (session cookie).
|
||||
|
||||
---
|
||||
|
||||
## GET /repositories
|
||||
|
||||
**Description:** List all repositories owned by the user, including external repositories not tied to any project.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "my-external-repo",
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"is_mirror": false,
|
||||
"project_id": null,
|
||||
"owner_id": "uuid",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /repositories
|
||||
|
||||
**Description:** Create a new external repository (not tied to any project). External repositories can be used across all projects for config profile git mounts.
|
||||
|
||||
### Request
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-external-repo",
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"ssh_key_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Repository name (unique per user for external repos) |
|
||||
| `remote_url` | `string` | No | Remote URL to clone from |
|
||||
| `ssh_key_id` | `string` | No | SSH key ID for authentication |
|
||||
| `force_original_url` | `boolean` | No | Skip URL parsing (default: false) |
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (201 Created)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "my-external-repo",
|
||||
"path": "/data/repos/external/{user_id}/{repo_id}",
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"is_mirror": false,
|
||||
"project_id": null,
|
||||
"owner_id": "uuid",
|
||||
"ssh_key_id": "uuid",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /projects/{project_id}/repositories
|
||||
|
||||
**Description:** List repositories in a project.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Using Git Repositories in Config Profiles
|
||||
|
||||
## Overview
|
||||
|
||||
Config profiles now support mounting files and directories from git repositories directly into your tool instances. This is useful for:
|
||||
|
||||
- Sharing configuration files across multiple instances
|
||||
- Mounting dotfiles or development environment configs
|
||||
- Including shared code or assets from other repositories
|
||||
- Pinning specific branches or versions of dependencies
|
||||
|
||||
## How It Works
|
||||
|
||||
When you start a tool instance with a config profile that has git mounts:
|
||||
|
||||
1. The system checks if the repository is cloned locally
|
||||
2. If not cloned and a remote URL is available, it automatically clones the repository
|
||||
3. If a branch is specified, it checks out that branch
|
||||
4. Files matching the source path pattern are mounted as bind mounts into the container
|
||||
5. Instance startup continues normally
|
||||
|
||||
## Adding Git Mounts
|
||||
|
||||
### Step 1: Select a Repository
|
||||
|
||||
In the config profile editor, find the "Git Mounts" section. Choose a repository from the dropdown. Only repositories from your projects are available.
|
||||
|
||||
### Step 2: Configure Source Path
|
||||
|
||||
The source path determines which files from the repository to mount:
|
||||
|
||||
- **`.`** (default): Mount the entire repository
|
||||
- **`configs/`**: Mount the configs directory
|
||||
- **`*.json`**: Mount all JSON files in the repository root
|
||||
- **`src/**/*.py`**: Mount all Python files in the src directory recursively
|
||||
|
||||
**Glob patterns are supported** - use `*` for any characters, `**` for recursive matching.
|
||||
|
||||
### Step 3: Set Target Path
|
||||
|
||||
The target path is where files appear inside the container:
|
||||
|
||||
- `/app/config` - Mount to /app/config
|
||||
- `/home/user/dotfiles` - Mount to user's home directory
|
||||
- `/workspace/shared` - Mount to workspace shared folder
|
||||
|
||||
Target paths must be absolute (start with `/`).
|
||||
|
||||
### Step 4: Optional Branch Selection
|
||||
|
||||
You can pin a specific branch or tag:
|
||||
|
||||
- `main` - Use the main branch
|
||||
- `develop` - Use the develop branch
|
||||
- `v1.2.3` - Pin to a specific tag
|
||||
|
||||
If not specified, the current checked-out branch is used.
|
||||
|
||||
## Examples
|
||||
|
||||
### Dotfiles Configuration
|
||||
|
||||
Mount your dotfiles repository into the home directory:
|
||||
|
||||
```
|
||||
Repository: dotfiles
|
||||
Source Path: .
|
||||
Target Path: /home/user
|
||||
Branch: main
|
||||
```
|
||||
|
||||
### Shared Configuration Files
|
||||
|
||||
Mount only JSON config files from a shared config repo:
|
||||
|
||||
```
|
||||
Repository: shared-configs
|
||||
Source Path: *.json
|
||||
Target Path: /app/config
|
||||
Branch: production
|
||||
```
|
||||
|
||||
### Development Tools Configuration
|
||||
|
||||
Mount specific tool configs:
|
||||
|
||||
```
|
||||
Repository: dev-tools
|
||||
Source Path: vscode/
|
||||
Target Path: /workspace/.vscode
|
||||
```
|
||||
|
||||
### Multiple Mounts
|
||||
|
||||
You can add multiple git mounts to a single profile:
|
||||
|
||||
1. Dotfiles → `/home/user`
|
||||
2. Shared configs → `/app/config`
|
||||
3. Assets → `/app/static`
|
||||
|
||||
## Profile Includes
|
||||
|
||||
Git mounts work with profile includes. If Profile A includes Profile B:
|
||||
|
||||
- Both profiles' git mounts are merged
|
||||
- Same repository + target path combinations override (later profile wins)
|
||||
- Different combinations are kept
|
||||
|
||||
Example:
|
||||
```
|
||||
Base Profile:
|
||||
- repo: dotfiles, target: /home/user, branch: main
|
||||
|
||||
Development Profile (includes Base):
|
||||
- repo: dotfiles, target: /home/user, branch: develop
|
||||
- repo: dev-tools, target: /opt/tools
|
||||
|
||||
Resolved Result:
|
||||
- repo: dotfiles, target: /home/user, branch: develop (overridden)
|
||||
- repo: dev-tools, target: /opt/tools (added)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Git mounts are non-blocking:
|
||||
|
||||
- **Repository not found**: Mount is skipped, instance continues starting
|
||||
- **Clone fails**: Mount is skipped, warning logged
|
||||
- **Branch doesn't exist**: Falls back to current branch, warning logged
|
||||
- **Glob pattern matches nothing**: Mount is skipped, warning logged
|
||||
- **Path outside repository**: Match is skipped, warning logged
|
||||
|
||||
You can check the instance logs to see which mounts succeeded and which failed.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use specific paths**: Instead of mounting the entire repository, mount only the files you need. This reduces startup time and avoids conflicts.
|
||||
|
||||
2. **Pin branches**: For reproducible environments, pin specific branches or tags rather than using the default branch.
|
||||
|
||||
3. **Keep repositories small**: Large repositories take longer to clone. Consider splitting config repositories from code repositories.
|
||||
|
||||
4. **Use absolute target paths**: Always use absolute paths (starting with `/`) for target paths to ensure files end up in the expected location.
|
||||
|
||||
5. **Test includes**: When using profile includes, use the Preview feature to verify that git mounts are merged as expected.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Issue**: Git mount not appearing in container
|
||||
**Solution**: Check instance logs for warnings. Common causes: repository not found, clone failure, or source path not matching any files.
|
||||
|
||||
**Issue**: Wrong branch mounted
|
||||
**Solution**: Verify branch name is correct. If branch doesn't exist locally, the system falls back to the current branch. Ensure the remote has the branch.
|
||||
|
||||
**Issue**: Too many files matched
|
||||
**Solution**: Use more specific glob patterns. The system limits matches to 100 files per glob pattern.
|
||||
|
||||
**Issue**: Permission denied
|
||||
**Solution**: Ensure the target path inside the container is writable. Some paths like `/usr` or `/etc` may require root access.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user