feat: implement configurable tool container home directory
- Add ToolType.home_directory column with default /home/user
- Add Alembic migration to add column, set existing rows, and rewrite
/workspace to /home/user/{{WORKSPACE_NAME}} in legacy templates
- Add merge migration fc8f1a20cbf6 to resolve Alembic multiple heads
- Update manifest compiler to honor manifest.home_directory for HOME,
WORKDIR, /workspace symlink, and default repo mount target
- Update legacy dockerfile/compose instance generation to use
tool_type.home_directory
- Thread resolved home_dir through config profile and git mount expansion
- Generate entrypoint permission fixer to chown home/mounts at startup
- Update base.dockerfile with sudo/passwordless sudo for permission fixer
- Add unit tests for manifest compiler, instance service, and migrations
- Add placeholder integration test for container lifecycle
- Update openspec/tasks/home-path-expansion.md task checkboxes
- Update project maps for modified files
Quality gates: py_compile, ruff, mypy, pytest tests/unit (205 passed),
pytest tests/integration (110 passed, 35 skipped). Alembic round-trip
and container lifecycle integration tests require Docker/PostgreSQL.
This commit is contained in:
@@ -2,7 +2,12 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.build.manifest_compiler import compile_dockerfile
|
||||
from src.services.build.manifest_compiler import (
|
||||
compile_compose,
|
||||
compile_dockerfile,
|
||||
compile_entrypoint,
|
||||
get_manifest_home_dir,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -36,3 +41,185 @@ def test_compile_dockerfile_no_user_does_not_create_home() -> None:
|
||||
|
||||
assert "useradd" not in dockerfile
|
||||
assert "/home/" not in dockerfile
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetManifestHomeDir:
|
||||
"""Tests for get_manifest_home_dir precedence."""
|
||||
|
||||
def test_home_directory_in_manifest_wins(self) -> None:
|
||||
manifest = {
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev"},
|
||||
}
|
||||
assert get_manifest_home_dir(manifest) == "/home/custom"
|
||||
|
||||
def test_user_name_derives_home(self) -> None:
|
||||
manifest = {"user": {"name": "dev", "uid": 1000, "gid": 1000}}
|
||||
assert get_manifest_home_dir(manifest) == "/home/dev"
|
||||
|
||||
def test_root_fallback(self) -> None:
|
||||
manifest = {"base_image": "ubuntu:24.04"}
|
||||
assert get_manifest_home_dir(manifest) == "/root"
|
||||
|
||||
def test_empty_home_directory_falls_back(self) -> None:
|
||||
manifest = {"home_directory": "", "user": {"name": "dev"}}
|
||||
assert get_manifest_home_dir(manifest) == "/home/dev"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCompileDockerfileHomeDirectory:
|
||||
"""Tests that compile_dockerfile honors manifest.home_directory."""
|
||||
|
||||
def test_env_home_and_workdir_use_home_directory(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"interface_type": "terminal",
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||
}
|
||||
dockerfile = compile_dockerfile(manifest)
|
||||
|
||||
assert "ENV HOME=/home/custom" in dockerfile
|
||||
assert "WORKDIR /home/custom" in dockerfile
|
||||
|
||||
def test_workspace_symlink_created(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"interface_type": "terminal",
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||
}
|
||||
dockerfile = compile_dockerfile(manifest)
|
||||
|
||||
assert "ln -sfn /home/custom/{{WORKSPACE_NAME}} /workspace" in dockerfile
|
||||
|
||||
def test_runtime_working_dir_overrides_home_workdir(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"interface_type": "terminal",
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||
"runtime": {"working_dir": "/app/code"},
|
||||
}
|
||||
dockerfile = compile_dockerfile(manifest)
|
||||
|
||||
assert "WORKDIR /app/code" in dockerfile
|
||||
assert "WORKDIR /home/custom" not in dockerfile
|
||||
|
||||
def test_working_dir_expands_tilde(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"interface_type": "terminal",
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||
"runtime": {"working_dir": "~/code"},
|
||||
}
|
||||
dockerfile = compile_dockerfile(manifest)
|
||||
|
||||
assert "WORKDIR /home/custom/code" in dockerfile
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCompileComposeHomeDirectory:
|
||||
"""Tests that compile_compose uses home_directory for volumes and working_dir."""
|
||||
|
||||
def test_default_repo_mount_synthesized(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"interface_type": "terminal",
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||
}
|
||||
variables = {
|
||||
"IMAGE_TAG": "test:latest",
|
||||
"INSTANCE_NAME": "test-instance",
|
||||
"REPO_PATH": "/host/repos/my-app",
|
||||
"WORKSPACE_NAME": "my-app",
|
||||
"TOOL_PORT": 0,
|
||||
"EXTRA_ENV": {},
|
||||
"EXTRA_VOLUMES": [],
|
||||
}
|
||||
compose = compile_compose(manifest, variables)
|
||||
|
||||
assert "/host/repos/my-app:/home/custom/my-app" in compose
|
||||
|
||||
def test_explicit_repo_mount_preserved(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"interface_type": "terminal",
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||
"mounts": [
|
||||
{"source_type": "repo", "target": "/opt/code", "readonly": True}
|
||||
],
|
||||
}
|
||||
variables = {
|
||||
"IMAGE_TAG": "test:latest",
|
||||
"INSTANCE_NAME": "test-instance",
|
||||
"REPO_PATH": "/host/repos/my-app",
|
||||
"WORKSPACE_NAME": "my-app",
|
||||
"TOOL_PORT": 0,
|
||||
"EXTRA_ENV": {},
|
||||
"EXTRA_VOLUMES": [],
|
||||
}
|
||||
compose = compile_compose(manifest, variables)
|
||||
|
||||
assert "/host/repos/my-app:/opt/code:ro" in compose
|
||||
assert "/home/custom/my-app" not in compose
|
||||
|
||||
def test_working_dir_expands_home(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"interface_type": "terminal",
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||
"runtime": {"working_dir": "$HOME/code"},
|
||||
}
|
||||
variables = {
|
||||
"IMAGE_TAG": "test:latest",
|
||||
"INSTANCE_NAME": "test-instance",
|
||||
"REPO_PATH": "/host/repos/my-app",
|
||||
"WORKSPACE_NAME": "my-app",
|
||||
"TOOL_PORT": 0,
|
||||
"EXTRA_ENV": {},
|
||||
"EXTRA_VOLUMES": [],
|
||||
}
|
||||
compose = compile_compose(manifest, variables)
|
||||
|
||||
assert "working_dir: /home/custom/code" in compose
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCompileEntrypoint:
|
||||
"""Tests for the generated permission-fixing entrypoint."""
|
||||
|
||||
def test_entrypoint_creates_home_and_workspace(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"interface_type": "terminal",
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||
}
|
||||
entrypoint = compile_entrypoint(manifest)
|
||||
|
||||
assert 'mkdir -p "$HOME_DIR"' in entrypoint
|
||||
assert 'mkdir -p "$WORKSPACE_TARGET"' in entrypoint
|
||||
assert 'ln -sfn "$WORKSPACE_TARGET" /workspace' in entrypoint
|
||||
|
||||
def test_entrypoint_fixes_mount_owners(self) -> None:
|
||||
manifest = {
|
||||
"base_image": "ubuntu:24.04",
|
||||
"interface_type": "terminal",
|
||||
"home_directory": "/home/custom",
|
||||
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
||||
"mounts": [
|
||||
{"target": "~/.config", "readonly": False},
|
||||
{"target": "/opt/readonly", "readonly": True},
|
||||
],
|
||||
}
|
||||
entrypoint = compile_entrypoint(manifest)
|
||||
|
||||
assert 'fix_owner "/home/custom/.config"' in entrypoint
|
||||
assert 'fix_owner "/opt/readonly"' not in entrypoint
|
||||
|
||||
|
||||
Reference in New Issue
Block a user