Files
headquarter/openspec/explorations/streamline-tool-container-definitions.md
T
Alex Blank 5deee8c65c feat: tool definition manifest system (PR 1)
- Add ToolDefinitionManifest model with base image versioning
- Add manifest compiler: Dockerfile + Compose generation from JSON manifests
- Add permission fixer: post-start chown/chmod for mount policies
- Add tool definition CRUD API with live compile preview endpoint
- Integrate manifest-based startup flow in start_instance
- Add Alembic migration with data conversion for pi-agent
- Add 48 unit tests for manifest compiler, permission fixer, docker service
- Keep backward compatibility with legacy dockerfile_template/compose_template

Migration: applied successfully. Pi-agent converted to manifest.
Quality gates: pytest (146 passed, 4 pre-existing unrelated failures)
2026-05-28 13:37:34 +02:00

363 lines
13 KiB
Markdown

# 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