fix: use runuser for privilege drop and capture container stderr in logs

The container still exited immediately after the su-based fix.  can
interfere with TTY/stdin handling for interactive shells. Switch to
, which is root-only, skips PAM, and preserves file descriptors so
bash stays interactive.

Also improve container failure diagnostics:
-  now combines stdout and stderr
- This helps surface the real reason when a container exits with code 0

Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
This commit is contained in:
Developer
2026-06-15 10:41:37 +00:00
parent e35e605914
commit 1d345eba32
20 changed files with 81 additions and 34 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/services
## role
Marks the `services` directory as a Python package for organizing business logic and service-layer modules.
Service layer package for the API application, intended to contain business logic implementations.
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: apps/api/src/services
index: apps/api/src/services/.pi-map.index.md
## role
Marks the `services` directory as a Python package for organizing business logic and service-layer modules.
Service layer package for the API application, intended to contain business logic implementations.
## files
- __init__.py | Empty file with no functionality
## arch
Standard Python package structure using `__init__.py` to define an importable namespace, following conventional layered architecture with an empty initializer awaiting future service modules.
Standard Python package structure with an empty initializer, awaiting service module implementations following a layered architecture pattern.
## tags
init, empty, functionality
## symbols
@@ -388,12 +388,12 @@ def compile_entrypoint(manifest: dict) -> str:
# Drop from root to the container user before running the real command.
# The Dockerfile no longer sets USER, so the entrypoint has root for the
# setup above. Use `su -l` to start a login shell as the container user
# and exec the original CMD, preserving TTY/stdin for interactive tools.
# setup above. `runuser` (root-only, no PAM) preserves the environment,
# stdin, and TTY so interactive tools like bash keep running.
if user:
name = user["name"]
lines.append("# Drop privileges to the container user")
lines.append(f'exec su -l {name} -s /bin/bash -c \'exec "$@"\' -- "$@"')
lines.append(f'exec runuser -u {name} -- "$@"')
else:
lines.append('exec "$@"')
return "\n".join(lines)
@@ -172,6 +172,47 @@ def _merge_mounts(
return result
def _find_mount_conflicts(
profile: ConfigProfile,
resolved: ResolvedProfile,
) -> list[dict[str, Any]]:
"""Find mounts on the profile that override mounts from included profiles.
Returns a list of conflict descriptors with the target path, the included
profile that originally provided the mount, and the current profile name.
"""
conflicts = []
own_targets = {m["target"] for m in (profile.mounts or [])}
own_files = {f for m in (profile.mounts or []) for f in m.get("files", {}).keys()}
for mount in resolved.mounts.values():
if mount.target in own_targets:
# The profile itself has a mount at the same target as an included one.
conflicts.append(
{
"type": "mount_target",
"target": mount.target,
"overridden_by": profile.name,
"source": resolved.profile_name,
}
)
continue
for rel_path in mount.files:
if rel_path in own_files:
conflicts.append(
{
"type": "mount_file",
"target": mount.target,
"file": rel_path,
"overridden_by": profile.name,
"source": resolved.profile_name,
}
)
return conflicts
def _merge_git_mounts(
base: list[dict[str, Any]],
overlay: list[dict[str, Any]],
@@ -2,7 +2,7 @@
dir: apps/api/src/services/docker
## role
Provides Docker infrastructure services for container lifecycle management, compose orchestration, secure configuration deployment, and external tunnel exposure.
Provides Docker infrastructure automation for container lifecycle management, service composition, secure configuration deployment, and external network tunneling.
## parent
index: apps/api/src/services/.pi-map.index.md
map: apps/api/src/services/.pi-map.md
+3 -3
View File
@@ -4,15 +4,15 @@ dir: apps/api/src/services/docker
index: apps/api/src/services/docker/.pi-map.index.md
## role
Provides Docker infrastructure services for container lifecycle management, compose orchestration, secure configuration deployment, and external tunnel exposure.
Provides Docker infrastructure automation for container lifecycle management, service composition, secure configuration deployment, and external network tunneling.
## files
- __init__.py | Package initialization file that exposes Docker-related service functions for container operations, compose management, configuration staging, and tunnel management. | dep: src.services.docker.compose, src.services.docker.config_staging, src.services.docker.container, src.services.docker.tunnel
- compose.py | Generates, renders, and executes Docker Compose files with volume sorting and template variable substitution. | exp: func:sort_volumes_by_specificity(volumes: list[str]) → list[str], call:vol.split, call:len, call:parts[1].rstrip, call:target.count, call:targets.append, call:Counter(targets).items, call:logger.warning, call:sorted, func:_target_depth(vol: str) → int, call:vol.split, call:len, call:parts[1].rstrip, call:target.count, func:render_compose_template(template: str, variables: dict[str, Any]) → str, call:variables.items, call:result.replace, call:str, call:aliases.items, call:variables.get, func:write_compose_file(instance_dir: str, content: str) → str, call:Path, call:compose_path.write_text, call:str, func:execute_compose_command(compose_path: str, action: str, timeout, env_file) → tuple[int, str, str], call:Path, call:cmd.extend, call:cmd.append, call:subprocess.run, call:str, raise:ValueError | dep: logging, subprocess, collections, pathlib, typing, collections.Counter, pathlib.Path, typing.Any
- config_staging.py | Stages configuration files into instance directories with security checks for path traversal. | exp: func:ensure_instance_directory(instance_id: str, base_path) → str, call:Settings, call:Path, call:instance_dir.mkdir, call:str, call:instance_dir.absolute, func:write_env_file(instance_dir: str, env_vars: dict[str, str]) → str, call:Path, call:env_vars.items, call:env_path.write_text, call:"\n".join, call:str, func:write_config_files(instance_dir: str, files: dict[str, str]) → None, call:Path, call:files.items, call:full_path.resolve().relative_to, call:instance_path.resolve, call:full_path.parent.mkdir, call:full_path.write_text, raise:ValueError | dep: logging, pathlib, src.config, src.config.Settings
- container.py | Provides Docker container runtime queries and network management utilities via subprocess calls to the Docker CLI. | exp: func:get_container_id(instance_name: str) → str | None, call:instance_name.lower, call:subprocess.run, call:result.stdout.strip, call:ps_result.stdout.strip().splitlines, call:line.split, call:len, call:name.lower, func:get_container_name(instance_name: str) → str | None, call:subprocess.run, call:instance_name.lower, call:result.stdout.strip().lstrip, func:get_backend_network_name() → str, call:subprocess.run, call:result.stdout.strip().split, call:net.lower, func:connect_container_to_network(container_name: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_ip_on_network(container_id: str, network_name) → str | None, call:get_backend_network_name, call:subprocess.run, call:result.stdout.strip, func:is_container_on_network(container_id: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_status(container_id: str) → dict[str, Any], call:subprocess.run, call:result.stdout.strip().split, call:int, call:len, call:parts[1].isdigit, func:wait_for_container_running(container_id: str, timeout, interval) → dict[str, Any], call:time.time, call:get_container_status, call:time.sleep, func:get_container_logs(container_id: str, tail) → str, call:subprocess.run, call:str, func:find_free_port(start, end) → int, call:range, call:socket.socket, call:s.connect_ex, raise:RuntimeError | dep: logging, subprocess, time, typing, socket
- container.py | Provides utility functions for querying Docker container runtime state, managing container network connections, and finding free TCP ports via subprocess calls to the Docker CLI. | exp: func:get_container_id(instance_name: str) → str | None, call:instance_name.lower, call:subprocess.run, call:result.stdout.strip, call:ps_result.stdout.strip().splitlines, call:line.split, call:len, call:name.lower, func:get_container_name(instance_name: str) → str | None, call:subprocess.run, call:instance_name.lower, call:result.stdout.strip().lstrip, func:get_backend_network_name() → str, call:subprocess.run, call:result.stdout.strip().split, call:net.lower, func:connect_container_to_network(container_name: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_ip_on_network(container_id: str, network_name) → str | None, call:get_backend_network_name, call:subprocess.run, call:result.stdout.strip, func:is_container_on_network(container_id: str, network_name) → bool, call:get_backend_network_name, call:subprocess.run, func:get_container_status(container_id: str) → dict[str, Any], call:subprocess.run, call:result.stdout.strip().split, call:int, call:len, call:parts[1].isdigit, func:wait_for_container_running(container_id: str, timeout, interval) → dict[str, Any], call:time.time, call:get_container_status, call:time.sleep, func:get_container_logs(container_id: str, tail) → str, call:subprocess.run, call:str, func:find_free_port(start, end) → int, call:range, call:socket.socket, call:s.connect_ex, raise:RuntimeError | dep: logging, subprocess, time, typing, socket
- tunnel.py | Manages Cloudflare tunnels by orchestrating cloudflared Docker containers to expose internal services via temporary public URLs. | exp: func:_tunnel_container_name(instance_name: str) → str, call:instance_name.lower, func:_ensure_image() → None, call:subprocess.run, call:result.stdout.strip, call:logger.info, call:logger.warning, func:_cleanup_stale_tunnel(tunnel_name: str) → None, call:subprocess.run, func:_get_tunnel_logs(tunnel_name: str) → tuple[str, str], call:subprocess.run, func:_get_tunnel_exit_code(tunnel_name: str) → int | None, call:subprocess.run, call:int, call:result.stdout.strip, func:start_tunnel(instance_name: str, container_port: int, timeout, target_url) → dict[str, str], call:_ensure_image, call:_tunnel_container_name, call:_cleanup_stale_tunnel, call:instance_name.lower, call:get_backend_network_name, call:logger.debug, call:" ".join, call:subprocess.run, call:proc.stdout.strip, call:re.compile, call:__import__("time").time, call:_get_tunnel_logs, call:url_pattern.search, call:match.group, call:_get_tunnel_exit_code, call:__import__("time").sleep, call:logger.info, raise:RuntimeError, func:stop_tunnel(instance_name: str) → None, call:_tunnel_container_name, call:_cleanup_stale_tunnel, call:logger.debug, func:recreate_tunnel(instance_name: str, container_port: int, target_url) → dict[str, str], call:stop_tunnel, call:start_tunnel, func:check_tunnel_health(url: str, timeout) → dict[str, Any], call:subprocess.run, call:int, call:result.stdout.strip, call:str(exc).lower, call:any | dep: logging, re, subprocess, typing, src.services.docker.container
## arch
Service-oriented utility modules with subprocess-based Docker CLI integration, Jinja2 templating for compose generation, and security-hardened file operations with path traversal validation.
Subprocess-based CLI wrapper architecture with template-driven file generation, security-validated file staging, and container-orchestrated tunnel proxying.
## tags
tunnel, container, call:subprocess.run, get, name, call:, network, call:result.stdout.strip
## symbols
+10 -4
View File
@@ -283,7 +283,7 @@ def get_container_logs(container_id: str, tail: int = 100) -> str:
tail: Number of lines to return
Returns:
Container logs
Container logs (stdout + stderr)
"""
result = subprocess.run(
["docker", "logs", "--tail", str(tail), container_id],
@@ -291,9 +291,15 @@ def get_container_logs(container_id: str, tail: int = 100) -> str:
text=True,
)
if result.returncode == 0:
return result.stdout
return f"Failed to get logs: {result.stderr}"
if result.returncode != 0:
return f"Failed to get logs: {result.stderr}"
logs = result.stdout
if result.stderr:
if logs:
logs += "\n"
logs += f"STDERR:\n{result.stderr}"
return logs
def find_free_port(start: int = 10000, end: int = 20000) -> int: