From 3e99e7f197a57cb60c5d05dcc6e10767dfea718d Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 28 May 2026 14:54:32 +0200 Subject: [PATCH] feat: legacy fallback tests and docs (PR 3) - Add test_tool_instances_legacy.py with 8 unit tests: - dockerfile definition type builds from template - dockerfile build failure raises HTTP 500 - compose definition type renders template - manifest compiler is NOT called for legacy types - start_instance legacy/compose/dockerfile types all skip manifest flow - start_instance manifest type correctly invokes compiler - Mark T3.2 and T3.3 tasks complete in OpenSpec - Add openspec/docs/tool-workshop-guide.md with user guide covering definition types, manifest creation workflow, base definitions, migration path, and permissions --- .../tests/unit/test_tool_instances_legacy.py | 804 ++++++++++++++++++ openspec/docs/tool-workshop-guide.md | 81 ++ openspec/tasks/tool-definition-manifest.md | 10 +- 3 files changed, 890 insertions(+), 5 deletions(-) create mode 100644 apps/api/tests/unit/test_tool_instances_legacy.py create mode 100644 openspec/docs/tool-workshop-guide.md diff --git a/apps/api/tests/unit/test_tool_instances_legacy.py b/apps/api/tests/unit/test_tool_instances_legacy.py new file mode 100644 index 0000000..d322800 --- /dev/null +++ b/apps/api/tests/unit/test_tool_instances_legacy.py @@ -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() diff --git a/openspec/docs/tool-workshop-guide.md b/openspec/docs/tool-workshop-guide.md new file mode 100644 index 0000000..633bc15 --- /dev/null +++ b/openspec/docs/tool-workshop-guide.md @@ -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. diff --git a/openspec/tasks/tool-definition-manifest.md b/openspec/tasks/tool-definition-manifest.md index ae5bd1f..6916e09 100644 --- a/openspec/tasks/tool-definition-manifest.md +++ b/openspec/tasks/tool-definition-manifest.md @@ -80,10 +80,10 @@ - [ ] Update existing pi-agent tool_type row ### T3.2 Legacy Fallback -- [ ] Ensure `definition_type == "legacy"` still uses old flow -- [ ] Ensure `dockerfile_template` / `compose_template` still work -- [ ] Tests for legacy path +- [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 -- [ ] Update API docs -- [ ] Add Tool Workshop user guide +- [x] Update API docs +- [x] Add Tool Workshop user guide