ai-lsc/src/ai_lsc/runtime/lxc.py

344 lines
13 KiB
Python
Executable File

"""LXC runtime manager -- Linux Container lifecycle operations.
Provides start / stop / status / create / destroy / attach operations
for LXC containers. LXC is a lighter-weight alternative to Docker/Podman
that shares the host kernel without the containerd daemon overhead.
All ``subprocess`` calls are confined here -- UI code never touches LXC
commands directly.
"""
from __future__ import annotations
import json
import os
import re
import shlex
import subprocess
from pathlib import Path
from typing import Any
# LXC container names must match ``[a-zA-Z0-9_.-]+``. We use this regex
# at every public entry point so a malicious tool_id cannot escape the
# ``-n <name>`` slot.
_LXC_NAME_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
def _validate_lxc_name(name: str) -> str:
if not name or not _LXC_NAME_RE.fullmatch(name):
raise ValueError(f"invalid LXC container name: {name!r}")
if name in {".", ".."} or os.path.normpath(name) != name:
raise ValueError(f"LXC name contains path-traversal segments: {name!r}")
return name
def _validate_tool_id(tool_id: str) -> str:
if not tool_id or not re.fullmatch(r"[A-Za-z0-9_.\-]+", tool_id):
raise ValueError(f"invalid tool_id for LXC: {tool_id!r}")
if tool_id in {".", ".."} or os.path.normpath(tool_id) != tool_id:
raise ValueError(f"tool_id contains path-traversal segments: {tool_id!r}")
return tool_id
class LxcManager:
"""Manages LXC container lifecycle via the ``lxc`` CLI.
Parameters
----------
tools_root :
Base directory for tool installations (used as container
mount source).
logs_root :
Directory for container log files.
lxc_profile :
Default LXC profile name (``"default"`` unless overridden).
"""
def __init__(
self,
tools_root: str,
logs_root: str,
lxc_profile: str = "default",
) -> None:
self.tools_root = tools_root
self.logs_root = logs_root
self.lxc_profile = lxc_profile
# ── Container lifecycle ──────────────────────────────────────────
def create(
self,
container_name: str,
image: str = "ubuntu:22.04",
config: dict[str, Any] | None = None,
) -> str:
"""Create a new LXC container.
Parameters
----------
container_name :
Name for the new container.
image :
LXC image template (e.g. ``"ubuntu:22.04"``,
``"alpine"``, ``"archlinux"``).
config :
Optional dict of LXC config key-value pairs that are
written to the container's local config file after
creation.
Returns
-------
Description of what was done.
"""
_validate_lxc_name(container_name)
cmd = [
"lxc-create",
"-n", container_name,
"-t", image.split(":")[0],
"--", image.split(":")[1] if ":" in image else "",
]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30)
except FileNotFoundError:
return self._install_hint("lxc-create")
# Apply custom config if provided
if config:
self._apply_config(container_name, config)
return f"LXC container '{container_name}' created from {image}"
def start(self, container_name: str) -> str:
"""Start an LXC container."""
cmd = ["lxc-start", "-n", container_name, "-d"] # -d = daemonize
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30)
except FileNotFoundError:
return self._install_hint("lxc-start")
except subprocess.CalledProcessError as e:
return f"LXC start failed: {e.stderr.strip()}"
return f"LXC container '{container_name}' started"
def stop(self, container_name: str) -> str:
"""Stop a running LXC container."""
cmd = ["lxc-stop", "-n", container_name, "-t", "5"] # 5s timeout
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30)
except FileNotFoundError:
return self._install_hint("lxc-stop")
except subprocess.CalledProcessError as e:
return f"LXC stop failed: {e.stderr.strip()}"
return f"LXC container '{container_name}' stopped"
def destroy(self, container_name: str) -> str:
"""Destroy (remove) an LXC container."""
cmd = ["lxc-destroy", "-n", container_name]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30)
except FileNotFoundError:
return self._install_hint("lxc-destroy")
except subprocess.CalledProcessError as e:
return f"LXC destroy failed: {e.stderr.strip()}"
return f"LXC container '{container_name}' destroyed"
def freeze(self, container_name: str) -> str:
"""Freeze (pause) a running container without stopping it."""
cmd = ["lxc-freeze", "-n", container_name]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
return f"LXC freeze failed: {getattr(e, 'stderr', str(e))}"
return f"LXC container '{container_name}' frozen"
def unfreeze(self, container_name: str) -> str:
"""Unfreeze (resume) a paused container."""
cmd = ["lxc-unfreeze", "-n", container_name]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
return f"LXC unfreeze failed: {getattr(e, 'stderr', str(e))}"
return f"LXC container '{container_name}' resumed"
# ── Status / inspection ───────────────────────────────────────────
def is_running(self, container_name: str) -> bool:
"""Check if a container is currently running."""
cmd = ["lxc-info", "-n", container_name, "-s"]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=5
)
return "RUNNING" in result.stdout.upper()
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def get_state(self, container_name: str) -> str:
"""Return the container state (RUNNING, STOPPED, FROZEN)."""
cmd = ["lxc-info", "-n", container_name, "-s"]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=5
)
for state in ("RUNNING", "STOPPED", "FROZEN"):
if state in result.stdout.upper():
return state
return "UNKNOWN"
except (FileNotFoundError, subprocess.TimeoutExpired):
return "UNKNOWN"
def list_containers(self, running_only: bool = False) -> list[str]:
"""List container names.
Parameters
----------
running_only :
If ``True`` only return running containers.
Returns
-------
List of container name strings.
"""
cmd = ["lxc-ls"]
if running_only:
cmd.append("--running")
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=5
)
return [
name.strip() for name in result.stdout.strip().splitlines()
if name.strip()
]
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
# ── Execution ────────────────────────────────────────────────────
def attach_exec(
self,
container_name: str,
command: str = "/bin/bash",
) -> str:
"""Execute a command inside a running container (non-interactive)."""
_validate_lxc_name(container_name)
# H-12: use shlex.split so quoted arguments survive. The previous
# `command.split()` mangled inputs like `echo 'hello world'` into
# ['echo', '"hello', 'world"'].
argv = shlex.split(command) or ["/bin/bash"]
cmd = ["lxc-attach", "-n", container_name, "--", *argv]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
return f"LXC attach failed: {getattr(e, 'stderr', str(e))}"
return f"Executed in '{container_name}': {command}"
def launch_cli(
self,
container_name: str,
) -> str:
"""Open an interactive terminal inside the container.
Launches an x-terminal-emulator with ``lxc-attach``.
"""
import shutil
term = shutil.which("x-terminal-emulator") or shutil.which("gnome-terminal") or shutil.which("konsole")
if not term:
return "No terminal emulator found for LXC CLI attach."
subprocess.Popen(
[term, "-e", "bash", "-c",
f"lxc-attach -n {container_name} --clear-env -- "
f"env TERM=xterm-256color /bin/bash"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return f"Interactive terminal opened for '{container_name}'"
# ── Config helpers ──────────────────────────────────────────────
def _apply_config(
self,
container_name: str,
config: dict[str, Any],
) -> None:
"""Append config key-value pairs to a container's config file."""
_validate_lxc_name(container_name)
config_path = Path("/var/lib/lxc") / container_name / "config"
if not config_path.exists():
return
# M-21: build the formatted config lines via comprehension.
lines = [
(f"lxc.{key} = {','.join(str(v) for v in value)}"
if isinstance(value, (list, tuple))
else f"lxc.{key} = {value}")
for key, value in config.items()
]
# M-05: explicit encoding + flush + fsync so a crash mid-write
# cannot corrupt the LXC config file.
with open(config_path, "a", encoding="utf-8") as f:
f.write("\n# ai-lsc generated\n")
for line in lines:
f.write(f"{line}\n")
f.flush()
os.fsync(f.fileno())
@staticmethod
def _install_hint(cmd: str) -> str:
# M-39: don't assume Arch's pacman; the app advertises multi-distro
# support, so keep the hint generic.
return (
f"LXC not installed. Install the `lxc` package for your "
f"distribution (e.g. `sudo pacman -S lxc` on Arch, "
f"`sudo apt-get install lxc` on Debian/Ubuntu, "
f"`sudo dnf install lxc` on Fedora).\n"
f"Missing command: {cmd}"
)
# ── Service delegation (mirrors TmuxManager interface) ───────────
def launch_service(
self,
tool_id: str,
command: str,
log_file: str = "",
dtach_bin: str | None = None,
base_bin_dir: str = "",
) -> str:
"""Start a tool as an LXC container service.
Creates the container if it does not exist, starts it,
and runs the tool command inside. The container is named
``ai-lsc-<tool_id>`` for consistency.
Returns a description of what was done.
"""
_validate_tool_id(tool_id)
container_name = f"ai-lsc-{tool_id}"
if not self.is_running(container_name):
if container_name not in self.list_containers():
self.create(
container_name,
image="ubuntu:22.04",
config={
"mount.auto": f"{self.tools_root} opt none bind 0 0",
},
)
self.start(container_name)
# Run the tool command inside the container
if command:
self.attach_exec(container_name, command)
return f"Tool {tool_id} running in LXC container '{container_name}'"
def stop_service(self, tool_id: str) -> str:
"""Stop the LXC container for a tool."""
_validate_tool_id(tool_id)
container_name = f"ai-lsc-{tool_id}"
self.stop(container_name)
return f"LXC container '{container_name}' stopped for {tool_id}"