refactor: organize API routers and services into subpackages
Service organization (19 files moved into 6 subpackages): - services/instance/ — event_bus, health_monitor, lifecycle_hooks - services/config/ — config_profile_resolver - services/git/ — clone, git_operations, git_service - services/build/ — docker_build, manifest_compiler - services/terminal/ — terminal_manager, terminal_session - services/shared/ — correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager API router organization (16 files moved into 6 subpackages): - api/tool/ — tool_instances, tool_types, tool_definitions, tool_types_validation, sessions (extracted from tool_instances) - api/config/ — config_profiles, user_config - api/workspace/ — workspaces, workspace_files, workspace_git, workspace_instances - api/user/ — users, auth, ssh_keys - api/project/ — projects, git_repositories - api/system/ — health, events, notifications, dashboard, terminal, instance_proxy Updated main.py imports and all __init__.py re-exports. Sessions router extracted from tool_instances.py into api/tool/sessions.py. Quality gates: py_compile passed, ruff passed.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user