feat: add live config profile refresh
- Standardize built-in tool users for shared writable profile mounts - Mount canonical non-Git profile sources across compatible instances - Report restart-required outcomes and guard active profile deletion - Surface restart feedback in config profile editing Quality gates: frontend build passed; backend py_compile and LSP passed. Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""Tests for the shared non-root user used by built-in tools."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from src.seeds.builtin_tool_types import (
|
||||
BUILTIN_TOOL_TYPES,
|
||||
BUILTIN_USER_GID,
|
||||
BUILTIN_USER_UID,
|
||||
_standardize_builtin_manifest_user,
|
||||
)
|
||||
|
||||
|
||||
def test_builtin_compose_templates_use_shared_runtime_ids() -> None:
|
||||
"""Every legacy built-in Compose tool declares the shared UID/GID."""
|
||||
templates = {
|
||||
str(tool["name"]): str(tool["compose_template"]) for tool in BUILTIN_TOOL_TYPES
|
||||
}
|
||||
|
||||
assert "- PUID=1000" in templates["code-server"]
|
||||
assert "- PGID=1000" in templates["code-server"]
|
||||
assert "- NB_UID=1000" in templates["jupyter-notebook"]
|
||||
assert "- NB_GID=1000" in templates["jupyter-notebook"]
|
||||
assert "setpriv --reuid=node --regid=node --init-groups" in templates["opencode"]
|
||||
|
||||
|
||||
def test_only_builtin_user_manifest_is_standardized() -> None:
|
||||
"""The startup migration cannot rewrite a future custom tool user."""
|
||||
builtin_manifest = {"user": {"name": "user", "uid": 1001, "gid": 1001}}
|
||||
custom_manifest = {"user": {"name": "custom", "uid": 2000, "gid": 2000}}
|
||||
|
||||
assert _standardize_builtin_manifest_user(builtin_manifest)
|
||||
assert builtin_manifest["user"] == {
|
||||
"name": "user",
|
||||
"uid": BUILTIN_USER_UID,
|
||||
"gid": BUILTIN_USER_GID,
|
||||
}
|
||||
assert not _standardize_builtin_manifest_user(custom_manifest)
|
||||
assert custom_manifest["user"] == {"name": "custom", "uid": 2000, "gid": 2000}
|
||||
|
||||
|
||||
def test_tool_image_templates_define_shared_ids() -> None:
|
||||
"""Project-owned image templates explicitly create or map UID/GID 1000."""
|
||||
root = Path(__file__).resolve().parents[4]
|
||||
sources = {
|
||||
name: (root / "tool-images" / name).read_text()
|
||||
for name in (
|
||||
"base.dockerfile",
|
||||
"opencode.dockerfile",
|
||||
"pi-agent.dockerfile",
|
||||
"code-server.dockerfile",
|
||||
"jupyter.dockerfile",
|
||||
)
|
||||
}
|
||||
|
||||
for name in ("base.dockerfile", "opencode.dockerfile", "pi-agent.dockerfile"):
|
||||
assert "groupadd -g 1000 user" in sources[name]
|
||||
assert "useradd -m -u 1000 -g 1000" in sources[name]
|
||||
|
||||
assert "PUID=1000" in sources["code-server.dockerfile"]
|
||||
assert "PGID=1000" in sources["code-server.dockerfile"]
|
||||
assert "NB_UID=1000" in sources["jupyter.dockerfile"]
|
||||
assert "NB_GID=1000" in sources["jupyter.dockerfile"]
|
||||
@@ -532,6 +532,54 @@ class TestApplyResolvedProfile:
|
||||
assert Path(volumes[0]["source"]).name == "workspace_x_y"
|
||||
assert (Path(volumes[0]["source"]) / "z.json").exists()
|
||||
|
||||
def test_top_level_files_use_profile_scoped_direct_bind_mounts(self, tmp_path) -> None:
|
||||
"""Top-level files are shared safely without mounting over a workspace."""
|
||||
profile_id = uuid.uuid4()
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=profile_id,
|
||||
profile_name="test",
|
||||
files={".tool/config.toml": "setting = true"},
|
||||
)
|
||||
|
||||
instance_root = tmp_path / "instances"
|
||||
_, files, volumes, _ = apply_resolved_profile(
|
||||
str(instance_root / "instance-a"),
|
||||
resolved,
|
||||
working_dir="/workspace/project",
|
||||
)
|
||||
|
||||
canonical_file = (
|
||||
instance_root / "config-profiles" / str(profile_id) / "files" / ".tool" / "config.toml"
|
||||
)
|
||||
assert files == {}
|
||||
assert volumes == [
|
||||
{
|
||||
"source": str(canonical_file),
|
||||
"target": "/workspace/project/.tool/config.toml",
|
||||
"type": "bind",
|
||||
"readonly": False,
|
||||
}
|
||||
]
|
||||
assert canonical_file.read_text() == "setting = true"
|
||||
|
||||
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
|
||||
"""Different instance paths resolve a profile to one canonical source."""
|
||||
profile_id = uuid.uuid4()
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=profile_id,
|
||||
profile_name="test",
|
||||
mounts={"/app": ResolvedMount(target="/app", mode="rw", files={"config.ini": "x"})},
|
||||
)
|
||||
|
||||
instance_root = tmp_path / "instances"
|
||||
_, _, first_volumes, _ = apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||
_, _, second_volumes, _ = apply_resolved_profile(str(instance_root / "instance-b"), resolved)
|
||||
|
||||
assert first_volumes[0]["source"] == second_volumes[0]["source"]
|
||||
assert first_volumes[0]["source"] == str(
|
||||
instance_root / "config-profiles" / str(profile_id) / "mounts" / "app"
|
||||
)
|
||||
|
||||
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
||||
"""A mount with no files should not produce any volume entries."""
|
||||
resolved = ResolvedProfile(
|
||||
@@ -579,7 +627,7 @@ class TestApplyResolvedProfile:
|
||||
|
||||
assert len(volumes) == 1
|
||||
assert volumes[0]["target"] == "/etc/app"
|
||||
assert volumes[0].get("readonly") is True
|
||||
assert volumes[0].get("readonly")
|
||||
|
||||
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."""
|
||||
@@ -598,7 +646,7 @@ class TestApplyResolvedProfile:
|
||||
|
||||
assert len(volumes) == 1
|
||||
assert volumes[0]["target"] == "/app"
|
||||
assert volumes[0].get("readonly") is False
|
||||
assert not volumes[0].get("readonly")
|
||||
|
||||
|
||||
class TestCheckIncludeCycle:
|
||||
|
||||
Reference in New Issue
Block a user