- Update README and USAGE documentation to reflect changes in rootfs handling, including the default local build from Ubuntu 22.04 packages. - Modify the installer to support building the rootfs locally with authenticated packages and to require SHA-256 checksums for prebuilt images. - Implement checks for setuid permissions on sudo and update the provisioning logic for guest users to ensure proper sudo access. - Adjust tests to validate the new rootfs building process and ensure that existing functionality is preserved. - Refactor code to improve clarity and maintainability, including the introduction of constants for Ubuntu release and guest packages.
186 lines
6.9 KiB
Python
186 lines
6.9 KiB
Python
"""Configuration and filesystem layout for the uvm command-line application."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from ipaddress import IPv4Address, IPv4Network
|
|
from pathlib import Path
|
|
|
|
from .errors import ConfigurationError
|
|
|
|
|
|
DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide"
|
|
MIN_STORAGE_MIB = 30 * 1024
|
|
MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Settings:
|
|
"""All host-specific values consumed by the application."""
|
|
|
|
app_name: str = "uvm"
|
|
base: Path = Path("/var/lib/uvm")
|
|
network: IPv4Network = IPv4Network("10.42.0.0/24")
|
|
gateway: IPv4Address = IPv4Address("10.42.0.1")
|
|
bridge: str = "uvm0"
|
|
architecture: str = field(default_factory=lambda: os.uname().machine)
|
|
firecracker_version: str = "v1.16.1"
|
|
default_ram_mib: int = 512
|
|
default_storage_mib: int = MIN_STORAGE_MIB
|
|
default_vcpu: float = 1
|
|
default_ssh_user: str = "root"
|
|
kernel_url: str = ""
|
|
rootfs_url: str = "" # Empty selects the locally built, package-managed Ubuntu image.
|
|
firecracker_sha256: str | None = None
|
|
firecracker_binary_sha256: str | None = None
|
|
kernel_sha256: str | None = None
|
|
rootfs_sha256: str | None = None
|
|
allow_unverified_downloads: bool = False
|
|
api_token: str | None = None
|
|
api_tls_cert: Path | None = None
|
|
api_tls_key: Path | None = None
|
|
known_hosts_files: tuple[Path, ...] | None = None
|
|
api_timeout_s: float = 2.0
|
|
api_socket_timeout_s: float = 5.0
|
|
terminate_timeout_s: float = 5.0
|
|
|
|
def __post_init__(self) -> None:
|
|
architecture = self.architecture.lower()
|
|
architecture = {"amd64": "x86_64", "arm64": "aarch64"}.get(architecture, architecture)
|
|
if architecture not in ("x86_64", "aarch64"):
|
|
raise ConfigurationError(f"unsupported host architecture: {self.architecture}")
|
|
object.__setattr__(self, "architecture", architecture)
|
|
|
|
# Resolve defaults at construction time, while preserving individual URL overrides.
|
|
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
|
|
if not self.kernel_url:
|
|
object.__setattr__(self, "kernel_url", f"{asset_base}/kernels/vmlinux.bin")
|
|
|
|
if self.gateway not in self.network:
|
|
raise ConfigurationError(
|
|
f"gateway {self.gateway} is outside configured network {self.network}"
|
|
)
|
|
if self.gateway in (self.network.network_address, self.network.broadcast_address):
|
|
raise ConfigurationError("gateway must be a usable host address")
|
|
if len(self.bridge) > 15:
|
|
raise ConfigurationError("bridge name must be 15 characters or fewer")
|
|
if self.known_hosts_files is not None and any(not path.is_absolute() for path in self.known_hosts_files):
|
|
raise ConfigurationError("UVM_KNOWN_HOSTS_FILES must contain absolute file paths")
|
|
if self.api_token is not None:
|
|
if not self.api_token or not all(33 <= ord(character) <= 126 for character in self.api_token):
|
|
raise ConfigurationError(
|
|
"UVM_API_TOKEN must contain only visible ASCII characters without whitespace"
|
|
)
|
|
|
|
@property
|
|
def bin_dir(self) -> Path:
|
|
return self.base / "bin"
|
|
|
|
@property
|
|
def images_dir(self) -> Path:
|
|
return self.base / "images"
|
|
|
|
@property
|
|
def vms_dir(self) -> Path:
|
|
return self.base / "vms"
|
|
|
|
@property
|
|
def state_path(self) -> Path:
|
|
return self.base / "state.json"
|
|
|
|
@property
|
|
def state_lock_path(self) -> Path:
|
|
return self.base / "state.lock"
|
|
|
|
@property
|
|
def operation_lock_path(self) -> Path:
|
|
return self.base / "operations.lock"
|
|
|
|
@property
|
|
def firecracker_binary(self) -> Path:
|
|
return self.bin_dir / "firecracker"
|
|
|
|
@property
|
|
def jailer_binary(self) -> Path:
|
|
return self.bin_dir / "jailer"
|
|
|
|
@property
|
|
def kernel_image(self) -> Path:
|
|
return self.images_dir / "vmlinux"
|
|
|
|
@property
|
|
def rootfs_image(self) -> Path:
|
|
return self.images_dir / "ubuntu.ext4"
|
|
|
|
@property
|
|
def integrity_manifest_path(self) -> Path:
|
|
return self.base / "integrity.json"
|
|
|
|
def vm_dir(self, vm_id: str) -> Path:
|
|
return self.vms_dir / vm_id
|
|
|
|
@classmethod
|
|
def from_environment(cls) -> "Settings":
|
|
"""Load optional deployment overrides while retaining script defaults."""
|
|
|
|
try:
|
|
network = IPv4Network(os.environ.get("UVM_NETWORK", "10.42.0.0/24"))
|
|
gateway = IPv4Address(os.environ.get("UVM_GATEWAY", "10.42.0.1"))
|
|
except ValueError as error:
|
|
raise ConfigurationError(f"invalid network configuration: {error}") from error
|
|
|
|
return cls(
|
|
base=Path(os.environ.get("UVM_BASE", "/var/lib/uvm")).expanduser(),
|
|
network=network,
|
|
gateway=gateway,
|
|
bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
|
|
kernel_url=os.environ.get("UVM_KERNEL_URL", ""),
|
|
rootfs_url=os.environ.get("UVM_ROOTFS_URL", ""),
|
|
firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"),
|
|
firecracker_binary_sha256=_optional_environment_value(
|
|
"UVM_FIRECRACKER_BINARY_SHA256"
|
|
),
|
|
kernel_sha256=_optional_environment_value("UVM_KERNEL_SHA256"),
|
|
rootfs_sha256=_optional_environment_value("UVM_ROOTFS_SHA256"),
|
|
allow_unverified_downloads=_boolean_environment_value(
|
|
"UVM_ALLOW_UNVERIFIED_DOWNLOADS", default=False
|
|
),
|
|
api_token=_optional_environment_value("UVM_API_TOKEN"),
|
|
api_tls_cert=_optional_environment_path("UVM_API_TLS_CERT"),
|
|
api_tls_key=_optional_environment_path("UVM_API_TLS_KEY"),
|
|
known_hosts_files=_known_hosts_environment_paths(),
|
|
)
|
|
|
|
|
|
def _optional_environment_value(name: str) -> str | None:
|
|
value = os.environ.get(name)
|
|
return value if value else None
|
|
|
|
|
|
def _optional_environment_path(name: str) -> Path | None:
|
|
value = _optional_environment_value(name)
|
|
return Path(value).expanduser() if value else None
|
|
|
|
|
|
def _known_hosts_environment_paths() -> tuple[Path, ...] | None:
|
|
value = _optional_environment_value("UVM_KNOWN_HOSTS_FILES")
|
|
if value is None:
|
|
return None
|
|
parts = value.split(os.pathsep)
|
|
if any(not part for part in parts):
|
|
raise ConfigurationError("UVM_KNOWN_HOSTS_FILES must be a colon-separated list of file paths")
|
|
return tuple(Path(part).expanduser() for part in parts)
|
|
|
|
|
|
def _boolean_environment_value(name: str, *, default: bool) -> bool:
|
|
value = os.environ.get(name)
|
|
if value is None:
|
|
return default
|
|
normalized = value.strip().lower()
|
|
if normalized in {"1", "true", "yes", "on"}:
|
|
return True
|
|
if normalized in {"0", "false", "no", "off"}:
|
|
return False
|
|
raise ConfigurationError(f"{name} must be one of true/false, yes/no, or 1/0")
|