Dev #2

Merged
kstyagi merged 4 commits from dev into main 2026-09-21 15:50:24 +00:00
38 changed files with 728 additions and 98 deletions
Showing only changes of commit fb80ce7c8a - Show all commits
+21 -1
View File
@@ -49,7 +49,7 @@ Requirements for real host operations:
- Root privileges for installation and lifecycle mutations - Root privileges for installation and lifecycle mutations
- `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`, - `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`,
`openssl`, and OpenSSH client tools `openssl`, and OpenSSH client tools
- An x86_64 host for the default kernel/rootfs URLs - An x86_64 or ARM64 (`aarch64`) host, including NVIDIA DGX Spark
Do not run real integration operations on a developer workstation unless the Do not run real integration operations on a developer workstation unless the
bridge, subnet, firewall changes, and guest workload are acceptable there. bridge, subnet, firewall changes, and guest workload are acceptable there.
@@ -432,6 +432,8 @@ The `firecracker/` package has three separate responsibilities.
The guest receives a static IP via the kernel `ip=` boot argument. The netmask The guest receives a static IP via the kernel `ip=` boot argument. The netmask
comes from `Settings.network`, not a hard-coded `/24` value. comes from `Settings.network`, not a hard-coded `/24` value.
ARM64 guests also receive `keep_bootcon`, as recommended by Firecracker's
getting-started guide; both architectures use `console=ttyS0`.
### Process Safety ### Process Safety
@@ -563,6 +565,9 @@ exclusive create for its replacement. Foreign ownership is an error, not an
adoption opportunity. Readback normalizes presentation differences but compares adoption opportunity. Readback normalizes presentation differences but compares
all effective objects, ordered rules, hooks, identity, and the full port set; all effective objects, ordered rules, hooks, identity, and the full port set;
rule counts or an owner comment alone are not proof of enforcement. rule counts or an owner comment alone are not proof of enforcement.
Normalization materializes implied EtherType constraints before folding
transport-protocol dependencies. This accepts nftables' omitted redundant
`ether type ip` checks while retaining the IPv4 scope of TCP rules.
The policy has prerouting/input/forward/output hooks. Early prerouting identity The policy has prerouting/input/forward/output hooks. Early prerouting identity
checks run at priority -310, before conntrack and bridge MAC learning; input checks run at priority -310, before conntrack and bridge MAC learning; input
@@ -590,6 +595,8 @@ of conntrack entries scoped to the guest IP in all four original/reply source
and destination fields, including NAT reply tuples. Only then may teardown and destination fields, including NAT reply tuples. Only then may teardown
release the address. Rule removal also requires an absent TAP and verified release the address. Rule removal also requires an absent TAP and verified
table ownership; it never flushes global firewall or conntrack state. table ownership; it never flushes global firewall or conntrack state.
Cleanup uses conntrack's documented `-D` (delete) and `-L` (list) commands,
including on conntrack-tools 1.4.8, where `--list` is not a supported option.
### Coexistence and Recovery ### Coexistence and Recovery
@@ -612,6 +619,15 @@ do not import its unimplemented topology checks into current documentation.
`Installer` is the only owner of apt installation, Firecracker artifact `Installer` is the only owner of apt installation, Firecracker artifact
handling, downloads, and integrity-manifest publication. handling, downloads, and integrity-manifest publication.
`Settings` detects and normalizes the host architecture at construction time
(`amd64` to `x86_64`, `arm64` to `aarch64`) and rejects unsupported architectures.
The same architecture selects the Firecracker release archive, guest kernel,
rootfs, and architecture-specific boot arguments. Default guest URLs use
Firecracker's `img/quickstart_guide/<architecture>/` assets: `kernels/vmlinux.bin`
and `rootfs/bionic.rootfs.ext4`. Kernel and rootfs URL overrides are independent;
custom assets and trusted digests must match the host architecture. ARM64
uses an uncompressed Linux `Image` (PE format) and an ARM64 ext4 rootfs.
The host package list includes `nftables` and `conntrack` as well as the The host package list includes `nftables` and `conntrack` as well as the
existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux
tools. UVM adds no reconciliation daemon or tested external firewall-manager tools. UVM adds no reconciliation daemon or tested external firewall-manager
@@ -788,6 +804,10 @@ prove the kernel's actual packet behavior.
FastAPI route tests are conditionally enabled when both `fastapi` and `httpx` FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
are installed. They should be enabled in CI using the `test` extra. are installed. They should be enabled in CI using the `test` extra.
When `nft` and `conntrack` are installed, command-parser tests append an invalid
final command/option so parsing is checked without executing any mutation.
These checks supplement fixture-based readback and cleanup tests; they do not
exercise the kernel packet path.
### Isolated Packet Tests ### Isolated Packet Tests
+14 -4
View File
@@ -38,7 +38,7 @@ The project is an application, not a reusable Python SDK.
- Root access for UVM commands that read or mutate the protected VM registry - Root access for UVM commands that read or mutate the protected VM registry
- KVM enabled and accessible at `/dev/kvm` - KVM enabled and accessible at `/dev/kvm`
- Linux 5.3 or newer with native bridge conntrack and safe pidfd support - Linux 5.3 or newer with native bridge conntrack and safe pidfd support
- An x86_64 host for the default guest assets - An x86_64 or ARM64 (`aarch64`) host, including NVIDIA DGX Spark
- Working host networking, `iproute2`, `iptables`, `nftables`, `conntrack`, - Working host networking, `iproute2`, `iptables`, `nftables`, `conntrack`,
`e2fsprogs`, `util-linux`, `openssl`, and `openssh-client` `e2fsprogs`, `util-linux`, `openssl`, and `openssh-client`
- An STP-disabled software bridge; no offload path that bypasses bridge hooks - An STP-disabled software bridge; no offload path that bypasses bridge hooks
@@ -48,10 +48,17 @@ before downloading VM artifacts. Boot operations additionally check that the
native nftables bridge policy is supported; installing packages alone is not native nftables bridge policy is supported; installing packages alone is not
a connectivity or isolation test. a connectivity or isolation test.
Firecracker, the guest kernel, and the Ubuntu rootfs are selected automatically
for the host architecture. On NVIDIA DGX Spark, UVM uses the ARM64 assets.
`arm64` and `amd64` architecture names are normalized to `aarch64` and
`x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides
must point to assets compatible with the host architecture.
## Trusted Installation ## Trusted Installation
Installation requires trusted SHA-256 digests by default. Supply digests from Installation requires trusted SHA-256 digests by default. Supply digests from
an independently trusted source before running the privileged installer: an independently trusted source for the selected architecture before running
the privileged installer:
```sh ```sh
sudo env \ sudo env \
@@ -407,8 +414,8 @@ Host and API settings can be overridden through environment variables:
| `UVM_NETWORK` | Guest private IPv4 CIDR; defaults to `10.42.0.0/24` | | `UVM_NETWORK` | Guest private IPv4 CIDR; defaults to `10.42.0.0/24` |
| `UVM_GATEWAY` | Bridge/guest gateway; defaults to `10.42.0.1` | | `UVM_GATEWAY` | Bridge/guest gateway; defaults to `10.42.0.1` |
| `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` | | `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` |
| `UVM_KERNEL_URL` | Guest kernel download URL | | `UVM_KERNEL_URL` | Override the host-architecture default guest kernel URL |
| `UVM_ROOTFS_URL` | Guest rootfs download URL | | `UVM_ROOTFS_URL` | Override the host-architecture default guest rootfs URL |
| `UVM_FIRECRACKER_SHA256` | Trusted Firecracker release archive SHA-256 | | `UVM_FIRECRACKER_SHA256` | Trusted Firecracker release archive SHA-256 |
| `UVM_KERNEL_SHA256` | Trusted guest kernel SHA-256 | | `UVM_KERNEL_SHA256` | Trusted guest kernel SHA-256 |
| `UVM_ROOTFS_SHA256` | Trusted guest rootfs SHA-256 | | `UVM_ROOTFS_SHA256` | Trusted guest rootfs SHA-256 |
@@ -458,6 +465,9 @@ retained-disk resume, bulk operations, lifecycle rollback, dashboard delivery,
and API routes. API route tests run when the FastAPI and `httpx` test and API routes. API route tests run when the FastAPI and `httpx` test
dependencies are installed. dependencies are installed.
When `nft` and `conntrack` are installed, the suite also checks their command
parsers using deliberately invalid final commands/options that prevent mutations.
Opt-in packet checks run **only in disposable isolated user/network Opt-in packet checks run **only in disposable isolated user/network
namespaces**, which may require privileges and the installed network tools: namespaces**, which may require privileges and the installed network tools:
+27 -5
View File
@@ -44,7 +44,7 @@ You need:
- KVM enabled and readable/writable at `/dev/kvm`. - KVM enabled and readable/writable at `/dev/kvm`.
- Linux >= 5.3 with native bridge conntrack and safe pidfd support. - Linux >= 5.3 with native bridge conntrack and safe pidfd support.
- Root access for installation and VM lifecycle operations. - Root access for installation and VM lifecycle operations.
- An x86_64 host for the default guest kernel/rootfs downloads. - An x86_64 or ARM64 (`aarch64`) host, including NVIDIA DGX Spark.
- Internet access if downloading Firecracker and guest assets. - Internet access if downloading Firecracker and guest assets.
- `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`, - `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`,
`openssl`, and `openssh-client`; `install` installs the host packages. `openssl`, and `openssh-client`; `install` installs the host packages.
@@ -140,6 +140,23 @@ UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest
rootfs template. It requires SHA-256 checksums by default because it downloads rootfs template. It requires SHA-256 checksums by default because it downloads
artifacts that will be used by a privileged process. artifacts that will be used by a privileged process.
### ARM64 Hosts (NVIDIA DGX Spark)
UVM detects the host architecture and selects matching Firecracker, kernel,
and rootfs downloads. NVIDIA DGX Spark uses the `aarch64` assets; the `arm64`
architecture alias is also accepted. The default ARM64 guest downloads are:
- Kernel: `https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64/kernels/vmlinux.bin`
- Rootfs: `https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64/rootfs/bionic.rootfs.ext4`
Use the installation commands below with checksums for those ARM64 artifacts
and the ARM64 Firecracker release archive. `UVM_KERNEL_URL` and
`UVM_ROOTFS_URL` can override either download independently. Custom images must
match the host architecture and the [guest image contract](README.md#guest-image-contract).
On ARM64, the kernel must be an uncompressed ARM64 Linux `Image` (PE format),
and the rootfs must be an ARM64 ext4 filesystem. UVM adds `keep_bootcon` to the
guest boot arguments for ARM64 console output.
### Scenario: Strict First Installation ### Scenario: Strict First Installation
Obtain trusted checksums independently, then run: Obtain trusted checksums independently, then run:
@@ -196,8 +213,7 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py start VM
| `/dev/kvm does not exist` | Enable hardware/nested virtualization first. | | `/dev/kvm does not exist` | Enable hardware/nested virtualization first. |
| `checksum is required` | Provide the requested `UVM_*_SHA256` variable. | | `checksum is required` | Provide the requested `UVM_*_SHA256` variable. |
| `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. | | `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. |
| `unsupported host architecture` | Use x86_64 defaults or provide compatible assets. | | `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. |
| `default guest assets support x86_64 only` | Configure both custom kernel and rootfs URLs for ARM. |
## 5. Create VMs ## 5. Create VMs
@@ -306,6 +322,12 @@ On failure, UVM attempts containment and cleanup. It does not release the
disk or IP reservation if safe process exit or cleanup cannot be confirmed; disk or IP reservation if safe process exit or cleanup cannot be confirmed;
inspect the retained `failed` record and `last_error` before retrying. inspect the retained `failed` record and `last_error` before retrying.
After updating UVM to fix a create/cleanup error, restart the API server to
load the updated code. To discard a failed creation and release its reserved
IP, run `sudo ./uvm.py delete VM -y` with that failed VM's exact ID, or send
an authenticated `DELETE /vms/VM`. Deletion retries the outstanding cleanup
and removes the failed VM's private disk. Then retry the create request.
## 6. Inspect VMs ## 6. Inspect VMs
```sh ```sh
@@ -938,8 +960,8 @@ automatically detects every conflict with host routes or other managers.
| `UVM_NETWORK` | Guest subnet; default `10.42.0.0/24` | | `UVM_NETWORK` | Guest subnet; default `10.42.0.0/24` |
| `UVM_GATEWAY` | Guest gateway / bridge IP; default `10.42.0.1` | | `UVM_GATEWAY` | Guest gateway / bridge IP; default `10.42.0.1` |
| `UVM_BRIDGE` | Linux bridge name; default `uvm0` | | `UVM_BRIDGE` | Linux bridge name; default `uvm0` |
| `UVM_KERNEL_URL` | Custom kernel download URL | | `UVM_KERNEL_URL` | Override the host-architecture default kernel URL |
| `UVM_ROOTFS_URL` | Custom rootfs download URL | | `UVM_ROOTFS_URL` | Override the host-architecture default rootfs URL |
| `UVM_FIRECRACKER_SHA256` | Strict Firecracker archive checksum | | `UVM_FIRECRACKER_SHA256` | Strict Firecracker archive checksum |
| `UVM_KERNEL_SHA256` | Strict kernel checksum | | `UVM_KERNEL_SHA256` | Strict kernel checksum |
| `UVM_ROOTFS_SHA256` | Strict rootfs checksum | | `UVM_ROOTFS_SHA256` | Strict rootfs checksum |
+1 -1
View File
@@ -296,7 +296,7 @@ def _sandbox(parent_fd):
print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True) print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True)
def service_ids(): def service_ids():
listing = run("conntrack", "--list", "--family", "ipv4", "--proto", "tcp", listing = run("conntrack", "-L", "--family", "ipv4", "--proto", "tcp",
"--orig-dst", vm.guest_ip, "--dport", "8080", "-o", "id") "--orig-dst", vm.guest_ip, "--dport", "8080", "-o", "id")
return set(re.findall(r"\bid=(\d+)", listing)) return set(re.findall(r"\bid=(\d+)", listing))
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import os
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from uvm.config import Settings
from uvm.errors import ConfigurationError
class ArchitectureSettingsTests(unittest.TestCase):
def test_resolves_native_defaults_when_settings_are_created(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("AMD64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
for factory in (Settings, Settings.from_environment):
with (
self.subTest(machine=machine, factory=factory),
patch.dict(os.environ, {}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = factory()
base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}"
)
self.assertEqual(settings.architecture, architecture)
self.assertEqual(settings.kernel_url, f"{base}/kernels/vmlinux.bin")
self.assertEqual(settings.rootfs_url, f"{base}/rootfs/bionic.rootfs.ext4")
def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None:
kernel = "https://example.com/custom-arm64-kernel"
rootfs = "https://example.com/custom-arm64-rootfs.ext4"
base = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64"
for overrides in (
{"UVM_KERNEL_URL": kernel},
{"UVM_ROOTFS_URL": rootfs},
{"UVM_KERNEL_URL": kernel, "UVM_ROOTFS_URL": rootfs},
):
with (
self.subTest(overrides=overrides),
patch.dict(os.environ, overrides, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="aarch64")),
):
settings = Settings.from_environment()
self.assertEqual(
settings.kernel_url,
overrides.get("UVM_KERNEL_URL", f"{base}/kernels/vmlinux.bin"),
)
self.assertEqual(
settings.rootfs_url,
overrides.get("UVM_ROOTFS_URL", f"{base}/rootfs/bionic.rootfs.ext4"),
)
def test_rejects_unsupported_hosts_even_with_custom_guest_assets(self) -> None:
with (
patch.dict(os.environ, {
"UVM_KERNEL_URL": "https://example.com/kernel",
"UVM_ROOTFS_URL": "https://example.com/rootfs.ext4",
}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="riscv64")),
self.assertRaisesRegex(ConfigurationError, "unsupported host architecture: riscv64"),
):
Settings.from_environment()
+27
View File
@@ -19,6 +19,33 @@ class RecordingFirecrackerClient(FirecrackerClient):
class FirecrackerConfigTests(unittest.TestCase): class FirecrackerConfigTests(unittest.TestCase):
def test_uses_arm_boot_console_arguments_only_on_arm64(self) -> None:
vm = VmRecord(
id="vm-test",
cpu=1,
ram_mib=512,
guest_ip="10.42.0.2",
gateway="10.42.0.1",
tap="uvm-test",
mac="02:fc:00:00:00:01",
socket="/tmp/firecracker.sock",
config="/tmp/config.json",
log="/tmp/firecracker.log",
)
for architecture in ("x86_64", "aarch64", "arm64"):
with self.subTest(architecture=architecture):
config = build_config(
Settings(architecture=architecture),
vm,
Path("/images/vmlinux"),
Path("/vm/rootfs.ext4"),
)
arguments = config["boot-source"]["boot_args"].split()
self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64")
self.assertIn("console=ttyS0", arguments)
self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments)
def test_builds_a_writable_vm_disk_configuration(self) -> None: def test_builds_a_writable_vm_disk_configuration(self) -> None:
vm = VmRecord( vm = VmRecord(
id="vm-test", id="vm-test",
+89 -6
View File
@@ -102,9 +102,9 @@ class FakeRunner:
self.after_batch() self.after_batch()
elif args == ("conntrack", "--version"): elif args == ("conntrack", "--version"):
stdout = "conntrack v1.4.8 (conntrack-tools)" stdout = "conntrack v1.4.8 (conntrack-tools)"
elif args[:2] == ("conntrack", "--delete"): elif args[:2] == ("conntrack", "-D"):
code = self.ct_delete_code code = self.ct_delete_code
elif args[:2] == ("conntrack", "--list"): elif args[:2] == ("conntrack", "-L"):
stdout = self.ct_remaining stdout = self.ct_remaining
else: else:
raise AssertionError(f"unexpected command: {args}") raise AssertionError(f"unexpected command: {args}")
@@ -376,6 +376,34 @@ class FirewallManagerTests(unittest.TestCase):
exprs[:-1] = reversed(exprs[:-1]) exprs[:-1] = reversed(exprs[:-1])
self.assertEqual(self.manager.status(self.vm), "enforced") self.assertEqual(self.manager.status(self.vm), "enforced")
def test_apply_accepts_nft_eliding_ethertype_from_the_ipv4_tcp_guard(self):
self.vm.ports = (PortRule(22),)
def elide_ethertype():
for item in self.policy():
expressions = item.get("rule", {}).get("expr", [])
if not any("&" in expr.get("match", {}).get("left", {}) for expr in expressions):
continue
# nft 1.0.9 keeps ip protocol tcp to bind TCP to IPv4 in a
# bridge table, but omits its redundant ether type ip check.
expressions[:] = [expr for expr in expressions if expr != {
"match": {"left": {"payload": {"protocol": "ether", "field": "type"}},
"op": "==", "right": "ip"},
}]
self.runner.after_batch = elide_ethertype
self.manager.apply(self.vm)
self.assertEqual(self.manager.status(self.vm), "enforced")
# Losing BOTH IPv4 constraints is a real policy change, not formatting.
for item in self.policy():
expressions = item.get("rule", {}).get("expr", [])
if any("&" in expr.get("match", {}).get("left", {}) for expr in expressions):
expressions[:] = [expr for expr in expressions if expr.get("match", {}).get("left") != {
"payload": {"protocol": "ip", "field": "protocol"},
}]
self.assertEqual(self.manager.status(self.vm), "drift")
def test_empty_sets_can_omit_elements_and_missing_conditions_are_drift(self): def test_empty_sets_can_omit_elements_and_missing_conditions_are_drift(self):
self.vm.ports = () self.vm.ports = ()
self.manager.apply(self.vm) self.manager.apply(self.vm)
@@ -430,7 +458,7 @@ class FirewallManagerTests(unittest.TestCase):
self.runner.ct_delete_code = 1 # conntrack uses 1 for no matching entries too. self.runner.ct_delete_code = 1 # conntrack uses 1 for no matching entries too.
self.manager.cleanup_connections(self.vm) self.manager.cleanup_connections(self.vm)
for selector in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"): for selector in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
for command in ("--delete", "--list"): for command in ("-D", "-L"):
self.assertIn(("conntrack", command, "--family", "ipv4", selector, self.vm.guest_ip), self.assertIn(("conntrack", command, "--family", "ipv4", selector, self.vm.guest_ip),
[args for args, _ in self.runner.calls]) [args for args, _ in self.runner.calls])
self.assertFalse(any("--flush" in args or "-F" in args for args, _ in self.runner.calls)) self.assertFalse(any("--flush" in args or "-F" in args for args, _ in self.runner.calls))
@@ -438,7 +466,7 @@ class FirewallManagerTests(unittest.TestCase):
with self.assertRaisesRegex(UvmError, "do not reuse"): with self.assertRaisesRegex(UvmError, "do not reuse"):
self.manager.cleanup_connections(self.vm) self.manager.cleanup_connections(self.vm)
self.runner.ct_remaining = "" self.runner.ct_remaining = ""
self.runner.fail_command = ("conntrack", "--list", "--family", "ipv4", "--orig-src", self.vm.guest_ip) self.runner.fail_command = ("conntrack", "-L", "--family", "ipv4", "--orig-src", self.vm.guest_ip)
with self.assertRaises(CommandError): with self.assertRaises(CommandError):
self.manager.cleanup_connections(self.vm) self.manager.cleanup_connections(self.vm)
@@ -455,7 +483,7 @@ class FirewallManagerTests(unittest.TestCase):
expected = [("ip", "-j", "-d", "link", "show")] expected = [("ip", "-j", "-d", "link", "show")]
expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7") expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7")
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst") for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst")
for command in ("--delete", "--list")) for command in ("-D", "-L"))
# No nft policy, publishing, or unrelated address/conntrack flush. # No nft policy, publishing, or unrelated address/conntrack flush.
self.assertEqual([args for args, _ in self.runner.calls], expected) self.assertEqual([args for args, _ in self.runner.calls], expected)
@@ -476,7 +504,7 @@ class FirewallManagerTests(unittest.TestCase):
with self.subTest(address=address, network=network): with self.subTest(address=address, network=network):
self.runner.calls.clear() self.runner.calls.clear()
self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None)) self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None))
deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "--delete")] deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "-D")]
self.assertEqual(len(deletes), 4) self.assertEqual(len(deletes), 4)
self.assertTrue(all(args[-1] == address for args in deletes)) self.assertTrue(all(args[-1] == address for args in deletes))
@@ -688,6 +716,32 @@ class NftDumpFixtureTests(unittest.TestCase):
self.assertEqual(self.normalize_rule(dumped), self.normalize_rule(numeric)) self.assertEqual(self.normalize_rule(dumped), self.normalize_rule(numeric))
self.assertNotEqual(self.normalize_rule(dumped), self.normalize_rule(numeric.replace('}}, 3]', '}}, 2]'))) self.assertNotEqual(self.normalize_rule(dumped), self.normalize_rule(numeric.replace('}}, 3]', '}}, 2]')))
def test_bridge_tcp_guard_normalizes_numeric_protocol_and_implicit_ethertype(self):
# nft 1.0.9 src/payload.c payload_may_dependency_kill() removes the
# EtherType before ip protocol, but retains ip protocol before TCP.
# Protocol and flag representations follow the upstream fixtures in
# tests/py/inet/ip_tcp.t.json.output and tests/py/inet/tcp.t.json.
dumped = '''[
{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}},
"op": "==", "right": 6}},
{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": "new"}},
{"match": {"left": {"&": [
{"payload": {"protocol": "tcp", "field": "flags"}}, ["fin", "syn", "rst", "ack"]
]}, "op": "!=", "right": "syn"}},
{"drop": null}
]'''
policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False)
guards = [item["rule"] for item in policy if "rule" in item
and any("&" in expr.get("match", {}).get("left", {}) for expr in item["rule"]["expr"])]
self.assertEqual({rule["chain"] for rule in guards}, {"from_vm", "to_vm"})
for rule in guards:
with self.subTest(chain=rule["chain"]):
expected = self.normalize_rule(json.dumps(rule["expr"]))
self.assertEqual(expected, self.normalize_rule(dumped))
self.assertNotEqual(expected, self.normalize_rule(json.dumps(json.loads(dumped)[1:])))
self.assertNotEqual(expected, self.normalize_rule(dumped.replace('"right": 6', '"right": 17')))
self.assertNotEqual(expected, self.normalize_rule(dumped.replace('"op": "!="', '"op": "=="')))
def test_upstream_full_dump_ignores_only_handles_and_metainfo(self): def test_upstream_full_dump_ignores_only_handles_and_metainfo(self):
# Literal RULESET from tests/shell/testcases/json/0006obj_comment_0. # Literal RULESET from tests/shell/testcases/json/0006obj_comment_0.
dumped = json.loads('''{"nftables": [ dumped = json.loads('''{"nftables": [
@@ -706,6 +760,35 @@ class NftDumpFixtureTests(unittest.TestCase):
self.assertNotEqual(_normalize(dumped), _normalize(expected)) self.assertNotEqual(_normalize(dumped), _normalize(expected))
@unittest.skipUnless(shutil.which("conntrack"), "conntrack unavailable for nonmutating option checks")
class ConntrackCommandParserTests(unittest.TestCase):
@staticmethod
def parse(command):
# An invalid final option aborts before any netlink operation, even
# when the tests run as root. This also checks delete command syntax.
return subprocess.run(
(*command, "--uvm-parser-sentinel"), text=True, capture_output=True,
timeout=5, env={**os.environ, "LC_ALL": "C"},
)
def test_cleanup_commands_reach_only_the_final_invalid_option(self):
runner = FakeRunner()
FirewallManager(Settings(), runner).cleanup_connections(vm_record())
commands = [args for args, _ in runner.calls if args[0] == "conntrack"]
self.assertEqual(len(commands), 8)
for command in commands:
with self.subTest(command=command):
result = self.parse(command)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertRegex(result.stderr, r"(?:unknown|unrecognized) option [`']--uvm-parser-sentinel'")
def test_invalid_option_is_rejected_before_the_sentinel(self):
result = self.parse(("conntrack", "--uvm-invalid-option"))
self.assertEqual(result.returncode, 2)
self.assertIn("--uvm-invalid-option", result.stderr)
self.assertNotIn("--uvm-parser-sentinel", result.stderr)
@unittest.skipUnless(shutil.which("nft"), "nft unavailable for noncommitting JSON parser checks") @unittest.skipUnless(shutil.which("nft"), "nft unavailable for noncommitting JSON parser checks")
class NftJsonParserTests(unittest.TestCase): class NftJsonParserTests(unittest.TestCase):
"""Exercise the actual JSON parser, NOT semantic/kernel/packet validation. """Exercise the actual JSON parser, NOT semantic/kernel/packet validation.
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import hashlib
import io
import os
import tarfile
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from uvm.config import Settings
from uvm.errors import UvmError
from uvm.install import Installer
from uvm.integrity import load_manifest
from uvm.state import StateStore
from uvm.system import CommandRunner
class InstallerArchitectureTests(unittest.TestCase):
def test_installs_matching_native_artifacts_with_a_verified_manifest(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
with (
self.subTest(machine=machine),
tempfile.TemporaryDirectory() as temporary_directory,
patch.dict(os.environ, {"UVM_BASE": temporary_directory}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = Settings.from_environment()
version = settings.firecracker_version
binaries = {
"firecracker": f"firecracker for {architecture}".encode(),
"jailer": f"jailer for {architecture}".encode(),
}
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
for name, content in binaries.items():
member = tarfile.TarInfo(
f"release-{version}-{architecture}/{name}-{version}-{architecture}"
)
member.size = len(content)
member.mode = 0o755
archive.addfile(member, io.BytesIO(content))
archive_bytes = archive_buffer.getvalue()
kernel_bytes = f"kernel for {architecture}".encode()
rootfs_bytes = f"rootfs for {architecture}".encode()
settings = replace(
settings,
firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(),
kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(),
rootfs_sha256=hashlib.sha256(rootfs_bytes).hexdigest(),
)
guest_base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}"
)
payloads = {
"https://github.com/firecracker-microvm/firecracker/releases/download/"
f"{version}/firecracker-{version}-{architecture}.tgz": archive_bytes,
f"{guest_base}/kernels/vmlinux.bin": kernel_bytes,
f"{guest_base}/rootfs/bionic.rootfs.ext4": rootfs_bytes,
}
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm"),
patch(
"uvm.install.urllib.request.urlopen",
side_effect=lambda url, timeout: io.BytesIO(payloads[url]),
) as download,
):
firecracker, assets = installer.install()
self.assertEqual(firecracker.read_bytes(), binaries["firecracker"])
self.assertEqual(settings.jailer_binary.read_bytes(), binaries["jailer"])
self.assertEqual(assets.kernel.read_bytes(), kernel_bytes)
self.assertEqual(assets.rootfs.read_bytes(), rootfs_bytes)
self.assertEqual(download.call_count, 3)
self.assertEqual({call.args[0] for call in download.call_args_list}, set(payloads))
manifest = load_manifest(settings.integrity_manifest_path)
self.assertTrue(manifest.verified)
self.assertEqual(manifest.checksums, {
name: hashlib.sha256(content).hexdigest()
for name, content in {
**binaries, "kernel": kernel_bytes, "rootfs": rootfs_bytes,
}.items()
})
def test_arm64_install_requires_checksums_before_host_commands_or_downloads(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
settings = Settings(base=Path(temporary_directory), architecture="aarch64")
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm") as check_kvm,
patch("uvm.install.urllib.request.urlopen") as download,
self.assertRaisesRegex(UvmError, "Firecracker archive checksum is required"),
):
installer.install()
runner.run.assert_not_called()
check_kvm.assert_not_called()
download.assert_not_called()
self.assertFalse(settings.integrity_manifest_path.exists())
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
View File
@@ -40,6 +40,7 @@ class VmCreateRequest(BaseModel):
cpu: float = Field(default=1, gt=0) cpu: float = Field(default=1, gt=0)
ram: str | int | float = "512" ram: str | int | float = "512"
storage: str | int | float = Field(default="30G", description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
guest_ip: IPv4Address | None = None guest_ip: IPv4Address | None = None
username: str = "root" username: str = "root"
password: SecretStr = SecretStr("root") password: SecretStr = SecretStr("root")
@@ -48,11 +49,27 @@ class VmCreateRequest(BaseModel):
no_default_ports: bool = Field(default=False, strict=True) no_default_ports: bool = Field(default=False, strict=True)
class VmResizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
cpu: float | None = Field(default=None, gt=0, allow_inf_nan=False, strict=True)
ram: str | int | float | None = None
storage: str | int | float | None = Field(default=None, description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
class VmResourcesModel(BaseModel):
cpu: float
ram_mib: int
storage_mib: int
class VmResponse(BaseModel): class VmResponse(BaseModel):
id: str id: str
name: str | None name: str | None
cpu: float cpu: float
ram_mib: int ram_mib: int
storage_mib: int | None
pending_resize: VmResourcesModel | None
guest_ip: str guest_ip: str
gateway: str gateway: str
mac: str mac: str
@@ -124,6 +141,8 @@ def vm_response(
name=vm.name, name=vm.name,
cpu=vm.cpu, cpu=vm.cpu,
ram_mib=vm.ram_mib, ram_mib=vm.ram_mib,
storage_mib=vm.storage_mib,
pending_resize=VmResourcesModel(**vm.pending_resize.to_dict()) if vm.pending_resize is not None else None,
guest_ip=vm.guest_ip, guest_ip=vm.guest_ip,
gateway=vm.gateway, gateway=vm.gateway,
mac=vm.mac, mac=vm.mac,
+40 -2
View File
@@ -22,6 +22,7 @@ from .validation import (
parse_password, parse_password,
parse_ports, parse_ports,
parse_ram, parse_ram,
parse_storage,
parse_timeout, parse_timeout,
parse_username, parse_username,
) )
@@ -97,6 +98,10 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
default=str(application.settings.default_ram_mib), default=str(application.settings.default_ram_mib),
help="RAM, e.g. 512, 1G, 512M", help="RAM, e.g. 512, 1G, 512M",
) )
create.add_argument(
"--storage", default=f"{application.settings.default_storage_mib}M",
help="private disk capacity, e.g. 30G, 50G, 1T; minimum 30 GiB (bare numbers use GiB)",
)
create.add_argument( create.add_argument(
"--guest-ip", "--guest-ip",
"--host-ip", "--host-ip",
@@ -130,6 +135,14 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
) )
create.add_argument("--json", action="store_true") create.add_argument("--json", action="store_true")
resize = subcommands.add_parser("resize", help="stop, resize CPU/RAM/storage, and boot the retained VM")
resize.add_argument("vm", help="VM ID, name, or guest IP")
resize.add_argument("--cpu", help="new CPU capacity; omitted values are retained")
resize.add_argument("--ram", help="new RAM, e.g. 512M or 4G")
resize.add_argument("--storage", help="new private disk capacity, e.g. 30G or 50G; minimum 30 GiB")
resize.add_argument("--timeout", help="seconds to wait after SIGTERM before SIGKILL (normally 5s)")
resize.add_argument("--json", action="store_true")
listing = subcommands.add_parser("list", help="list VMs") listing = subcommands.add_parser("list", help="list VMs")
listing.add_argument("--status", action="append", default=[], help="observed status (repeatable)") listing.add_argument("--status", action="append", default=[], help="observed status (repeatable)")
output = listing.add_mutually_exclusive_group() output = listing.add_mutually_exclusive_group()
@@ -293,6 +306,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
VmSpec( VmSpec(
cpu=parse_cpu(args.cpu), cpu=parse_cpu(args.cpu),
ram_mib=parse_ram(args.ram), ram_mib=parse_ram(args.ram),
storage_mib=parse_storage(args.storage),
guest_ip=guest_ip, guest_ip=guest_ip,
username=parse_username(args.username), username=parse_username(args.username),
password=parse_password(password), password=parse_password(password),
@@ -321,6 +335,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
print(f" Name: {vm.name}") print(f" Name: {vm.name}")
print(f" IP: {vm.guest_ip}") print(f" IP: {vm.guest_ip}")
print(f" RAM: {vm.ram_mib} MiB") print(f" RAM: {vm.ram_mib} MiB")
print(f" Storage: {vm.storage_mib} MiB")
print(f" CPU: {vm.cpu}") print(f" CPU: {vm.cpu}")
print(f" TAP: {vm.tap}") print(f" TAP: {vm.tap}")
print(f" Username: {vm.username}") print(f" Username: {vm.username}")
@@ -329,6 +344,27 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
print(f" SSH: ssh {vm.username}@{vm.guest_ip}") print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
return 0 return 0
if args.command == "resize":
if args.cpu is None and args.ram is None and args.storage is None:
raise ValidationError("resize requires at least one of --cpu, --ram, or --storage")
vm = application.lifecycle.resize(
args.vm,
cpu=parse_cpu(args.cpu) if args.cpu is not None else None,
ram_mib=parse_ram(args.ram) if args.ram is not None else None,
storage_mib=parse_storage(args.storage) if args.storage is not None else None,
timeout=parse_timeout(args.timeout) if args.timeout is not None else None,
)
if args.json:
_print_json(_vm_output(vm, policy_status="enforced"))
else:
print(f"VM resized and started: {vm.id}")
print(f" CPU: {vm.cpu}")
print(f" RAM: {vm.ram_mib} MiB")
print(f" Storage: {vm.storage_mib} MiB")
print(f" IP: {vm.guest_ip}")
print(f" Username: {vm.username}")
return 0
if args.command == "list": if args.command == "list":
vms = [ vms = [
listed for listed in application.lifecycle.list_vms() listed for listed in application.lifecycle.list_vms()
@@ -349,14 +385,14 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
return 0 return 0
print( print(
f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} " f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} "
f"{'RAM':<8} {'STATUS':<12} {'POLICY':<12} PID" f"{'RAM':<8} {'DISK(MiB)':<12} {'STATUS':<12} {'POLICY':<12} PID"
) )
for listed in vms: for listed in vms:
vm = listed.vm vm = listed.vm
pid = vm.pid if vm.pid is not None else "" pid = vm.pid if vm.pid is not None else ""
print( print(
f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} " f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
f"{vm.ram_mib:<8} {listed.observed_status:<12} {listed.policy_status:<12} {pid}" f"{vm.ram_mib:<8} {vm.storage_mib or '-':<12} {listed.observed_status:<12} {listed.policy_status:<12} {pid}"
) )
return 0 return 0
@@ -543,6 +579,8 @@ def _vm_output(
"name": vm.name, "name": vm.name,
"cpu": vm.cpu, "cpu": vm.cpu,
"ram_mib": vm.ram_mib, "ram_mib": vm.ram_mib,
"storage_mib": vm.storage_mib,
"pending_resize": vm.pending_resize.to_dict() if vm.pending_resize is not None else None,
"guest_ip": vm.guest_ip, "guest_ip": vm.guest_ip,
"gateway": vm.gateway, "gateway": vm.gateway,
"tap": vm.tap, "tap": vm.tap,
+23 -13
View File
@@ -3,21 +3,16 @@
from __future__ import annotations from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass, field
from ipaddress import IPv4Address, IPv4Network from ipaddress import IPv4Address, IPv4Network
from pathlib import Path from pathlib import Path
from .errors import ConfigurationError from .errors import ConfigurationError
DEFAULT_KERNEL_URL = ( DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide"
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/" MIN_STORAGE_MIB = 30 * 1024
"x86_64/kernels/vmlinux.bin" MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
)
DEFAULT_ROOTFS_URL = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
"x86_64/rootfs/bionic.rootfs.ext4"
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -29,12 +24,14 @@ class Settings:
network: IPv4Network = IPv4Network("10.42.0.0/24") network: IPv4Network = IPv4Network("10.42.0.0/24")
gateway: IPv4Address = IPv4Address("10.42.0.1") gateway: IPv4Address = IPv4Address("10.42.0.1")
bridge: str = "uvm0" bridge: str = "uvm0"
architecture: str = field(default_factory=lambda: os.uname().machine)
firecracker_version: str = "v1.16.1" firecracker_version: str = "v1.16.1"
default_ram_mib: int = 512 default_ram_mib: int = 512
default_storage_mib: int = MIN_STORAGE_MIB
default_vcpu: float = 1 default_vcpu: float = 1
default_ssh_user: str = "root" default_ssh_user: str = "root"
kernel_url: str = DEFAULT_KERNEL_URL kernel_url: str = ""
rootfs_url: str = DEFAULT_ROOTFS_URL rootfs_url: str = ""
firecracker_sha256: str | None = None firecracker_sha256: str | None = None
firecracker_binary_sha256: str | None = None firecracker_binary_sha256: str | None = None
kernel_sha256: str | None = None kernel_sha256: str | None = None
@@ -48,6 +45,19 @@ class Settings:
terminate_timeout_s: float = 5.0 terminate_timeout_s: float = 5.0
def __post_init__(self) -> None: 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 not self.rootfs_url:
object.__setattr__(self, "rootfs_url", f"{asset_base}/rootfs/bionic.rootfs.ext4")
if self.gateway not in self.network: if self.gateway not in self.network:
raise ConfigurationError( raise ConfigurationError(
f"gateway {self.gateway} is outside configured network {self.network}" f"gateway {self.gateway} is outside configured network {self.network}"
@@ -124,8 +134,8 @@ class Settings:
network=network, network=network,
gateway=gateway, gateway=gateway,
bridge=os.environ.get("UVM_BRIDGE", "uvm0"), bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
kernel_url=os.environ.get("UVM_KERNEL_URL", DEFAULT_KERNEL_URL), kernel_url=os.environ.get("UVM_KERNEL_URL", ""),
rootfs_url=os.environ.get("UVM_ROOTFS_URL", DEFAULT_ROOTFS_URL), rootfs_url=os.environ.get("UVM_ROOTFS_URL", ""),
firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"), firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"),
firecracker_binary_sha256=_optional_environment_value( firecracker_binary_sha256=_optional_environment_value(
"UVM_FIRECRACKER_BINARY_SHA256" "UVM_FIRECRACKER_BINARY_SHA256"
+49 -1
View File
@@ -2,16 +2,18 @@
from __future__ import annotations from __future__ import annotations
import math
import time import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from ipaddress import IPv4Address from ipaddress import IPv4Address
from typing import Any from typing import Any
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import StateError, ValidationError from .errors import StateError, ValidationError
STATE_SCHEMA_VERSION = 2 STATE_SCHEMA_VERSION = 3
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -52,6 +54,40 @@ class VmSpec:
password: str = field(default="root", repr=False) password: str = field(default="root", repr=False)
name: str | None = None name: str | None = None
ports: tuple[PortRule, ...] = (PortRule(22),) ports: tuple[PortRule, ...] = (PortRule(22),)
storage_mib: int = MIN_STORAGE_MIB
@dataclass(frozen=True, slots=True)
class VmResources:
"""Complete resource target retained while a stop/resize/start is pending."""
cpu: float
ram_mib: int
storage_mib: int
def __post_init__(self) -> None:
try:
valid_cpu = type(self.cpu) in (int, float) and math.isfinite(self.cpu) and self.cpu > 0
except OverflowError:
valid_cpu = False
if not valid_cpu:
raise ValidationError("CPU must be a finite value greater than 0")
if type(self.ram_mib) is not int or self.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
if type(self.storage_mib) is not int or not MIN_STORAGE_MIB <= self.storage_mib <= MAX_STORAGE_MIB:
raise ValidationError(f"storage must be between {MIN_STORAGE_MIB} and {MAX_STORAGE_MIB} MiB (minimum 30 GiB)")
def to_dict(self) -> dict[str, Any]:
return {"cpu": self.cpu, "ram_mib": self.ram_mib, "storage_mib": self.storage_mib}
@classmethod
def from_dict(cls, value: object) -> "VmResources":
if not isinstance(value, dict) or set(value) != {"cpu", "ram_mib", "storage_mib"}:
raise StateError("invalid pending resize resources")
try:
return cls(**value)
except ValidationError as error:
raise StateError(f"invalid pending resize resources: {error}") from error
@dataclass(slots=True) @dataclass(slots=True)
@@ -87,12 +123,16 @@ class VmRecord:
spawn_pending: bool = False spawn_pending: bool = False
network: str | None = None network: str | None = None
bridge: str | None = None bridge: str | None = None
storage_mib: int | None = None
pending_resize: VmResources | None = None
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = { data: dict[str, Any] = {
"id": self.id, "id": self.id,
"cpu": self.cpu, "cpu": self.cpu,
"ram_mib": self.ram_mib, "ram_mib": self.ram_mib,
"storage_mib": self.storage_mib,
"pending_resize": self.pending_resize.to_dict() if self.pending_resize is not None else None,
"guest_ip": self.guest_ip, "guest_ip": self.guest_ip,
"gateway": self.gateway, "gateway": self.gateway,
"tap": self.tap, "tap": self.tap,
@@ -155,6 +195,12 @@ class VmRecord:
if ports is not None and len({port.port for port in ports}) != len(ports): if ports is not None and len({port.port for port in ports}) != len(ports):
raise StateError("VM has duplicate TCP port rules") raise StateError("VM has duplicate TCP port rules")
legacy_ready = bool(value.get("disk")) and value.get("status") in {"running", "stopped"} legacy_ready = bool(value.get("disk")) and value.get("status") in {"running", "stopped"}
storage_mib = value.get("storage_mib")
if storage_mib is not None and (
type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB
):
raise StateError("VM storage_mib must be a supported integer capacity of at least 30 GiB or null")
pending_resize = value.get("pending_resize")
for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"): for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"):
if name in value and type(value[name]) is not bool: if name in value and type(value[name]) is not bool:
raise StateError(f"VM {name} must be a boolean") raise StateError(f"VM {name} must be a boolean")
@@ -200,6 +246,8 @@ class VmRecord:
spawn_pending=value.get("spawn_pending", value.get("operation") == "spawning"), spawn_pending=value.get("spawn_pending", value.get("operation") == "spawning"),
network=str(value["network"]) if value.get("network") is not None else None, network=str(value["network"]) if value.get("network") is not None else None,
bridge=str(value["bridge"]) if value.get("bridge") is not None else None, bridge=str(value["bridge"]) if value.get("bridge") is not None else None,
storage_mib=storage_mib,
pending_resize=VmResources.from_dict(pending_resize) if pending_resize is not None else None,
) )
except (TypeError, ValueError) as error: except (TypeError, ValueError) as error:
raise StateError(f"invalid VM record: {error}") from error raise StateError(f"invalid VM record: {error}") from error
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -44,6 +44,8 @@ def build_config(
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network), boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
) )
) )
if settings.architecture == "aarch64":
boot_args = f"keep_bootcon {boot_args}"
return { return {
"boot-source": { "boot-source": {
"kernel_image_path": str(kernel), "kernel_image_path": str(kernel),
+31 -19
View File
@@ -161,8 +161,8 @@ class FirewallManager:
address = str(address) address = str(address)
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"): for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
selector = ("--family", "ipv4", field, address) selector = ("--family", "ipv4", field, address)
self._run(("conntrack", "--delete", *selector), check=False) self._run(("conntrack", "-D", *selector), check=False)
remaining = self._run(("conntrack", "--list", *selector)) remaining = self._run(("conntrack", "-L", *selector))
if remaining.stdout.strip(): if remaining.stdout.strip():
raise UvmError(f"conntrack entries remain for {address}; do not reuse its IP") raise UvmError(f"conntrack entries remain for {address}; do not reuse its IP")
@@ -408,8 +408,9 @@ class FirewallManager:
def _normalize(objects: list[dict[str, Any]]) -> str: def _normalize(objects: list[dict[str, Any]]) -> str:
"""Canonical nft JSON, retaining every effective object, rule and verdict. """Canonical nft JSON, retaining every effective object, rule and verdict.
Ignore only handles, empty set presentation and nft's redundant protocol Normalize handles, empty set presentation and implied protocol dependencies
dependencies. Rule order is significant; pure conjunctions and sets are not. without losing address-family constraints. Rule order is significant;
pure conjunctions and sets are not.
Unknown expressions/properties remain different rather than being ignored. Unknown expressions/properties remain different rather than being ignored.
""" """
def canonical(value: Any) -> Any: def canonical(value: Any) -> Any:
@@ -442,6 +443,18 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
return result return result
return value return value
def payload_protocols(value: Any) -> set[str]:
if not isinstance(value, dict):
return set()
payload = value.get("payload")
if (set(value) == {"payload"} and isinstance(payload, dict)
and set(payload) == {"protocol", "field"}
and isinstance(payload["protocol"], str) and isinstance(payload["field"], str)):
return {payload["protocol"]}
if set(value) == {"&"} and isinstance(value["&"], list) and len(value["&"]) == 2:
return payload_protocols(value["&"][0]) | payload_protocols(value["&"][1])
return set()
definitions, rules = [], {} definitions, rules = [], {}
for item in objects: for item in objects:
kind, raw = next(iter(item.items())) kind, raw = next(iter(item.items()))
@@ -490,25 +503,24 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
left["&"][1] = bitmask(left["&"][1], tcp_flags) left["&"][1] = bitmask(left["&"][1], tcp_flags)
match["right"] = bitmask(match["right"], tcp_flags) match["right"] = bitmask(match["right"], tcp_flags)
# nft's replayable JSON omits dependencies implied by named payloads.
encoded = json.dumps(expressions, sort_keys=True)
has_tcp = '"protocol": "tcp"' in encoded
has_icmp = '"protocol": "icmp"' in encoded
expressions = [expr for expr in expressions if not (
(has_tcp and expr == _match(_payload("ip", "protocol"), "tcp"))
or (has_icmp and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
encoded = json.dumps(expressions, sort_keys=True)
expressions = [expr for expr in expressions if not (
('"protocol": "arp"' in encoded and expr == _match(_payload("ether", "type"), "arp"))
or (('"protocol": "ip"' in encoded or has_icmp)
and expr == _match(_payload("ether", "type"), "ip"))
)]
count = len(expressions) count = len(expressions)
if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}): if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}):
count -= 1 count -= 1
if all(isinstance(expr, dict) and set(expr) == {"match"} for expr in expressions[:count]): if all(isinstance(expr, dict) and set(expr) == {"match"} for expr in expressions[:count]):
expressions = sorted(expressions[:count], key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:] matches = expressions[:count]
protocols = set().union(*(payload_protocols(expr["match"].get("left")) for expr in matches))
# nft elides ether type ip before ip protocol tcp. Materialize its
# IPv4 constraint BEFORE folding the transport dependency: a TCP
# payload alone can also match IPv6 in a bridge table.
for payloads, ethertype in (({"ip", "icmp"}, "ip"), ({"arp"}, "arp")):
dependency = _match(_payload("ether", "type"), ethertype)
if protocols & payloads and dependency not in matches:
matches.append(dependency)
matches = [expr for expr in matches if not (
("tcp" in protocols and expr == _match(_payload("ip", "protocol"), "tcp"))
or ("icmp" in protocols and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
expressions = sorted(matches, key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:]
obj["expr"] = expressions obj["expr"] = expressions
rules.setdefault(obj.get("chain", ""), []).append(obj) rules.setdefault(obj.get("chain", ""), []).append(obj)
definitions.sort(key=lambda item: json.dumps(item, sort_keys=True)) definitions.sort(key=lambda item: json.dumps(item, sort_keys=True))
+76 -6
View File
@@ -2,14 +2,15 @@
from __future__ import annotations from __future__ import annotations
import os
import re import re
import shutil import stat
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .config import Settings from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
from .errors import UvmError from .errors import UvmError, ValidationError
from .integrity import load_manifest, verify_file from .integrity import load_manifest, verify_file
from .system import CommandRunner from .system import CommandRunner
@@ -88,23 +89,92 @@ class ImageStore:
allow_unverified=self._settings.allow_unverified_downloads) allow_unverified=self._settings.allow_unverified_downloads)
return kernel return kernel
def create_vm_disk(self, source: Path, destination: Path) -> Path: def create_vm_disk(
self, source: Path, destination: Path, *, storage_mib: int = MIN_STORAGE_MIB,
) -> Path:
"""Copy the template before Firecracker opens it read-write for a VM.""" """Copy the template before Firecracker opens it read-write for a VM."""
self._validate_storage(storage_mib)
if destination.exists(): if destination.exists():
raise UvmError(f"VM disk already exists: {destination}") raise UvmError(f"VM disk already exists: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
try: try:
shutil.copy2(source, destination) self._copy_sparse(source, destination)
destination.chmod(0o600) destination.chmod(0o600)
except OSError as error: self._resize_filesystem(destination, storage_mib)
except BaseException as error:
try: try:
destination.unlink() destination.unlink()
except FileNotFoundError: except FileNotFoundError:
pass pass
if isinstance(error, OSError):
raise UvmError(f"could not create VM disk {destination}: {error}") from error raise UvmError(f"could not create VM disk {destination}: {error}") from error
raise
return destination return destination
def resize_vm_disk(self, disk: Path, storage_mib: int) -> None:
"""Resize a proven-offline private disk on a copy, then publish atomically."""
self._validate_storage(storage_mib)
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError(f"private VM disk is unsafe to resize: {disk}")
target_bytes = storage_mib * 1024 * 1024
if metadata.st_size == target_bytes and self._filesystem_size(disk) == target_bytes:
return # A previous resize may have published its disk before saving metadata.
descriptor, name = tempfile.mkstemp(prefix=".rootfs-resize-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(name)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
self._resize_filesystem(temporary, storage_mib)
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
except OSError as error:
raise UvmError(f"could not resize VM disk {disk}: {error}") from error
finally:
temporary.unlink(missing_ok=True)
@staticmethod
def _validate_storage(storage_mib: int) -> None:
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
raise ValidationError("storage must be a supported whole-MiB capacity of at least 30 GiB")
def _copy_sparse(self, source: Path, destination: Path) -> None:
self._runner.run(("cp", "--reflink=auto", "--sparse=always", "--", source, destination), capture=True)
def _filesystem_size(self, disk: Path) -> int:
result = self._runner.run(("env", "LC_ALL=C", "dumpe2fs", "-h", disk), capture=True)
blocks = re.search(r"^Block count:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
block_size = re.search(r"^Block size:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
if blocks is None or block_size is None:
raise UvmError(f"could not determine ext4 filesystem capacity: {disk}")
return int(blocks.group(1)) * int(block_size.group(1))
def _resize_filesystem(self, disk: Path, storage_mib: int) -> None:
"""Grow the file before ext4; shrink ext4 before truncating the file."""
checked = self._runner.run(("e2fsck", "-f", "-p", disk), check=False, capture=True)
if checked.returncode not in (0, 1):
detail = checked.stderr.strip() or checked.stdout.strip()
raise UvmError(f"filesystem check failed before resize ({checked.returncode}): {detail}")
target_bytes = storage_mib * 1024 * 1024
if disk.stat().st_size < target_bytes:
with disk.open("r+b") as image:
image.truncate(target_bytes)
self._runner.run(("resize2fs", disk, f"{storage_mib}M"), capture=True)
if self._filesystem_size(disk) != target_bytes:
raise UvmError("resized ext4 filesystem does not match the requested capacity")
with disk.open("r+b") as image:
image.truncate(target_bytes)
image.flush()
os.fsync(image.fileno())
def provision_credentials(self, disk: Path, username: str, password: str) -> None: def provision_credentials(self, disk: Path, username: str, password: str) -> None:
"""Set an existing guest account password in an offline ext4 disk.""" """Set an existing guest account password in an offline ext4 disk."""
+3 -15
View File
@@ -11,7 +11,7 @@ import urllib.request
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from .config import DEFAULT_KERNEL_URL, DEFAULT_ROOTFS_URL, Settings from .config import Settings
from .errors import UvmError from .errors import UvmError
from .images import GuestAssets from .images import GuestAssets
from .integrity import require_checksum, sha256_file, verify_file, write_manifest from .integrity import require_checksum, sha256_file, verify_file, write_manifest
@@ -42,7 +42,6 @@ class Installer:
return self._install_locked(force_assets=force_assets) return self._install_locked(force_assets=force_assets)
def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]: def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]:
self._validate_guest_asset_architecture()
self._validate_integrity_policy() self._validate_integrity_policy()
if self._settings.allow_unverified_downloads and self._emit is not None: if self._settings.allow_unverified_downloads and self._emit is not None:
self._emit( self._emit(
@@ -56,16 +55,6 @@ class Installer:
self._write_integrity_manifest(firecracker, assets) self._write_integrity_manifest(firecracker, assets)
return firecracker, assets return firecracker, assets
def _validate_guest_asset_architecture(self) -> None:
architecture = os.uname().machine
default_kernel = self._settings.kernel_url == DEFAULT_KERNEL_URL
default_rootfs = self._settings.rootfs_url == DEFAULT_ROOTFS_URL
if architecture == "aarch64" and (default_kernel or default_rootfs):
raise UvmError(
"default guest assets support x86_64 only; set both UVM_KERNEL_URL "
"and UVM_ROOTFS_URL to compatible aarch64 assets"
)
def _validate_integrity_policy(self) -> None: def _validate_integrity_policy(self) -> None:
require_checksum( require_checksum(
self._settings.firecracker_sha256, self._settings.firecracker_sha256,
@@ -112,6 +101,7 @@ class Installer:
"iptables", "iptables",
"nftables", "nftables",
"conntrack", "conntrack",
"coreutils",
"e2fsprogs", "e2fsprogs",
"util-linux", "util-linux",
"ca-certificates", "ca-certificates",
@@ -122,9 +112,7 @@ class Installer:
self._runner.run(("apt-get", "install", "-y", *packages)) self._runner.run(("apt-get", "install", "-y", *packages))
def install_firecracker_binary(self, *, force: bool = False) -> Path: def install_firecracker_binary(self, *, force: bool = False) -> Path:
architecture = os.uname().machine architecture = self._settings.architecture
if architecture not in ("x86_64", "aarch64"):
raise UvmError(f"unsupported host architecture: {architecture}")
firecracker = self._settings.firecracker_binary firecracker = self._settings.firecracker_binary
jailer = self._settings.jailer_binary jailer = self._settings.jailer_binary
+80 -21
View File
@@ -11,8 +11,8 @@ from dataclasses import dataclass, replace
from ipaddress import IPv4Network from ipaddress import IPv4Network
from pathlib import Path from pathlib import Path
from .config import Settings from .config import MIN_STORAGE_MIB, Settings
from .domain import PortRule, VmRecord, VmSpec, new_vm_id from .domain import PortRule, VmRecord, VmResources, VmSpec, new_vm_id
from .errors import ConflictError, NotFoundError, UvmError, ValidationError from .errors import ConflictError, NotFoundError, UvmError, ValidationError
from .firecracker.api import FirecrackerClient from .firecracker.api import FirecrackerClient
from .firecracker.config import build_config, write_config from .firecracker.config import build_config, write_config
@@ -22,7 +22,7 @@ from .integrity import load_manifest, verify_file
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 .validation import parse_cpu, parse_name, parse_password, parse_ports, parse_username from .validation import parse_name, parse_password, parse_ports, parse_timeout, parse_username
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -68,9 +68,7 @@ class LifecycleService:
def create(self, spec: VmSpec) -> VmRecord: def create(self, spec: VmSpec) -> VmRecord:
require_root() require_root()
parse_cpu(str(spec.cpu)) VmResources(spec.cpu, spec.ram_mib, spec.storage_mib)
if type(spec.ram_mib) is not int or spec.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
parse_username(spec.username) parse_username(spec.username)
parse_password(spec.password) parse_password(spec.password)
if spec.name is not None: if spec.name is not None:
@@ -86,7 +84,7 @@ class LifecycleService:
runtime.mkdir(mode=0o700, exist_ok=False) runtime.mkdir(mode=0o700, exist_ok=False)
vm.runtime_owned = True vm.runtime_owned = True
self._save(vm) self._save(vm)
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk)) disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk), storage_mib=spec.storage_mib)
self._images.provision_credentials(disk, vm.username, vm.password) self._images.provision_credentials(disk, vm.username, vm.password)
vm.disk_ready = True vm.disk_ready = True
self._save(vm) self._save(vm)
@@ -164,7 +162,7 @@ class LifecycleService:
with self._state_store.operation_lock(): with self._state_store.operation_lock():
return self._start_locked(self._load_for_operation(identifier)) return self._start_locked(self._load_for_operation(identifier))
def _start_locked(self, vm: VmRecord) -> VmRecord: def _start_locked(self, vm: VmRecord, *, kernel: Path | None = None) -> VmRecord:
self._not_deleting(vm) self._not_deleting(vm)
if vm.ports is None: if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start") raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
@@ -172,22 +170,18 @@ class LifecycleService:
if observation == "unknown": if observation == "unknown":
raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying") raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying")
if observation == "alive": if observation == "alive":
if vm.pending_resize is not None:
raise ConflictError(f"resize is pending for {vm.id}; retry resize or stop it before starting")
if vm.status != "running" or vm.operation not in {None, "policy"}: if vm.status != "running" or vm.operation not in {None, "policy"}:
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting") raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
return self._sync_ports_locked(vm) return self._sync_ports_locked(vm)
if not vm.disk_ready: self._validate_resume(vm)
raise ConflictError(f"{vm.id} has no completely provisioned private disk") if kernel is None:
disk = Path(vm.disk)
if not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
self._check_boot() self._check_boot()
kernel = self._images.installed_kernel() kernel = self._images.installed_kernel()
try: try:
self._cleanup_runtime(vm) self._cleanup_runtime(vm)
self._apply_pending_resize(vm)
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
self._boot(vm, kernel) self._boot(vm, kernel)
return vm return vm
@@ -209,12 +203,13 @@ class LifecycleService:
def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord: def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
deleting = vm.operation == "delete" or vm.status == "terminating" deleting = vm.operation == "delete" or vm.status == "terminating"
vm.status, vm.operation = "stopping", "delete" if deleting else "stop" pending = "delete" if deleting else "resize" if vm.pending_resize is not None else None
vm.status, vm.operation = "stopping", pending or "stop"
self._save(vm) self._save(vm)
try: try:
self._cleanup_runtime(vm, timeout=timeout) self._cleanup_runtime(vm, timeout=timeout)
vm.status = "stopped" vm.status = "stopped"
vm.operation = "delete" if deleting else None vm.operation = pending
vm.last_error = None vm.last_error = None
self._save(vm) self._save(vm)
return vm return vm
@@ -232,6 +227,69 @@ class LifecycleService:
self._stop_locked(vm, timeout=timeout) self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm) return self._start_locked(vm)
def resize(
self, identifier: str, *, cpu: float | None = None, ram_mib: int | None = None,
storage_mib: int | None = None, timeout: float | None = None,
) -> VmRecord:
"""Stop, apply resource changes to the retained disk, then boot under one lock."""
require_root()
if cpu is None and ram_mib is None and storage_mib is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
VmResources(cpu if cpu is not None else 1, ram_mib if ram_mib is not None else 128,
storage_mib if storage_mib is not None else MIN_STORAGE_MIB)
if timeout is not None:
timeout = parse_timeout(str(timeout))
with self._state_store.operation_lock():
vm = self._load_for_operation(identifier)
self._not_deleting(vm)
self._validate_resume(vm)
if self._process.observe(vm) == "unknown":
raise ConflictError(f"cannot resize {vm.id}: process identity is unknown")
# Verify boot prerequisites before stopping an otherwise healthy VM.
self._check_boot()
kernel = self._images.installed_kernel()
previous = vm.pending_resize
capacity = previous.storage_mib if previous is not None else vm.storage_mib
if capacity is None:
capacity = max(MIN_STORAGE_MIB, (Path(vm.disk).stat().st_size + 1024 * 1024 - 1) // (1024 * 1024))
vm.pending_resize = VmResources(
cpu=cpu if cpu is not None else previous.cpu if previous is not None else vm.cpu,
ram_mib=ram_mib if ram_mib is not None else previous.ram_mib if previous is not None else vm.ram_mib,
storage_mib=storage_mib if storage_mib is not None else capacity,
)
vm.operation = "resize"
self._save(vm)
try:
self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm, kernel=kernel)
except UvmError as error:
raise UvmError(f"failed to resize {vm.id}: {error}") from error
def _validate_resume(self, vm: VmRecord) -> None:
if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
if not vm.disk_ready:
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
disk = Path(vm.disk)
if not vm.disk or not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
def _apply_pending_resize(self, vm: VmRecord) -> None:
target = vm.pending_resize
if target is None:
return
if self._process.observe(vm) != "gone":
raise ConflictError("cannot resize a disk without confirmed process exit")
if target.storage_mib != vm.storage_mib:
self._images.resize_vm_disk(Path(vm.disk), target.storage_mib)
vm.cpu, vm.ram_mib, vm.storage_mib = target.cpu, target.ram_mib, target.storage_mib
vm.pending_resize = None
self._save(vm)
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord: def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
require_root() require_root()
with self._state_store.operation_lock(): with self._state_store.operation_lock():
@@ -472,7 +530,8 @@ class LifecycleService:
results.append({"name": name, "ok": True, "message": "available"}) results.append({"name": name, "ok": True, "message": "available"})
except (UvmError, OSError) as error: except (UvmError, OSError) as error:
results.append({"name": name, "ok": False, "message": str(error)}) results.append({"name": name, "ok": False, "message": str(error)})
for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "openssl", "ssh-keygen", "ssh"): for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "e2fsck", "resize2fs", "dumpe2fs",
"cp", "openssl", "ssh-keygen", "ssh"):
path = shutil.which(command) path = shutil.which(command)
results.append({"name": command, "ok": path is not None, "message": path or "not installed"}) results.append({"name": command, "ok": path is not None, "message": path or "not installed"})
return results return results
@@ -529,7 +588,7 @@ class LifecycleService:
state.next_mac_index += 1 state.next_mac_index += 1
directory = self._settings.vm_dir(vm_id) directory = self._settings.vm_dir(vm_id)
vm = VmRecord( vm = VmRecord(
id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib, id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib, storage_mib=spec.storage_mib,
guest_ip=str(guest_ip), gateway=str(self._settings.gateway), guest_ip=str(guest_ip), gateway=str(self._settings.gateway),
network=str(self._settings.network), bridge=self._settings.bridge, network=str(self._settings.network), bridge=self._settings.bridge,
tap=self._network.tap_name(vm_id), mac=mac, tap=self._network.tap_name(vm_id), mac=mac,
+23
View File
@@ -13,16 +13,19 @@ from ..api_models import (
VmCreateRequest, VmCreateRequest,
VmLogResponse, VmLogResponse,
VmResponse, VmResponse,
VmResizeRequest,
vm_response, vm_response,
) )
from ..app import Application from ..app import Application
from ..domain import PortRule, VmRecord, VmSpec from ..domain import PortRule, VmRecord, VmSpec
from ..errors import ValidationError
from ..validation import ( from ..validation import (
parse_cpu, parse_cpu,
parse_name, parse_name,
parse_password, parse_password,
parse_ports, parse_ports,
parse_ram, parse_ram,
parse_storage,
parse_timeout, parse_timeout,
parse_username, parse_username,
) )
@@ -71,6 +74,7 @@ def create_vm(
VmSpec( VmSpec(
cpu=parse_cpu(str(request.cpu)), cpu=parse_cpu(str(request.cpu)),
ram_mib=parse_ram(str(request.ram)), ram_mib=parse_ram(str(request.ram)),
storage_mib=parse_storage(str(request.storage)),
guest_ip=request.guest_ip, guest_ip=request.guest_ip,
username=parse_username(request.username), username=parse_username(request.username),
password=parse_password(request.password.get_secret_value()), password=parse_password(request.password.get_secret_value()),
@@ -141,6 +145,25 @@ def stop_vm(
return vm_response(vm, policy_status=_mutation_policy_status(vm)) return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/resize", response_model=VmResponse)
def resize_vm(
vm_id: str,
request: VmResizeRequest,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
if request.cpu is None and request.ram is None and request.storage is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
vm = application.lifecycle.resize(
vm_id,
cpu=parse_cpu(str(request.cpu)) if request.cpu is not None else None,
ram_mib=parse_ram(str(request.ram)) if request.ram is not None else None,
storage_mib=parse_storage(str(request.storage)) if request.storage is not None else None,
timeout=parse_timeout(str(timeout)) if timeout is not None else None,
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.delete("/{vm_id}", response_model=DestroyResponse) @router.delete("/{vm_id}", response_model=DestroyResponse)
def destroy_vm( def destroy_vm(
vm_id: str, vm_id: str,
+17 -1
View File
@@ -5,13 +5,15 @@ from __future__ import annotations
import math import math
import re import re
from collections.abc import Sequence from collections.abc import Sequence
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation, ROUND_CEILING
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import ValidationError from .errors import ValidationError
from .domain import PortRule from .domain import PortRule
_RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE) _RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE)
_STORAGE_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMGT]i?B?)?\s*", re.IGNORECASE)
_USERNAME_PATTERN = re.compile(r"[a-z_][a-z0-9_-]{0,31}\$?") _USERNAME_PATTERN = re.compile(r"[a-z_][a-z0-9_-]{0,31}\$?")
_MIB_FACTORS = { _MIB_FACTORS = {
"b": Decimal(1) / Decimal(1024 * 1024), "b": Decimal(1) / Decimal(1024 * 1024),
@@ -62,6 +64,20 @@ def parse_cpu(value: str) -> float:
return cpu return cpu
def parse_storage(value: str) -> int:
"""Parse GiB-default storage, rounding capacity up to a whole MiB."""
match = _STORAGE_PATTERN.fullmatch(value)
if match is None:
raise ValidationError(f"invalid storage value: {value}; use e.g. 30G, 50G, or 1T")
factors = {**_MIB_FACTORS, **dict.fromkeys(("t", "tb", "ti", "tib"), Decimal(1024 * 1024))}
amount = Decimal(match.group(1)) * factors[(match.group(2) or "g").lower()]
if amount < MIN_STORAGE_MIB:
raise ValidationError("storage must be at least 30 GiB")
if amount > MAX_STORAGE_MIB:
raise ValidationError(f"storage must not exceed {MAX_STORAGE_MIB} MiB")
return int(amount.to_integral_value(rounding=ROUND_CEILING))
def parse_username(value: str) -> str: def parse_username(value: str) -> str:
"""Validate a conventional Linux account name.""" """Validate a conventional Linux account name."""