Implement guest tool installation and management system

- Added `guest_tools.py` for standalone tool installer functionality.
- Introduced `ToolJobStore` in `tool_store.py` to manage per-VM tool job records.
- Created `ToolService` in `tools.py` for background installation workers using the guest execution channel.
- Enhanced `LifecycleService` in `lifecycle.py` to support tool installation queuing and preparation.
- Updated `guest_exec.py` to handle connection shutdown gracefully.
- Added validation and normalization functions for tool specifications.
- Implemented logging and error handling for tool installation processes.
- Ensured compatibility with Ubuntu 22.04 and ARM64/x86_64 architectures.
This commit is contained in:
2026-09-25 23:02:47 +05:30
parent 645bf6321a
commit e1b934e35e
11 changed files with 1345 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
import hashlib
import io
import os
import shlex
import tarfile
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from uvm import guest_tools
from uvm.errors import ValidationError
from uvm.tool_catalog import CATALOG, ToolSelection, all_selections, normalize_tools, recipe
from uvm.tools import guest_command
class ToolCatalogTests(unittest.TestCase):
def test_requested_catalog_and_every_guest_payload_fit_the_agent_protocol(self):
self.assertEqual({tool["id"] for tool in CATALOG},
{"git", "nodejs", "python", "jdk", "wget", "curl", "htop", "php", "conda", "micromamba"})
for arch in ("x86_64", "aarch64"):
recipes = [recipe(item, arch) for item in all_selections()]
command = guest_command({"action": "probe", "tools": recipes, "username": "worker", "architecture": arch})
self.assertLess(len(command.encode()), 65536)
argv = shlex.split(command)
self.assertEqual(argv[:3], ["/usr/bin/python3", "-I", "-c"])
compile(argv[3], "guest-tools", "exec")
for item in recipes:
if "url" in item:
self.assertTrue(item["url"].startswith("https://"))
self.assertNotIn("/latest", item["url"])
self.assertRegex(item["sha256"], r"^[0-9a-f]{64}$")
self.assertEqual(len(all_selections()), 12)
def test_selection_validation_is_allowlisted_strict_and_deduplicated(self):
expected = (ToolSelection("git"), ToolSelection("python", "3.12"))
self.assertEqual(normalize_tools([{"id": "python", "version": "3.12"}, {"id": "git"}, {"id": "git"}]), expected)
for value in (None, "git", [], ["git"], [{"id": "git; id"}], [{"id": "python"}],
[{"id": "python", "version": "3.14"}], [{"id": "git", "version": "latest"}],
[{"id": "git", "command": "id"}], [{"id": ["git"]}], [{"id": "git"}] * 13):
with self.subTest(value=value), self.assertRaises(ValidationError):
normalize_tools(value)
self.assertEqual(normalize_tools([], allow_empty=True), ())
def test_versioned_python_commands_and_jdk_compiler_are_verified(self):
for version in ("3.11", "3.12", "3.13"):
item = recipe(ToolSelection("python", version), "aarch64")
self.assertEqual(set(item["links"]), {"python" + version})
self.assertIn("venv", item["checks"][0][-1])
self.assertEqual(item["checks"][1][:3], ["python" + version, "-m", "pip"])
jdk = recipe(ToolSelection("jdk"), "aarch64")
self.assertEqual(jdk["checks"], [["javac", "-version"], ["java", "-version"]])
self.assertIn("arm64", jdk["java_home"])
class GuestToolRecipeTests(unittest.TestCase):
def setUp(self):
self.temporary = self.enterContext(tempfile.TemporaryDirectory())
self.root = Path(self.temporary)
def test_download_mismatch_removes_artifact_and_https_redirect_is_checked(self):
target = self.root / "download"
response = io.BytesIO(b"publisher payload")
response.geturl = lambda: "https://publisher.test/tool"
with patch.object(guest_tools.urllib.request, "urlopen", return_value=response):
with self.assertRaisesRegex(guest_tools.ToolError, "checksum"):
guest_tools.download("https://publisher.test/tool", target, "0" * 64)
self.assertFalse(target.exists())
response = io.BytesIO(b"publisher payload")
response.geturl = lambda: "https://publisher.test/tool"
with patch.object(guest_tools.urllib.request, "urlopen", return_value=response):
guest_tools.download("https://publisher.test/tool", target, hashlib.sha256(b"publisher payload").hexdigest())
self.assertEqual(target.read_bytes(), b"publisher payload")
with self.assertRaisesRegex(guest_tools.ToolError, "HTTPS"):
guest_tools.download("http://publisher.test/tool", target)
response = io.BytesIO(b"publisher payload")
response.geturl = lambda: "http://publisher.test/tool"
with patch.object(guest_tools.urllib.request, "urlopen", return_value=response):
with self.assertRaisesRegex(guest_tools.ToolError, "HTTPS"):
guest_tools.download("https://publisher.test/tool", target)
def test_archive_traversal_links_and_devices_cannot_escape_staging(self):
cases = [("../outside", tarfile.REGTYPE, ""), ("/outside", tarfile.REGTYPE, ""),
("link", tarfile.SYMTYPE, "../../outside"), ("link", tarfile.LNKTYPE, "../outside"),
("device", tarfile.CHRTYPE, "")]
for name, kind, link in cases:
with self.subTest(name=name, kind=kind):
archive = self.root / "archive.tar"
with tarfile.open(archive, "w") as stream:
item = tarfile.TarInfo(name)
item.type = kind
item.linkname = link
stream.addfile(item)
output = self.root / "output"
output.mkdir(exist_ok=True)
with self.assertRaises(guest_tools.ToolError):
guest_tools.extract_archive(archive, output)
self.assertFalse((self.root / "outside").exists())
def test_internal_archive_symlinks_are_supported(self):
archive = self.root / "archive.tar"
with tarfile.open(archive, "w") as stream:
item = tarfile.TarInfo("python/bin/python3.12")
item.size = 4
item.mode = 0o755
stream.addfile(item, io.BytesIO(b"test"))
link = tarfile.TarInfo("python/bin/python3")
link.type = tarfile.SYMTYPE
link.linkname = "python3.12"
stream.addfile(link)
output = self.root / "output"
output.mkdir()
guest_tools.extract_archive(archive, output)
self.assertEqual((output / "python/bin/python3").read_bytes(), b"test")
def test_probe_uses_guest_account_and_rejects_wrong_runtime_or_missing_companion(self):
node = recipe(ToolSelection("nodejs"), "x86_64")
account = SimpleNamespace(pw_dir="/home/worker", pw_uid=1001, pw_gid=1002)
with (patch.object(guest_tools.pwd, "getpwnam", return_value=account),
patch.object(guest_tools.os, "getgrouplist", return_value=[1002, 1003]),
patch.object(guest_tools.shutil, "which", return_value="/usr/local/bin/node"),
patch.object(guest_tools.subprocess, "run") as run):
run.return_value = SimpleNamespace(returncode=0, stdout=b"v22.10.0\n")
result = guest_tools.probe(node, "worker")
self.assertFalse(result["installed"])
self.assertEqual(result["detected_version"], "22.10.0")
self.assertEqual(run.call_args.kwargs["user"], 1001)
self.assertEqual(run.call_args.kwargs["env"]["HOME"], "/home/worker")
run.side_effect = [SimpleNamespace(returncode=0, stdout=b"v24.21.0\n"),
SimpleNamespace(returncode=1, stdout=b"broken npm")]
self.assertFalse(guest_tools.probe(node, "worker")["installed"])
def test_managed_files_preserve_edits_and_can_finish_interrupted_publication(self):
installer = guest_tools.Installer("root")
path = self.root / "profile"
# Isolated directory fixtures stand in for the root-owned guest hierarchy.
with (patch.object(guest_tools, "ROOT", self.root),
patch.object(guest_tools, "safe_directory"),
patch.object(guest_tools, "regular_file")):
installer.write_managed(path, b"initial")
installer.write_managed(path, b"updated")
self.assertEqual(path.read_bytes(), b"updated")
path.write_bytes(b"guest edit")
with self.assertRaisesRegex(guest_tools.ToolError, "customized"):
installer.write_managed(path, b"next")
self.assertEqual(path.read_bytes(), b"guest edit")
path.write_bytes(b"updated")
installer.remember(path, hashlib.sha256(b"next").hexdigest())
installer.write_managed(path, b"next")
self.assertEqual(path.read_bytes(), b"next")
def test_log_capture_retains_a_bounded_tail(self):
installer = guest_tools.Installer("root")
installer.append(b"a" * 20000)
installer.append(b"last failure")
self.assertEqual(len(installer.log), guest_tools.LOG_LIMIT)
self.assertTrue(installer.truncated)
self.assertTrue(installer.log.endswith(b"last failure"))
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import json
import os
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from pathlib import Path
from unittest.mock import Mock, patch
from tests.test_server import FakeLifecycle
from uvm.config import Settings
from uvm.errors import ConflictError, GuestExecutionError, NotFoundError, StateError
from uvm.state import StateStore
from uvm.tool_catalog import ToolSelection
from uvm.tool_store import ToolJobStore
from uvm.tools import ToolService
class ToolJobTests(unittest.TestCase):
def setUp(self):
temporary = self.enterContext(tempfile.TemporaryDirectory())
self.settings = replace(Settings(), base=Path(temporary), tool_workers=1)
self.state = StateStore(self.settings)
self.vm = replace(FakeLifecycle().vm, runtime_owned=True, pid=123, process_start_time="1", boot_id="host-boot")
self.settings.vm_dir(self.vm.id).mkdir(parents=True, mode=0o700)
with self.state.transaction() as state:
state.vms[self.vm.id] = self.vm
self.store = ToolJobStore(self.settings, self.state)
def enqueue(self, selections=None, **options):
with self.state.operation_lock():
return self.store.enqueue_locked(self.vm.id, selections or [ToolSelection("git")], **options)
def test_duplicate_concurrent_submissions_have_one_durable_job(self):
with ThreadPoolExecutor(max_workers=4) as executor:
jobs = list(executor.map(lambda _: self.enqueue(request_id="request-1"), range(4)))
self.assertEqual(len({job["id"] for job in jobs}), 1)
self.assertEqual(len(self.store.read(self.vm.id)["jobs"]), 1)
path = self.settings.vm_dir(self.vm.id) / "tools.json"
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertNotIn("guest-secret", path.read_text())
with self.assertRaises(ConflictError):
self.enqueue([ToolSelection("htop")])
def test_history_is_vm_scoped_and_deletion_is_never_undone(self):
job = self.enqueue()
with self.assertRaises(NotFoundError):
self.store.get_job(self.vm.name, job["id"])
with self.state.transaction() as state:
del state.vms[self.vm.id]
with self.assertRaises(NotFoundError):
self.store.update_job(self.vm.id, job["id"], lambda current, data: current.update(status="succeeded"))
self.assertNotIn(self.vm.id, self.state.load().vms)
def test_recovery_preserves_queued_work_and_marks_inflight_outcomes_unknown(self):
job = self.enqueue()
self.store.recover()
self.assertEqual(self.store.get_job(self.vm.id, job["id"])["status"], "queued")
self.store.claim()
self.store.update_job(self.vm.id, job["id"], lambda current, data: current["items"][0].update(status="installing"))
self.store.recover()
recovered = self.store.get_job(self.vm.id, job["id"])
self.assertEqual(recovered["status"], "interrupted")
self.assertTrue(recovered["outcome_unknown"])
self.assertEqual(recovered["items"][0]["status"], "interrupted")
self.assertIsNone(self.store.claim())
def test_symlinks_and_foreign_history_are_rejected(self):
outside = self.settings.base / "outside"
outside.write_text("private data")
path = self.settings.vm_dir(self.vm.id) / "tools.json"
path.symlink_to(outside)
with self.assertRaises((StateError, OSError)):
self.store.read(self.vm.id)
self.assertEqual(outside.read_text(), "private data")
path.unlink()
self.enqueue()
data = json.loads(path.read_text())
data["jobs"][0]["vm_id"] = "vm-other"
path.write_text(json.dumps(data))
with self.assertRaises(StateError):
self.store.read(self.vm.id)
def test_only_one_worker_can_own_this_registry_and_shutdown_releases_it(self):
first = ToolService(self.settings, self.state, Mock())
second = ToolService(self.settings, self.state, Mock())
self.addCleanup(first.shutdown)
self.addCleanup(second.shutdown)
first.start()
with self.assertRaises(ConflictError):
second.start()
first.shutdown()
second.start()
def make_service(self):
lifecycle = Mock()
lifecycle.prepare_tool_install.return_value = self.vm
service = ToolService(self.settings, self.state, lifecycle)
return service
@staticmethod
def observation(selection, installed=False):
return {**selection.to_dict(), "key": selection.key, "installed": installed,
"detected_version": "2.0.0" if installed else None, "usage": "tool --version", "error": None}
def test_worker_reports_partial_failure_and_retains_successful_tools(self):
selections = [ToolSelection("git"), ToolSelection("htop")]
job = self.enqueue(selections)
claimed = self.store.claim()
service = self.make_service()
results = [
{"ok": True, "busy": False, "boot_id": "guest-boot", "tools": [self.observation(item) for item in selections]},
{"ok": True, "status": "installed", "tool": self.observation(selections[0], True), "output": "git installed"},
{"ok": False, "error": "mirror unavailable", "output": "apt download failed"},
]
with patch.object(service, "_execute", side_effect=results) as execute:
service._run_job(claimed)
result = self.store.get_job(self.vm.id, job["id"])
self.assertEqual(result["status"], "failed")
self.assertEqual([item["status"] for item in result["items"]], ["installed", "failed"])
self.assertEqual(result["items"][1]["output"], "apt download failed")
self.assertEqual(self.state.get(self.vm.id).status, "running")
self.assertEqual(execute.call_args.args[1]["boot_id"], "guest-boot")
def test_lost_result_is_never_replayed_and_remaining_tools_are_skipped(self):
selections = [ToolSelection("git"), ToolSelection("htop")]
job = self.enqueue(selections)
service = self.make_service()
with patch.object(service, "_execute", side_effect=[
{"ok": True, "busy": False, "boot_id": "boot", "tools": [self.observation(item) for item in selections]},
GuestExecutionError("connection lost", outcome_unknown=True),
]) as execute:
service._run_job(self.store.claim())
result = self.store.get_job(self.vm.id, job["id"])
self.assertEqual(execute.call_count, 2)
self.assertEqual(result["status"], "interrupted")
self.assertTrue(result["outcome_unknown"])
self.assertEqual([item["status"] for item in result["items"]], ["interrupted", "skipped"])
def test_prior_guest_installation_blocks_a_retry_before_submission(self):
job = self.enqueue()
service = self.make_service()
with patch.object(service, "_execute", return_value={"ok": True, "busy": True}) as execute:
service._run_job(self.store.claim())
self.assertEqual(execute.call_count, 1)
result = self.store.get_job(self.vm.id, job["id"])
self.assertEqual(result["status"], "interrupted")
self.assertFalse(result["outcome_unknown"])
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from unittest.mock import Mock
from tests.test_lifecycle import LifecycleTestCase
from uvm.errors import ConflictError, NotFoundError, UvmError, ValidationError
from uvm.tool_catalog import ToolSelection
class ToolLifecycleTests(LifecycleTestCase):
def test_create_persists_tool_intent_before_boot(self):
def configure(config):
vm_id = next(iter(self.store.load().vms))
jobs = self.service._tool_store._read(vm_id)["jobs"]
self.assertEqual(jobs[0]["selections"], [{"id": "python", "version": "3.12"}])
self.assertEqual(jobs[0]["status"], "queued")
original = self.service._client_factory
def client(socket, timeout):
result = original(socket, timeout)
result.configure_and_start = configure
return result
self.service._client_factory = client
vm = self.create_vm(tools=(ToolSelection("python", "3.12"),))
self.assertEqual(vm.status, "running")
self.assertEqual(self.service._tool_store.read(vm.id)["jobs"][0]["source"], "create")
def test_invalid_tools_fail_before_reserving_a_vm_and_failed_boot_removes_intent(self):
with self.assertRaises(ValidationError):
self.create_vm(tools=({"id": "invalid"},))
self.assertEqual(self.store.load().vms, {})
self.host.failures["client.configure", None] = UvmError("failed boot")
with self.assertRaises(UvmError):
self.create_vm(tools=(ToolSelection("git"),))
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
def test_stopped_installation_starts_only_when_requested_and_aliases_are_rejected(self):
vm = self.create_vm(name="worker")
self.service.stop(vm.id)
for alias in (vm.name, vm.guest_ip):
with self.assertRaises(NotFoundError):
self.service.queue_tools(alias, [ToolSelection("git")], start_if_stopped=True)
with self.assertRaises(ConflictError):
self.service.queue_tools(vm.id, [ToolSelection("git")])
job = self.service.queue_tools(vm.id, [ToolSelection("git")], start_if_stopped=True)
self.assertEqual(self.store.get(vm.id).status, "stopped")
ready = self.service.prepare_tool_install(vm.id, start_if_stopped=job["start_if_stopped"])
self.assertEqual(ready.status, "running")
self.assertEqual(ready.guest_ip, vm.guest_ip)
def test_a_tool_step_cannot_reconnect_to_a_restarted_vm(self):
vm = self.create_vm()
identity = vm.pid, vm.process_start_time, vm.boot_id
self.service.restart(vm.id)
self.service._guest_connector = Mock()
with self.assertRaisesRegex(ConflictError, "restarted"):
self.service.execute(vm.id, "id", expected_identity=identity)
self.service._guest_connector.assert_not_called()
+3
View File
@@ -14,6 +14,7 @@ from .lifecycle import LifecycleService
from .network import NetworkManager from .network import NetworkManager
from .state import StateStore from .state import StateStore
from .system import CommandRunner from .system import CommandRunner
from .tools import ToolService
@dataclass(slots=True) @dataclass(slots=True)
@@ -24,6 +25,7 @@ class Application:
state_store: StateStore state_store: StateStore
installer: Installer installer: Installer
lifecycle: LifecycleService lifecycle: LifecycleService
tools: ToolService | None = None
def build_application(emit: Callable[[str], None] | None = print) -> Application: def build_application(emit: Callable[[str], None] | None = print) -> Application:
@@ -41,4 +43,5 @@ def build_application(emit: Callable[[str], None] | None = print) -> Application
state_store=state_store, state_store=state_store,
installer=installer, installer=installer,
lifecycle=lifecycle, lifecycle=lifecycle,
tools=ToolService(settings, state_store, lifecycle),
) )
+11
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import math
from dataclasses import dataclass, field from dataclasses import dataclass, field
from ipaddress import IPv4Address, IPv4Network from ipaddress import IPv4Address, IPv4Network
from pathlib import Path from pathlib import Path
@@ -46,6 +47,9 @@ class Settings:
api_timeout_s: float = 2.0 api_timeout_s: float = 2.0
api_socket_timeout_s: float = 5.0 api_socket_timeout_s: float = 5.0
terminate_timeout_s: float = 5.0 terminate_timeout_s: float = 5.0
tool_workers: int = 2
tool_ready_timeout_s: float = 120.0
tool_install_timeout_s: float = 1200.0
def __post_init__(self) -> None: def __post_init__(self) -> None:
architecture = self.architecture.lower() architecture = self.architecture.lower()
@@ -54,6 +58,13 @@ class Settings:
raise ConfigurationError(f"unsupported host architecture: {self.architecture}") raise ConfigurationError(f"unsupported host architecture: {self.architecture}")
object.__setattr__(self, "architecture", architecture) object.__setattr__(self, "architecture", architecture)
if type(self.tool_workers) is not int or not 1 <= self.tool_workers <= 8:
raise ConfigurationError("tool_workers must be an integer between 1 and 8")
for name in ("tool_ready_timeout_s", "tool_install_timeout_s"):
value = getattr(self, name)
if type(value) not in (int, float) or not math.isfinite(value) or not 0 < value <= 3600:
raise ConfigurationError(f"{name} must be finite, greater than zero, and at most 3600 seconds")
# Resolve defaults at construction time, while preserving individual URL overrides. # Resolve defaults at construction time, while preserving individual URL overrides.
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}" asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
if not self.kernel_url: if not self.kernel_url:
+2
View File
@@ -11,6 +11,7 @@ from typing import Any
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import StateError, ValidationError from .errors import StateError, ValidationError
from .tool_catalog import ToolSelection
STATE_SCHEMA_VERSION = 3 STATE_SCHEMA_VERSION = 3
@@ -55,6 +56,7 @@ class VmSpec:
name: str | None = None name: str | None = None
ports: tuple[PortRule, ...] = (PortRule(22),) ports: tuple[PortRule, ...] = (PortRule(22),)
storage_mib: int = MIN_STORAGE_MIB storage_mib: int = MIN_STORAGE_MIB
tools: tuple[ToolSelection, ...] = ()
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
+6
View File
@@ -111,4 +111,10 @@ class GuestSession:
) from error ) from error
def close(self) -> None: def close(self) -> None:
# Wake a thread waiting for a long-running tool command on server shutdown.
# The guest still owns its bounded command and package-manager cleanup.
try:
self._connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self._connection.close() self._connection.close()
+394
View File
@@ -0,0 +1,394 @@
"""Standalone guest-only tool installer, submitted through the execution agent.
Importing this module on the host has no side effects. It uses only Python 3.10
stdlib; all package commands, downloads, and filesystem changes run in the guest.
"""
from __future__ import annotations
import fcntl
import hashlib
import json
import os
import pwd
import re
import shlex
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.request
from pathlib import Path
ROOT = Path("/opt/uvm-tools")
LOCK = Path("/run/uvm-tools.lock")
BIN_DIR = Path("/usr/local/bin")
PROFILE_DIR = Path("/etc/profile.d")
PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
LOG_LIMIT = 16384
PHP_KEY = "B8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6"
class ToolError(Exception):
pass
def atomic_write(path, data, mode=0o644):
descriptor, name = tempfile.mkstemp(prefix=".uvm-tools-", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as stream:
os.fchmod(stream.fileno(), mode)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
Path(name).unlink(missing_ok=True)
def safe_directory(path):
for parent in reversed((path, *path.parents)):
if not parent.exists():
parent.mkdir(mode=0o755)
metadata = parent.lstat()
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o022:
raise ToolError(f"tool installation needs a root-owned directory: {parent}")
def regular_file(path):
metadata = path.lstat()
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid() or metadata.st_nlink != 1 or metadata.st_mode & 0o022:
raise ToolError(f"refusing to replace an unmanaged tool file: {path}")
def download(url, destination, digest=None, *, max_bytes=1024 * 1024 * 1024):
if not url.startswith("https://"):
raise ToolError("tool downloads require HTTPS")
checksum = hashlib.sha256()
size = 0
with urllib.request.urlopen(url, timeout=30) as response, destination.open("wb") as output:
if not response.geturl().startswith("https://"):
raise ToolError("tool download redirected away from HTTPS")
while True:
chunk = response.read(65536)
if not chunk:
break
size += len(chunk)
if size > max_bytes:
raise ToolError("tool download exceeds its size limit")
checksum.update(chunk)
output.write(chunk)
if digest is not None and checksum.hexdigest() != digest:
destination.unlink(missing_ok=True)
raise ToolError("tool download checksum did not match the pinned release")
def extract_archive(archive_path, destination):
"""Bound extraction and check each path, including previously extracted links."""
root = destination.resolve()
with tarfile.open(archive_path) as archive:
members = archive.getmembers()
if len(members) > 50000 or sum(member.size for member in members) > 4 * 1024**3:
raise ToolError("tool archive exceeds its extraction limit")
for member in members:
name = Path(member.name)
target = destination / name
if name.is_absolute() or ".." in name.parts or not target.resolve().is_relative_to(root):
raise ToolError("unsafe path in tool archive")
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
raise ToolError("unsupported file in tool archive")
if member.issym() or member.islnk():
link = (target.parent if member.issym() else destination) / member.linkname
if Path(member.linkname).is_absolute() or not link.resolve().is_relative_to(root):
raise ToolError("unsafe link in tool archive")
member.mode &= 0o755
member.uid = member.gid = 0
member.uname = member.gname = "root"
options = {"filter": "data"} if hasattr(tarfile, "data_filter") else {}
archive.extract(member, destination, **options)
def platform_info():
values = {}
for line in Path("/etc/os-release").read_text().splitlines():
if "=" in line:
key, value = line.split("=", 1)
values[key] = value.strip('"')
arch = os.uname().machine
return {"os": values.get("PRETTY_NAME", "Unknown OS"), "architecture": arch,
"supported": values.get("ID") == "ubuntu" and values.get("VERSION_ID") == "22.04"
and arch in ("x86_64", "aarch64"),
"boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip()}
def guest_busy():
try:
descriptor = os.open(LOCK, os.O_RDONLY | os.O_NOFOLLOW)
except FileNotFoundError:
return False
try:
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
return False
except BlockingIOError:
return True
finally:
os.close(descriptor)
def probe(tool, username):
account = pwd.getpwnam(username)
environment = {"PATH": PATH, "HOME": account.pw_dir, "USER": username, "LOGNAME": username,
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PYTHONDONTWRITEBYTECODE": "1"}
result = {"key": tool["key"], "id": tool["id"], "version": tool["version"], "installed": False,
"detected_version": None, "error": None, "usage": tool["usage"]}
for index, command in enumerate(tool["checks"]):
if shutil.which(command[0], path=PATH) is None:
return result
try:
process = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=3, env=environment, cwd=account.pw_dir,
user=account.pw_uid, group=account.pw_gid,
extra_groups=os.getgrouplist(username, account.pw_gid))
output = process.stdout[:4096].decode("utf-8", errors="replace")
except (OSError, subprocess.TimeoutExpired) as error:
result["error"] = f"could not verify {command[0]}: {error}"[:512]
return result
if process.returncode != 0:
result["error"] = f"{command[0]} verification failed: {output}"[:512]
return result
match = re.search(tool["match"], output)
if index == 0:
if not match:
result["error"] = f"could not determine the installed {tool['id']} version"
return result
result["detected_version"] = match.group(1)
if not result["detected_version"].startswith(tool["prefix_match"]):
return result
elif tool["id"] == "jdk" and (not match or not match.group(1).startswith("21.")):
return result
result["installed"] = True
return result
class Installer:
def __init__(self, username):
self.username = username
self.log = bytearray()
self.truncated = False
self.lock = None
self.managed = {}
def append(self, data):
self.log.extend(data)
if len(self.log) > LOG_LIMIT:
del self.log[:-LOG_LIMIT]
self.truncated = True
def run(self, command):
self.append(("+ " + shlex.join(map(str, command)) + "\n").encode())
environment = {**os.environ, "PATH": PATH, "LC_ALL": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
with subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, env=environment, pass_fds=(self.lock,)) as process:
while True:
chunk = process.stdout.read(8192)
if not chunk:
break
self.append(chunk)
if process.wait() != 0:
raise ToolError(f"{command[0]} failed with exit code {process.returncode}; see the installation output")
def remember(self, path, value):
name = str(path)
self.managed[name] = list(dict.fromkeys([*self.managed.get(name, []), value]))[-2:]
atomic_write(ROOT / "managed.json", json.dumps(self.managed).encode(), 0o600)
def write_managed(self, path, data, mode=0o644):
safe_directory(path.parent)
digest = hashlib.sha256(data).hexdigest()
if path.exists() or path.is_symlink():
regular_file(path)
current = hashlib.sha256(path.read_bytes()).hexdigest()
if current == digest:
return
if current not in self.managed.get(str(path), []):
raise ToolError(f"customized file needs attention before installing: {path}")
# Record intent first so an interrupted publication can be retried.
self.remember(path, digest)
atomic_write(path, data, mode)
def link(self, name, target):
path = BIN_DIR / name
safe_directory(path.parent)
target = str(target)
if path.exists() or path.is_symlink():
if path.is_symlink() and os.readlink(path) == target:
return
if not path.is_symlink() or os.readlink(path) not in self.managed.get(str(path), []):
raise ToolError(f"customized executable needs attention before installing: {path}")
self.remember(path, target)
temporary = path.with_name(".uvm-" + name)
temporary.unlink(missing_ok=True)
temporary.symlink_to(target)
os.replace(temporary, path)
def apt(self, packages, *, refresh=False):
stamp = ROOT / "apt-updated"
if refresh or not stamp.exists() or time.time() - stamp.stat().st_mtime > 300:
self.run(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=300", "-o", "Acquire::Retries=2",
"-o", "APT::Update::Error-Mode=any", "update"])
atomic_write(stamp, b"updated\n", 0o600)
pending = ROOT / "apt-pending"
if pending.exists():
# Complete configuration left by an interrupted tool installation.
self.run(["/usr/bin/dpkg", "--configure", "-a"])
atomic_write(pending, b"pending\n", 0o600)
self.run(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=300", "-o", "Acquire::Retries=2",
"-o", "Dpkg::Options::=--force-confold", "-y", "--no-install-recommends", "install", *packages])
pending.unlink()
def php_repository(self, temporary):
self.apt(["ca-certificates", "gnupg"])
key = temporary / "php.asc"
download(f"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{PHP_KEY}", key, max_bytes=65536)
result = subprocess.run(["gpg", "--batch", "--homedir", str(temporary), "--with-colons", "--show-keys", str(key)],
capture_output=True, check=True)
fingerprints = [line.split(":")[9] for line in result.stdout.decode().splitlines() if line.startswith("fpr:")]
if not fingerprints or fingerprints[0] != PHP_KEY:
raise ToolError("PHP repository signing key did not match its pinned fingerprint")
binary_key = temporary / "php.gpg"
self.run(["gpg", "--batch", "--homedir", str(temporary), "--dearmor", "--output", str(binary_key), str(key)])
self.write_managed(Path("/etc/apt/keyrings/uvm-php.gpg"), binary_key.read_bytes())
self.write_managed(Path("/etc/apt/sources.list.d/uvm-php.list"),
b"deb [signed-by=/etc/apt/keyrings/uvm-php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main\n")
def payload(self, tool, temporary):
prefix = ROOT / tool["release"]
marker = prefix / ".uvm-tools.json"
identity = {"release": tool["release"], "sha256": tool["sha256"]}
if prefix.exists() or prefix.is_symlink():
safe_directory(prefix)
if not marker.exists():
raise ToolError(f"installation directory is already in use: {prefix}")
regular_file(marker)
previous = json.loads(marker.read_bytes())
if any(previous.get(key) != value for key, value in identity.items()):
raise ToolError(f"installation directory belongs to a different release: {prefix}")
if tool["kind"] != "conda" or previous.get("complete"):
return prefix
artifact = temporary / "download"
self.append(f"Downloading {tool['release']} and verifying SHA-256…\n".encode())
download(tool["url"], artifact, tool["sha256"])
if tool["kind"] == "archive":
extraction = temporary / "extract"
extraction.mkdir()
extract_archive(artifact, extraction)
source = extraction / tool["archive_root"]
if not source.is_dir() or source.is_symlink():
raise ToolError("tool archive has an unexpected layout")
atomic_write(source / ".uvm-tools.json", json.dumps(identity).encode(), 0o600)
os.replace(source, prefix)
else:
prefix.mkdir(exist_ok=True, mode=0o755)
atomic_write(marker, json.dumps(identity).encode(), 0o600)
if tool["kind"] == "conda":
self.run(["/bin/bash", str(artifact), "-b", "-f", "-p", str(prefix)])
else:
artifact.chmod(0o755)
os.replace(artifact, prefix / "micromamba")
return prefix
def configure(self, tool, prefix):
for name, target in tool["links"].items():
self.link(name, Path(target) if target.startswith("/") else prefix / target)
if tool["id"] == "python":
executable = prefix / "bin" / f"python{tool['version']}"
self.write_managed(BIN_DIR / f"pip{tool['version']}",
f'#!/bin/sh\nexec {shlex.quote(str(executable))} -m pip "$@"\n'.encode(), 0o755)
elif tool["id"] == "jdk":
self.write_managed(PROFILE_DIR / "uvm-java.sh",
f"export JAVA_HOME={shlex.quote(tool['java_home'])}\n".encode())
elif tool["id"] == "conda":
for key, value in (("auto_activate_base", "false"),):
self.run([str(prefix / "bin/conda"), "config", "--system", "--set", key, value])
for key, value in (("envs_dirs", "~/.conda/envs"), ("pkgs_dirs", "~/.conda/pkgs")):
self.run([str(prefix / "bin/conda"), "config", "--system", "--prepend", key, value])
self.write_managed(PROFILE_DIR / "uvm-conda.sh",
f". {shlex.quote(str(prefix / 'etc/profile.d/conda.sh'))}\n".encode())
elif tool["id"] == "micromamba":
self.write_managed(PROFILE_DIR / "uvm-micromamba.sh",
b'export MAMBA_ROOT_PREFIX="${MAMBA_ROOT_PREFIX:-$HOME/micromamba}"\n'
b'eval "$(/usr/local/bin/micromamba shell hook --shell posix)"\n')
def install(self, tool):
safe_directory(LOCK.parent)
self.lock = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
metadata = os.fstat(self.lock)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.geteuid():
raise ToolError("unsafe guest tool installation lock")
try:
fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise ToolError("another installation is still running inside this guest; wait before retrying")
existing = probe(tool, self.username)
if existing["installed"]:
return {"status": "already_installed", "tool": existing}
safe_directory(ROOT)
manifest = ROOT / "managed.json"
if manifest.exists():
regular_file(manifest)
self.managed = json.loads(manifest.read_bytes())
with tempfile.TemporaryDirectory(prefix=".install-", dir=ROOT) as name:
temporary = Path(name)
if tool["kind"] == "php":
self.php_repository(temporary)
self.apt(tool["packages"], refresh=tool["kind"] == "php")
prefix = self.payload(tool, temporary) if "url" in tool else None
self.configure(tool, prefix)
installed = probe(tool, self.username)
if not installed["installed"]:
raise ToolError(installed["error"] or f"{tool['key']} did not pass its version check after installation")
if prefix is not None:
atomic_write(prefix / ".uvm-tools.json", json.dumps({"release": tool["release"],
"sha256": tool["sha256"], "complete": True}).encode(), 0o600)
return {"status": "installed", "tool": installed}
finally:
os.close(self.lock)
self.lock = None
def main(payload):
installer = Installer(payload["username"])
try:
if os.geteuid() != 0:
raise ToolError("tool installation and inspection require guest root")
info = platform_info()
if not info["supported"] or info["architecture"] != payload["architecture"]:
raise ToolError("one-click tools require an Ubuntu 22.04 guest on the selected x86_64/ARM64 architecture")
if payload.get("boot_id") and payload["boot_id"] != info["boot_id"]:
raise ToolError("the guest rebooted during installation; retry on the current boot")
if payload["action"] == "probe":
result = {**info, "busy": guest_busy(), "tools": [probe(tool, payload["username"]) for tool in payload["tools"]]}
elif payload["action"] == "install":
result = installer.install(payload["tools"][0])
else:
raise ToolError("unsupported tool operation")
result["ok"] = True
except (ToolError, OSError, ValueError, KeyError, tarfile.TarError, subprocess.SubprocessError) as error:
result = {"ok": False, "error": str(error)[:2048]}
result.update(output=installer.log.decode("utf-8", errors="replace"), truncated=installer.truncated)
print(json.dumps(result, ensure_ascii=True))
if __name__ == "__main__":
main(json.loads(sys.argv[1]))
+46
View File
@@ -26,6 +26,8 @@ from .known_hosts import KnownHostsManager
from .network import NetworkManager from .network import NetworkManager
from .state import StateStore from .state import StateStore
from .system import check_kvm, ensure_data_directories, require_root from .system import check_kvm, ensure_data_directories, require_root
from .tool_catalog import normalize_tools
from .tool_store import ToolJobStore
from .validation import parse_name, parse_password, parse_ports, parse_timeout, parse_username from .validation import parse_name, parse_password, parse_ports, parse_timeout, parse_username
@@ -75,6 +77,7 @@ class LifecycleService:
self._guest_connector = guest_connector self._guest_connector = guest_connector
self._terminal_connector = terminal_connector self._terminal_connector = terminal_connector
self._clock = clock self._clock = clock
self._tool_store = ToolJobStore(settings, state_store, clock=clock)
def create(self, spec: VmSpec) -> VmRecord: def create(self, spec: VmSpec) -> VmRecord:
require_root() require_root()
@@ -84,6 +87,7 @@ class LifecycleService:
if spec.name is not None: if spec.name is not None:
parse_name(spec.name) parse_name(spec.name)
ports = self._validated_ports(spec.ports) ports = self._validated_ports(spec.ports)
tools = normalize_tools(spec.tools, allow_empty=True)
ensure_data_directories(self._settings) ensure_data_directories(self._settings)
with self._state_store.operation_lock(): with self._state_store.operation_lock():
self._check_boot() self._check_boot()
@@ -100,6 +104,8 @@ class LifecycleService:
self._images.provision_guest_agent(disk, vm.id, vm.username) self._images.provision_guest_agent(disk, vm.id, vm.username)
vm.disk_ready = True vm.disk_ready = True
self._save(vm) self._save(vm)
if tools:
self._tool_store.enqueue_locked(vm.id, tools, source="create")
self._boot(vm, assets.kernel) self._boot(vm, assets.kernel)
return vm return vm
except BaseException as error: except BaseException as error:
@@ -506,6 +512,8 @@ class LifecycleService:
def execute( def execute(
self, vm_id: str, command: str, *, sudo: bool = False, self, vm_id: str, command: str, *, sudo: bool = False,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
expected_identity: tuple | None = None,
on_connect: Callable[[GuestSession], None] | None = None,
) -> ExecResult: ) -> ExecResult:
"""Pin a running VM's guest connection under the lock, then wait unlocked.""" """Pin a running VM's guest connection under the lock, then wait unlocked."""
require_root() require_root()
@@ -516,14 +524,52 @@ class LifecycleService:
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "alive"): or self._process.observe(vm) != "alive"):
raise ConflictError("guest execution requires a running VM with no pending lifecycle operation") raise ConflictError("guest execution requires a running VM with no pending lifecycle operation")
if expected_identity is not None and expected_identity != (vm.pid, vm.process_start_time, vm.boot_id):
raise ConflictError("the VM restarted during tool installation; retry on the current boot")
session = self._guest_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid) session = self._guest_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
try: try:
if on_connect is not None:
on_connect(session)
# This session stays attached to that VMM even if the VM is stopped, # This session stays attached to that VMM even if the VM is stopped,
# deleted, or restarted concurrently. Never reconnect/retry a command. # deleted, or restarted concurrently. Never reconnect/retry a command.
return session.execute(command, sudo=sudo, timeout_seconds=timeout_seconds) return session.execute(command, sudo=sudo, timeout_seconds=timeout_seconds)
finally: finally:
session.close() session.close()
def _validate_tool_target(self, vm: VmRecord, *, start_if_stopped: bool, restart: bool) -> str:
self._not_deleting(vm)
observation = self._process.observe(vm)
if vm.operation is not None or vm.pending_resize is not None or not vm.disk_ready:
raise ConflictError("finish pending lifecycle operations before installing tools")
if vm.status == "running" and observation == "alive":
return "running"
if vm.status == "stopped" and observation == "gone" and (start_if_stopped or restart):
self._validate_resume(vm)
return "stopped"
raise ConflictError("tool installation requires a running VM; use Start & install for a stopped VM")
def queue_tools(self, vm_id: str, selections, *, start_if_stopped=False, restart=False, request_id=None) -> dict:
require_root()
selections = normalize_tools(selections)
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._validate_tool_target(vm, start_if_stopped=start_if_stopped, restart=restart)
return self._tool_store.enqueue_locked(vm.id, selections, start_if_stopped=start_if_stopped,
restart=restart, request_id=request_id)
def prepare_tool_install(self, vm_id: str, *, start_if_stopped=False, restart=False) -> VmRecord:
"""Perform requested startup/agent refresh atomically before a tool worker connects."""
require_root()
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
status = self._validate_tool_target(vm, start_if_stopped=start_if_stopped, restart=restart)
if status == "running" and restart:
self._stop_locked(vm)
if status == "stopped" or restart:
# The normal retained-disk boot also provisions the current agent.
vm = self._start_locked(vm)
return vm
def open_terminal(self, vm_id: str, *, rows: int = 24, cols: int = 80) -> tuple[GuestTerminal, str]: def open_terminal(self, vm_id: str, *, rows: int = 24, cols: int = 80) -> tuple[GuestTerminal, str]:
"""Connect to the exact running VMM under the lock, then open its guest PTY.""" """Connect to the exact running VMM under the lock, then open its guest PTY."""
require_root() require_root()
+195
View File
@@ -0,0 +1,195 @@
"""Atomic per-VM tool job records, serialized with lifecycle filesystem changes."""
from __future__ import annotations
import copy
import json
import os
import re
import stat
import tempfile
import time
import uuid
from contextlib import contextmanager
from .config import Settings
from .errors import ConflictError, NotFoundError, StateError
from .state import StateStore
from .tool_catalog import normalize_tools
ACTIVE_JOB_STATUSES = frozenset({"queued", "starting", "waiting", "installing"})
MAX_RETAINED_JOBS = 10
MAX_DOCUMENT_BYTES = 16 * 1024 * 1024
class ToolJobStore:
"""Only short transactions hold operations.lock; guest execution never does."""
def __init__(self, settings: Settings, state_store: StateStore, *, clock=time.time) -> None:
self.settings = settings
self.state = state_store
self.clock = clock
def _path(self, vm_id):
vm = self.state.get(vm_id) # Exact ID only, including while creating a VM.
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id):
raise StateError("invalid VM ID in tool history")
directory = self.settings.vm_dir(vm.id)
try:
metadata = directory.lstat()
except FileNotFoundError as error:
raise NotFoundError("VM runtime directory is no longer available") from error
if (not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.geteuid()
or metadata.st_mode & 0o077 or not vm.runtime_owned):
raise StateError("tool history requires a private, owned VM runtime directory")
return directory / "tools.json"
@staticmethod
def _empty():
return {"schema_version": 1, "jobs": [], "inventory": [], "checked_at": None,
"agent_ready": False, "guest_busy": False, "supported": None, "error": None}
def _read(self, vm_id):
path = self._path(vm_id)
try:
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except FileNotFoundError:
return self._empty()
try:
with os.fdopen(descriptor, "rb") as stream:
metadata = os.fstat(stream.fileno())
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o077):
raise StateError("unsafe tool history file")
raw = stream.read(MAX_DOCUMENT_BYTES + 1)
if len(raw) > MAX_DOCUMENT_BYTES:
raise StateError("tool history exceeds its size limit")
data = json.loads(raw)
if (not isinstance(data, dict) or data.get("schema_version") != 1
or not isinstance(data.get("jobs"), list) or not isinstance(data.get("inventory"), list)
or len(data["jobs"]) > MAX_RETAINED_JOBS):
raise StateError("invalid tool history document")
for job in data["jobs"]:
if (not isinstance(job, dict) or job.get("vm_id") != vm_id
or not re.fullmatch(r"tools-[0-9a-f]{32}", str(job.get("id", "")))
or not isinstance(job.get("items"), list) or len(job["items"]) > 12):
raise StateError("invalid tool installation job")
return data
except (OSError, ValueError) as error:
raise StateError("could not read tool installation history") from error
def _write(self, vm_id, data):
path = self._path(vm_id) # Never recreate a deleted VM directory.
descriptor, name = tempfile.mkstemp(prefix=".tools-", suffix=".json", dir=path.parent)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
os.fchmod(stream.fileno(), 0o600)
json.dump(data, stream, ensure_ascii=True, allow_nan=False)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
self.state._fsync_parent(path)
except OSError as error:
raise StateError("could not save tool installation history") from error
finally:
try:
os.unlink(name)
except FileNotFoundError:
pass
@contextmanager
def transaction(self, vm_id):
with self.state.operation_lock():
data = self._read(vm_id)
yield data
self._write(vm_id, data)
def read(self, vm_id):
with self.state.operation_lock():
return self._read(vm_id)
def enqueue_locked(self, vm_id, selections, *, start_if_stopped=False, restart=False,
request_id=None, source="user"):
"""Called by lifecycle create/submit with operations.lock already held."""
selections = normalize_tools(selections)
data = self._read(vm_id)
wanted = [item.to_dict() for item in selections]
for previous in reversed(data["jobs"]):
same = (previous["selections"] == wanted and previous["start_if_stopped"] == start_if_stopped
and previous.get("restart", False) == restart)
if request_id is not None and previous.get("request_id") == request_id:
if not same:
raise ConflictError("this installation request ID was already used for different tools")
return copy.deepcopy(previous)
if previous["status"] in ACTIVE_JOB_STATUSES:
if same:
return copy.deepcopy(previous)
raise ConflictError("a tool installation is already queued or running for this VM")
now = self.clock()
job = {"id": "tools-" + uuid.uuid4().hex, "vm_id": vm_id, "status": "queued", "source": source,
"selections": wanted, "start_if_stopped": start_if_stopped, "restart": restart,
"request_id": request_id, "created_at": now, "updated_at": now, "completed_at": None,
"error": None, "outcome_unknown": False,
"items": [{**item.to_dict(), "key": item.key, "status": "queued", "detected_version": None,
"error": None, "output": "", "truncated": False} for item in selections]}
data["jobs"] = [*data["jobs"], job][-MAX_RETAINED_JOBS:]
self._write(vm_id, data)
return copy.deepcopy(job)
def get_job(self, vm_id, job_id):
for job in self.read(vm_id)["jobs"]:
if job["id"] == job_id:
return job
raise NotFoundError("tool installation job not found for this VM")
def update_job(self, vm_id, job_id, update):
with self.transaction(vm_id) as data:
for job in data["jobs"]:
if job["id"] == job_id:
update(job, data)
job["updated_at"] = self.clock()
return copy.deepcopy(job)
raise NotFoundError("tool installation job not found for this VM")
def claim(self):
with self.state.operation_lock():
for vm in self.state.load().vms.values():
# A failed/interrupted create may not own a runtime directory.
if not vm.runtime_owned or not self.settings.vm_dir(vm.id).is_dir():
continue
data = self._read(vm.id)
for job in data["jobs"]:
if job["status"] == "queued":
job["status"] = "starting" if vm.status == "stopped" or job.get("restart") else "waiting"
job["updated_at"] = self.clock()
self._write(vm.id, data)
return copy.deepcopy(job)
return None
@staticmethod
def interrupt(job, message, now, *, outcome_unknown=True):
job.update(status="interrupted", error=message, outcome_unknown=outcome_unknown,
updated_at=now, completed_at=now)
for item in job["items"]:
if item["status"] in {"installing", "queued"}:
item["status"] = "interrupted" if item["status"] == "installing" else "skipped"
item["error"] = message
def recover(self):
"""The worker owner lock proves that previous in-flight host workers exited."""
with self.state.operation_lock():
for vm in self.state.load().vms.values():
if not vm.runtime_owned or not self.settings.vm_dir(vm.id).is_dir():
continue
data = self._read(vm.id)
changed = False
for job in data["jobs"]:
if job["status"] in ACTIVE_JOB_STATUSES - {"queued"}:
self.interrupt(job, "Management server restarted. Refresh tool status before retrying; "
"the last installation may have completed inside the guest.", self.clock())
changed = True
if changed:
data.update(agent_ready=False, guest_busy=False)
self._write(vm.id, data)
+319
View File
@@ -0,0 +1,319 @@
"""Bounded background installation workers using the existing guest exec channel."""
from __future__ import annotations
import fcntl
import json
import logging
import os
import re
import shlex
import stat
import threading
import time
from functools import lru_cache
from pathlib import Path
from . import guest_tools
from .errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError, UvmError, ValidationError
from .guest_exec import validate_exec
from .system import require_root
from .tool_catalog import all_selections, normalize_tools, recipe
from .tool_store import ToolJobStore
@lru_cache(maxsize=1)
def _guest_source():
return Path(guest_tools.__file__).read_text(encoding="utf-8")
def guest_command(payload: dict) -> str:
command = shlex.join(["/usr/bin/python3", "-I", "-c", _guest_source(), json.dumps(payload, ensure_ascii=True)])
validate_exec(command, True, 60)
return command
def _identity(vm):
return vm.pid, vm.process_start_time, vm.boot_id
def _text(value, limit=16384):
return str(value or "").encode("utf-8", errors="replace")[-limit:].decode("utf-8", errors="replace")
class ToolService:
def __init__(self, settings, state_store, lifecycle) -> None:
self.settings = settings
self.state = state_store
self.lifecycle = lifecycle
self.store = ToolJobStore(settings, state_store)
self._mutex = threading.RLock()
self._wake = threading.Condition()
self._stop = threading.Event()
self._threads = []
self._sessions = {}
self._owner = None
self._workers_left = 0
def start(self):
with self._mutex:
if self._stop.is_set():
raise ConflictError("the tool installer is shutting down")
if self._owner is not None:
return
with self.state.operation_lock():
descriptor = os.open(self.settings.base / "tool-worker.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.geteuid():
raise UvmError("unsafe tool worker ownership file")
os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BaseException as error:
os.close(descriptor)
if isinstance(error, BlockingIOError):
raise ConflictError("another management server is already processing tool installations for this registry") from error
raise
self._owner = descriptor
try:
self.store.recover()
self._workers_left = self.settings.tool_workers
self._threads = [threading.Thread(target=self._worker, name=f"uvm-tools-{index}", daemon=True)
for index in range(self.settings.tool_workers)]
for thread in self._threads:
thread.start()
except BaseException:
self._release_owner()
raise
def _release_owner(self):
if self._owner is not None:
os.close(self._owner)
self._owner = None
def notify(self):
with self._wake:
self._wake.notify_all()
def shutdown(self):
self._stop.set()
self.notify()
with self._mutex:
sessions = list(self._sessions.values())
for session in sessions:
session.close()
deadline = time.monotonic() + 8
for thread in self._threads:
thread.join(max(0, deadline - time.monotonic()))
# A worker finishing a host start keeps ownership until that start ends.
# Its guest command is never submitted after shutdown was requested.
with self._mutex:
if not self._workers_left:
self._release_owner()
def submit(self, vm_id, selections, *, start_if_stopped=False, restart=False, request_id=None):
require_root()
selections = normalize_tools(selections)
if type(start_if_stopped) is not bool or type(restart) is not bool:
raise ValidationError("start_if_stopped and restart must be booleans")
if request_id is not None and (not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", request_id)):
raise ValidationError("request_id must contain 1–80 letters, numbers, underscores, or hyphens")
self.start()
job = self.lifecycle.queue_tools(vm_id, selections, start_if_stopped=start_if_stopped,
restart=restart, request_id=request_id)
self.notify()
return job
def _execute(self, vm, payload, timeout):
if self._stop.is_set():
raise InterruptedError("management server is shutting down")
thread_id = threading.get_ident()
def connected(session):
with self._mutex:
self._sessions[thread_id] = session
if self._stop.is_set():
session.close()
try:
result = self.lifecycle.execute(vm.id, guest_command(payload), sudo=True, timeout_seconds=timeout,
expected_identity=_identity(vm), on_connect=connected)
finally:
with self._mutex:
self._sessions.pop(thread_id, None)
if result.timed_out:
return {"ok": False, "error": f"installation/verification timed out after {timeout:g} seconds; retry to finish it",
"output": _text(result.stderr), "truncated": result.truncated}
try:
value = json.loads(result.stdout)
if result.exit_code != 0 or result.truncated or not isinstance(value, dict) or type(value.get("ok")) is not bool:
raise ValueError("invalid result")
return value
except (ValueError, TypeError) as error:
raise GuestExecutionError("guest returned an incomplete tool result; refresh status before retrying",
outcome_unknown=payload["action"] == "install") from error
def _payload(self, vm, action, selections, *, boot_id=None):
return {"action": action, "username": vm.username, "architecture": self.settings.architecture,
"boot_id": boot_id, "tools": [recipe(selection, self.settings.architecture) for selection in selections]}
@staticmethod
def _inventory(result, selections):
expected = {item.key: item for item in selections}
records = result.get("tools")
if not isinstance(records, list) or len(records) != len(expected):
raise GuestExecutionError("invalid guest tool inventory")
inventory = []
for value in records:
if (not isinstance(value, dict) or value.get("key") not in expected
or type(value.get("installed")) is not bool):
raise GuestExecutionError("invalid guest tool inventory")
selection = expected.pop(value["key"])
inventory.append({**selection.to_dict(), "key": selection.key, "installed": value["installed"],
"detected_version": _text(value.get("detected_version"), 120) or None,
"error": _text(value.get("error"), 512) or None,
"usage": _text(value.get("usage"), 512)})
return inventory
def snapshot(self, vm_id, *, refresh=False):
require_root()
vm = self.state.get(vm_id)
data = self.store.read(vm_id)
running = self.lifecycle.inspect(vm.id).observed_status == "running"
if refresh and running:
try:
result = self._execute(vm, self._payload(vm, "probe", all_selections()), 90)
if not result["ok"]:
raise UvmError(_text(result.get("error"), 2048))
inventory = self._inventory(result, all_selections())
with self.store.transaction(vm_id) as current:
current.update(inventory=inventory, checked_at=time.time(), error=None, agent_ready=True,
guest_busy=result.get("busy") is True, supported=result.get("supported") is True)
data = self.store.read(vm_id)
except UvmError as error:
with self.store.transaction(vm_id) as current:
current.update(error=_text(error, 2048), agent_ready=not isinstance(error, GuestAgentUnavailableError))
data = self.store.read(vm_id)
if not running:
data.update(agent_ready=False, guest_busy=False)
return {"vm_id": vm_id, **{key: data[key] for key in (
"inventory", "checked_at", "agent_ready", "guest_busy", "supported", "error")},
"stale": not running or data["checked_at"] is None or time.time() - data["checked_at"] > 60,
"latest_job": data["jobs"][-1] if data["jobs"] else None,
"jobs": [{key: job[key] for key in ("id", "status", "created_at", "completed_at")}
for job in reversed(data["jobs"])]}
def _worker(self):
try:
while not self._stop.is_set():
try:
job = self.store.claim()
if job is not None:
self._run_job(job)
continue
except (UvmError, OSError):
logging.getLogger(__name__).exception("Could not process a tool installation job")
with self._wake:
self._wake.wait(timeout=1)
finally:
with self._mutex:
self._workers_left -= 1
if self._workers_left == 0 and self._stop.is_set():
self._release_owner()
def _update(self, job, update):
return self.store.update_job(job["vm_id"], job["id"], update)
def _wait_ready(self, vm, selections):
deadline = time.monotonic() + self.settings.tool_ready_timeout_s
while not self._stop.is_set():
try:
result = self._execute(vm, self._payload(vm, "probe", selections), 90)
if not result["ok"]:
raise UvmError(_text(result.get("error"), 2048))
if result.get("busy"):
raise ConflictError("an earlier installation is still running inside this VM; wait and refresh before retrying")
if not isinstance(result.get("boot_id"), str) or not result["boot_id"]:
raise GuestExecutionError("guest did not report its boot identity")
return result
except GuestAgentUnavailableError:
if time.monotonic() >= deadline:
raise GuestAgentUnavailableError("Guest agent did not become ready. Restart & retry to apply agent setup, "
"then check the VM log if startup still fails.")
self._stop.wait(min(2, max(0, deadline - time.monotonic())))
raise InterruptedError("management server is shutting down")
def _run_job(self, job):
submitted = False
try:
if self._stop.is_set():
raise InterruptedError("management server is shutting down")
selections = normalize_tools(job["selections"])
vm = self.lifecycle.prepare_tool_install(job["vm_id"], start_if_stopped=job["start_if_stopped"],
restart=job.get("restart", False))
self._update(job, lambda current, data: current.update(status="waiting"))
info = self._wait_ready(vm, selections)
initial = self._inventory(info, selections)
def ready(current, data):
observed = {item["key"]: item for item in data["inventory"]}
observed.update({item["key"]: item for item in initial})
data.update(inventory=list(observed.values()), checked_at=time.time(), agent_ready=True,
guest_busy=False, supported=True, error=None)
self._update(job, ready)
for index, selection in enumerate(selections):
if self._stop.is_set():
raise InterruptedError("management server is shutting down")
def begin(current, data):
current["status"] = "installing"
current["items"][index]["status"] = "installing"
self._update(job, begin)
submitted = True
result = self._execute(vm, self._payload(vm, "install", [selection], boot_id=info["boot_id"]),
self.settings.tool_install_timeout_s)
observed = None
if result["ok"]:
observed, = self._inventory({"tools": [result.get("tool")]}, [selection])
if not observed["installed"] or result.get("status") not in {"installed", "already_installed"}:
raise GuestExecutionError("guest installation did not verify the selected tool", outcome_unknown=True)
def finish_item(current, data):
current["items"][index].update(
status=result["status"] if result["ok"] else "failed",
detected_version=observed["detected_version"] if observed else None,
error=None if result["ok"] else _text(result.get("error"), 2048),
output=_text(result.get("output")), truncated=result.get("truncated") is True)
inventory = {item["key"]: item for item in data["inventory"]}
if observed:
inventory[selection.key] = observed
elif selection.key in inventory:
inventory[selection.key].update(installed=False, error="Refresh to verify after the failed installation.")
data.update(inventory=list(inventory.values()), checked_at=time.time())
self._update(job, finish_item)
submitted = False
def finish(current, data):
failures = sum(item["status"] == "failed" for item in current["items"])
current.update(status="failed" if failures else "succeeded", completed_at=time.time(),
error=f"{failures} tool installation(s) failed; see the per-tool output and retry." if failures else None)
self._update(job, finish)
except NotFoundError:
return # Deletion owns removal of the whole runtime, including history.
except (InterruptedError, GuestExecutionError, ConflictError) as error:
try:
self._update(job, lambda current, data: self.store.interrupt(
current, _text(error, 2048), time.time(),
outcome_unknown=getattr(error, "outcome_unknown", submitted)))
except NotFoundError:
pass
except (UvmError, OSError, ValueError) as error:
def failed(current, data):
self.store.interrupt(current, _text(error, 2048), time.time(), outcome_unknown=submitted)
current["status"] = "failed" if not submitted else "interrupted"
if isinstance(error, GuestAgentUnavailableError):
data.update(agent_ready=False, error=_text(error, 2048))
try:
self._update(job, failed)
except NotFoundError:
pass