Files
headquarter/apps/api/tests/unit/test_manifest_compiler.py
T
Developer 9f720930ea fix: run tool terminal sessions as container user instead of root
- Remove compose-level user: 0:0 override from manifest_compiler.py so the
  entrypoint can start as root, fix mount ownership, and drop privileges to
  the container user internally.
- Add get_manifest_container_user() helper to resolve the manifest-declared
  container user (with uid:gid fallback).
- Pass container user through TerminalSession, TerminalManager, and the
  terminal WebSocket handler so docker exec is invoked with --user <user>.
- Update and add unit tests for the manifest compiler and terminal session.
- Record the additional root-user fix in the fix-pi-container-mount-permissions
  OpenSpec change/tasks.

Quality gates: pytest tests/unit/ (226 passed), pytest tests/services/test_terminal_manager_multi.py (7 passed), ruff check on changed files (clean), mypy on changed files (clean)
2026-06-17 20:51:46 +00:00

392 lines
14 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_container_user,
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_project_directory(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"workspace_name": "my-app",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
dockerfile = compile_dockerfile(manifest)
assert "ENV HOME=/home/custom" in dockerfile
assert "WORKDIR /home/custom/my-app" in dockerfile
def test_project_directory_created(self) -> None:
"""When workspace name is known at build time, create the project directory."""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"home_directory": "/home/custom",
"workspace_name": "my-app",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
dockerfile = compile_dockerfile(manifest)
assert "mkdir -p /home/custom/my-app" in dockerfile
assert "ln -sfn" not in dockerfile
def test_runtime_workspace_not_baked_into_image(self) -> None:
"""When workspace name is a runtime placeholder, do not create literal
{{WORKSPACE_NAME}} directories in the image."""
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}}"}],
}
dockerfile = compile_dockerfile(manifest)
assert "{{WORKSPACE_NAME}}" not in dockerfile
assert "ln -sfn" not in dockerfile
assert "WORKDIR /home/custom/{{WORKSPACE_NAME}}" not in dockerfile
assert "WORKDIR /home/custom\n" 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 "/host/repos/my-app:/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
def test_compile_dockerfile_starts_as_root_and_drops_privileges() -> None:
"""The Dockerfile must not set USER so the entrypoint starts as root.
The entrypoint itself drops privileges to the container user before
exec-ing the real command.
"""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
dockerfile = compile_dockerfile(manifest)
entrypoint = compile_entrypoint(manifest)
assert "USER dev" not in dockerfile
assert "exec runuser -u dev -- /bin/bash -il" in entrypoint
assert 'exec runuser -u dev -- "$@"' in entrypoint
@pytest.mark.unit
def test_compile_compose_does_not_pin_root_user() -> None:
"""The compose service must not override the user to root.
The Dockerfile intentionally omits USER so the entrypoint starts as root,
fixes mount ownership, and drops privileges internally. Setting a
compose-level user would pin docker exec sessions to root even after the
entrypoint drops privileges.
"""
manifest = {
"base_image": "ubuntu:24.04",
"interface_type": "terminal",
"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 "user:" not in compose
@pytest.mark.unit
def test_get_manifest_container_user_returns_name() -> None:
"""The container user helper returns the manifest user name."""
manifest = {
"user": {"name": "dev", "uid": 1000, "gid": 1000},
}
assert get_manifest_container_user(manifest) == "dev"
@pytest.mark.unit
def test_get_manifest_container_user_falls_back_to_uid_gid() -> None:
"""When the user name is missing, return uid:gid."""
manifest = {
"user": {"uid": 1000, "gid": 1000},
}
assert get_manifest_container_user(manifest) == "1000:1000"
@pytest.mark.unit
def test_get_manifest_container_user_returns_none_without_user() -> None:
"""When no user is declared, return None."""
manifest = {"base_image": "ubuntu:24.04"}
assert get_manifest_container_user(manifest) is None
@pytest.mark.unit
class TestCompileEntrypoint:
"""Tests for the generated permission-fixing entrypoint."""
def test_entrypoint_creates_home_and_project_directory(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" not in entrypoint
assert 'WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"' in entrypoint
def test_entrypoint_removes_stale_placeholder_directory(self) -> None:
"""Older images baked in a literal {{WORKSPACE_NAME}} directory."""
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 [ -d "${HOME_DIR}/{{WORKSPACE_NAME}}" ]; then' in entrypoint
assert 'rm -rf "${HOME_DIR}/{{WORKSPACE_NAME}}"' in entrypoint
def test_entrypoint_does_not_create_workspace_symlink(self) -> None:
"""The /workspace compatibility symlink is no longer created."""
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 "/workspace" not 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