b483a34517
- 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)
311 lines
8.8 KiB
Python
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
|