Files
headquarter/apps/api/tests/unit/test_instance_service.py
alex 1c6dbcede8 fix: resolve project variable in create_tool_instance
create_tool_instance fetched `project` only inside the auto-display-name
branch, so callers supplying display_name left it unbound and the
manifest/dockerfile/compose branches crashed with UnboundLocalError at
_get_repository_mount_name (in-container layout /home/user/{project_name}).

Resolve project unconditionally after repo validation with a not-found
guard (narrowing Project|None -> Project), and drop the now-redundant
fetch from the auto-name branch. Add a regression test covering the
manifest + display_name path.

Quality gates: ruff, mypy, pytest (74 unit tests passed)
2026-06-17 16:12:22 +02:00

409 lines
13 KiB
Python

"""Unit tests for the tool instance service."""
import uuid
from unittest.mock import MagicMock, AsyncMock
import pytest
from src.services.tool.instance_service import (
_get_repository_mount_name,
_stack_profile_mounts_with_git_mounts,
modify_compose_file,
prepare_manifest_instance,
)
@pytest.mark.unit
class TestModifyComposeFile:
"""Tests for modify_compose_file home-directory expansion."""
def test_extra_volumes_expand_home_dir(self, tmp_path):
compose_path = tmp_path / "docker-compose.yml"
compose_path.write_text(
"services:\n app:\n image: test:latest\n volumes: []\n"
)
modify_compose_file(
str(compose_path),
extra_volumes=[
{"source": "/host/config", "target": "~/.config", "type": "bind"},
{"source": "/host/code", "target": "$HOME/code", "type": "bind"},
],
home_dir="/home/user",
)
content = compose_path.read_text()
assert "/host/config:/home/user/.config" in content
assert "/host/code:/home/user/code" in content
def test_working_directory_expands_home_dir(self, tmp_path):
compose_path = tmp_path / "docker-compose.yml"
compose_path.write_text("services:\n app:\n image: test:latest\n")
modify_compose_file(
str(compose_path),
working_directory="~/workspace",
home_dir="/home/user",
)
content = compose_path.read_text()
assert "working_dir: /home/user/workspace" in content
@pytest.mark.unit
class TestGetRepositoryMountName:
"""Tests for _get_repository_mount_name."""
def test_uses_project_name(self):
project = MagicMock()
project.name = "My Project"
repo = MagicMock()
assert _get_repository_mount_name(project, repo) == "my-project"
def test_slugifies_project_name(self):
project = MagicMock()
project.name = "Project v2.0!"
repo = MagicMock()
assert _get_repository_mount_name(project, repo) == "project-v2-0"
@pytest.mark.unit
class TestStackProfileMountsWithGitMounts:
"""Tests for _stack_profile_mounts_with_git_mounts."""
def test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) -> None:
"""When a profile mount targets the same directory as a git mount,
the profile files should be copied into the git-mount source so the
container sees both sets of files through one bind mount."""
git_source = tmp_path / "git" / "repo-clone"
git_source.mkdir(parents=True)
(git_source / "existing.txt").write_text("from git")
profile_source = tmp_path / "profile" / "home_user_.pi"
profile_source.mkdir(parents=True)
(profile_source / "settings.json").write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
"readonly": False,
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == []
assert (git_source / "existing.txt").read_text() == "from git"
assert (git_source / "settings.json").read_text() == "{}"
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None:
"""Profile mounts targeting a child directory are copied into the
corresponding subdirectory of the git-mount source."""
git_source = tmp_path / "git"
git_source.mkdir()
(git_source / "README").write_text("repo")
profile_source = tmp_path / "profile" / "agent"
profile_source.mkdir(parents=True)
(profile_source / "settings.json").write_text("x")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/agent",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == []
assert (git_source / "agent" / "settings.json").read_text() == "x"
assert (git_source / "README").read_text() == "repo"
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
"""Profile mounts that do not overlap a git mount are returned as-is."""
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "config").write_text("c")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.config",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_git_source_file_does_not_consume_profile_mount(self, tmp_path) -> None:
"""If the overlapping git-mount source is a file, the profile mount
cannot be merged and must be kept."""
git_source = tmp_path / "file.txt"
git_source.write_text("file")
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
}
]
git_mount_volumes = [
{
"source": str(git_source),
"target": "/home/user/.pi/file.txt",
"type": "bind",
}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None:
"""A profile mount that supplies a single file is copied into the
git-mount source directory."""
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "settings.json"
profile_source.write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/settings.json",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == []
assert (git_source / "settings.json").read_text() == "{}"
@pytest.mark.unit
async def test_prepare_manifest_instance_uses_workspace_path_basename():
"""WORKSPACE_NAME must be the repo-named workspace directory, not workspace.name."""
repo = MagicMock()
repo.name = "src"
repo.remote_url = "git@git.example.com:acme/headquarter.git"
repo.path = "/data/repos/main"
workspace = MagicMock()
workspace.id = "workspace-uuid"
workspace.repo_id = "repo-uuid"
workspace.name = "main"
workspace.path = "/data/working-copies/workspace-uuid/headquarter"
tool_type = MagicMock()
tool_type.name = "pi-agent"
tool_type.manifest_id = "manifest-uuid"
manifest_def = MagicMock()
manifest_def.id = "manifest-uuid"
manifest_def.manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "user", "uid": 1001, "gid": 1001},
}
manifest_def.base_definition_id = None
instance = MagicMock()
instance.id = "instance-uuid"
instance.name = "pi-agent-headquarter-abc123"
instance.repository_id = "repo-uuid"
instance.tool_type_id = "tooltype-uuid"
instance.port = 0
instance.selected_config_profile_id = None
instance.workspace_id = "workspace-uuid"
session = AsyncMock()
project = MagicMock()
project.name = "acme"
async def session_get(model, obj_id):
if model.__name__ == "ToolType":
return tool_type
if model.__name__ == "ToolDefinitionManifest":
return manifest_def
if model.__name__ == "GitRepository":
return repo
if model.__name__ == "Workspace":
return workspace
if model.__name__ == "Project":
return project
return None
session.get.side_effect = session_get
# Patch docker images check to report the image already exists so we skip
# the actual Docker build.
import subprocess
from src.services.tool import instance_service
original_run = subprocess.run
def fake_run(cmd, **kwargs):
class Result:
returncode = 0
stdout = "image-id"
stderr = ""
return Result()
instance_service.subprocess.run = fake_run
try:
(
image_tag,
compose_content,
manifest,
home_dir,
) = await prepare_manifest_instance(
session=session,
instance=instance,
instance_dir="/tmp/instance",
repo_path="/data/working-copies/workspace-uuid/headquarter",
env_vars={},
extra_volumes=[],
working_directory=None,
)
finally:
instance_service.subprocess.run = original_run
assert "WORKSPACE_NAME: acme" in compose_content
assert (
"/data/working-copies/workspace-uuid/headquarter:/home/user/acme"
in compose_content
)
@pytest.mark.unit
async def test_create_tool_instance_resolves_project_when_display_name_given(
monkeypatch, tmp_path
):
"""Regression: create_tool_instance must resolve `project` even when a
display_name is supplied.
Previously `project` was only fetched inside the auto-display-name branch,
so providing display_name left it unbound and the manifest/dockerfile/compose
branches crashed with UnboundLocalError at `_get_repository_mount_name`.
The mount name must derive from the project name regardless.
"""
from src.schemas.tool import CreateInstanceRequest
from src.services.tool import instance_service
project_id = uuid.uuid4()
repo_id = uuid.uuid4()
user_id = uuid.uuid4()
tool_type_id = uuid.uuid4()
manifest_id = uuid.uuid4()
repo = MagicMock()
repo.project_id = project_id
repo.name = "src"
repo.path = "/data/repos/src"
project = MagicMock()
project.name = "My Project" # slugifies to "my-project"
tool_type = MagicMock()
tool_type.id = tool_type_id
tool_type.name = "pi-agent"
tool_type.definition_type = "manifest"
tool_type.manifest_id = manifest_id
tool_type.home_directory = None
manifest_def = MagicMock()
manifest_def.manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "user", "uid": 1001, "gid": 1001},
}
manifest_def.base_definition_id = None
async def session_get(model, _obj_id):
name = getattr(model, "__name__", "")
if name == "GitRepository":
return repo
if name == "Project":
return project
if name == "ToolType":
return tool_type
if name == "ToolDefinitionManifest":
return manifest_def
return None
session = AsyncMock()
session.get.side_effect = session_get
session.add = MagicMock()
monkeypatch.setattr(
instance_service, "validate_config_profile", AsyncMock(return_value=None)
)
monkeypatch.setattr(
instance_service, "ensure_instance_directory", lambda _name: str(tmp_path)
)
monkeypatch.setattr(instance_service, "find_free_port", lambda: 12345)
monkeypatch.setattr(
instance_service, "compute_image_tag", lambda *a, **k: "img:tag"
)
compile_compose = MagicMock(return_value="services:\n app: {}")
monkeypatch.setattr(instance_service, "compile_compose", compile_compose)
monkeypatch.setattr(instance_service, "write_compose_file", MagicMock())
monkeypatch.setattr(instance_service, "publish_lifecycle_event", AsyncMock())
data = CreateInstanceRequest(
tool_type_id=str(tool_type_id), display_name="Custom Name"
)
instance = await instance_service.create_tool_instance(
session, user_id, project_id, repo_id, data
)
# Must not raise UnboundLocalError; instance returned.
assert instance is not None
# The mount name must derive from project.name, proving `project` was
# resolved on the display_name path (the previous crash site).
variables = compile_compose.call_args.args[1]
assert variables["REPO_NAME"] == "my-project"
assert variables["WORKSPACE_NAME"] == "my-project"