47de2a0133
The previous ordering checked SUDO before checking if the process was already running as root. When Docker starts the container with a non-root user, SUDO may be empty, but the real fix is that the entrypoint should try root first (e.g. when the image is started as root) and only then fall back to sudo. - Reorder symlink creation logic: root first, then sudo, then best-effort - Update unit test to assert root is checked before sudo Quality gates: - pytest tests/unit: 208 passed - ruff: clean on changed files - mypy: clean on changed files
290 lines
10 KiB
Python
290 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_root_then_sudo_for_workspace_symlink(self) -> None:
|
|
"""/workspace is under /, so root takes precedence; non-root falls back to sudo."""
|
|
manifest = {
|
|
"base_image": "ubuntu:24.04",
|
|
"interface_type": "terminal",
|
|
"home_directory": "/home/custom",
|
|
"user": {"name": "dev", "uid": 1000, "gid": 1000},
|
|
}
|
|
entrypoint = compile_entrypoint(manifest)
|
|
|
|
root_idx = entrypoint.find('if [ "$(id -u)" = "0" ]; then')
|
|
sudo_idx = entrypoint.find('elif [ -n "$SUDO" ]; then')
|
|
assert root_idx != -1
|
|
assert sudo_idx != -1
|
|
assert root_idx < sudo_idx
|
|
assert 'sudo 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
|