bd94cc9bbf
The previous commit moved the pi-agent repo mount from /workspace to
/home/user/{repo_name}. This exposed a permission bug: the Dockerfile
creates /workspace as a root-owned symlink in the image, and the
non-root entrypoint could not replace it because / is owned by root.
- Update compile_entrypoint to recreate /workspace via sudo when running
as the container user, or directly when running as root
- Add unit test covering sudo/root symlink creation
- Update OpenSpec change docs with the additional root cause
Quality gates:
- pytest tests/unit: 208 passed
- ruff: clean on changed files
- mypy: clean on changed files
- alembic heads: single head
283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""Unit tests for the manifest compiler."""
|
|
|
|
import pytest
|
|
|
|
from src.services.build.manifest_compiler import (
|
|
compile_compose,
|
|
compile_dockerfile,
|
|
compile_entrypoint,
|
|
get_manifest_home_dir,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_compile_dockerfile_creates_config_dirs_for_user() -> None:
|
|
"""The Dockerfile must pre-create ~/.config and chown it to the container user."""
|
|
manifest = {
|
|
"base_image": "ubuntu:24.04",
|
|
"interface_type": "terminal",
|
|
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
|
}
|
|
|
|
dockerfile = compile_dockerfile(manifest)
|
|
|
|
assert "groupadd -g 1000 dev" in dockerfile
|
|
assert "useradd -u 1000 -g 1000 -m -s /bin/bash dev" in dockerfile
|
|
assert "mkdir -p /home/dev && chown -R dev:dev /home/dev" in dockerfile
|
|
assert "mkdir -p /home/dev/.config && chown -R dev:dev /home/dev/.config" in dockerfile
|
|
assert "mkdir -p /home/dev/.local/share && chown -R dev:dev /home/dev/.local/share" in dockerfile
|
|
assert "mkdir -p /home/dev/.cache && chown -R dev:dev /home/dev/.cache" in dockerfile
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_compile_dockerfile_no_user_does_not_create_home() -> None:
|
|
"""Without a user config, no home/user setup should be emitted."""
|
|
manifest = {
|
|
"base_image": "ubuntu:24.04",
|
|
"interface_type": "terminal",
|
|
}
|
|
|
|
dockerfile = compile_dockerfile(manifest)
|
|
|
|
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 "mkdir -p /home/custom" in dockerfile
|
|
assert "ln -sfn" 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_workspace_name_substituted_in_mount_target(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": "~/{{WORKSPACE_NAME}}"}
|
|
],
|
|
}
|
|
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
|
|
assert "WORKSPACE_NAME: my-app" 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
|
|
def test_compile_dockerfile_uses_user_npm_prefix() -> None:
|
|
"""npm global packages must be installed into a user-writable prefix."""
|
|
manifest = {
|
|
"base_image": "ubuntu:24.04",
|
|
"interface_type": "terminal",
|
|
"home_directory": "/home/custom",
|
|
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
|
"packages": {"npm_global": ["@scope/pkg"]},
|
|
}
|
|
dockerfile = compile_dockerfile(manifest)
|
|
|
|
assert "npm install -g --prefix /home/custom/.npm-global" in dockerfile
|
|
assert "/home/custom/.npm-global/bin:$PATH" in dockerfile
|
|
assert "ENV PATH=/home/custom/.npm-global/bin:$PATH" in dockerfile
|
|
|
|
|
|
@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
|
|
assert 'WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"' in entrypoint
|
|
|
|
def test_entrypoint_uses_sudo_for_workspace_symlink(self) -> None:
|
|
"""/workspace is under /, so the non-root entrypoint needs sudo to recreate it."""
|
|
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 'if [ -n "$SUDO" ]; then' in entrypoint
|
|
assert 'sudo ln -sfn "$WORKSPACE_TARGET" /workspace' in entrypoint
|
|
assert 'elif [ "$(id -u)" = "0" ]; then' 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
|
|
|