5deee8c65c
- 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)
752 lines
30 KiB
Markdown
752 lines
30 KiB
Markdown
# 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.
|