fix: deep-merge manifest with base to resolve container user UID/GID

- start_instance now deep-merges manifest with base definition before extracting user.uid/user.gid
- The user config is typically defined in the base image (ubuntu-24.04-dev), not the extending manifest
- Add debug logging to verify resolved uid/gid/home_dir
- Add logging to prepare_ssh_key_files for chown success/failure visibility
- Log current process uid when chown fails to diagnose permission issues

Quality gates: pytest 239 passed (6 pre-existing failures), tsc --noEmit clean
This commit is contained in:
Alex Blank
2026-05-29 14:49:24 +02:00
parent b11089896a
commit de8c47c81c
3 changed files with 36 additions and 11 deletions
+14
View File
@@ -1341,11 +1341,25 @@ async def start_instance(
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
if manifest_def:
manifest = dict(manifest_def.manifest)
# Merge with base definition if referenced (user config is often in base)
if manifest_def.base_definition_id:
base_def = await session.get(
ToolDefinitionManifest, manifest_def.base_definition_id
)
if base_def:
manifest = resolve_base(deep_merge(dict(base_def.manifest), manifest))
home_dir = get_manifest_home_dir(manifest)
user_cfg = manifest.get("user")
if user_cfg:
container_uid = user_cfg.get("uid", 0)
container_gid = user_cfg.get("gid", 0)
logger.debug(
"Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s",
instance.id,
container_uid,
container_gid,
home_dir,
)
# Apply selected config profile if any
instance_dir = os.path.dirname(instance.compose_path)
+17 -4
View File
@@ -1,5 +1,6 @@
"""SSH key service utilities for preparing keys for container use."""
import logging
import os
from pathlib import Path
@@ -7,6 +8,8 @@ from cryptography.fernet import Fernet
from src.config import Settings
logger = logging.getLogger(__name__)
def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
@@ -75,10 +78,20 @@ def prepare_ssh_key_files(
os.chown(private_key_path, effective_uid, effective_gid)
os.chown(public_key_path, effective_uid, effective_gid)
os.chown(config_path, effective_uid, effective_gid)
except PermissionError:
# API process may not be running as root; permission fixer will
# handle this post-start if the mount is read-write
pass
logger.debug(
"Set SSH key ownership to uid=%s gid=%s for %s",
effective_uid,
effective_gid,
ssh_dir,
)
except PermissionError as exc:
logger.warning(
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
effective_uid,
effective_gid,
os.getuid(),
exc,
)
return str(ssh_dir)
+5 -7
View File
@@ -13,7 +13,9 @@ class TestPrepareSshKeyFiles:
"""Tests for prepare_ssh_key_files."""
@patch("src.services.ssh_keys._get_fernet")
def test_creates_files_with_default_permissions(self, mock_fernet, tmp_path) -> None:
def test_creates_files_with_default_permissions(
self, mock_fernet, tmp_path
) -> None:
mock_fernet.return_value.decrypt.return_value = b"private-key-content"
ssh_key = MagicMock()
ssh_key.private_key_encrypted = "enc"
@@ -35,9 +37,7 @@ class TestPrepareSshKeyFiles:
ssh_key.public_key = "ssh-ed25519 AAA test@test"
with patch("os.chown") as mock_chown:
ssh_dir = prepare_ssh_key_files(
str(tmp_path), ssh_key, uid=1001, gid=1001
)
ssh_dir = prepare_ssh_key_files(str(tmp_path), ssh_key, uid=1001, gid=1001)
# os.chown is called for the directory and each of the 3 files
assert mock_chown.call_count == 4
@@ -56,8 +56,6 @@ class TestPrepareSshKeyFiles:
with patch("os.chown", side_effect=PermissionError("not allowed")):
# Should not raise
ssh_dir = prepare_ssh_key_files(
str(tmp_path), ssh_key, uid=1001, gid=1001
)
ssh_dir = prepare_ssh_key_files(str(tmp_path), ssh_key, uid=1001, gid=1001)
assert Path(ssh_dir).exists()