fix: stage config-profile mounts as directories so ~/.config stays writable
- Switch apply_resolved_profile from per-file bind mounts to one
directory-level bind mount per ResolvedMount target.
- Stage all configured files under instance_dir/mounts/<sanitized_target>
and bind-mount that directory, so Docker no longer creates a root-owned
parent directory such as ~/.config.
- Propagate read-only mode ('ro') as the 'readonly' flag on volume entries.
- Update unit tests to expect directory-level mounts and add coverage for
readonly/writable flags.
Quality gates: python3 -m py_compile, pytest (313 passed, 34 skipped),
npm run typecheck, npm run lint.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/tests/unit
|
||||
|
||||
## role
|
||||
Contains comprehensive unit tests for the API application's core services, utilities, and infrastructure components.
|
||||
Contains comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
|
||||
## parent
|
||||
index: apps/api/tests/.pi-map.index.md
|
||||
map: apps/api/tests/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: apps/api/tests/unit
|
||||
index: apps/api/tests/unit/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains comprehensive unit tests for the API application's core services, utilities, and infrastructure components.
|
||||
Contains comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
|
||||
## files
|
||||
- __init__.py | Empty file with no functionality
|
||||
- test_config.py | Tests configuration settings and database URL building for an application, verifying defaults, environment variable overrides, and environment-specific behavior. | exp: func:test_settings_default_database_url_uses_asyncpg(monkeypatch) → None, call:monkeypatch.delenv, call:Settings, func:test_build_database_url_uses_explicit_values() → None, call:build_database_url, func:test_settings_prefers_explicit_database_url_env(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_auth_settings_have_secure_defaults() → None, call:Settings, call:settings.resolved_authentik_authorize_url.endswith, call:settings.resolved_authentik_token_url.endswith, call:settings.resolved_authentik_jwks_url.endswith, func:test_cookie_policy_is_strict_in_production(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) → None, call:monkeypatch.setenv, call:Settings | dep: pytest, src.config, src.database
|
||||
- test_config_profile_resolver.py | Unit tests for config profile resolution including merge functions, profile inheritance with includes, cycle detection, and git mount normalization | exp: class:TestMergeFunctions, method:test_merge_env_vars_basic(self) → None, call:_merge_env_vars, method:test_merge_env_vars_tracks_overrides(self) → None, call:_merge_env_vars, method:test_merge_runtime_hints_basic(self) → None, call:_merge_runtime_hints, method:test_merge_files_basic(self) → None, call:_merge_files, method:test_merge_mounts_basic(self) → None, call:_merge_mounts, method:test_merge_mounts_file_override(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_mounts_mode_conflict(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_git_mounts_basic(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_concatenate_same_repo_branch(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_dedup_same_mapping(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_repos(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_branches(self) → None, call:_merge_git_mounts, call:len, call:m.get, class:TestResolveProfile, class:TestApplyResolvedProfile, method:test_mounts_individual_files_not_directory(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, method:test_file_mount_preserves_sibling_files(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0]["source"].endswith, method:test_empty_mount_produces_no_volumes(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, method:test_home_expansion_in_file_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, class:TestCheckIncludeCycle | dep: uuid, pytest, sqlalchemy.ext.asyncio, src.models.config.config_profile, src.services.config.config_profile_resolver
|
||||
- test_config_profile_resolver.py | Tests the config profile resolution system including merge helpers, profile inheritance with cycle detection, and git mount normalization | exp: class:TestMergeFunctions, method:test_merge_env_vars_basic(self) → None, call:_merge_env_vars, method:test_merge_env_vars_tracks_overrides(self) → None, call:_merge_env_vars, method:test_merge_runtime_hints_basic(self) → None, call:_merge_runtime_hints, method:test_merge_files_basic(self) → None, call:_merge_files, method:test_merge_mounts_basic(self) → None, call:_merge_mounts, method:test_merge_mounts_file_override(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_mounts_mode_conflict(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_git_mounts_basic(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_concatenate_same_repo_branch(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_dedup_same_mapping(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_repos(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_branches(self) → None, call:_merge_git_mounts, call:len, call:m.get, class:TestResolveProfile, class:TestApplyResolvedProfile, method:test_mounts_directory_not_individual_files(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path(volumes[0]["source"]).is_dir, call:(Path(volumes[0]["source"]) / "config.json").exists, call:(Path(volumes[0]["source"]) / "nested" / "file.txt").exists, method:test_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path, call:(Path(volumes[0]["source"]) / "z.json").exists, method:test_empty_mount_produces_no_volumes(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, method:test_home_expansion_in_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:(Path(volumes[0]["source"]) / "app.toml").exists, call:Path, method:test_readonly_mount_sets_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, method:test_writable_mount_does_not_set_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, class:TestCheckIncludeCycle | dep: uuid, pathlib, pytest, sqlalchemy.ext.asyncio, src.models.config.config_profile, src.services.config.config_profile_resolver
|
||||
- test_docker_build.py | Unit tests for a Docker image build service that verifies Dockerfile creation, command structure, context file handling, path traversal prevention, and error handling. | exp: class:TestBuildImage | dep: subprocess, tempfile, pathlib, unittest.mock, pytest, src.services.build.docker_build
|
||||
- test_docker_service.py | Unit tests for Docker service utilities including container ID/name retrieval and volume sorting by specificity. | exp: class:TestGetContainerId, class:TestGetContainerName, class:TestSortVolumesBySpecificity, method:test_parent_before_child(self) → None, call:sort_volumes_by_specificity, method:test_stable_sort_for_equal_depth(self) → None, call:sort_volumes_by_specificity, method:test_with_type_suffix(self) → None, call:sort_volumes_by_specificity, method:test_empty_list(self) → None, call:sort_volumes_by_specificity, method:test_single_volume(self) → None, call:sort_volumes_by_specificity, method:test_duplicate_target_warning(self, caplog) → None, call:caplog.at_level, call:sort_volumes_by_specificity | dep: unittest.mock, logging, src.services.docker.container, src.services.docker.compose, subprocess
|
||||
- test_event_bus.py | Unit tests for InstanceEventBus verifying publish/subscribe, exception isolation, unsubscribe, and async callback support. | exp: func:event_bus() → InstanceEventBus, call:InstanceEventBus, call:bus._reset_for_testing, func:sample_payload() → InstanceEventPayload, call:str, call:uuid.uuid4, func:test_publish_delivers_to_all_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, call:len, func:callback_1(payload: InstanceEventPayload) → None, call:received.append, func:callback_2(payload: InstanceEventPayload) → None, call:received.append, func:callback_3(payload: InstanceEventPayload) → None, call:received.append, func:test_subscriber_exception_isolation(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, raise:RuntimeError, func:bad_callback(_payload: InstanceEventPayload) → None, raise:RuntimeError, func:good_callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_unsubscribe_removes_callback(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:unsubscribe, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_publish_to_empty_subscriber_list(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:event_bus.publish, func:test_async_subscriber_supported(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, call:event_bus.subscribe, call:event_bus.publish, func:async_callback(_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, func:test_unsubscribe_all_clears_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.unsubscribe_all, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append | dep: asyncio, uuid, typing, pytest, src.services.instance.event_bus
|
||||
@@ -28,9 +28,9 @@ Contains comprehensive unit tests for the API application's core services, utili
|
||||
- test_readiness_probe.py | Unit tests for a Docker container readiness probe service that executes commands via docker exec with retry logic. | exp: class:TestExecuteProbe, class:TestIntegrationScenarios | dep: unittest.mock, src.services.shared.readiness_probe, subprocess
|
||||
- test_ssh_keys.py | Unit tests for SSH key preparation functionality including file creation, permissions, ownership, and error handling | exp: class:TestPrepareSshKeyFiles | dep: os, pathlib, unittest.mock, pytest, src.services.shared.ssh_keys
|
||||
## arch
|
||||
Standard Python unittest/pytest structure with heavy use of mocking for external dependencies (Docker, Git subprocess, database), testing business logic in isolation without integration requirements.
|
||||
Standard Python unittest/pytest pattern with heavy mocking of external dependencies (subprocess, docker, filesystem) to test service layer logic in isolation, organized by functional domain with one test module per service component.
|
||||
## tags
|
||||
test, url, call:notification, git, call:db, merge, call:, src
|
||||
test, url, call:notification, git, call:, call:db, merge, src
|
||||
## symbols
|
||||
- TestMergeFunctions
|
||||
- TestResolveProfile
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -483,10 +485,10 @@ class TestResolveProfile:
|
||||
|
||||
|
||||
class TestApplyResolvedProfile:
|
||||
"""Unit tests for apply_resolved_profile file-level mount behavior."""
|
||||
"""Unit tests for apply_resolved_profile directory-level mount behavior."""
|
||||
|
||||
def test_mounts_individual_files_not_directory(self, tmp_path) -> None:
|
||||
"""Each file in a ResolvedMount should be mounted individually, not the staging dir."""
|
||||
def test_mounts_directory_not_individual_files(self, tmp_path) -> None:
|
||||
"""Each ResolvedMount should produce one directory-level bind mount."""
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=uuid.uuid4(),
|
||||
profile_name="test",
|
||||
@@ -503,15 +505,15 @@ class TestApplyResolvedProfile:
|
||||
)
|
||||
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||
|
||||
assert len(volumes) == 2
|
||||
targets = {v["target"] for v in volumes}
|
||||
assert "/app/config.json" in targets
|
||||
assert "/app/nested/file.txt" in targets
|
||||
# No directory-level mount
|
||||
assert "/app" not in targets
|
||||
assert len(volumes) == 1
|
||||
assert volumes[0]["target"] == "/app"
|
||||
assert Path(volumes[0]["source"]).name == "app"
|
||||
assert Path(volumes[0]["source"]).is_dir()
|
||||
assert (Path(volumes[0]["source"]) / "config.json").exists()
|
||||
assert (Path(volumes[0]["source"]) / "nested" / "file.txt").exists()
|
||||
|
||||
def test_file_mount_preserves_sibling_files(self, tmp_path) -> None:
|
||||
"""File-level mounts should not hide sibling files from other mounts."""
|
||||
def test_directory_mount_target(self, tmp_path) -> None:
|
||||
"""A directory-level mount targets the configured directory path."""
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=uuid.uuid4(),
|
||||
profile_name="test",
|
||||
@@ -526,8 +528,9 @@ class TestApplyResolvedProfile:
|
||||
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||
|
||||
assert len(volumes) == 1
|
||||
assert volumes[0]["target"] == "/workspace/x/y/z.json"
|
||||
assert volumes[0]["source"].endswith("z.json")
|
||||
assert volumes[0]["target"] == "/workspace/x/y"
|
||||
assert Path(volumes[0]["source"]).name == "workspace_x_y"
|
||||
assert (Path(volumes[0]["source"]) / "z.json").exists()
|
||||
|
||||
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
||||
"""A mount with no files should not produce any volume entries."""
|
||||
@@ -539,8 +542,8 @@ class TestApplyResolvedProfile:
|
||||
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||
assert volumes == []
|
||||
|
||||
def test_home_expansion_in_file_mount_target(self, tmp_path) -> None:
|
||||
"""~ in mount target should be expanded to home_dir for file mounts."""
|
||||
def test_home_expansion_in_directory_mount_target(self, tmp_path) -> None:
|
||||
"""~ in mount target should be expanded to home_dir for directory mounts."""
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=uuid.uuid4(),
|
||||
profile_name="test",
|
||||
@@ -555,7 +558,47 @@ class TestApplyResolvedProfile:
|
||||
env, files, volumes, hints = apply_resolved_profile(
|
||||
str(tmp_path), resolved, home_dir="/home/user"
|
||||
)
|
||||
assert volumes[0]["target"] == "/home/user/.config/app.toml"
|
||||
assert len(volumes) == 1
|
||||
assert volumes[0]["target"] == "/home/user/.config"
|
||||
assert (Path(volumes[0]["source"]) / "app.toml").exists()
|
||||
|
||||
def test_readonly_mount_sets_readonly_flag(self, tmp_path) -> None:
|
||||
"""A mount with mode 'ro' should set readonly on the volume entry."""
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=uuid.uuid4(),
|
||||
profile_name="test",
|
||||
mounts={
|
||||
"/etc/app": ResolvedMount(
|
||||
target="/etc/app",
|
||||
mode="ro",
|
||||
files={"config.cfg": "value"},
|
||||
)
|
||||
},
|
||||
)
|
||||
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||
|
||||
assert len(volumes) == 1
|
||||
assert volumes[0]["target"] == "/etc/app"
|
||||
assert volumes[0].get("readonly") is True
|
||||
|
||||
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
|
||||
"""A mount with mode 'rw' should not set readonly on the volume entry."""
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=uuid.uuid4(),
|
||||
profile_name="test",
|
||||
mounts={
|
||||
"/app": ResolvedMount(
|
||||
target="/app",
|
||||
mode="rw",
|
||||
files={"config.json": "{}"},
|
||||
)
|
||||
},
|
||||
)
|
||||
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||
|
||||
assert len(volumes) == 1
|
||||
assert volumes[0]["target"] == "/app"
|
||||
assert volumes[0].get("readonly") is False
|
||||
|
||||
|
||||
class TestCheckIncludeCycle:
|
||||
|
||||
Reference in New Issue
Block a user