Dev #2
+21
-1
@@ -49,7 +49,7 @@ Requirements for real host operations:
|
||||
- Root privileges for installation and lifecycle mutations
|
||||
- `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`,
|
||||
`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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -563,6 +565,9 @@ exclusive create for its replacement. Foreign ownership is an error, not an
|
||||
adoption opportunity. Readback normalizes presentation differences but compares
|
||||
all effective objects, ordered rules, hooks, identity, and the full port set;
|
||||
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
|
||||
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
|
||||
release the address. Rule removal also requires an absent TAP and verified
|
||||
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
|
||||
|
||||
@@ -612,6 +619,15 @@ do not import its unimplemented topology checks into current documentation.
|
||||
`Installer` is the only owner of apt installation, Firecracker artifact
|
||||
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
|
||||
existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux
|
||||
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`
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
- KVM enabled and accessible at `/dev/kvm`
|
||||
- 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`,
|
||||
`e2fsprogs`, `util-linux`, `openssl`, and `openssh-client`
|
||||
- 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
|
||||
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
|
||||
|
||||
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
|
||||
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_GATEWAY` | Bridge/guest gateway; defaults to `10.42.0.1` |
|
||||
| `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` |
|
||||
| `UVM_KERNEL_URL` | Guest kernel download URL |
|
||||
| `UVM_ROOTFS_URL` | Guest rootfs download URL |
|
||||
| `UVM_KERNEL_URL` | Override the host-architecture default guest kernel URL |
|
||||
| `UVM_ROOTFS_URL` | Override the host-architecture default guest rootfs URL |
|
||||
| `UVM_FIRECRACKER_SHA256` | Trusted Firecracker release archive SHA-256 |
|
||||
| `UVM_KERNEL_SHA256` | Trusted guest kernel 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
|
||||
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
|
||||
namespaces**, which may require privileges and the installed network tools:
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ You need:
|
||||
- KVM enabled and readable/writable at `/dev/kvm`.
|
||||
- Linux >= 5.3 with native bridge conntrack and safe pidfd support.
|
||||
- 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.
|
||||
- `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`,
|
||||
`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
|
||||
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
|
||||
|
||||
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. |
|
||||
| `checksum is required` | Provide the requested `UVM_*_SHA256` variable. |
|
||||
| `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. |
|
||||
| `unsupported host architecture` | Use x86_64 defaults or provide compatible assets. |
|
||||
| `default guest assets support x86_64 only` | Configure both custom kernel and rootfs URLs for ARM. |
|
||||
| `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. |
|
||||
|
||||
## 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;
|
||||
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
|
||||
|
||||
```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_GATEWAY` | Guest gateway / bridge IP; default `10.42.0.1` |
|
||||
| `UVM_BRIDGE` | Linux bridge name; default `uvm0` |
|
||||
| `UVM_KERNEL_URL` | Custom kernel download URL |
|
||||
| `UVM_ROOTFS_URL` | Custom rootfs download URL |
|
||||
| `UVM_KERNEL_URL` | Override the host-architecture default kernel URL |
|
||||
| `UVM_ROOTFS_URL` | Override the host-architecture default rootfs URL |
|
||||
| `UVM_FIRECRACKER_SHA256` | Strict Firecracker archive checksum |
|
||||
| `UVM_KERNEL_SHA256` | Strict kernel checksum |
|
||||
| `UVM_ROOTFS_SHA256` | Strict rootfs checksum |
|
||||
|
||||
@@ -296,7 +296,7 @@ def _sandbox(parent_fd):
|
||||
print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True)
|
||||
|
||||
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")
|
||||
return set(re.findall(r"\bid=(\d+)", listing))
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -19,6 +19,33 @@ class RecordingFirecrackerClient(FirecrackerClient):
|
||||
|
||||
|
||||
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:
|
||||
vm = VmRecord(
|
||||
id="vm-test",
|
||||
|
||||
+89
-6
@@ -102,9 +102,9 @@ class FakeRunner:
|
||||
self.after_batch()
|
||||
elif args == ("conntrack", "--version"):
|
||||
stdout = "conntrack v1.4.8 (conntrack-tools)"
|
||||
elif args[:2] == ("conntrack", "--delete"):
|
||||
elif args[:2] == ("conntrack", "-D"):
|
||||
code = self.ct_delete_code
|
||||
elif args[:2] == ("conntrack", "--list"):
|
||||
elif args[:2] == ("conntrack", "-L"):
|
||||
stdout = self.ct_remaining
|
||||
else:
|
||||
raise AssertionError(f"unexpected command: {args}")
|
||||
@@ -376,6 +376,34 @@ class FirewallManagerTests(unittest.TestCase):
|
||||
exprs[:-1] = reversed(exprs[:-1])
|
||||
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):
|
||||
self.vm.ports = ()
|
||||
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.manager.cleanup_connections(self.vm)
|
||||
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),
|
||||
[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"):
|
||||
self.manager.cleanup_connections(self.vm)
|
||||
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):
|
||||
self.manager.cleanup_connections(self.vm)
|
||||
|
||||
@@ -455,7 +483,7 @@ class FirewallManagerTests(unittest.TestCase):
|
||||
expected = [("ip", "-j", "-d", "link", "show")]
|
||||
expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7")
|
||||
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.
|
||||
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):
|
||||
self.runner.calls.clear()
|
||||
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.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.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):
|
||||
# Literal RULESET from tests/shell/testcases/json/0006obj_comment_0.
|
||||
dumped = json.loads('''{"nftables": [
|
||||
@@ -706,6 +760,35 @@ class NftDumpFixtureTests(unittest.TestCase):
|
||||
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")
|
||||
class NftJsonParserTests(unittest.TestCase):
|
||||
"""Exercise the actual JSON parser, NOT semantic/kernel/packet validation.
|
||||
|
||||
@@ -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.
@@ -40,6 +40,7 @@ class VmCreateRequest(BaseModel):
|
||||
|
||||
cpu: float = Field(default=1, gt=0)
|
||||
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
|
||||
username: str = "root"
|
||||
password: SecretStr = SecretStr("root")
|
||||
@@ -48,11 +49,27 @@ class VmCreateRequest(BaseModel):
|
||||
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):
|
||||
id: str
|
||||
name: str | None
|
||||
cpu: float
|
||||
ram_mib: int
|
||||
storage_mib: int | None
|
||||
pending_resize: VmResourcesModel | None
|
||||
guest_ip: str
|
||||
gateway: str
|
||||
mac: str
|
||||
@@ -124,6 +141,8 @@ def vm_response(
|
||||
name=vm.name,
|
||||
cpu=vm.cpu,
|
||||
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,
|
||||
gateway=vm.gateway,
|
||||
mac=vm.mac,
|
||||
|
||||
+40
-2
@@ -22,6 +22,7 @@ from .validation import (
|
||||
parse_password,
|
||||
parse_ports,
|
||||
parse_ram,
|
||||
parse_storage,
|
||||
parse_timeout,
|
||||
parse_username,
|
||||
)
|
||||
@@ -97,6 +98,10 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
default=str(application.settings.default_ram_mib),
|
||||
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(
|
||||
"--guest-ip",
|
||||
"--host-ip",
|
||||
@@ -130,6 +135,14 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
)
|
||||
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.add_argument("--status", action="append", default=[], help="observed status (repeatable)")
|
||||
output = listing.add_mutually_exclusive_group()
|
||||
@@ -293,6 +306,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
VmSpec(
|
||||
cpu=parse_cpu(args.cpu),
|
||||
ram_mib=parse_ram(args.ram),
|
||||
storage_mib=parse_storage(args.storage),
|
||||
guest_ip=guest_ip,
|
||||
username=parse_username(args.username),
|
||||
password=parse_password(password),
|
||||
@@ -321,6 +335,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
print(f" Name: {vm.name}")
|
||||
print(f" IP: {vm.guest_ip}")
|
||||
print(f" RAM: {vm.ram_mib} MiB")
|
||||
print(f" Storage: {vm.storage_mib} MiB")
|
||||
print(f" CPU: {vm.cpu}")
|
||||
print(f" TAP: {vm.tap}")
|
||||
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}")
|
||||
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":
|
||||
vms = [
|
||||
listed for listed in application.lifecycle.list_vms()
|
||||
@@ -349,14 +385,14 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
print(
|
||||
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:
|
||||
vm = listed.vm
|
||||
pid = vm.pid if vm.pid is not None else ""
|
||||
print(
|
||||
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
|
||||
|
||||
@@ -543,6 +579,8 @@ def _vm_output(
|
||||
"name": vm.name,
|
||||
"cpu": vm.cpu,
|
||||
"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,
|
||||
"gateway": vm.gateway,
|
||||
"tap": vm.tap,
|
||||
|
||||
+23
-13
@@ -3,21 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import ConfigurationError
|
||||
|
||||
|
||||
DEFAULT_KERNEL_URL = (
|
||||
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
||||
"x86_64/kernels/vmlinux.bin"
|
||||
)
|
||||
DEFAULT_ROOTFS_URL = (
|
||||
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
||||
"x86_64/rootfs/bionic.rootfs.ext4"
|
||||
)
|
||||
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)
|
||||
@@ -29,12 +24,14 @@ class Settings:
|
||||
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 = DEFAULT_KERNEL_URL
|
||||
rootfs_url: str = DEFAULT_ROOTFS_URL
|
||||
kernel_url: str = ""
|
||||
rootfs_url: str = ""
|
||||
firecracker_sha256: str | None = None
|
||||
firecracker_binary_sha256: str | None = None
|
||||
kernel_sha256: str | None = None
|
||||
@@ -48,6 +45,19 @@ class Settings:
|
||||
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 not self.rootfs_url:
|
||||
object.__setattr__(self, "rootfs_url", f"{asset_base}/rootfs/bionic.rootfs.ext4")
|
||||
|
||||
if self.gateway not in self.network:
|
||||
raise ConfigurationError(
|
||||
f"gateway {self.gateway} is outside configured network {self.network}"
|
||||
@@ -124,8 +134,8 @@ class Settings:
|
||||
network=network,
|
||||
gateway=gateway,
|
||||
bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
|
||||
kernel_url=os.environ.get("UVM_KERNEL_URL", DEFAULT_KERNEL_URL),
|
||||
rootfs_url=os.environ.get("UVM_ROOTFS_URL", DEFAULT_ROOTFS_URL),
|
||||
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"
|
||||
|
||||
+49
-1
@@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any
|
||||
|
||||
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
|
||||
from .errors import StateError, ValidationError
|
||||
|
||||
|
||||
STATE_SCHEMA_VERSION = 2
|
||||
STATE_SCHEMA_VERSION = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -52,6 +54,40 @@ class VmSpec:
|
||||
password: str = field(default="root", repr=False)
|
||||
name: str | None = None
|
||||
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)
|
||||
@@ -87,12 +123,16 @@ class VmRecord:
|
||||
spawn_pending: bool = False
|
||||
network: str | None = None
|
||||
bridge: str | None = None
|
||||
storage_mib: int | None = None
|
||||
pending_resize: VmResources | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"cpu": self.cpu,
|
||||
"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,
|
||||
"gateway": self.gateway,
|
||||
"tap": self.tap,
|
||||
@@ -155,6 +195,12 @@ class VmRecord:
|
||||
if ports is not None and len({port.port for port in ports}) != len(ports):
|
||||
raise StateError("VM has duplicate TCP port rules")
|
||||
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"):
|
||||
if name in value and type(value[name]) is not bool:
|
||||
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"),
|
||||
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,
|
||||
storage_mib=storage_mib,
|
||||
pending_resize=VmResources.from_dict(pending_resize) if pending_resize is not None else None,
|
||||
)
|
||||
except (TypeError, ValueError) as 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.
@@ -44,6 +44,8 @@ def build_config(
|
||||
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
|
||||
)
|
||||
)
|
||||
if settings.architecture == "aarch64":
|
||||
boot_args = f"keep_bootcon {boot_args}"
|
||||
return {
|
||||
"boot-source": {
|
||||
"kernel_image_path": str(kernel),
|
||||
|
||||
+31
-19
@@ -161,8 +161,8 @@ class FirewallManager:
|
||||
address = str(address)
|
||||
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
|
||||
selector = ("--family", "ipv4", field, address)
|
||||
self._run(("conntrack", "--delete", *selector), check=False)
|
||||
remaining = self._run(("conntrack", "--list", *selector))
|
||||
self._run(("conntrack", "-D", *selector), check=False)
|
||||
remaining = self._run(("conntrack", "-L", *selector))
|
||||
if remaining.stdout.strip():
|
||||
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:
|
||||
"""Canonical nft JSON, retaining every effective object, rule and verdict.
|
||||
|
||||
Ignore only handles, empty set presentation and nft's redundant protocol
|
||||
dependencies. Rule order is significant; pure conjunctions and sets are not.
|
||||
Normalize handles, empty set presentation and implied protocol dependencies
|
||||
without losing address-family constraints. Rule order is significant;
|
||||
pure conjunctions and sets are not.
|
||||
Unknown expressions/properties remain different rather than being ignored.
|
||||
"""
|
||||
def canonical(value: Any) -> Any:
|
||||
@@ -442,6 +443,18 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
|
||||
return result
|
||||
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 = [], {}
|
||||
for item in objects:
|
||||
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)
|
||||
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)
|
||||
if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}):
|
||||
count -= 1
|
||||
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
|
||||
rules.setdefault(obj.get("chain", ""), []).append(obj)
|
||||
definitions.sort(key=lambda item: json.dumps(item, sort_keys=True))
|
||||
|
||||
+76
-6
@@ -2,14 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .errors import UvmError
|
||||
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
||||
from .errors import UvmError, ValidationError
|
||||
from .integrity import load_manifest, verify_file
|
||||
from .system import CommandRunner
|
||||
|
||||
@@ -88,23 +89,92 @@ class ImageStore:
|
||||
allow_unverified=self._settings.allow_unverified_downloads)
|
||||
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."""
|
||||
|
||||
self._validate_storage(storage_mib)
|
||||
if destination.exists():
|
||||
raise UvmError(f"VM disk already exists: {destination}")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(source, destination)
|
||||
self._copy_sparse(source, destination)
|
||||
destination.chmod(0o600)
|
||||
except OSError as error:
|
||||
self._resize_filesystem(destination, storage_mib)
|
||||
except BaseException as error:
|
||||
try:
|
||||
destination.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if isinstance(error, OSError):
|
||||
raise UvmError(f"could not create VM disk {destination}: {error}") from error
|
||||
raise
|
||||
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:
|
||||
"""Set an existing guest account password in an offline ext4 disk."""
|
||||
|
||||
|
||||
+3
-15
@@ -11,7 +11,7 @@ import urllib.request
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DEFAULT_KERNEL_URL, DEFAULT_ROOTFS_URL, Settings
|
||||
from .config import Settings
|
||||
from .errors import UvmError
|
||||
from .images import GuestAssets
|
||||
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)
|
||||
|
||||
def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]:
|
||||
self._validate_guest_asset_architecture()
|
||||
self._validate_integrity_policy()
|
||||
if self._settings.allow_unverified_downloads and self._emit is not None:
|
||||
self._emit(
|
||||
@@ -56,16 +55,6 @@ class Installer:
|
||||
self._write_integrity_manifest(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:
|
||||
require_checksum(
|
||||
self._settings.firecracker_sha256,
|
||||
@@ -112,6 +101,7 @@ class Installer:
|
||||
"iptables",
|
||||
"nftables",
|
||||
"conntrack",
|
||||
"coreutils",
|
||||
"e2fsprogs",
|
||||
"util-linux",
|
||||
"ca-certificates",
|
||||
@@ -122,9 +112,7 @@ class Installer:
|
||||
self._runner.run(("apt-get", "install", "-y", *packages))
|
||||
|
||||
def install_firecracker_binary(self, *, force: bool = False) -> Path:
|
||||
architecture = os.uname().machine
|
||||
if architecture not in ("x86_64", "aarch64"):
|
||||
raise UvmError(f"unsupported host architecture: {architecture}")
|
||||
architecture = self._settings.architecture
|
||||
|
||||
firecracker = self._settings.firecracker_binary
|
||||
jailer = self._settings.jailer_binary
|
||||
|
||||
+80
-21
@@ -11,8 +11,8 @@ from dataclasses import dataclass, replace
|
||||
from ipaddress import IPv4Network
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .domain import PortRule, VmRecord, VmSpec, new_vm_id
|
||||
from .config import MIN_STORAGE_MIB, Settings
|
||||
from .domain import PortRule, VmRecord, VmResources, VmSpec, new_vm_id
|
||||
from .errors import ConflictError, NotFoundError, UvmError, ValidationError
|
||||
from .firecracker.api import FirecrackerClient
|
||||
from .firecracker.config import build_config, write_config
|
||||
@@ -22,7 +22,7 @@ from .integrity import load_manifest, verify_file
|
||||
from .network import NetworkManager
|
||||
from .state import StateStore
|
||||
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)
|
||||
@@ -68,9 +68,7 @@ class LifecycleService:
|
||||
|
||||
def create(self, spec: VmSpec) -> VmRecord:
|
||||
require_root()
|
||||
parse_cpu(str(spec.cpu))
|
||||
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")
|
||||
VmResources(spec.cpu, spec.ram_mib, spec.storage_mib)
|
||||
parse_username(spec.username)
|
||||
parse_password(spec.password)
|
||||
if spec.name is not None:
|
||||
@@ -86,7 +84,7 @@ class LifecycleService:
|
||||
runtime.mkdir(mode=0o700, exist_ok=False)
|
||||
vm.runtime_owned = True
|
||||
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)
|
||||
vm.disk_ready = True
|
||||
self._save(vm)
|
||||
@@ -164,7 +162,7 @@ class LifecycleService:
|
||||
with self._state_store.operation_lock():
|
||||
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)
|
||||
if vm.ports is None:
|
||||
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
|
||||
@@ -172,22 +170,18 @@ class LifecycleService:
|
||||
if observation == "unknown":
|
||||
raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying")
|
||||
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"}:
|
||||
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
|
||||
return self._sync_ports_locked(vm)
|
||||
if not vm.disk_ready:
|
||||
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
|
||||
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._validate_resume(vm)
|
||||
if kernel is None:
|
||||
self._check_boot()
|
||||
kernel = self._images.installed_kernel()
|
||||
try:
|
||||
self._cleanup_runtime(vm)
|
||||
self._apply_pending_resize(vm)
|
||||
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
|
||||
self._boot(vm, kernel)
|
||||
return vm
|
||||
@@ -209,12 +203,13 @@ class LifecycleService:
|
||||
|
||||
def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
|
||||
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)
|
||||
try:
|
||||
self._cleanup_runtime(vm, timeout=timeout)
|
||||
vm.status = "stopped"
|
||||
vm.operation = "delete" if deleting else None
|
||||
vm.operation = pending
|
||||
vm.last_error = None
|
||||
self._save(vm)
|
||||
return vm
|
||||
@@ -232,6 +227,69 @@ class LifecycleService:
|
||||
self._stop_locked(vm, timeout=timeout)
|
||||
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:
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
@@ -472,7 +530,8 @@ class LifecycleService:
|
||||
results.append({"name": name, "ok": True, "message": "available"})
|
||||
except (UvmError, OSError) as 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)
|
||||
results.append({"name": command, "ok": path is not None, "message": path or "not installed"})
|
||||
return results
|
||||
@@ -529,7 +588,7 @@ class LifecycleService:
|
||||
state.next_mac_index += 1
|
||||
directory = self._settings.vm_dir(vm_id)
|
||||
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),
|
||||
network=str(self._settings.network), bridge=self._settings.bridge,
|
||||
tap=self._network.tap_name(vm_id), mac=mac,
|
||||
|
||||
@@ -13,16 +13,19 @@ from ..api_models import (
|
||||
VmCreateRequest,
|
||||
VmLogResponse,
|
||||
VmResponse,
|
||||
VmResizeRequest,
|
||||
vm_response,
|
||||
)
|
||||
from ..app import Application
|
||||
from ..domain import PortRule, VmRecord, VmSpec
|
||||
from ..errors import ValidationError
|
||||
from ..validation import (
|
||||
parse_cpu,
|
||||
parse_name,
|
||||
parse_password,
|
||||
parse_ports,
|
||||
parse_ram,
|
||||
parse_storage,
|
||||
parse_timeout,
|
||||
parse_username,
|
||||
)
|
||||
@@ -71,6 +74,7 @@ def create_vm(
|
||||
VmSpec(
|
||||
cpu=parse_cpu(str(request.cpu)),
|
||||
ram_mib=parse_ram(str(request.ram)),
|
||||
storage_mib=parse_storage(str(request.storage)),
|
||||
guest_ip=request.guest_ip,
|
||||
username=parse_username(request.username),
|
||||
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))
|
||||
|
||||
|
||||
@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)
|
||||
def destroy_vm(
|
||||
vm_id: str,
|
||||
|
||||
+17
-1
@@ -5,13 +5,15 @@ from __future__ import annotations
|
||||
import math
|
||||
import re
|
||||
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 .domain import PortRule
|
||||
|
||||
|
||||
_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}\$?")
|
||||
_MIB_FACTORS = {
|
||||
"b": Decimal(1) / Decimal(1024 * 1024),
|
||||
@@ -62,6 +64,20 @@ def parse_cpu(value: str) -> float:
|
||||
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:
|
||||
"""Validate a conventional Linux account name."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user