Files
headquarter/apps/api/src/services/permission_fixer.py
T
Alex Blank b483a34517 fix: skip read-only mounts in permission fixer and remove redundant ssh_keys manifest mount
- apply_mount_permissions now skips mounts with readonly=true to avoid
  'Read-only file system' warnings on post-start chown/chmod
- Removed the ssh_keys mount from the pi-agent manifest definition;
  instance-level SSH key mounting now handles this exclusively
- Added unit test for read-only mount skipping

Quality gates: pytest (15 passed)
2026-05-29 15:32:00 +02:00

311 lines
8.8 KiB
Python

"""Permission fixer: applies mount permission policies post-start."""
import logging
import subprocess
from typing import Any
logger = logging.getLogger(__name__)
def apply_mount_permissions(
container_id: str,
mounts: list[dict],
timeout: int = 10,
) -> list[dict[str, Any]]:
"""Apply permission policies to mounted directories in a running container.
Runs `chown`, `chmod`, and file-mode fixes for each mount that declares
an owner, mode, or file_mode. Requires the container to have a root user.
Args:
container_id: Docker container ID or name.
mounts: List of mount definitions from the manifest.
timeout: Max seconds per docker exec command.
Returns:
List of result dicts: [{mount_name, success, error}]
"""
results = []
for mount in mounts:
name = mount.get("name", "unknown")
target = mount["target"]
owner = mount.get("owner")
mode = mount.get("mode")
file_mode = mount.get("file_mode")
result: dict[str, Any] = {
"mount_name": name,
"success": True,
"error": None,
}
# Skip read-only mounts — their permissions cannot be changed
# post-start because the bind mount is locked.
if mount.get("readonly", False):
logger.debug(
"Skipping permission fix for read-only mount %s (target=%s)",
name,
target,
)
results.append(result)
continue
# Skip if no permission policy defined
if not owner and not mode and not file_mode:
results.append(result)
continue
try:
if owner:
_run_in_container(
container_id,
["chown", "-R", f"{owner}:{owner}", target],
timeout,
)
logger.debug(
"Applied owner %s to %s in container %s",
owner,
target,
container_id,
)
if mode and result["success"]:
_run_in_container(
container_id,
["chmod", mode, target],
timeout,
)
logger.debug(
"Applied mode %s to %s in container %s",
mode,
target,
container_id,
)
if file_mode and result["success"]:
_run_in_container(
container_id,
[
"sh",
"-c",
f"find {target} -type f -exec chmod {file_mode} {{}} +",
],
timeout,
)
logger.debug(
"Applied file_mode %s to files in %s in container %s",
file_mode,
target,
container_id,
)
except PermissionFixError as exc:
result["success"] = False
result["error"] = str(exc)
logger.warning(
"Permission fix failed for mount %s (target=%s): %s",
name,
target,
exc,
)
results.append(result)
return results
def _exec_and_log(
container_id: str,
command: list[str],
timeout: int,
description: str,
) -> str:
"""Run a docker exec command and log stdout/stderr for debugging."""
cmd = ["docker", "exec", "--user", "root", container_id] + command
logger.debug("[SSH-fix] %s: %s", description, " ".join(cmd))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
raise PermissionFixError(
f"Command timed out after {timeout}s: {' '.join(command)}"
)
except FileNotFoundError:
raise PermissionFixError(f"Docker command not found: {' '.join(command)}")
stdout = result.stdout.strip()
stderr = result.stderr.strip()
if stdout:
logger.debug("[SSH-fix] %s stdout: %s", description, stdout)
if stderr:
logger.debug("[SSH-fix] %s stderr: %s", description, stderr)
if result.returncode != 0:
raise PermissionFixError(
f"Command failed (rc={result.returncode}): {stderr or '(no stderr)'}"
)
return stdout
def apply_ssh_permissions(
container_id: str,
ssh_target: str,
container_user: str,
timeout: int = 10,
) -> dict[str, Any]:
"""Fix SSH directory ownership and permissions in a running container.
Runs chown and chmod on the ~/.ssh directory so the container user
can use the keys (SSH requires the private key to be owned by the
user with mode 600).
Args:
container_id: Docker container ID or name.
ssh_target: Absolute path to the .ssh directory inside the container.
container_user: The container user that should own the keys.
timeout: Max seconds per docker exec command.
Returns:
Result dict with keys: success, error.
"""
result: dict[str, Any] = {"success": True, "error": None}
try:
# 1. Ensure directory is owned by the container user
_exec_and_log(
container_id,
["chown", "-R", f"{container_user}:{container_user}", ssh_target],
timeout,
"chown",
)
# 2. Set directory permissions
_exec_and_log(
container_id,
["chmod", "700", ssh_target],
timeout,
"chmod-dir",
)
# 3. Set private key permissions (id_ed25519, id_rsa, etc.)
_exec_and_log(
container_id,
[
"sh",
"-c",
f"find {ssh_target} -name 'id_*' -type f -exec chmod 600 {{}} +",
],
timeout,
"chmod-keys",
)
# 4. Verify final state
ls_output = _exec_and_log(
container_id,
["ls", "-la", ssh_target],
timeout,
"verify-ls",
)
stat_output = _exec_and_log(
container_id,
["stat", "-c", "%U:%G %a %n", ssh_target],
timeout,
"verify-stat-dir",
)
key_stat = _exec_and_log(
container_id,
[
"sh",
"-c",
f"stat -c '%U:%G %a %n' {ssh_target}/id_* 2>/dev/null || echo 'no id_* files found'",
],
timeout,
"verify-stat-keys",
)
logger.info(
"SSH permissions fixed for container %s (user=%s, target=%s). "
"ls:\n%s\nstat-dir: %s\nstat-keys: %s",
container_id,
container_user,
ssh_target,
ls_output,
stat_output,
key_stat,
)
except PermissionFixError as exc:
result["success"] = False
result["error"] = str(exc)
logger.warning(
"SSH permission fix failed for container %s (target=%s): %s",
container_id,
ssh_target,
exc,
)
return result
class PermissionFixError(Exception):
"""Raised when a permission fix command fails."""
pass
def _run_in_container(
container_id: str,
command: list[str],
timeout: int,
) -> None:
"""Run a command inside a container as root.
Args:
container_id: Docker container ID or name.
command: Command + args to execute.
timeout: Max seconds to wait.
Raises:
PermissionFixError: If the command fails or times out.
"""
cmd = ["docker", "exec", "--user", "root", container_id] + command
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
raise PermissionFixError(
f"Command timed out after {timeout}s: {' '.join(command)}"
)
except FileNotFoundError:
raise PermissionFixError(f"Docker command not found: {' '.join(command)}")
if result.returncode != 0:
raise PermissionFixError(
f"Command failed (rc={result.returncode}): {result.stderr.strip()}"
)
def check_root_user_available(container_id: str, timeout: int = 5) -> bool:
"""Check if the container has a root user we can exec as.
Args:
container_id: Docker container ID or name.
timeout: Max seconds to wait.
Returns:
True if root user exists and is usable.
"""
try:
_run_in_container(container_id, ["id", "root"], timeout)
return True
except PermissionFixError:
return False