Files
headquarter/openspec/specs/tool-definition-manifest.md
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

324 lines
12 KiB
Markdown

# 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