Compare commits
37 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 |
@@ -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))
|
||||
|
||||
@@ -56,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")
|
||||
@@ -98,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")
|
||||
@@ -150,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")
|
||||
@@ -193,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
|
||||
@@ -227,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),
|
||||
@@ -238,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": [
|
||||
@@ -321,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())
|
||||
@@ -339,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,
|
||||
)
|
||||
@@ -353,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)
|
||||
|
||||
|
||||
@@ -415,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)
|
||||
@@ -432,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()
|
||||
@@ -444,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)
|
||||
|
||||
|
||||
@@ -464,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
|
||||
|
||||
|
||||
@@ -549,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))
|
||||
|
||||
|
||||
|
||||
@@ -13,9 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||
from src.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,
|
||||
@@ -222,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
|
||||
@@ -232,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],
|
||||
@@ -301,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
|
||||
|
||||
@@ -11,7 +11,6 @@ from src.auth.dependencies import _get_owned_project, _get_user, get_current_use
|
||||
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"])
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Shared Pydantic validators for API schemas."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
MAX_FOLDER_SIZE_MB = 10
|
||||
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||
|
||||
@@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.config import Settings
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -11,6 +10,7 @@ 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
|
||||
|
||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||
|
||||
|
||||
@@ -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
-190
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -9,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.tool_types_validation import (
|
||||
check_port_exposed,
|
||||
sanitize_template_vars,
|
||||
validate_compose_yaml,
|
||||
validate_required_variables,
|
||||
)
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
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_user, get_current_user_id, get_db_session
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||
|
||||
|
||||
@@ -103,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,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
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
@@ -21,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";
|
||||
@@ -47,6 +48,7 @@ export const ConfigProfilesPage = () => {
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
});
|
||||
@@ -67,6 +69,7 @@ export const ConfigProfilesPage = () => {
|
||||
setProfiles(profs || []);
|
||||
setProjects(projs || []);
|
||||
setToolTypes(types || []);
|
||||
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
@@ -84,6 +87,7 @@ export const ConfigProfilesPage = () => {
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
});
|
||||
@@ -102,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,
|
||||
});
|
||||
@@ -1239,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" />
|
||||
|
||||
@@ -9,7 +9,6 @@ import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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, type CommitHistoryResponse } from "../api/git_repositories";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAuth } from "../state/auth";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
@@ -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";
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
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";
|
||||
@@ -21,7 +20,6 @@ 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);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: keys, status, error, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||
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>>({});
|
||||
|
||||
+1965
-1459
File diff suppressed because it is too large
Load Diff
@@ -4006,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-26
|
||||
@@ -0,0 +1,75 @@
|
||||
## Context
|
||||
|
||||
Config profiles currently support inline files and inline mounts, but users cannot reference git repositories. This forces users to either copy-paste file contents or use the generic volume mounts system, which doesn't integrate with the git repository model already present in the system.
|
||||
|
||||
Git repositories already have clone, branch, and path management. We need to bridge config profiles with git repositories so users can manage dotfiles and configurations in git and mount them into containers via profiles.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Allow config profiles to reference git repositories for file mounting
|
||||
- Support path mapping (source path in repo → target path in container)
|
||||
- Support branch/tag pinning for reproducible mounts
|
||||
- Integrate seamlessly with existing profile resolution and instance startup
|
||||
- Maintain backward compatibility with existing profiles
|
||||
|
||||
**Non-Goals:**
|
||||
- Manual git clone management by users (system handles cloning automatically)
|
||||
- Writing back to git repos from containers
|
||||
- Git merge conflict resolution inside profiles
|
||||
- Submodules support (out of scope for initial implementation)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Git Mounts as Separate Field (Not Inline in `mounts`)
|
||||
**Decision**: Add `git_mounts` as a top-level field on `ConfigProfile`, separate from existing `mounts`.
|
||||
**Rationale**: Existing `mounts` are inline files staged at instance startup. Git mounts are references to external repositories. Keeping them separate maintains clear semantics and allows independent validation.
|
||||
|
||||
### 2. Bind Mount at Instance Startup (Not Copy)
|
||||
**Decision**: Create bind mounts from the repo filesystem path into the container.
|
||||
**Rationale**: Bind mounts are immediate and don't require copying files. Changes in the repo are reflected in running containers. Alternative (copying files) would require restaging on every instance start and wouldn't reflect live changes.
|
||||
|
||||
### 3. Lazy Repo Validation (Not Strict at Save Time)
|
||||
**Decision**: Validate that the referenced repository exists when the profile is saved, but don't require the repo to be cloned or the branch to exist.
|
||||
**Rationale**: Repositories may be created after profiles. The instance startup process will handle missing repos gracefully (log warning, skip mount).
|
||||
|
||||
### 4. Single Repo per Mount Entry (Not Multiple)
|
||||
**Decision**: Each `git_mounts` entry references exactly one repository.
|
||||
**Rationale**: Simplifies the data model and UI. Users can add multiple entries if they need multiple repos.
|
||||
|
||||
### 5. Glob Pattern Support in Source Path
|
||||
**Decision**: Support glob patterns in `source_path` using standard glob syntax (e.g., `configs/**/*`, `*.sh`).
|
||||
**Rationale**: Users often want to mount categories of files (all config files, all scripts) without listing them individually. The system will expand globs at instance startup and create individual bind mounts for each matched file.
|
||||
|
||||
### 6. Auto-Clone on Instance Startup
|
||||
**Decision**: If a referenced repository is not cloned when an instance starts, the system automatically clones it using the existing clone service.
|
||||
**Rationale**: Users should not need to manually manage repository state. The system already has clone logic (SSH keys, branch checkout) that can be reused. Clone happens lazily at first use.
|
||||
|
||||
### 7. Git Mounts in Profile Preview
|
||||
**Decision**: Include resolved git mounts in the profile preview output with repository names, paths, and branch information.
|
||||
**Rationale**: Users need visibility into what will be mounted before starting an instance. This helps debug configuration issues.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] Repository clone failure** → **Mitigation**: Clone is attempted at instance startup with full error logging. If clone fails (e.g., bad SSH key, network issue), a clear error is shown and the mount is skipped.
|
||||
|
||||
**[Risk] Glob pattern matches too many files** → **Mitigation**: Limit glob expansion to 100 files per mount. Warn if limit exceeded. Users can use more specific patterns.
|
||||
|
||||
**[Risk] Branch/tag may not exist** → **Mitigation**: Instance startup attempts checkout after clone. Falls back to default branch with warning.
|
||||
|
||||
**[Risk] Performance impact on instance startup** → **Mitigation**: Git mounts are processed in parallel with other startup steps. Clone only happens once per repo. Subsequent instances reuse existing clone.
|
||||
|
||||
**[Trade-off] Bind mounts vs inline files** → Bind mounts don't work across filesystem boundaries (repo must be on same host as Docker). This is acceptable for our single-host deployment model.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Database migration adds `git_mounts` column (nullable JSONB, default empty list)
|
||||
2. Existing profiles have `git_mounts: []` and continue to work
|
||||
3. Frontend UI shows new git mounts section only when editing (not required)
|
||||
4. No changes needed to running instances
|
||||
|
||||
## Decisions Resolved
|
||||
|
||||
1. **Glob patterns**: YES - Support standard glob syntax in `source_path`
|
||||
2. **Profile preview**: YES - Include git mounts in preview/resolve output
|
||||
3. **Auto-clone**: YES - System clones repos automatically, no user reliance
|
||||
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
Config profiles currently only support inline file content, which is impractical for dotfiles and configuration repositories that users manage with git. Users need a way to include files from git repositories (similar to yadm) so they can version-control their dotfiles and mount them into containers at startup.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `git_mounts` field to config profiles, allowing references to git repositories
|
||||
- Mount specific paths from git repositories into containers at configurable target paths
|
||||
- Support branch/tag selection for reproducible mounts
|
||||
- Integrate with existing profile resolution and instance startup pipeline
|
||||
- Update config profile UI to manage git repository mounts alongside existing mounts
|
||||
- **No breaking changes** - existing profiles continue to work unchanged
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `config-profile-git-mounts`: Mounting files from git repositories into containers via config profiles, including repo selection, path mapping, and branch pinning
|
||||
|
||||
### Modified Capabilities
|
||||
- `tool-instances`: Instance startup pipeline now processes git mounts from resolved profiles before container creation
|
||||
- `git-repo`: Repository model may need branch/tag listing for mount configuration
|
||||
|
||||
## Impact
|
||||
|
||||
- **Backend**: `apps/api/src/models/config_profile.py` - add git_mounts field
|
||||
- **Backend**: `apps/api/src/services/config_profile_resolver.py` - resolve git mounts in profile resolution
|
||||
- **Backend**: `apps/api/src/services/docker.py` or instance startup - bind mount from repo path to container
|
||||
- **Backend**: `apps/api/src/api/config_profiles.py` - CRUD for git mounts
|
||||
- **Frontend**: `apps/web/src/pages/config-profiles.tsx` - UI for managing git mounts
|
||||
- **Frontend**: `apps/web/src/api/config_profiles.ts` - API types for git mounts
|
||||
- **Database**: Migration to add git_mounts column to config_profiles table
|
||||
@@ -0,0 +1,149 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Config profiles can reference git repositories for file mounting
|
||||
The system SHALL allow config profiles to include git repository mounts that bind repository paths into containers.
|
||||
|
||||
#### Scenario: Create profile with git mount
|
||||
- **WHEN** a user creates or updates a config profile with `git_mounts` entries
|
||||
- **THEN** the profile stores each git mount with:
|
||||
- `remote_url`: Direct git URL (e.g., "https://github.com/user/repo.git", "git@github.com:user/repo.git")
|
||||
- `source_path`: Path within the repository to mount (e.g., ".", "configs/")
|
||||
- `target_path`: Absolute path inside the container (e.g., "/home/user")
|
||||
- `branch`: Optional branch or tag name (defaults to "main")
|
||||
|
||||
#### Scenario: Git mount validation
|
||||
- **WHEN** a profile with git mounts is saved
|
||||
- **THEN** the system validates that:
|
||||
- `remote_url` is a valid git URL (starts with https://, git@, or ssh://)
|
||||
- `source_path` is a relative path (no leading `/`)
|
||||
- `target_path` can be absolute (starts with `/`) or relative (resolved against working directory, defaulting to `/home/user`)
|
||||
- `target_path` does not contain path traversal sequences (`..`)
|
||||
- No database lookup or repository existence check is performed (validation is deferred to clone time)
|
||||
|
||||
#### Scenario: Profile with git mounts is resolved
|
||||
- **GIVEN** a config profile with git mounts
|
||||
- **WHEN** the profile is resolved for instance startup
|
||||
- **THEN** the resolved profile includes the git mounts as configured
|
||||
- **AND** repository cloning happens at instance startup time, not at profile resolution
|
||||
|
||||
#### Scenario: Git mount is applied at instance startup
|
||||
- **GIVEN** a resolved profile with git mounts
|
||||
- **WHEN** an instance is started with this profile
|
||||
- **THEN** for each git mount:
|
||||
- The repository is cloned from `remote_url` to a temporary location
|
||||
- The source path within the cloned repository exists
|
||||
- A bind mount is created from `clone_path/source_path` to `container:target_path`
|
||||
- **AND** if the clone fails or path is missing, a warning is logged and the mount is skipped
|
||||
|
||||
### Requirement: Git mounts support glob patterns
|
||||
The system SHALL support glob patterns in `source_path` for matching multiple files.
|
||||
|
||||
#### Scenario: Mount files matching glob pattern
|
||||
- **GIVEN** a git mount with `source_path: "configs/**/*.json"`
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system expands the glob pattern within the repository
|
||||
- **AND** creates individual bind mounts for each matched file
|
||||
- **AND** preserves directory structure relative to `target_path`
|
||||
|
||||
#### Scenario: Glob pattern matches nothing
|
||||
- **GIVEN** a git mount with `source_path: "nonexistent/**/*"`
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system logs a warning that no files matched the pattern
|
||||
- **AND** the mount is skipped
|
||||
|
||||
#### Scenario: Glob pattern limit exceeded
|
||||
- **GIVEN** a git mount with `source_path: "**/*"` matching 500 files
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system limits expansion to 100 files
|
||||
- **AND** logs a warning: "Glob pattern matched 500 files, limited to 100"
|
||||
|
||||
### Requirement: Git mounts trigger automatic cloning
|
||||
The system SHALL automatically clone referenced repositories to a persistent storage location on every new container creation. Each instance gets its own fresh clone.
|
||||
|
||||
#### Scenario: Repository cloned on container creation
|
||||
- **GIVEN** a git mount with a `remote_url`
|
||||
- **WHEN** a new container is created with this profile
|
||||
- **THEN** the system clones the repository from the URL to an instance-specific directory
|
||||
- **AND** the clone proceeds as part of instance startup
|
||||
- **AND** instance startup continues once clone completes
|
||||
|
||||
#### Scenario: Existing clone updated on new container creation
|
||||
- **GIVEN** a repository that was previously cloned for this instance
|
||||
- **WHEN** a new container is created with this profile
|
||||
- **THEN** the system pulls the latest updates from the remote_url
|
||||
- **AND** checks out the specified branch (or default branch if not specified)
|
||||
- **AND** uses the updated clone for the bind mount
|
||||
|
||||
#### Scenario: Clone failure handling
|
||||
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
||||
- **WHEN** the instance attempts to clone
|
||||
- **THEN** the clone operation fails
|
||||
- **AND** an error is logged with details
|
||||
- **AND** the mount is skipped
|
||||
- **AND** instance startup continues with remaining mounts
|
||||
|
||||
#### Scenario: Per-instance isolation
|
||||
- **GIVEN** a git mount referencing a repository
|
||||
- **WHEN** multiple instances are created using the same profile
|
||||
- **THEN** each instance gets its own independent clone
|
||||
- **AND** changes made in one container do not affect other containers
|
||||
|
||||
### Requirement: Git mounts support branch pinning
|
||||
The system SHALL support pinning git mounts to specific branches or tags.
|
||||
|
||||
#### Scenario: Mount specific branch
|
||||
- **GIVEN** a git mount with `branch: "develop"`
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system attempts to checkout the "develop" branch in the repository
|
||||
- **AND** the bind mount uses the files from the checked-out branch
|
||||
|
||||
#### Scenario: Branch fallback to default
|
||||
- **GIVEN** a git mount with `branch: "nonexistent"`
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system logs a warning that the branch does not exist
|
||||
- **AND** falls back to the repository's current/default branch
|
||||
- **AND** the bind mount proceeds with the fallback branch
|
||||
|
||||
### Requirement: Git mounts are visible in profile UI
|
||||
The system SHALL display git mounts in the config profile editor.
|
||||
|
||||
#### Scenario: View git mounts in profile editor
|
||||
- **GIVEN** a config profile with git mounts
|
||||
- **WHEN** the user views the profile in the UI
|
||||
- **THEN** the git mounts section displays each mount with:
|
||||
- Git URL
|
||||
- Source path within repository
|
||||
- Target path in container
|
||||
- Branch/tag (if specified)
|
||||
|
||||
#### Scenario: Add git mount via UI
|
||||
- **WHEN** a user adds a git mount in the profile editor
|
||||
- **THEN** they can:
|
||||
- Enter a git URL directly (https://, git@, or ssh://)
|
||||
- Specify the source path (with autocomplete or validation)
|
||||
- Specify the target path in the container
|
||||
- Optionally enter a branch/tag name
|
||||
|
||||
#### Scenario: Remove git mount via UI
|
||||
- **WHEN** a user removes a git mount from the profile editor
|
||||
- **THEN** the mount is removed from the profile
|
||||
- **AND** existing instances using this profile are unaffected
|
||||
|
||||
### Requirement: Git mounts are visible in profile preview
|
||||
The system SHALL include git mounts in the profile preview/resolve output.
|
||||
|
||||
#### Scenario: Preview shows git mount details
|
||||
- **GIVEN** a config profile with git mounts
|
||||
- **WHEN** the user requests a profile preview
|
||||
- **THEN** the preview includes a "git_mounts" section showing:
|
||||
- Repository name and URL
|
||||
- Source path (with expanded glob matches if applicable)
|
||||
- Target path in container
|
||||
- Resolved branch name
|
||||
- Clone status (will clone on container creation)
|
||||
|
||||
#### Scenario: Preview warns about missing repository
|
||||
- **GIVEN** a config profile with a git mount referencing a non-existent repository
|
||||
- **WHEN** the user requests a profile preview
|
||||
- **THEN** the preview shows a warning: "Repository [name] not found"
|
||||
- **AND** indicates that the mount will be skipped at startup
|
||||
@@ -0,0 +1,43 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Instance startup processes git mounts from config profiles
|
||||
The system SHALL process git repository mounts from resolved config profiles during instance startup.
|
||||
|
||||
#### Scenario: Instance startup with git mounts
|
||||
- **GIVEN** a tool instance configured with a profile that has git mounts
|
||||
- **WHEN** the instance starts
|
||||
- **THEN** the startup pipeline:
|
||||
1. Resolves the config profile (including inherited profiles)
|
||||
2. Collects all git mounts from the resolved profile
|
||||
3. For each git mount, verifies the repository filesystem path exists
|
||||
4. Creates bind mount entries in the compose file for each valid git mount
|
||||
5. Logs warnings for any invalid or missing git mounts without failing startup
|
||||
|
||||
#### Scenario: Git mount bind mount creation
|
||||
- **GIVEN** a resolved git mount with:
|
||||
- repository filesystem path: `/data/repos/user/project/dotfiles`
|
||||
- source path: `.`
|
||||
- target path: `/home/user`
|
||||
- **WHEN** the instance compose file is generated
|
||||
- **THEN** a volume entry is added:
|
||||
```yaml
|
||||
volumes:
|
||||
- /data/repos/user/project/dotfiles:/home/user:ro
|
||||
```
|
||||
- **AND** the mount is read-only by default
|
||||
|
||||
#### Scenario: Repository auto-clone on startup
|
||||
- **GIVEN** a git mount referencing a repository that has not been cloned
|
||||
- **WHEN** the instance starts
|
||||
- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key
|
||||
- **AND** the system waits for clone completion before proceeding
|
||||
- **AND** the bind mount is created from the cloned repository path
|
||||
|
||||
#### Scenario: Clone failure handling
|
||||
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
||||
- **WHEN** the instance attempts to clone during startup
|
||||
- **THEN** the clone operation fails
|
||||
- **AND** an error is logged with details
|
||||
- **AND** the mount is skipped
|
||||
- **AND** instance startup continues with remaining mounts
|
||||
- **AND** the instance status is not affected
|
||||
@@ -0,0 +1,82 @@
|
||||
## 1. Database and Models
|
||||
|
||||
- [x] 1.1 Create Alembic migration to add `git_mounts` column to `config_profiles` table (JSONB, nullable, default empty list)
|
||||
- [x] 1.2 Update `ConfigProfile` SQLAlchemy model to include `git_mounts` field
|
||||
- [x] 1.3 Create Pydantic models for GitMount and GitMountCreate schemas with validation
|
||||
- [x] 1.4 Add validation for git mount fields (source_path relative or glob, target_path absolute, no path traversal)
|
||||
|
||||
## 2. Backend API
|
||||
|
||||
- [x] 2.1 Update `POST /config-profiles` endpoint to accept `git_mounts` in request body
|
||||
- [x] 2.2 Update `PUT /config-profiles/{id}` endpoint to accept `git_mounts` updates
|
||||
- [x] 2.3 Update config profile response schemas to include `git_mounts` in output
|
||||
- [x] 2.4 Add validation that referenced repositories exist in the same project
|
||||
- [x] 2.5 Update `GET /config-profiles/{id}/preview` to include resolved git mounts
|
||||
|
||||
## 3. Profile Resolution
|
||||
|
||||
- [x] 3.1 Update `config_profile_resolver.py` to include git mounts in resolved profile output
|
||||
- [x] 3.2 Ensure git mounts from included profiles are merged (with override rules)
|
||||
- [x] 3.3 Add tests for profile resolution with git mounts
|
||||
|
||||
## 4. Instance Startup Integration
|
||||
|
||||
- [x] 4.1 Modify instance startup pipeline to process git mounts from resolved profile
|
||||
- [x] 4.2 Add helper function to clone repository if not present (reusing existing clone service)
|
||||
- [x] 4.3 Add glob pattern expansion for source_path (using standard glob library)
|
||||
- [x] 4.4 Add helper function to checkout specified branch after clone
|
||||
- [x] 4.5 Generate bind mount entries in compose file for each valid git mount
|
||||
- [x] 4.6 Add error logging for missing repos (non-blocking, mount skipped)
|
||||
- [x] 4.7 Ensure git mounts are processed in parallel with other startup steps
|
||||
|
||||
## 5. Frontend Types and API
|
||||
|
||||
- [x] 5.1 Update TypeScript types in `apps/web/src/api/config_profiles.ts` to include GitMount interface
|
||||
- [x] 5.2 Update API client functions to include git_mounts in create/update payloads
|
||||
- [x] 5.3 Add validation helpers for git mount form fields
|
||||
|
||||
## 6. Frontend UI
|
||||
|
||||
- [x] 6.1 Add "Git Mounts" section to config profile editor (below existing mounts)
|
||||
- [x] 6.2 Create GitMountEditor component with repo selector, source/target path inputs (with glob hint), branch selector
|
||||
- [x] 6.3 Add "Add Git Mount" button that opens the editor
|
||||
- [x] 6.4 Display existing git mounts with edit/delete actions
|
||||
- [x] 6.5 Integrate git mounts into profile save flow (include in form submission)
|
||||
- [x] 6.6 Add validation feedback in UI (repo exists, paths valid, branch exists)
|
||||
|
||||
## 7. Testing and Verification
|
||||
|
||||
- [x] 7.1 Backend unit tests for git mount validation
|
||||
- [x] 7.2 Backend integration tests for profile CRUD with git mounts
|
||||
- [x] 7.3 Test instance startup with git mounts (verify bind mounts created)
|
||||
- [x] 7.4 Test auto-clone behavior (clone triggered, mount created)
|
||||
- [x] 7.5 Test clone failure handling (error logged, mount skipped, startup continues)
|
||||
- [x] 7.6 Test glob pattern expansion (files matched, limit enforced)
|
||||
- [x] 7.7 Test branch checkout behavior (success and fallback)
|
||||
- [x] 7.8 Frontend type check passes
|
||||
- [x] 7.9 Frontend production build succeeds
|
||||
- [x] 7.10 Manual end-to-end test: create profile with git mount, start instance, verify files mounted
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
- [x] 8.1 Update API documentation with new git_mounts fields
|
||||
- [x] 8.2 Add user guide section for using git repositories in config profiles
|
||||
- [x] 8.3 Document branch pinning behavior and fallback rules
|
||||
|
||||
## 9. External Repository Support
|
||||
|
||||
- [x] 9.1 Remove project requirement from git mount validation
|
||||
- [x] 9.2 Add endpoint to create external repositories (no project_id)
|
||||
- [x] 9.3 Update list_repositories endpoint to return all user repos
|
||||
- [x] 9.4 Add endpoint to list external repositories
|
||||
- [x] 9.5 Update spec: repos can be external (not tied to project)
|
||||
- [x] 9.6 Update spec: auto-clone to persistent location on every container creation
|
||||
- [x] 9.7 Update spec: pull updates when creating new containers
|
||||
- [x] 9.8 Update spec: per-instance isolation (no shared clones)
|
||||
|
||||
## 10. UI Improvements
|
||||
|
||||
- [x] 10.1 Add ability to create external repositories from git mount editor
|
||||
- [x] 10.2 Show "+ Add new repository..." option in repo dropdown
|
||||
- [x] 10.3 Add form fields for repo name and remote URL
|
||||
- [x] 10.4 Auto-refresh repo list after creating new repository
|
||||
@@ -0,0 +1,37 @@
|
||||
project:
|
||||
name: Headquarter
|
||||
description: Docker-based development platform for managing coding agent tool instances
|
||||
repository: https://git.commumedia.org/alex/headquarter
|
||||
|
||||
stack:
|
||||
backend:
|
||||
framework: FastAPI
|
||||
language: Python 3.11
|
||||
database: PostgreSQL 15 (async SQLAlchemy)
|
||||
cache: Redis 7
|
||||
migrations: Alembic
|
||||
testing: pytest
|
||||
frontend:
|
||||
framework: React + Vite
|
||||
language: TypeScript
|
||||
infrastructure:
|
||||
local: Docker Compose
|
||||
production: Docker Compose + Traefik
|
||||
auth: Authentik SSO
|
||||
|
||||
sdd:
|
||||
execution_mode: interactive
|
||||
artifact_store: openspec
|
||||
chained_pr_strategy: auto-forecast
|
||||
review_budget_lines: 400
|
||||
|
||||
strict_tdd:
|
||||
enabled: true
|
||||
test_command: docker exec hq-api pytest
|
||||
evidence_required: red_green_triangulate_refactor
|
||||
|
||||
phase_rules:
|
||||
explore_before_proposal: true
|
||||
spec_before_design: true
|
||||
design_before_tasks: true
|
||||
verify_before_archive: true
|
||||
@@ -0,0 +1,751 @@
|
||||
# Design: Tool Definition Manifest System
|
||||
|
||||
## Status
|
||||
**Phase:** design
|
||||
**Date:** 2026-05-28
|
||||
**Owner:** el Gentleman
|
||||
**Based on:** Spec `tool-definition-manifest`
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Tool Workshop (Frontend) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ Base Image │ │ Packages │ │ Mount Schema Designer │ │
|
||||
│ │ Selector │ │ Editors │ │ (target, owner, mode) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||
│ │ Live Preview: Dockerfile + Compose ││
|
||||
│ └─────────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ POST /tool-definitions
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ API Backend │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────┐ │
|
||||
│ │ ManifestSchema │───▶│ ManifestCompiler │───▶│ LiveBuild │ │
|
||||
│ │ (validation) │ │ (Dockerfile + │ │ (optional) │ │
|
||||
│ │ │ │ Compose gen) │ │ │ │
|
||||
│ └─────────────────┘ └──────────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ ▼ save to DB
|
||||
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||
│ │ ToolDefinitionManifest (JSONB in PostgreSQL) ││
|
||||
│ └─────────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ POST /instances/{id}/start
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Instance Startup Flow │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Resolve │──▶│ Compile │──▶│ Build Image │ │
|
||||
│ │ Manifest │ │ to Dockerfile│ │ (docker build) │ │
|
||||
│ │ (base merge)│ │ + Compose │ │ │ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────────────┘ │
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Permission │◀──│ docker comp. │◀──│ Generate Compose │ │
|
||||
│ │ Fixer │ │ up │ │ (mount resolution) │ │
|
||||
│ │ (post-start)│ │ │ │ │ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manifest JSON Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": ["name", "interface_type"],
|
||||
"oneOf": [
|
||||
{"required": ["base_image"]},
|
||||
{"required": ["base_definition_id"]}
|
||||
],
|
||||
"properties": {
|
||||
"name": {"type": "string", "pattern": "^[a-z0-9-]+$", "maxLength": 64},
|
||||
"display_name": {"type": "string", "maxLength": 128},
|
||||
"description": {"type": "string"},
|
||||
"category": {"type": "string", "maxLength": 64},
|
||||
"interface_type": {"type": "string", "enum": ["web", "terminal"]},
|
||||
"base_image": {"type": "string", "maxLength": 256},
|
||||
"base_definition_id": {"type": "string", "format": "uuid"},
|
||||
"base_version": {"type": "string", "maxLength": 32, "default": "latest"},
|
||||
"packages": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"apt": {"type": "array", "items": {"type": "string"}},
|
||||
"node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {"type": "string", "pattern": "^\\d+$"}
|
||||
},
|
||||
"required": ["version"]
|
||||
},
|
||||
"npm_global": {"type": "array", "items": {"type": "string"}},
|
||||
"pip": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "maxLength": 32},
|
||||
"uid": {"type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"gid": {"type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"create_home": {"type": "boolean", "default": true},
|
||||
"shell": {"type": "string", "maxLength": 64, "default": "/bin/bash"}
|
||||
},
|
||||
"required": ["name", "uid", "gid"]
|
||||
},
|
||||
"env": {"type": "object", "additionalProperties": {"type": "string"}},
|
||||
"scripts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"build": {"type": "array", "items": {"type": "string"}},
|
||||
"startup": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"mounts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "target", "source_type"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "maxLength": 64},
|
||||
"target": {"type": "string", "maxLength": 256},
|
||||
"source_type": {"type": "string", "enum": ["repo", "ssh_key", "instance", "git_mount", "host_path"]},
|
||||
"writable": {"type": "boolean", "default": true},
|
||||
"owner": {"type": "string", "maxLength": 32},
|
||||
"mode": {"type": "string", "pattern": "^[0-7]{3,4}$"},
|
||||
"file_mode": {"type": "string", "pattern": "^[0-7]{3,4}$"},
|
||||
"readonly": {"type": "boolean", "default": false},
|
||||
"git_mount_ref": {"type": "string", "maxLength": 64}
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "array", "items": {"type": "string"}},
|
||||
"stdin_open": {"type": "boolean", "default": false},
|
||||
"tty": {"type": "boolean", "default": false},
|
||||
"working_dir": {"type": "string", "maxLength": 256}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manifest Compiler Algorithm
|
||||
|
||||
### Step 1: Resolve Base
|
||||
|
||||
```python
|
||||
def resolve_base(manifest: dict) -> dict:
|
||||
"""Merge base definition into the manifest."""
|
||||
if manifest.get("base_definition_id"):
|
||||
base = load_base_definition(manifest["base_definition_id"],
|
||||
manifest.get("base_version", "latest"))
|
||||
# Deep merge: base first, then tool-specific overrides
|
||||
merged = deep_merge(base, manifest)
|
||||
# Remove base fields from the merged result
|
||||
merged.pop("base_definition_id", None)
|
||||
merged.pop("base_version", None)
|
||||
return merged
|
||||
return manifest
|
||||
```
|
||||
|
||||
Merge rules:
|
||||
- `packages`: Union arrays (base apt + tool apt = combined apt)
|
||||
- `env`: Tool overrides base (dict merge, tool wins on key conflict)
|
||||
- `scripts.build`: Concatenate arrays (base scripts first, then tool)
|
||||
- `scripts.startup`: Concatenate arrays
|
||||
- `user`: Tool overrides base entirely
|
||||
- `mounts`: Concatenate arrays
|
||||
- `runtime`: Tool overrides base (dict merge)
|
||||
|
||||
### Step 2: Generate Dockerfile
|
||||
|
||||
```python
|
||||
def compile_dockerfile(manifest: dict) -> str:
|
||||
"""Compile a resolved manifest to a Dockerfile string."""
|
||||
lines = []
|
||||
|
||||
# FROM
|
||||
lines.append(f"FROM {manifest['base_image']}")
|
||||
lines.append("")
|
||||
|
||||
# ENV (build-time)
|
||||
for key, value in manifest.get("env", {}).items():
|
||||
lines.append(f"ENV {key}={shlex.quote(value)}")
|
||||
if manifest.get("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:
|
||||
lines.append(f" {pkg} \\\\")
|
||||
lines.append(" && rm -rf /var/lib/apt/lists/*")
|
||||
lines.append("")
|
||||
|
||||
# Node.js
|
||||
node = manifest.get("packages", {}).get("node")
|
||||
if node:
|
||||
lines.append(
|
||||
f"RUN curl -fsSL https://deb.nodesource.com/setup_{node['version']}.x | bash - && \\\\")
|
||||
lines.append(" apt-get install -y nodejs && \\\\")
|
||||
lines.append(" rm -rf /var/lib/apt/lists/*")
|
||||
lines.append("")
|
||||
|
||||
# NPM global
|
||||
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
|
||||
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:
|
||||
lines.append(
|
||||
f"RUN groupadd -g {user['gid']} {user['name']} && \\\\")
|
||||
lines.append(
|
||||
f" useradd -u {user['uid']} -g {user['gid']} "
|
||||
f"{'-m ' if user.get('create_home', True) else ''}"
|
||||
f"-s {user['shell']} {user['name']}")
|
||||
lines.append("")
|
||||
|
||||
# Build scripts
|
||||
build_scripts = manifest.get("scripts", {}).get("build", [])
|
||||
for script in build_scripts:
|
||||
# Each script block becomes one RUN command
|
||||
# Normalize multi-line scripts
|
||||
normalized = " && ".join(line.strip() for line in script.strip().split("\n") if line.strip())
|
||||
lines.append(f"RUN {normalized}")
|
||||
if build_scripts:
|
||||
lines.append("")
|
||||
|
||||
# Create mount target directories and pre-set ownership
|
||||
mounts = manifest.get("mounts", [])
|
||||
if mounts:
|
||||
dirs = []
|
||||
for mount in mounts:
|
||||
dirs.append(mount["target"])
|
||||
if dirs:
|
||||
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 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)
|
||||
```
|
||||
|
||||
### Step 3: Generate Entrypoint Script
|
||||
|
||||
```python
|
||||
def compile_entrypoint(manifest: dict) -> str:
|
||||
"""Generate the startup entrypoint script."""
|
||||
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)
|
||||
```
|
||||
|
||||
### Step 4: Generate Compose
|
||||
|
||||
```python
|
||||
def compile_compose(manifest: dict, variables: dict) -> str:
|
||||
"""Compile a resolved manifest to a Docker Compose string."""
|
||||
runtime = manifest.get("runtime", {})
|
||||
user = manifest.get("user")
|
||||
interface_type = manifest["interface_type"]
|
||||
|
||||
service = {
|
||||
"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 in compose (helps with permission consistency)
|
||||
if user:
|
||||
service["user"] = f"{user['uid']}:{user['gid']}"
|
||||
|
||||
# Ports for web tools
|
||||
if interface_type == "web" and manifest.get("default_port"):
|
||||
service["ports"] = [f"{variables['TOOL_PORT']}:{manifest['default_port']}"]
|
||||
|
||||
# Environment
|
||||
env = manifest.get("env", {})
|
||||
if env:
|
||||
service["environment"] = env
|
||||
|
||||
# Volumes from mounts
|
||||
volumes = []
|
||||
for mount in manifest.get("mounts", []):
|
||||
source = resolve_mount_source(mount, variables)
|
||||
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)
|
||||
```
|
||||
|
||||
### Step 5: Image Tag Hash
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
||||
"""Deterministic image tag from manifest content."""
|
||||
# Normalize: 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mount Resolution
|
||||
|
||||
Each `source_type` resolves differently at instance creation time:
|
||||
|
||||
| source_type | Resolution | Example |
|
||||
|-------------|------------|---------|
|
||||
| `repo` | `{repo_path}` (mount or clone) | `/data/repos/headquarter` |
|
||||
| `ssh_key` | `{instance_dir}/.ssh` | `/data/instances/.../.ssh` |
|
||||
| `instance` | `{instance_dir}/{name}` | `/data/instances/.../mounts/tmp_.pi_agents` |
|
||||
| `git_mount` | Resolved from ConfigProfile git_mounts | `/data/instances/.../git-mounts/...` |
|
||||
| `host_path` | Literal host path | `/var/run/docker.sock` |
|
||||
|
||||
```python
|
||||
def resolve_mount_source(mount: dict, variables: dict) -> str:
|
||||
source_type = mount["source_type"]
|
||||
if source_type == "repo":
|
||||
return variables["REPO_PATH"]
|
||||
elif source_type == "ssh_key":
|
||||
return variables["SSH_PATH"]
|
||||
elif source_type == "instance":
|
||||
instance_dir = variables["INSTANCE_DIR"]
|
||||
mount_name = mount["name"]
|
||||
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", "")
|
||||
else:
|
||||
raise ValueError(f"Unknown source_type: {source_type}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Permission Fixer (Post-Start)
|
||||
|
||||
```python
|
||||
def apply_mount_permissions(
|
||||
container_id: str,
|
||||
mounts: list[dict],
|
||||
timeout: int = 10
|
||||
) -> list[dict]:
|
||||
"""Apply permission policies to mounted directories in a running container.
|
||||
|
||||
Returns a list of results: [{mount_name, success, error}]
|
||||
"""
|
||||
results = []
|
||||
for mount in mounts:
|
||||
name = mount["name"]
|
||||
target = mount["target"]
|
||||
owner = mount.get("owner")
|
||||
mode = mount.get("mode")
|
||||
file_mode = mount.get("file_mode")
|
||||
|
||||
result = {"mount_name": name, "success": True, "error": None}
|
||||
|
||||
try:
|
||||
if owner:
|
||||
proc = subprocess.run(
|
||||
["docker", "exec", container_id, "chown", "-R",
|
||||
f"{owner}:{owner}", target],
|
||||
capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
result["success"] = False
|
||||
result["error"] = f"chown failed: {proc.stderr}"
|
||||
|
||||
if mode and result["success"]:
|
||||
proc = subprocess.run(
|
||||
["docker", "exec", container_id, "chmod", mode, target],
|
||||
capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
result["success"] = False
|
||||
result["error"] = f"chmod failed: {proc.stderr}"
|
||||
|
||||
if file_mode and result["success"]:
|
||||
proc = subprocess.run(
|
||||
["docker", "exec", container_id, "sh", "-c",
|
||||
f"find {target} -type f -exec chmod {file_mode} {{}} +"],
|
||||
capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
result["success"] = False
|
||||
result["error"] = f"file_mode chmod failed: {proc.stderr}"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
result["success"] = False
|
||||
result["error"] = "Permission fix timed out"
|
||||
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
**Important:** The permission fixer checks if `root` exists in the container before running. If the container has no `root` user (e.g., distroless images), it logs a warning and skips.
|
||||
|
||||
---
|
||||
|
||||
## Modified Startup Flow
|
||||
|
||||
The `start_instance` function in `tool_instances.py` is modified as follows:
|
||||
|
||||
```python
|
||||
async def start_instance(...):
|
||||
# ... existing validation ...
|
||||
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
|
||||
if tool_type.definition_type == "manifest" and tool_type.manifest_id:
|
||||
# NEW: Manifest-based startup flow
|
||||
manifest = await load_manifest(session, tool_type.manifest_id)
|
||||
|
||||
# Merge base definition
|
||||
resolved = resolve_base(manifest)
|
||||
|
||||
# Merge tool configs and config profile
|
||||
resolved = apply_tool_configs(resolved, configs)
|
||||
resolved = apply_config_profile(resolved, profile)
|
||||
|
||||
# Compile
|
||||
dockerfile = compile_dockerfile(resolved)
|
||||
entrypoint = compile_entrypoint(resolved)
|
||||
image_tag = compute_image_tag(tool_type.name, resolved)
|
||||
|
||||
# Build image
|
||||
build_context = {
|
||||
"Dockerfile": dockerfile,
|
||||
".headquarter/entrypoint.sh": entrypoint,
|
||||
}
|
||||
returncode, stdout, stderr = build_image(
|
||||
instance_dir=instance_dir,
|
||||
dockerfile=dockerfile, # The Dockerfile references entrypoint.sh
|
||||
tag=image_tag,
|
||||
build_context=build_context,
|
||||
)
|
||||
|
||||
# Generate compose with resolved variables
|
||||
variables = {
|
||||
"IMAGE_TAG": image_tag,
|
||||
"INSTANCE_NAME": instance.name.lower(),
|
||||
"REPO_PATH": repo_path,
|
||||
"SSH_PATH": ssh_dir,
|
||||
"INSTANCE_DIR": instance_dir,
|
||||
# ... git mount resolutions from config profile ...
|
||||
}
|
||||
compose_content = compile_compose(resolved, variables)
|
||||
write_compose_file(instance_dir, compose_content)
|
||||
|
||||
# Store image tag for reuse
|
||||
instance.image_tag = image_tag
|
||||
|
||||
else:
|
||||
# LEGACY: Existing dockerfile_template / compose_template flow
|
||||
...
|
||||
|
||||
# ... docker compose up ...
|
||||
# ... wait for running ...
|
||||
|
||||
# NEW: Apply mount permissions post-start
|
||||
if tool_type.definition_type == "manifest":
|
||||
manifest = await load_manifest(session, tool_type.manifest_id)
|
||||
resolved = resolve_base(manifest)
|
||||
permission_results = apply_mount_permissions(
|
||||
instance.container_id,
|
||||
resolved.get("mounts", [])
|
||||
)
|
||||
for result in permission_results:
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"Permission fix failed for mount %s: %s",
|
||||
result["mount_name"], result["error"]
|
||||
)
|
||||
|
||||
# ... readiness probe ...
|
||||
# ... tunnel creation ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
apps/api/src/
|
||||
├── models/
|
||||
│ ├── tool_definition_manifest.py # NEW: SQLAlchemy model
|
||||
│ └── tool_type.py # MOD: add manifest_id, definition_type
|
||||
├── services/
|
||||
│ ├── manifest_compiler.py # NEW: compile_dockerfile, compile_compose, resolve_base
|
||||
│ ├── permission_fixer.py # NEW: apply_mount_permissions
|
||||
│ └── docker_build.py # MOD: support build_context files
|
||||
├── api/
|
||||
│ ├── tool_definitions.py # NEW: CRUD + compile endpoints
|
||||
│ └── tool_instances.py # MOD: manifest-based startup flow
|
||||
├── schemas/
|
||||
│ └── manifest_schema.py # NEW: JSON Schema + Pydantic validators
|
||||
└── alembic/versions/
|
||||
└── 20260528_add_tool_definition_manifests.py # NEW: migration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
| Module | Tests | Coverage |
|
||||
|--------|-------|----------|
|
||||
| `manifest_compiler.py` | Dockerfile generation for all package managers, base merging, entrypoint generation | All branches |
|
||||
| `permission_fixer.py` | chown/chmod success, failure, timeout, missing root user | All branches |
|
||||
| `manifest_schema.py` | Valid manifest acceptance, invalid manifest rejection (all error paths) | All validation rules |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
| Scenario | Test |
|
||||
|----------|------|
|
||||
| Manifest → Dockerfile → Build | Create manifest, compile, build image, verify it runs |
|
||||
| ConfigProfile merge | Start instance with profile, verify mounts merged correctly |
|
||||
| Permission fix | Start non-root container, verify workspace is writable |
|
||||
| SSH key mount | Start clone-mode instance, verify SSH keys accessible and have correct permissions |
|
||||
| Legacy compatibility | Start instance from old dockerfile_template tool type, verify it still works |
|
||||
| Base versioning | Create tool with base v1, update base to v2, verify tool still uses v1 |
|
||||
|
||||
### E2E Tests
|
||||
|
||||
| Scenario | Test |
|
||||
|----------|------|
|
||||
| Tool Workshop CRUD | Create, edit, preview, delete a tool definition via UI |
|
||||
| Instance lifecycle | Create instance from manifest tool, start, terminal connect, stop, delete |
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Step 1: Schema Migration
|
||||
|
||||
```python
|
||||
# alembic migration
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
def upgrade():
|
||||
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"
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column("tool_types", sa.Column("manifest_id", sa.UUID(), nullable=True))
|
||||
op.add_column("tool_types", sa.Column("definition_type", sa.String(16), nullable=False, server_default="legacy"))
|
||||
op.create_foreign_key(
|
||||
"fk_tool_types_manifest_id",
|
||||
"tool_types", "tool_definition_manifests",
|
||||
["manifest_id"], ["id"]
|
||||
)
|
||||
|
||||
op.add_column("tool_instances", sa.Column("manifest_compiled_at", sa.TIMESTAMP(timezone=True), nullable=True))
|
||||
op.add_column("tool_instances", sa.Column("image_tag", sa.String(256), nullable=True))
|
||||
```
|
||||
|
||||
### Step 2: Data Migration
|
||||
|
||||
Convert the existing pi-agent tool type from `dockerfile_template` to manifest:
|
||||
|
||||
```python
|
||||
def upgrade_data():
|
||||
conn = op.get_bind()
|
||||
|
||||
# Create the base definition for ubuntu-24.04-dev
|
||||
base_id = uuid.uuid4()
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO tool_definition_manifests
|
||||
(id, name, display_name, description, interface_type, base_image, manifest, is_base, version)
|
||||
VALUES (:id, 'ubuntu-24.04-dev', 'Ubuntu 24.04 Dev Base', 'Base development environment',
|
||||
'terminal', 'ubuntu:24.04', :manifest, true, 'v1')
|
||||
"""), {
|
||||
"id": base_id,
|
||||
"manifest": json.dumps({
|
||||
"name": "ubuntu-24.04-dev",
|
||||
"base_image": "ubuntu:24.04",
|
||||
"packages": {"apt": ["curl", "wget", "git", "build-essential", "ca-certificates"]},
|
||||
"user": {"name": "user", "uid": 1000, "gid": 1000},
|
||||
})
|
||||
})
|
||||
|
||||
# Create pi-agent manifest referencing the base
|
||||
pi_agent_id = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO tool_definition_manifests
|
||||
(id, name, display_name, description, category, interface_type,
|
||||
base_definition_id, base_version, manifest, version)
|
||||
VALUES (:id, 'pi-agent', 'Pi Agent', 'Terminal-based coding harness',
|
||||
'development', 'terminal', :base_id, 'v1', :manifest, 'v1')
|
||||
"""), {
|
||||
"id": pi_agent_id,
|
||||
"base_id": base_id,
|
||||
"manifest": json.dumps({
|
||||
"name": "pi-agent",
|
||||
"display_name": "Pi Agent",
|
||||
"interface_type": "terminal",
|
||||
"packages": {
|
||||
"apt": ["neovim", "ranger", "tmux", "htop", "tree", "jq", "python3", "python3-pip"],
|
||||
"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 the existing tool_types row
|
||||
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_id})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Workload Forecast
|
||||
|
||||
| PR | Scope | Est. Lines | Review Risk |
|
||||
|----|-------|-----------|-------------|
|
||||
| PR 1: Backend | Compiler, fixer, API, tests, migration | ~1800 | Medium — core algorithm changes |
|
||||
| PR 2: Frontend | Tool Workshop UI | ~1200 | Medium — new feature, self-contained |
|
||||
| PR 3: Migration | Data migration, legacy fallback | ~300 | Low — additive only |
|
||||
|
||||
All PRs are under the 400-line budget individually. Chained PRs recommended for sequential review.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Tool Workshop User Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Tool Workshop lets you define and manage tool types — the blueprints for
|
||||
containers that run inside Headquarter. Each tool type specifies how to build
|
||||
and start a container (Docker image, Compose file, or a declarative manifest).
|
||||
|
||||
## Definition Types
|
||||
|
||||
### 1. Compose (Legacy)
|
||||
Write a raw Docker Compose template. Variable substitution is supported:
|
||||
- `${REPO_PATH}` — path to the mounted repository
|
||||
- `${TOOL_PORT}` — dynamically assigned free port
|
||||
- `${INSTANCE_NAME}` — generated instance name
|
||||
- `${USER_ID}`, `${PROJECT_ID}` — IDs for reference
|
||||
|
||||
Best for: simple web services, databases, or anything that already has a
|
||||
Docker image.
|
||||
|
||||
### 2. Dockerfile (Legacy)
|
||||
Write a raw Dockerfile. Headquarter builds the image and generates a minimal
|
||||
Compose file automatically.
|
||||
|
||||
Best for: custom environments where you need full control over the image build.
|
||||
|
||||
### 3. Manifest (Declarative) — **Recommended**
|
||||
Define your tool with structured JSON instead of raw Docker files:
|
||||
- **Base image** — pick a base definition (e.g. `ubuntu-24.04-dev`)
|
||||
- **Packages** — declare apt, npm global, pip, and Node.js version
|
||||
- **Scripts** — build scripts (run at image build time) and startup scripts
|
||||
(run when container starts)
|
||||
- **Mounts** — workspace, SSH keys, instance state, git-mounted dotfiles
|
||||
- **Runtime** — command, working directory, stdin/tty settings
|
||||
- **Live preview** — see generated Dockerfile and Compose as you edit
|
||||
|
||||
Best for: reproducible, versioned, self-documenting tool definitions.
|
||||
|
||||
## Creating a Manifest-Based Tool
|
||||
|
||||
1. Go to **Settings → Tool Workshop**
|
||||
2. Click **New Tool Type**
|
||||
3. Select **Manifest (Declarative)** as the definition type
|
||||
4. Choose a **Base Image** (e.g. `ubuntu-24.04-dev v1`)
|
||||
5. Add packages:
|
||||
- Apt: `neovim`, `tmux`, `git`
|
||||
- NPM global: `@earendil-works/pi-coding-agent`
|
||||
- Node.js version: `20`
|
||||
6. Add build scripts (e.g. configure git defaults)
|
||||
7. Add startup scripts (e.g. fix workspace permissions)
|
||||
8. Configure mounts:
|
||||
- Workspace → `/workspace` (writable)
|
||||
- SSH keys → `/home/user/.ssh` (readonly, mode 0700)
|
||||
9. Set runtime: command `/bin/bash`, working dir `/workspace`
|
||||
10. Click **Preview** to verify generated Dockerfile and Compose
|
||||
11. Save
|
||||
|
||||
## Base Definitions
|
||||
|
||||
Base definitions are versioned manifest templates that other tools extend.
|
||||
They are marked with the **Base** badge in the list.
|
||||
|
||||
The default base `ubuntu-24.04-dev` provides:
|
||||
- Ubuntu 24.04 base image
|
||||
- Common build tools (curl, wget, git, build-essential)
|
||||
- A non-root `user` account (uid 1000)
|
||||
|
||||
## Migration from Legacy
|
||||
|
||||
Existing tool types using Compose or Dockerfile continue to work unchanged.
|
||||
You can migrate a tool type to Manifest by:
|
||||
1. Editing the tool type
|
||||
2. Switching definition type to **Manifest**
|
||||
3. Re-creating the configuration in the manifest editor
|
||||
4. Saving (the old template is cleared automatically)
|
||||
|
||||
## Permissions
|
||||
|
||||
For manifest-based tools, mount permissions are fixed automatically after the
|
||||
container starts. The system runs `chown` and `chmod` via `docker exec` as
|
||||
root, then drops back to the configured runtime user.
|
||||
@@ -0,0 +1,362 @@
|
||||
# SDD Exploration: Streamline Tool Container Definitions
|
||||
|
||||
## Status
|
||||
**Phase:** explore
|
||||
**Date:** 2026-05-28
|
||||
**Owner:** el Gentleman (parent session)
|
||||
**Scope:** Tool container definition, build, and mount system
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The current tool container system works for the happy path (pi-agent on Ubuntu) but has deep structural inflexibility:
|
||||
|
||||
1. **Monolithic Dockerfile strings** in the database — impossible to review, version, or compose
|
||||
2. **Ad-hoc compose generation** — string formatting with hardcoded fields (`stdin_open`, `tty`, `working_dir` missing for dockerfile types)
|
||||
3. **Hardcoded mount paths** — `/workspace` and `/root/.ssh` don't adapt to the container's runtime user
|
||||
4. **No package/base-image modularity** — every tool type carries a full Dockerfile copy
|
||||
5. **Permission mismatch** — bind mounts come in as root-owned; non-root container users can't write
|
||||
6. **Config overlap** — tool configs, config profiles, and compose templates fight for control of the same fields
|
||||
|
||||
This exploration proposes a **layered, declarative container definition system** where tool types compose from reusable base images, mount schemas, and permission policies.
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture Map
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
ToolType (DB table)
|
||||
├── name, display_name, description, category
|
||||
├── interface_type: "web" | "terminal"
|
||||
├── definition_type: "dockerfile" | "compose"
|
||||
├── dockerfile_template: TEXT (giant Dockerfile string)
|
||||
├── compose_template: TEXT (Jinja-like {{VAR}} string)
|
||||
├── build_context: JSON {path: content}
|
||||
├── required_variables: JSON ["REPO_PATH", ...]
|
||||
├── default_port: int
|
||||
└── readiness_probe: JSON
|
||||
|
||||
ToolInstance (DB table)
|
||||
├── name, display_name, status
|
||||
├── tool_type_id → ToolType
|
||||
├── repository_id → GitRepository
|
||||
├── compose_path: str
|
||||
├── container_id, container_name
|
||||
├── port, url, public_url, tunnel_id
|
||||
├── clone_mode: "mount" | "clone"
|
||||
├── branch, new_branch
|
||||
└── selected_config_profile_id → ConfigProfile
|
||||
|
||||
ToolConfig (DB table, per-user per-tool-type)
|
||||
├── config_type: "env" | "file"
|
||||
├── key, value, file_path
|
||||
├── port_override, start_command, working_directory
|
||||
├── environment_variables: JSON
|
||||
└── volumes: JSON [{source, target, type}]
|
||||
|
||||
ConfigProfile (DB table)
|
||||
├── name, description
|
||||
├── user_id, project_id, tool_type_id
|
||||
├── environment_variables: JSON
|
||||
├── files: JSON {path: content}
|
||||
├── mounts: JSON [{source, target, type}]
|
||||
├── git_mounts: JSON [{remote_url, source_path, target_path, branch}]
|
||||
└── parent_profile_id → ConfigProfile (hierarchy)
|
||||
```
|
||||
|
||||
### Creation Flow (`create_instance`)
|
||||
|
||||
```
|
||||
POST /projects/{id}/repositories/{id}/instances
|
||||
→ validate tool_type, repo, config_profile
|
||||
→ generate instance_name = "{tool_type}-{repo}-{uuid8}"
|
||||
→ ensure_instance_directory(instance_name)
|
||||
→ find_free_port()
|
||||
→ determine repo_path (mount = repo.path; clone = clone_repository())
|
||||
→ IF tool_type.definition_type == "dockerfile":
|
||||
build_image(instance_dir, dockerfile_template, tag, build_context)
|
||||
generate compose_content (HARDCODED STRING FORMATTING)
|
||||
ELSE:
|
||||
render_compose_template(tool_type.compose_template, variables)
|
||||
→ write_compose_file()
|
||||
→ create ToolInstance DB record (status="pending")
|
||||
```
|
||||
|
||||
### Startup Flow (`start_instance`)
|
||||
|
||||
```
|
||||
POST /instances/{id}/start
|
||||
→ fetch ToolConfigs (env, files, port_override, start_command, working_dir, volumes)
|
||||
→ IF selected_config_profile:
|
||||
resolve_profile() → env, files, mounts, git_mounts, hints
|
||||
→ write .env file, config files
|
||||
→ IF clone_mode: mount SSH keys at /root/.ssh (HARDCODED)
|
||||
→ _modify_compose_file(port, command, working_dir, extra_volumes)
|
||||
→ _sanitize_compose_file()
|
||||
→ execute_compose_command("up")
|
||||
→ get_container_id(instance.name) ← CASE-SENSITIVE BUG (fixed)
|
||||
→ get_container_name(instance.name)
|
||||
→ connect_container_to_network("backend")
|
||||
→ wait_for_container_running()
|
||||
→ IF web: start_cloudflared_tunnel()
|
||||
→ instance.status = "running"
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `apps/api/src/api/tool_instances.py` | create_instance, start_instance, stop_instance, restart_instance, proxy, logs |
|
||||
| `apps/api/src/api/tool_types.py` | CRUD for ToolType (DB strings) |
|
||||
| `apps/api/src/services/docker.py` | compose execution, container queries, tunnel management |
|
||||
| `apps/api/src/services/docker_build.py` | `docker build` wrapper |
|
||||
| `apps/api/src/services/terminal_session.py` | PTY-based terminal over `docker exec` |
|
||||
| `apps/api/src/services/terminal_manager.py` | WebSocket ↔ terminal session lifecycle |
|
||||
| `apps/api/src/models/tool_type.py` | SQLAlchemy model |
|
||||
|
||||
---
|
||||
|
||||
## Pain Points (Detailed)
|
||||
|
||||
### 1. Monolithic Dockerfile Templates
|
||||
|
||||
The pi-agent Dockerfile template is a 40-line string stored in the DB migration:
|
||||
|
||||
```sql
|
||||
INSERT INTO tool_types (... dockerfile_template ...)
|
||||
VALUES ('...# Pi Coding Agent - Terminal-based coding harness\nFROM ubuntu:24.04\n...')
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
- No syntax highlighting, linting, or `docker build` validation at edit time
|
||||
- Every tool type copies the entire Dockerfile; no reuse of common layers
|
||||
- Changes require a DB migration
|
||||
- No way for users to customize packages without forking the whole template
|
||||
|
||||
### 2. Ad-Hoc Compose Generation
|
||||
|
||||
For `dockerfile` type tools, the compose is generated by Python f-string:
|
||||
|
||||
```python
|
||||
compose_content = f"""version: "3.8"
|
||||
services:
|
||||
app:
|
||||
image: {image_tag}
|
||||
container_name: {instance_name.lower()}
|
||||
{ports_section} volumes:
|
||||
- {repo_path}:/workspace
|
||||
restart: unless-stopped
|
||||
"""
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
- Missing `stdin_open: true` and `tty: true` (essential for terminal tools)
|
||||
- Missing `working_dir: /workspace`
|
||||
- No way to add labels, networks, healthchecks, or extra services
|
||||
- Port section is conditionally included with awkward string concatenation
|
||||
|
||||
### 3. Hardcoded Mount Paths
|
||||
|
||||
| Mount | Current Target | Problem |
|
||||
|-------|---------------|---------|
|
||||
| Repository | `/workspace` | Always root-owned; no permission fix for non-root users |
|
||||
| SSH keys (clone mode) | `/root/.ssh` | Invisible to containers running as `user` |
|
||||
| Git-mount configs | `/tmp/.pi` | May be root-owned; conflicts with user's `.pi` |
|
||||
| Config profile files | Instance-relative paths | No validation against container filesystem |
|
||||
|
||||
### 4. No Base Image / Layer Composition
|
||||
|
||||
Every tool type must specify a complete Dockerfile from `FROM` to `CMD`. There's no way to say:
|
||||
|
||||
```yaml
|
||||
base: ubuntu-24.04-dev # pre-built with curl, git, build-essential
|
||||
layers:
|
||||
- nodejs-20
|
||||
- pi-coding-agent
|
||||
- custom-packages: [neovim, ranger, tmux]
|
||||
```
|
||||
|
||||
### 5. Permission Mismatch (Non-Root Users)
|
||||
|
||||
The pi-agent Dockerfile creates a `user` account and uses `USER user`. Bind mounts from the host come in as root-owned. The API has **no automatic permission fix** — this caused the workspace-unwritable bug.
|
||||
|
||||
Workarounds considered:
|
||||
- Post-start `docker exec --user root chown` (current fix)
|
||||
- Dockerfile entrypoint script that chowns before dropping privileges
|
||||
- Matching container UID to host UID
|
||||
|
||||
None of these are systematic or configurable.
|
||||
|
||||
### 6. Config Overlap and Precedence Confusion
|
||||
|
||||
Three systems control the same container aspects:
|
||||
|
||||
| System | Controls | Stored |
|
||||
|--------|----------|--------|
|
||||
| ToolConfig | env vars, files, port, command, working_dir, volumes | DB (per-user per-tool) |
|
||||
| ConfigProfile | env vars, files, mounts, git_mounts, hints | DB (hierarchical) |
|
||||
| Compose template / generation | volumes, ports, command, working_dir | DB string / Python f-string |
|
||||
|
||||
**Precedence is unclear:**
|
||||
- ToolConfig `working_directory` vs ConfigProfile hint `working_directory` vs compose `working_dir`
|
||||
- ToolConfig `volumes` vs ConfigProfile `mounts` vs compose `volumes`
|
||||
- `start_command` from ToolConfig vs ConfigProfile vs Dockerfile `CMD`
|
||||
|
||||
### 7. Build Context Limitations
|
||||
|
||||
`build_context` is a JSON dictionary of `{relative_path: file_content}`. This is stored in the DB as text.
|
||||
|
||||
**Problems:**
|
||||
- Binary files (images, tarballs) can't be stored
|
||||
- Large files bloat the DB
|
||||
- No versioning or external reference (e.g., "use file from git repo")
|
||||
|
||||
---
|
||||
|
||||
## Extensibility Gaps
|
||||
|
||||
| Want | Current State | Gap |
|
||||
|------|--------------|-----|
|
||||
| Add a new language runtime (e.g., Go, Rust) | Copy entire Dockerfile, edit | No modular package/layer system |
|
||||
| Use a custom base image (e.g., `my-registry/dev-base:v2`) | Edit full Dockerfile | No base-image reference field |
|
||||
| Mount a second repo or a secrets file | Write ConfigProfile or ToolConfig JSON | No declarative mount schema |
|
||||
| Run as root instead of `user` | Edit full Dockerfile | No runtime-user field |
|
||||
| Add a sidecar (e.g., postgres for integration tests) | Edit compose_template string | No multi-service compose support |
|
||||
| Pre-install VS Code server | Edit full Dockerfile | No "feature" or "extension" mechanism |
|
||||
| Custom entrypoint script | Edit full Dockerfile | No entrypoint field |
|
||||
|
||||
---
|
||||
|
||||
## Design Directions (Pre-Proposal)
|
||||
|
||||
### Direction A: Declarative Tool Manifests
|
||||
|
||||
Replace the monolithic `dockerfile_template` with a structured manifest:
|
||||
|
||||
```yaml
|
||||
# Example: tool manifest for pi-agent
|
||||
name: pi-agent
|
||||
base_image: ubuntu:24.04
|
||||
user:
|
||||
name: user
|
||||
uid: 1000
|
||||
home: /home/user
|
||||
packages:
|
||||
apt: [curl, wget, git, neovim, ranger, tmux, htop, tree, jq, python3, python3-pip, build-essential]
|
||||
npm_global: [@earendil-works/pi-coding-agent]
|
||||
node_version: "20"
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
config_files:
|
||||
/home/user/.tmux.conf: "set -g mouse on\n..."
|
||||
/home/user/.config/ranger/rc.conf: "set preview_files true\n..."
|
||||
working_directory: /workspace
|
||||
command: ["/bin/bash"]
|
||||
ports: []
|
||||
mounts:
|
||||
repo: {target: /workspace, writable: true}
|
||||
ssh: {target: /home/user/.ssh, mode: "0600"}
|
||||
tmp_state: {target: /tmp/.pi, writable: true}
|
||||
```
|
||||
|
||||
**Pros:** Structured, reviewable, composable
|
||||
**Cons:** Requires a manifest-to-Dockerfile compiler; migration complexity
|
||||
|
||||
### Direction B: Base Image Registry + Layers
|
||||
|
||||
Maintain a registry of pre-built base images:
|
||||
|
||||
```
|
||||
headquarter/base/ubuntu-24.04-dev
|
||||
headquarter/base/nodejs-20
|
||||
headquarter/base/python-3.11
|
||||
```
|
||||
|
||||
Tool types reference a base image and a list of layers:
|
||||
|
||||
```yaml
|
||||
base_image: headquarter/base/ubuntu-24.04-dev
|
||||
layers:
|
||||
- type: npm_install
|
||||
package: @earendil-works/pi-coding-agent
|
||||
- type: config_file
|
||||
path: /home/user/.tmux.conf
|
||||
content: "..."
|
||||
```
|
||||
|
||||
**Pros:** Fast builds (base images cached), reusable, versioned
|
||||
**Cons:** Requires image registry management, layer ordering complexity
|
||||
|
||||
### Direction C: Compose-First with Dockerfile Overrides
|
||||
|
||||
Treat `compose_template` as the primary definition. For simple cases, use a pre-built image. For custom cases, allow an inline Dockerfile or a `build` section in the compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM ubuntu:24.04
|
||||
...
|
||||
stdin_open: true
|
||||
tty: true
|
||||
working_dir: /workspace
|
||||
volumes:
|
||||
- ${REPO_PATH}:/workspace
|
||||
- ${SSH_PATH}:/home/user/.ssh:ro
|
||||
user: "${CONTAINER_USER:-user}"
|
||||
```
|
||||
|
||||
**Pros:** Leverages Docker Compose native features, familiar to users
|
||||
**Cons:** Still string-based; inline Dockerfiles are hard to edit
|
||||
|
||||
### Direction D: Permission-Aware Mount Schema
|
||||
|
||||
Decouple mount declaration from mount implementation:
|
||||
|
||||
```python
|
||||
class MountPolicy:
|
||||
source: str # host path
|
||||
target: str # container path
|
||||
owner: str | None # container user to own the mount
|
||||
permissions: str # chmod string
|
||||
readonly: bool
|
||||
```
|
||||
|
||||
At startup, the API runs a post-start "permission fixer" that applies all policies:
|
||||
|
||||
```bash
|
||||
docker exec --user root <container> chown -R <owner> <target>
|
||||
docker exec --user root <container> chmod <permissions> <target>
|
||||
```
|
||||
|
||||
**Pros:** Systematic, works with any base image, configurable per mount
|
||||
**Cons:** Adds startup latency, requires root to exist in container
|
||||
|
||||
---
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Proposal phase:** Evaluate Direction A (Declarative Manifests) vs Direction C (Compose-First) for the primary architecture
|
||||
2. **Design phase:** Detail the manifest schema or compose enhancement, migration path, and API changes
|
||||
3. **Consider Direction D** as a cross-cutting concern regardless of primary direction
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- **Migration risk:** Existing `dockerfile_template` and `compose_template` columns need backward-compatible migration
|
||||
- **Build cache invalidation:** Changing the build system may invalidate Docker layer caches
|
||||
- **User confusion:** Adding a manifest layer on top of Dockerfiles may feel like "yet another abstraction"
|
||||
- **Scope creep:** This touches tool types, tool configs, config profiles, compose generation, and the startup flow — high cross-cutting surface
|
||||
|
||||
---
|
||||
|
||||
## Artifacts
|
||||
|
||||
- `openspec/config.yaml` — SDD configuration
|
||||
- `openspec/explorations/streamline-tool-container-definitions.md` — This document
|
||||
@@ -0,0 +1,447 @@
|
||||
# SDD Proposal: Declarative Tool Container Compiler
|
||||
|
||||
## Status
|
||||
**Phase:** proposal
|
||||
**Date:** 2026-05-28
|
||||
**Owner:** el Gentleman
|
||||
**Based on:** Exploration `streamline-tool-container-definitions`
|
||||
|
||||
---
|
||||
|
||||
## User Story
|
||||
|
||||
As a platform operator, I want to define a tool container by specifying:
|
||||
- A **base image** (e.g. `ubuntu:24.04` or a pre-built `headquarter/base:dev-ubuntu`)
|
||||
- A **list of packages** to install (apt, npm, pip, etc.)
|
||||
- **Setup scripts** that run at build-time or container-startup
|
||||
- **Mount policies** that automatically fix permissions for the runtime user
|
||||
|
||||
I do **not** want to write Dockerfiles or Compose files by hand.
|
||||
|
||||
The system should compile these declarations into Dockerfiles and Compose files automatically, while remaining fully compatible with the existing ConfigProfile mount system.
|
||||
|
||||
---
|
||||
|
||||
## Core Concept: The Tool Definition Manifest
|
||||
|
||||
Replace the monolithic `dockerfile_template` and `compose_template` strings with a single structured **Tool Definition Manifest**.
|
||||
|
||||
```yaml
|
||||
# Tool Definition Manifest (stored as JSON in DB)
|
||||
name: pi-agent
|
||||
display_name: "Pi Agent"
|
||||
description: "Terminal-based coding harness"
|
||||
category: development
|
||||
interface_type: terminal # web | terminal
|
||||
|
||||
# ── Base Image ───────────────────────────────────────────────
|
||||
base_image: ubuntu:24.04
|
||||
# OR reference a pre-built base definition:
|
||||
# base_definition_id: "base-ubuntu-24.04-dev"
|
||||
|
||||
# ── Packages ─────────────────────────────────────────────────
|
||||
packages:
|
||||
apt:
|
||||
- curl
|
||||
- wget
|
||||
- git
|
||||
- neovim
|
||||
- ranger
|
||||
- tmux
|
||||
- htop
|
||||
- tree
|
||||
- jq
|
||||
- ca-certificates
|
||||
- python3
|
||||
- python3-pip
|
||||
- build-essential
|
||||
node:
|
||||
version: "20" # triggers nodesource setup
|
||||
npm_global:
|
||||
- "@earendil-works/pi-coding-agent"
|
||||
# pip:
|
||||
# - requests
|
||||
# - httpx
|
||||
|
||||
# ── Runtime User ─────────────────────────────────────────────
|
||||
user:
|
||||
name: user
|
||||
uid: 1001
|
||||
gid: 1001
|
||||
create_home: true
|
||||
shell: /bin/bash
|
||||
|
||||
# ── Environment ──────────────────────────────────────────────
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
|
||||
# ── Setup Scripts ────────────────────────────────────────────
|
||||
scripts:
|
||||
# build: runs during `docker build` → becomes RUN commands
|
||||
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: runs when container starts → becomes entrypoint script
|
||||
startup:
|
||||
- |
|
||||
# Ensure workspace is owned by runtime user
|
||||
if [ -d /workspace ]; then
|
||||
sudo chown -R user:user /workspace 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Mount Schema ─────────────────────────────────────────────
|
||||
mounts:
|
||||
- name: workspace
|
||||
target: /workspace
|
||||
source_type: repo # resolved from repository path at instance creation
|
||||
writable: true
|
||||
owner: user # post-start: chown -R user:user /workspace
|
||||
|
||||
- name: ssh_keys
|
||||
target: /home/user/.ssh
|
||||
source_type: ssh_key # resolved from repository's SSH key
|
||||
mode: "0700" # post-start: chmod 0700 /home/user/.ssh
|
||||
file_mode: "0600" # post-start: chmod 0600 files inside
|
||||
readonly: true
|
||||
|
||||
- name: pi_state
|
||||
target: /tmp/.pi/agents
|
||||
source_type: instance # resolved to {instance_dir}/mounts/tmp_.pi_agents
|
||||
writable: true
|
||||
|
||||
- name: pi_config
|
||||
target: /home/user/.pi
|
||||
source_type: git_mount # resolved from config profile git_mounts
|
||||
git_mount_ref: dotfiles # references a named git mount in the config profile
|
||||
writable: true
|
||||
owner: user
|
||||
|
||||
# ── Runtime ──────────────────────────────────────────────────
|
||||
runtime:
|
||||
command: ["/bin/bash"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
working_dir: /workspace
|
||||
# ports are auto-derived from interface_type:
|
||||
# web: expose default_port
|
||||
# terminal: no port mapping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Compiles
|
||||
|
||||
### 1. Dockerfile Generation
|
||||
|
||||
The manifest compiler transforms the spec into a Dockerfile:
|
||||
|
||||
```dockerfile
|
||||
# Generated Dockerfile — do not edit manually
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── System Packages ──
|
||||
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/*
|
||||
|
||||
# ── Node.js ──
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── NPM Packages ──
|
||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||
|
||||
# ── Runtime User ──
|
||||
RUN groupadd -g 1001 user && \
|
||||
useradd -u 1001 -g 1001 -m -s /bin/bash user
|
||||
|
||||
# ── Build Scripts ──
|
||||
RUN git config --global init.defaultBranch main && \
|
||||
git config --global user.email "dev@headquarter.local" && \
|
||||
git config --global user.name "Developer"
|
||||
|
||||
RUN mkdir -p /home/user/.config/ranger && \
|
||||
echo 'set preview_files true' > /home/user/.config/ranger/rc.conf
|
||||
|
||||
# ── Environment ──
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── Setup Directories ──
|
||||
RUN mkdir -p /workspace /tmp/.pi/agents /home/user/.pi && \
|
||||
chown -R user:user /workspace /tmp/.pi /home/user/.pi
|
||||
|
||||
# ── Entrypoint for Startup Scripts ──
|
||||
COPY .headquarter/entrypoint.sh /usr/local/bin/headquarter-entrypoint
|
||||
RUN chmod +x /usr/local/bin/headquarter-entrypoint
|
||||
|
||||
USER user
|
||||
WORKDIR /home/user
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]
|
||||
CMD ["/bin/bash"]
|
||||
```
|
||||
|
||||
The generated `entrypoint.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Run startup scripts
|
||||
echo "Ensure workspace is owned by runtime user"
|
||||
if [ -d /workspace ]; then
|
||||
sudo chown -R user:user /workspace 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Pass through to the main command
|
||||
exec "$@"
|
||||
```
|
||||
|
||||
### 2. Compose File Generation
|
||||
|
||||
The manifest compiler also generates the Compose file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
container_name: ${INSTANCE_NAME}
|
||||
stdin_open: true
|
||||
tty: true
|
||||
working_dir: /workspace
|
||||
user: "1001:1001" # from manifest user.uid/gid
|
||||
volumes:
|
||||
- ${REPO_PATH}:/workspace
|
||||
- ${SSH_PATH}:/home/user/.ssh:ro
|
||||
- ${INSTANCE_DIR}/mounts/tmp_.pi_agents:/tmp/.pi/agents
|
||||
- ${GIT_MOUNT_dotfiles}:/home/user/.pi
|
||||
environment:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Variables are resolved at instance creation time:
|
||||
- `${REPO_PATH}` → the repository working directory
|
||||
- `${SSH_PATH}` → prepared SSH key directory
|
||||
- `${INSTANCE_DIR}` → the instance working directory
|
||||
- `${GIT_MOUNT_dotfiles}` → resolved from config profile git_mounts
|
||||
|
||||
### 3. Permission Fixer (Post-Start)
|
||||
|
||||
After `docker compose up`, the API iterates over the mount schema and applies permission policies:
|
||||
|
||||
```python
|
||||
for mount in manifest.mounts:
|
||||
if mount.owner:
|
||||
docker_exec(f"chown -R {mount.owner}:{mount.owner} {mount.target}")
|
||||
if mount.mode:
|
||||
docker_exec(f"chmod {mount.mode} {mount.target}")
|
||||
if mount.file_mode:
|
||||
docker_exec(f"find {mount.target} -type f -exec chmod {mount.file_mode} {{}} +")
|
||||
```
|
||||
|
||||
This is **systematic and configurable** — not hardcoded to `/workspace`.
|
||||
|
||||
---
|
||||
|
||||
## Config Profile Compatibility
|
||||
|
||||
The existing ConfigProfile system provides:
|
||||
- `environment_variables` → merged into compose `environment`
|
||||
- `files` → written to instance dir, mounted via `volumes`
|
||||
- `mounts` → appended to compose `volumes`
|
||||
- `git_mounts` → resolved to host paths, appended to compose `volumes`
|
||||
- `hints.start_command` → overrides `runtime.command`
|
||||
- `hints.working_directory` → overrides `runtime.working_dir`
|
||||
|
||||
With the manifest system, ConfigProfiles **extend** the default mount schema:
|
||||
|
||||
1. Tool manifest defines the **default mount schema** (workspace, ssh, state)
|
||||
2. ConfigProfile can add **additional mounts** or **override runtime hints**
|
||||
3. Both are merged at instance-start time into the final compose file
|
||||
|
||||
The merge precedence:
|
||||
1. Tool manifest (defaults)
|
||||
2. ToolConfig overrides (per-user per-tool settings)
|
||||
3. ConfigProfile overrides (hierarchical, can inherit from parent)
|
||||
4. User-provided start options (e.g. branch selection)
|
||||
|
||||
---
|
||||
|
||||
## Base Image Definitions
|
||||
|
||||
A base definition is itself a manifest with no `runtime` section:
|
||||
|
||||
```yaml
|
||||
# Base Definition: "ubuntu-24.04-dev"
|
||||
name: ubuntu-24.04-dev
|
||||
description: "Ubuntu 24.04 with build tools"
|
||||
base_image: ubuntu:24.04
|
||||
packages:
|
||||
apt:
|
||||
- curl
|
||||
- wget
|
||||
- git
|
||||
- build-essential
|
||||
- ca-certificates
|
||||
user:
|
||||
name: user
|
||||
uid: 1000
|
||||
gid: 1000
|
||||
create_home: true
|
||||
```
|
||||
|
||||
A tool definition can reference it:
|
||||
|
||||
```yaml
|
||||
base_definition_id: "ubuntu-24.04-dev"
|
||||
packages:
|
||||
apt:
|
||||
- neovim
|
||||
- ranger
|
||||
- tmux
|
||||
node:
|
||||
version: "20"
|
||||
```
|
||||
|
||||
The compiler **merges** the base definition with the tool-specific overrides:
|
||||
- Packages are **unioned** (base apt + tool apt)
|
||||
- Scripts are **appended** (base build scripts, then tool build scripts)
|
||||
- User/env are **overridden** (tool wins)
|
||||
|
||||
This enables a family of tool types to share a common base.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (Proposed)
|
||||
|
||||
### New Table: `tool_definition_manifests`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | PK |
|
||||
| `name` | str | Unique identifier |
|
||||
| `display_name` | str | Human-readable |
|
||||
| `description` | str | |
|
||||
| `category` | str | development, data-science, etc. |
|
||||
| `interface_type` | enum | web, terminal |
|
||||
| `base_image` | str | e.g. `ubuntu:24.04` |
|
||||
| `base_definition_id` | UUID? | FK to another manifest |
|
||||
| `manifest` | JSONB | The full manifest JSON |
|
||||
| `dockerfile_cache` | TEXT | Last generated Dockerfile (for inspection) |
|
||||
| `created_at` | datetime | |
|
||||
| `updated_at` | datetime | |
|
||||
|
||||
### Migration: `tool_types` table
|
||||
|
||||
Add a nullable `manifest_id` column to `tool_types`.
|
||||
|
||||
For backward compatibility:
|
||||
- If `manifest_id` is set → use the new manifest system
|
||||
- If `manifest_id` is null → fall back to `dockerfile_template` / `compose_template`
|
||||
|
||||
A data migration converts existing pi-agent to the new manifest format.
|
||||
|
||||
---
|
||||
|
||||
## API Changes
|
||||
|
||||
### New Endpoints
|
||||
|
||||
```
|
||||
GET /tool-definitions → list all base/tool definitions
|
||||
GET /tool-definitions/{id} → get a definition
|
||||
POST /tool-definitions → create a new definition
|
||||
PUT /tool-definitions/{id} → update a definition
|
||||
DELETE /tool-definitions/{id} → delete (if not in use)
|
||||
POST /tool-definitions/{id}/compile → preview generated Dockerfile + compose
|
||||
```
|
||||
|
||||
### Modified Endpoints
|
||||
|
||||
```
|
||||
POST /tool-types → can now accept manifest_id instead of templates
|
||||
GET /tool-types/{id} → includes manifest if available
|
||||
```
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
New UI page: **Tool Workshop**
|
||||
|
||||
- Base image selector (dropdown of existing bases or custom FROM)
|
||||
- Package manager tabs (apt, npm, pip, etc.)
|
||||
- Script editor (build vs startup)
|
||||
- Mount schema designer (drag-drop or form)
|
||||
- Live preview of generated Dockerfile
|
||||
- Test build button (builds image and reports success/failure)
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should we support multi-stage builds?**
|
||||
- Pros: smaller images, separation of build deps from runtime
|
||||
- Cons: more complexity in the manifest schema
|
||||
|
||||
2. **Should base definitions be versioned?**
|
||||
- Pros: reproducible builds, safe updates
|
||||
- Cons: more DB complexity
|
||||
|
||||
3. **How do we handle binary build context files?**
|
||||
- Current: JSON text in DB
|
||||
- Option A: Store in filesystem, reference by path
|
||||
- Option B: Upload to object storage (S3/minio)
|
||||
|
||||
4. **Should generated images be cached/pushed to a registry?**
|
||||
- Currently: built locally per instance
|
||||
- Option: push to `headquarter/tools/{tool-name}:{hash}` for reuse
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Migration complexity | Medium | Keep old fields nullable; gradual adoption |
|
||||
| Build cache invalidation | Medium | Use deterministic Dockerfile generation; hash manifest for image tag |
|
||||
| User confusion ("yet another abstraction") | Low | Provide live preview + "view generated Dockerfile" button |
|
||||
| Scope creep into full CI/CD | High | Keep scope to container definition only; no pipeline/orchestration |
|
||||
| Binary files in manifests | Medium | Limit build context to text; document workaround for binaries |
|
||||
|
||||
---
|
||||
|
||||
## Effort Estimate
|
||||
|
||||
| Phase | Files | Lines (est) | Complexity |
|
||||
|-------|-------|-------------|------------|
|
||||
| DB migration + models | 3 | 200 | Low |
|
||||
| Manifest compiler (Dockerfile) | 2 | 400 | Medium |
|
||||
| Manifest compiler (Compose) | 2 | 300 | Medium |
|
||||
| Permission fixer refactor | 2 | 200 | Low |
|
||||
| API endpoints | 3 | 400 | Medium |
|
||||
| Frontend Tool Workshop | 8 | 1200 | High |
|
||||
| Tests | 4 | 600 | Medium |
|
||||
| **Total** | **24** | **~3300** | **High** |
|
||||
|
||||
**Review workload forecast:** ~3300 lines is well above the 400-line budget. This should be split into **chained PRs**:
|
||||
1. Backend: manifest schema, compiler, API (PR 1)
|
||||
2. Frontend: Tool Workshop UI (PR 2)
|
||||
3. Migration + data conversion (PR 3)
|
||||
|
||||
---
|
||||
|
||||
## Next Recommended Phase
|
||||
|
||||
**Design** — Detail the manifest JSON schema, compiler internals, and migration plan.
|
||||
|
||||
Should I proceed to design?
|
||||
@@ -0,0 +1,323 @@
|
||||
# Spec: Tool Definition Manifest System
|
||||
|
||||
## Status
|
||||
**Phase:** spec
|
||||
**Date:** 2026-05-28
|
||||
**Owner:** el Gentleman
|
||||
**Based on:** Proposal `streamline-tool-container-definitions`
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. Declarative Tool Definitions
|
||||
Users must be able to define a tool container without writing Dockerfiles or Compose files. The definition is a structured manifest specifying base image, packages, scripts, mounts, and runtime configuration.
|
||||
|
||||
### R2. Base Image Versioning
|
||||
Base definitions must be versioned. Tool definitions reference a specific base version. Updating a base creates a new version; existing tools remain pinned to their version until explicitly updated.
|
||||
|
||||
### R3. Package Managers
|
||||
The manifest must support multiple package managers: `apt`, `npm` (global), `pip`, and `node` (version installation).
|
||||
|
||||
### R4. Build vs Startup Scripts
|
||||
Scripts are categorized by execution phase:
|
||||
- **Build scripts**: Run during `docker build` (e.g., `git config`, config file setup)
|
||||
- **Startup scripts**: Run when the container starts (e.g., permission fixes, dynamic setup)
|
||||
|
||||
### R5. Mount Schema with Permission Policies
|
||||
Mounts declare:
|
||||
- `target`: Container path
|
||||
- `source_type`: How the source is resolved (`repo`, `ssh_key`, `instance`, `git_mount`, `host_path`)
|
||||
- `writable`: Whether the mount is read-write
|
||||
- `owner`: Container user to own the target path (post-start chown)
|
||||
- `mode`: Directory permissions (post-start chmod)
|
||||
- `file_mode`: File permissions inside the directory
|
||||
- `readonly`: Whether mounted read-only in compose
|
||||
|
||||
### R6. Config Profile Compatibility
|
||||
ConfigProfiles continue to add `env`, `files`, `mounts`, and `git_mounts` on top of the manifest defaults. The merge precedence is: manifest defaults → ToolConfig → ConfigProfile → user options.
|
||||
|
||||
### R7. Backward Compatibility
|
||||
Existing `dockerfile_template` and `compose_template` columns remain functional. New tool types use the manifest system; old types continue to work. A data migration converts the existing pi-agent to the new format.
|
||||
|
||||
### R8. Local Builds
|
||||
Images are built locally per instance using the standard `docker build` command. No registry integration in this phase.
|
||||
|
||||
### R9. Live Preview
|
||||
The API provides a `compile` endpoint that returns the generated Dockerfile and Compose file without building.
|
||||
|
||||
### R10. Deterministic Image Tags
|
||||
The image tag is derived from a hash of the manifest content, enabling build cache reuse when the manifest hasn't changed.
|
||||
|
||||
---
|
||||
|
||||
## Scenarios
|
||||
|
||||
### S1. Creating a New Tool Definition
|
||||
|
||||
**Given** a user on the Tool Workshop page
|
||||
**When** they select base "ubuntu-24.04-dev:v1", add packages `[neovim, tmux]`, add a build script for git config, and define mounts for workspace + ssh
|
||||
**Then** the system generates a manifest, compiles a Dockerfile + Compose preview, and upon save stores the manifest in the database.
|
||||
|
||||
### S2. Building an Instance from a Manifest
|
||||
|
||||
**Given** a tool instance created from a manifest-based tool type
|
||||
**When** `start_instance` is called
|
||||
**Then** the API compiles the manifest to a Dockerfile, builds the image, generates the Compose file with resolved mount paths, starts the container, and applies permission policies post-start.
|
||||
|
||||
### S3. Permission Fix on Non-Root Containers
|
||||
|
||||
**Given** a manifest with `user: {name: user, uid: 1001}` and a mount `target: /workspace, owner: user`
|
||||
**When** the container starts with the workspace bind-mounted from host (root-owned)
|
||||
**Then** the post-start permission fixer runs `docker exec --user root chown -R user:user /workspace`, making the directory writable for the container user.
|
||||
|
||||
### S4. SSH Key Mount for Non-Root User
|
||||
|
||||
**Given** a manifest with a mount `target: /home/user/.ssh, source_type: ssh_key, mode: "0700"`
|
||||
**When** the container starts
|
||||
**Then** SSH keys are mounted from the instance `.ssh` directory to `/home/user/.ssh`, and post-start fixes permissions to `0700` for the directory and `0600` for key files.
|
||||
|
||||
### S5. Config Profile Extends Manifest
|
||||
|
||||
**Given** a manifest with default mount `workspace: /workspace` and a ConfigProfile that adds `git_mounts: [{remote_url: "...", target_path: "/home/user/.config"}]`
|
||||
**When** the instance starts with that profile selected
|
||||
**Then** the final Compose includes both the workspace mount and the config git mount, merged in the correct precedence.
|
||||
|
||||
### S6. Base Version Pinning
|
||||
|
||||
**Given** a tool definition referencing `base_definition_id: "ubuntu-24.04-dev", base_version: "v1"`
|
||||
**When** the base definition is updated to "v2"
|
||||
**Then** the tool definition continues to use "v1" until explicitly updated. New tool definitions default to the latest version.
|
||||
|
||||
### S7. Deterministic Image Tag
|
||||
|
||||
**Given** a manifest with specific packages and scripts
|
||||
**When** compiled
|
||||
**Then** the generated image tag is `headquarter/{tool-name}-{manifest-hash}:latest`, and rebuilding the same manifest reuses the cached image layer.
|
||||
|
||||
### S8. Live Preview Without Build
|
||||
|
||||
**Given** a manifest being edited
|
||||
**When** the user clicks "Preview"
|
||||
**Then** the API returns the generated Dockerfile and Compose file within 500ms, without invoking Docker.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### A1. Manifest Schema Validation
|
||||
- [ ] The manifest JSON must validate against a defined JSON Schema
|
||||
- [ ] Invalid manifests return 400 with detailed field-level errors
|
||||
- [ ] Missing required fields (name, base_image or base_definition_id) are rejected
|
||||
|
||||
### A2. Dockerfile Compilation
|
||||
- [ ] Generated Dockerfile builds successfully with `docker build`
|
||||
- [ ] Build scripts appear as `RUN` commands in order
|
||||
- [ ] Startup scripts appear in the generated entrypoint script
|
||||
- [ ] Packages are installed in a single layer per package manager
|
||||
- [ ] User creation uses the declared uid/gid
|
||||
|
||||
### A3. Compose Compilation
|
||||
- [ ] Generated Compose file starts successfully with `docker compose up`
|
||||
- [ ] Mounts are resolved from `source_type` to actual host paths
|
||||
- [ ] `stdin_open` and `tty` are set for terminal interface types
|
||||
- [ ] Ports are only included for web interface types
|
||||
|
||||
### A4. Permission Fixer
|
||||
- [ ] Post-start chown runs for all mounts with an `owner` declared
|
||||
- [ ] Post-start chmod runs for all mounts with `mode` or `file_mode` declared
|
||||
- [ ] Permission fixes complete within 5 seconds of container start
|
||||
- [ ] If the container has no `root` user, permission fixes are skipped with a warning
|
||||
|
||||
### A5. Config Profile Merge
|
||||
- [ ] ConfigProfile env vars override manifest defaults
|
||||
- [ ] ConfigProfile mounts are appended to manifest mounts
|
||||
- [ ] ConfigProfile git_mounts are resolved and appended
|
||||
- [ ] ToolConfig values override both manifest and ConfigProfile
|
||||
|
||||
### A6. Backward Compatibility
|
||||
- [ ] Existing tool types with `dockerfile_template` continue to work
|
||||
- [ ] Existing tool types with `compose_template` continue to work
|
||||
- [ ] The pi-agent tool type is migrated to the new manifest format
|
||||
- [ ] Old and new tool types can coexist in the same project
|
||||
|
||||
### A7. Base Versioning
|
||||
- [ ] Base definitions store a version string
|
||||
- [ ] Tool definitions store the base version they reference
|
||||
- [ ] Updating a base creates a new version; old versions remain accessible
|
||||
- [ ] The "latest" version can be referenced explicitly or by omission
|
||||
|
||||
### A8. Image Tag Determinism
|
||||
- [ ] Same manifest produces the same image tag
|
||||
- [ ] Changing any field (package, script, env) produces a different tag
|
||||
- [ ] The tag is lowercased and valid as a Docker image reference
|
||||
|
||||
### A9. API Endpoints
|
||||
- [ ] `POST /tool-definitions` creates a definition (201)
|
||||
- [ ] `GET /tool-definitions/{id}` returns the definition with compiled preview
|
||||
- [ ] `POST /tool-definitions/{id}/compile` returns Dockerfile + Compose (no build)
|
||||
- [ ] `PUT /tool-definitions/{id}` updates and re-validates
|
||||
|
||||
### A10. Frontend Tool Workshop
|
||||
- [ ] Users can create a tool definition via form (no raw JSON editing required)
|
||||
- [ ] Live preview shows generated Dockerfile and Compose
|
||||
- [ ] Package lists support add/remove/reorder
|
||||
- [ ] Mount schema supports add/remove with visual feedback
|
||||
- [ ] Base image selector shows available versions
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Multi-stage builds** — Out of scope for this phase. The compiler generates single-stage Dockerfiles.
|
||||
- **Docker registry integration** — Images are built locally per instance.
|
||||
- **Binary build context files** — Build context is limited to text files stored in the DB.
|
||||
- **Custom Dockerfile editing** — Users work exclusively through the manifest; no raw Dockerfile editing.
|
||||
- **Container orchestration beyond Compose** — No Kubernetes, Swarm, or other orchestrators.
|
||||
- **Real-time collaborative editing** — Tool Workshop is single-user editing.
|
||||
|
||||
---
|
||||
|
||||
## API Contract
|
||||
|
||||
### POST /tool-definitions
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "pi-agent",
|
||||
"display_name": "Pi Agent",
|
||||
"description": "Terminal coding harness",
|
||||
"category": "development",
|
||||
"interface_type": "terminal",
|
||||
"base_image": "ubuntu:24.04",
|
||||
"packages": {
|
||||
"apt": ["curl", "git", "neovim", "tmux"],
|
||||
"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",
|
||||
"mkdir -p /home/user/.config/ranger"
|
||||
],
|
||||
"startup": [
|
||||
"if [ -d /workspace ]; then sudo chown -R user:user /workspace; fi"
|
||||
]
|
||||
},
|
||||
"mounts": [
|
||||
{
|
||||
"name": "workspace",
|
||||
"target": "/workspace",
|
||||
"source_type": "repo",
|
||||
"writable": true,
|
||||
"owner": "user"
|
||||
},
|
||||
{
|
||||
"name": "ssh",
|
||||
"target": "/home/user/.ssh",
|
||||
"source_type": "ssh_key",
|
||||
"mode": "0700",
|
||||
"file_mode": "0600",
|
||||
"readonly": true
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"command": ["/bin/bash"],
|
||||
"stdin_open": true,
|
||||
"tty": true,
|
||||
"working_dir": "/workspace"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
"id": "d07b8376-2151-4119-8c1d-27f792aae9a3",
|
||||
"name": "pi-agent",
|
||||
"display_name": "Pi Agent",
|
||||
"manifest": { ... },
|
||||
"dockerfile_preview": "FROM ubuntu:24.04\n...",
|
||||
"compose_preview": "services:\n app:\n image: ...",
|
||||
"created_at": "2026-05-28T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /tool-definitions/{id}/compile
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"dockerfile": "FROM ubuntu:24.04\n...",
|
||||
"compose": "services:\n app:\n...",
|
||||
"image_tag": "headquarter/pi-agent-a3f7c2d9:latest",
|
||||
"mounts_resolved": [
|
||||
{"name": "workspace", "source": "/data/repos/headquarter", "target": "/workspace"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `tool_definition_manifests`
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | PK |
|
||||
| `name` | VARCHAR(64) | UNIQUE, NOT NULL |
|
||||
| `display_name` | VARCHAR(128) | NOT NULL |
|
||||
| `description` | TEXT | |
|
||||
| `category` | VARCHAR(64) | |
|
||||
| `interface_type` | VARCHAR(16) | CHECK IN ('web', 'terminal') |
|
||||
| `base_image` | VARCHAR(256) | |
|
||||
| `base_definition_id` | UUID | FK → `tool_definition_manifests.id` |
|
||||
| `base_version` | VARCHAR(32) | DEFAULT 'latest' |
|
||||
| `manifest` | JSONB | NOT NULL |
|
||||
| `dockerfile_cache` | TEXT | |
|
||||
| `compose_cache` | TEXT | |
|
||||
| `version` | VARCHAR(32) | DEFAULT 'v1' |
|
||||
| `is_base` | BOOLEAN | DEFAULT FALSE |
|
||||
| `created_by_id` | UUID | FK → `users.id` |
|
||||
| `created_at` | TIMESTAMPTZ | DEFAULT now() |
|
||||
| `updated_at` | TIMESTAMPTZ | DEFAULT now() |
|
||||
|
||||
**Check constraint:** Exactly one of `base_image` or `base_definition_id` must be set.
|
||||
|
||||
### Alter `tool_types`
|
||||
|
||||
```sql
|
||||
ALTER TABLE tool_types
|
||||
ADD COLUMN manifest_id UUID REFERENCES tool_definition_manifests(id),
|
||||
ADD COLUMN definition_type VARCHAR(16) DEFAULT 'legacy'; -- 'legacy' | 'manifest'
|
||||
```
|
||||
|
||||
### Alter `tool_instances`
|
||||
|
||||
```sql
|
||||
ALTER TABLE tool_instances
|
||||
ADD COLUMN manifest_compiled_at TIMESTAMPTZ,
|
||||
ADD COLUMN image_tag VARCHAR(256);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
- `apps/api/src/models/tool_definition_manifest.py` — New model
|
||||
- `apps/api/src/models/tool_type.py` — Add manifest_id, definition_type
|
||||
- `apps/api/src/services/manifest_compiler.py` — New compiler
|
||||
- `apps/api/src/services/permission_fixer.py` — Refactored mount policy applier
|
||||
- `apps/api/src/api/tool_definitions.py` — New endpoints
|
||||
- `apps/api/src/api/tool_instances.py` — Modified start_instance flow
|
||||
- `apps/api/alembic/versions/20260528_add_tool_definition_manifests.py` — Migration
|
||||
@@ -0,0 +1,89 @@
|
||||
# Tasks: Tool Definition Manifest System
|
||||
|
||||
## PR 1: Backend Manifest System
|
||||
|
||||
### T1.1 Database Migration
|
||||
- [ ] Create `tool_definition_manifests` table
|
||||
- [ ] Add `manifest_id`, `definition_type` to `tool_types`
|
||||
- [ ] Add `manifest_compiled_at`, `image_tag` to `tool_instances`
|
||||
- [ ] Data migration: convert pi-agent to manifest
|
||||
|
||||
### T1.2 Models
|
||||
- [ ] `ToolDefinitionManifest` SQLAlchemy model
|
||||
- [ ] Update `ToolType` model with manifest relationship
|
||||
- [ ] Update `ToolInstance` model with image_tag
|
||||
|
||||
### T1.3 Manifest Compiler
|
||||
- [ ] `resolve_base()` — deep merge base + tool manifest
|
||||
- [ ] `compile_dockerfile()` — generate Dockerfile from manifest
|
||||
- [ ] `compile_entrypoint()` — generate startup entrypoint script
|
||||
- [ ] `compile_compose()` — generate Compose from manifest
|
||||
- [ ] `compute_image_tag()` — deterministic hash-based tag
|
||||
- [ ] `resolve_mount_source()` — mount source resolution
|
||||
|
||||
### T1.4 Permission Fixer
|
||||
- [ ] `apply_mount_permissions()` — post-start chown/chmod
|
||||
- [ ] Handle missing root user gracefully
|
||||
- [ ] Timeout and error reporting
|
||||
|
||||
### T1.5 API Endpoints
|
||||
- [ ] `POST /tool-definitions` — create
|
||||
- [ ] `GET /tool-definitions` — list
|
||||
- [ ] `GET /tool-definitions/{id}` — get
|
||||
- [ ] `PUT /tool-definitions/{id}` — update
|
||||
- [ ] `DELETE /tool-definitions/{id}` — delete
|
||||
- [ ] `POST /tool-definitions/{id}/compile` — preview
|
||||
|
||||
### T1.6 Modified Startup Flow
|
||||
- [ ] Update `start_instance` to use manifest when `definition_type == "manifest"`
|
||||
- [ ] Integrate permission fixer post-start
|
||||
- [ ] Store image_tag on instance for reuse
|
||||
|
||||
### T1.7 Tests
|
||||
- [ ] Unit: manifest compiler (all package managers, base merge)
|
||||
- [ ] Unit: permission fixer (success, failure, timeout)
|
||||
- [ ] Unit: mount resolution (all source types)
|
||||
- [ ] Integration: manifest → build → start → terminal works
|
||||
- [ ] Integration: legacy tool types still work
|
||||
|
||||
---
|
||||
|
||||
## PR 2: Frontend Tool Workshop
|
||||
|
||||
### T2.1 Tool Definitions API Client
|
||||
- [ ] Add tool definition endpoints to `client.ts`
|
||||
- [ ] Type definitions for manifest schema
|
||||
|
||||
### T2.2 Tool Workshop Page
|
||||
- [ ] Base image selector (with version dropdown)
|
||||
- [ ] Package manager editors (apt list, npm list, node version)
|
||||
- [ ] Script editors (build vs startup, tabbed)
|
||||
- [ ] Mount schema designer (form table with add/remove)
|
||||
- [ ] Runtime config (command, working_dir, stdin_open, tty)
|
||||
|
||||
### T2.3 Live Preview
|
||||
- [ ] Preview panel showing generated Dockerfile
|
||||
- [ ] Preview panel showing generated Compose
|
||||
- [ ] "Compile" button calling API preview endpoint
|
||||
|
||||
### T2.4 Tool Definitions List
|
||||
- [ ] Table view of all definitions
|
||||
- [ ] Create / Edit / Delete actions
|
||||
- [ ] Base indicator (shows if it's a base definition)
|
||||
|
||||
---
|
||||
|
||||
## PR 3: Migration & Legacy Fallback
|
||||
|
||||
### T3.1 Data Migration
|
||||
- [ ] Alembic migration creating base definition + pi-agent manifest
|
||||
- [ ] Update existing pi-agent tool_type row
|
||||
|
||||
### T3.2 Legacy Fallback
|
||||
- [x] Ensure `definition_type == "legacy"` still uses old flow
|
||||
- [x] Ensure `dockerfile_template` / `compose_template` still work
|
||||
- [x] Tests for legacy path
|
||||
|
||||
### T3.3 Documentation
|
||||
- [x] Update API docs
|
||||
- [x] Add Tool Workshop user guide
|
||||
@@ -0,0 +1,52 @@
|
||||
# Tool Images
|
||||
|
||||
This directory contains Dockerfile templates for the base tool types used in Headquarter.
|
||||
|
||||
## Available Images
|
||||
|
||||
| File | Tool | Description |
|
||||
|------|------|-------------|
|
||||
| `base.dockerfile` | - | Common base with git, nvim, ranger, tmux, node |
|
||||
| `code-server.dockerfile` | VS Code | Browser-based VS Code with extra tools |
|
||||
| `jupyter.dockerfile` | Jupyter | Jupyter Lab/Notebook with extra tools |
|
||||
| `opencode.dockerfile` | OpenCode | OpenCode agent server |
|
||||
| `pi-agent.dockerfile` | Pi Agent | Pi coding agent with full terminal setup |
|
||||
|
||||
## Usage
|
||||
|
||||
When creating a tool type in the Tool Workshop, you can reference these Dockerfiles:
|
||||
|
||||
1. Copy the contents of the desired `.dockerfile`
|
||||
2. Paste into the "Dockerfile Template" field
|
||||
3. Set `definition_type` to `dockerfile`
|
||||
|
||||
## Building Locally
|
||||
|
||||
To test an image locally:
|
||||
|
||||
```bash
|
||||
cd tool-images
|
||||
docker build -f pi-agent.dockerfile -t pi-agent:latest .
|
||||
docker run -it pi-agent:latest
|
||||
```
|
||||
|
||||
## Customizing
|
||||
|
||||
All images include:
|
||||
- **git** - Version control
|
||||
- **neovim** - Terminal editor
|
||||
- **ranger** - Terminal file manager
|
||||
- **tmux** - Terminal multiplexer
|
||||
- **htop** - Process viewer
|
||||
- **tree** - Directory tree
|
||||
- **jq** - JSON processor
|
||||
- **Node.js 20** - For npm-based tools
|
||||
|
||||
The `pi-agent` image additionally includes the [Pi Coding Agent](https://pi.dev/) for AI-assisted development.
|
||||
|
||||
## Adding New Images
|
||||
|
||||
1. Create a new `.dockerfile` in this directory
|
||||
2. Use `base.dockerfile` as a starting point if applicable
|
||||
3. Document it in the table above
|
||||
4. Update the tool type in the database via the Tool Workshop
|
||||
@@ -0,0 +1,36 @@
|
||||
# Base development image with common tools
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# Prevent interactive prompts during apt install
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install base tools
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
neovim \
|
||||
ranger \
|
||||
tmux \
|
||||
htop \
|
||||
tree \
|
||||
jq \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user
|
||||
RUN useradd -m -s /bin/bash user
|
||||
WORKDIR /home/user
|
||||
|
||||
# Install Node.js (needed for pi and many dev tools)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up git configuration defaults
|
||||
RUN git config --global init.defaultBranch main \
|
||||
&& git config --global user.email "dev@headquarter.local" \
|
||||
&& git config --global user.name "Developer"
|
||||
|
||||
USER user
|
||||
CMD ["/bin/bash"]
|
||||
@@ -0,0 +1,28 @@
|
||||
# VS Code in browser
|
||||
FROM lscr.io/linuxserver/code-server:latest
|
||||
|
||||
USER root
|
||||
|
||||
# Install additional tools
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
neovim \
|
||||
ranger \
|
||||
tmux \
|
||||
htop \
|
||||
tree \
|
||||
jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up git
|
||||
RUN git config --global init.defaultBranch main
|
||||
|
||||
# Code-server runs as abc user by default
|
||||
USER abc
|
||||
|
||||
EXPOSE 8443
|
||||
@@ -0,0 +1,23 @@
|
||||
# Jupyter Notebook/Lab
|
||||
FROM jupyter/scipy-notebook:latest
|
||||
|
||||
USER root
|
||||
|
||||
# Install additional tools
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
neovim \
|
||||
ranger \
|
||||
tmux \
|
||||
htop \
|
||||
tree \
|
||||
jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up git
|
||||
RUN git config --global init.defaultBranch main
|
||||
|
||||
# Switch back to jovyan user (default for scipy-notebook)
|
||||
USER ${NB_UID}
|
||||
|
||||
EXPOSE 8888
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user