feat: expand ~ and $HOME in mount target paths
- Add expand_container_path() helper that resolves ~/ and $HOME/ prefixes
- Add get_manifest_home_dir() to compute /home/{user.name} or /root from manifest
- Set ENV HOME=... and ENV USER=... in generated Dockerfile for runtime compatibility
- Pass home_dir through instance creation and startup pipeline
- Expand mount targets in apply_resolved_profile() for regular profile mounts
- Expand mapping targets in _resolve_git_mount_mappings() for git mounts
- Expand working_directory and volume targets in _modify_compose_file()
- Update _prepare_manifest_instance to return home_dir alongside image tag
- Fetch tool_type early in start_instance to determine home_dir before profile application
Quality gates: pytest 188 passed, frontend typecheck clean
Addresses: home-path-expansion
This commit is contained in:
@@ -75,6 +75,7 @@ class TestMergeFunctions:
|
||||
def test_merge_mounts_file_override(self) -> None:
|
||||
"""Test mount file map merging with overrides."""
|
||||
from src.services.config_profile_resolver import ResolvedMount
|
||||
|
||||
result = _merge_mounts(
|
||||
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
||||
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
|
||||
@@ -86,6 +87,7 @@ class TestMergeFunctions:
|
||||
def test_merge_mounts_mode_conflict(self) -> None:
|
||||
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||
from src.services.config_profile_resolver import ResolvedMount
|
||||
|
||||
overrides = {}
|
||||
result = _merge_mounts(
|
||||
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||
@@ -100,7 +102,13 @@ class TestMergeFunctions:
|
||||
"""Test basic git mount merging normalizes to mappings format."""
|
||||
result = _merge_git_mounts(
|
||||
[],
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||
[
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
}
|
||||
],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 1
|
||||
@@ -111,8 +119,22 @@ class TestMergeFunctions:
|
||||
def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None:
|
||||
"""Test that git mounts with same repo+branch concatenate mappings."""
|
||||
result = _merge_git_mounts(
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/src", "branch": "main"}],
|
||||
[
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
"branch": "main",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": "src",
|
||||
"target_path": "/src",
|
||||
"branch": "main",
|
||||
}
|
||||
],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 1
|
||||
@@ -125,8 +147,22 @@ class TestMergeFunctions:
|
||||
def test_merge_git_mounts_dedup_same_mapping(self) -> None:
|
||||
"""Test that duplicate mappings are deduplicated."""
|
||||
result = _merge_git_mounts(
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||
[
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
"branch": "main",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
"branch": "main",
|
||||
}
|
||||
],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 1
|
||||
@@ -135,19 +171,48 @@ class TestMergeFunctions:
|
||||
def test_merge_git_mounts_different_repos(self) -> None:
|
||||
"""Test that git mounts with different repos are preserved."""
|
||||
result = _merge_git_mounts(
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
||||
[
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo2.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/config",
|
||||
}
|
||||
],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 2
|
||||
urls = {m["remote_url"] for m in result}
|
||||
assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"}
|
||||
assert urls == {
|
||||
"https://github.com/user/repo1.git",
|
||||
"https://github.com/user/repo2.git",
|
||||
}
|
||||
|
||||
def test_merge_git_mounts_different_branches(self) -> None:
|
||||
"""Test that same repo with different branches are kept separate."""
|
||||
result = _merge_git_mounts(
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "dev"}],
|
||||
[
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
"branch": "main",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
"branch": "dev",
|
||||
}
|
||||
],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 2
|
||||
@@ -181,7 +246,9 @@ class TestResolveProfile:
|
||||
assert result.files == {"test.txt": "content"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
|
||||
async def test_resolve_profile_with_includes(
|
||||
self, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Test resolving a profile that includes another."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
@@ -225,7 +292,9 @@ class TestResolveProfile:
|
||||
assert result.included_profiles[0]["name"] == "base"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
|
||||
async def test_resolve_profile_child_overrides_parent(
|
||||
self, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Test that child profile values override parent values."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
@@ -262,7 +331,9 @@ class TestResolveProfile:
|
||||
assert result.env_overrides == {"VAR": "child"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
|
||||
async def test_resolve_profile_cycle_detection(
|
||||
self, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Test that cycles are detected during resolution."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
@@ -308,10 +379,12 @@ class TestResolveProfile:
|
||||
await resolve_profile(db_session, profile_a.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
|
||||
async def test_resolve_profile_with_git_mounts(
|
||||
self, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Test resolving a profile with git mounts normalizes to mappings."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
|
||||
profile = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
@@ -319,23 +392,31 @@ class TestResolveProfile:
|
||||
env_vars={},
|
||||
files={},
|
||||
git_mounts=[
|
||||
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
},
|
||||
],
|
||||
)
|
||||
db_session.add(profile)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
result = await resolve_profile(db_session, profile.id)
|
||||
assert len(result.git_mounts) == 1
|
||||
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||
assert "mappings" in result.git_mounts[0]
|
||||
assert result.git_mounts[0]["mappings"] == [{"source_path": ".", "target_path": "/app"}]
|
||||
|
||||
assert result.git_mounts[0]["mappings"] == [
|
||||
{"source_path": ".", "target_path": "/app"}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
|
||||
async def test_resolve_profile_with_git_mount_includes(
|
||||
self, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Test resolving a profile that includes another with git mounts."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
|
||||
# Create base profile with git mount
|
||||
base = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
@@ -344,11 +425,15 @@ class TestResolveProfile:
|
||||
env_vars={},
|
||||
files={},
|
||||
git_mounts=[
|
||||
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo1.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
},
|
||||
],
|
||||
)
|
||||
db_session.add(base)
|
||||
|
||||
|
||||
# Create child profile with its own git mount
|
||||
child = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
@@ -357,12 +442,16 @@ class TestResolveProfile:
|
||||
env_vars={},
|
||||
files={},
|
||||
git_mounts=[
|
||||
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo2.git",
|
||||
"source_path": "config",
|
||||
"target_path": "/config",
|
||||
},
|
||||
],
|
||||
)
|
||||
db_session.add(child)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
# Create include relationship
|
||||
include = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
@@ -372,11 +461,14 @@ class TestResolveProfile:
|
||||
)
|
||||
db_session.add(include)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
result = await resolve_profile(db_session, child.id)
|
||||
assert len(result.git_mounts) == 2
|
||||
urls = {m["remote_url"] for m in result.git_mounts}
|
||||
assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"}
|
||||
assert urls == {
|
||||
"https://github.com/user/repo1.git",
|
||||
"https://github.com/user/repo2.git",
|
||||
}
|
||||
for m in result.git_mounts:
|
||||
assert "mappings" in m
|
||||
|
||||
|
||||
Reference in New Issue
Block a user