Dev #4
+168
-34
@@ -211,7 +211,8 @@ Default runtime storage is `/var/lib/uvm`:
|
|||||||
│ └── jailer
|
│ └── jailer
|
||||||
├── images/
|
├── images/
|
||||||
│ ├── vmlinux
|
│ ├── vmlinux
|
||||||
│ └── ubuntu.ext4
|
│ ├── ubuntu.ext4
|
||||||
|
│ └── ubuntu-build.log
|
||||||
├── vms/
|
├── vms/
|
||||||
│ └── vm-<uuid>/
|
│ └── vm-<uuid>/
|
||||||
│ ├── rootfs.ext4
|
│ ├── rootfs.ext4
|
||||||
@@ -335,7 +336,7 @@ a ruleset/attachment snapshot, not application health or a reachability test.
|
|||||||
3. Reserve ID/name/IP/MAC/ports with `status=starting`, `operation=preparing`.
|
3. Reserve ID/name/IP/MAC/ports with `status=starting`, `operation=preparing`.
|
||||||
4. Create the owned mode-0700 runtime directory, sparse-copy a mode-0600 private
|
4. Create the owned mode-0700 runtime directory, sparse-copy a mode-0600 private
|
||||||
disk, resize ext4 to the requested storage capacity, provision credentials/host
|
disk, resize ext4 to the requested storage capacity, provision credentials/host
|
||||||
keys, and persist `disk_ready=True`.
|
keys and the managed guest network profile, then persist `disk_ready=True`.
|
||||||
5. Ensure bridge/egress networking and prepare an owned, attached TAP **down**.
|
5. Ensure bridge/egress networking and prepare an owned, attached TAP **down**.
|
||||||
6. Apply/read back the nftables bridge policy, then activate the TAP.
|
6. Apply/read back the nftables bridge policy, then activate the TAP.
|
||||||
7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`,
|
7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`,
|
||||||
@@ -372,9 +373,19 @@ not needed. It never recopies the rootfs, reprovisions credentials, or
|
|||||||
regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes
|
regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes
|
||||||
survive stop/start; guest RAM does not.
|
survive stop/start; guest RAM does not.
|
||||||
|
|
||||||
Start on a healthy running VM synchronizes policy without spawning another
|
After runtime cleanup confirms process exit and any pending resize finishes,
|
||||||
VMM. An ambiguous process or interrupted live boot/stop is not adopted as a
|
start specializes an original UVM Ubuntu network profile with the retained
|
||||||
successful start. Failed resume retains the private disk for retry. Restart
|
guest address/gateway. This one-time migration repairs older guests that lost
|
||||||
|
their default route. It uses a checked sparse copy of the retained private
|
||||||
|
disk; it never replaces that disk with the shared template. Guest-edited
|
||||||
|
profiles are preserved. The image layer's network checks and writes run only
|
||||||
|
under the lifecycle operation lock, before the TAP and VMM are started.
|
||||||
|
|
||||||
|
Start on a healthy running VM validates its retained network, synchronizes
|
||||||
|
policy, then restores shared bridge/NAT/forwarding rules without spawning another
|
||||||
|
VMM. A shared-network setup failure retains the running VM for retry. An ambiguous
|
||||||
|
process or interrupted live boot/stop is not adopted as a successful start.
|
||||||
|
Failed resume retains the private disk for retry. Restart
|
||||||
performs stop and start under one operation lock, not two independent public
|
performs stop and start under one operation lock, not two independent public
|
||||||
calls with a race between them.
|
calls with a race between them.
|
||||||
|
|
||||||
@@ -487,10 +498,19 @@ The `firecracker/` package has three separate responsibilities.
|
|||||||
2. `PUT /boot-source`
|
2. `PUT /boot-source`
|
||||||
3. `PUT /drives/rootfs`
|
3. `PUT /drives/rootfs`
|
||||||
4. `PUT /network-interfaces/eth0`
|
4. `PUT /network-interfaces/eth0`
|
||||||
5. `PUT /actions` with `InstanceStart`
|
5. `PUT /entropy` with `{}` (a host-backed virtio RNG device)
|
||||||
|
6. `PUT /actions` with `InstanceStart`
|
||||||
|
|
||||||
|
Every generated config includes `entropy`, including retained-disk boots.
|
||||||
|
An empty object enables the device with default settings; do not skip it with
|
||||||
|
a truthiness check. A missing/invalid configuration or an entropy API failure
|
||||||
|
must prevent `InstanceStart`. Without guest entropy, systemd's random-seed
|
||||||
|
service and OpenSSH can remain in startup while the VM process and IP respond.
|
||||||
|
|
||||||
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. The Ubuntu
|
||||||
|
systemd-networkd profile also receives explicit `Address=` and `Gateway=`
|
||||||
|
settings so it can maintain the same configuration after boot or reconfiguration.
|
||||||
ARM64 guests also receive `keep_bootcon`, as recommended by Firecracker's
|
ARM64 guests also receive `keep_bootcon`, as recommended by Firecracker's
|
||||||
getting-started guide; both architectures use `console=ttyS0`.
|
getting-started guide; both architectures use `console=ttyS0`.
|
||||||
|
|
||||||
@@ -545,10 +565,41 @@ The new `/home/USERNAME` directory is mode `0700`, owned by the new UID/GID;
|
|||||||
Directory ownership/modes and account-file writes are read back before the
|
Directory ownership/modes and account-file writes are read back before the
|
||||||
disk can be marked ready. The default `root` account must already exist.
|
disk can be marked ready. The default `root` account must already exist.
|
||||||
|
|
||||||
|
Custom login users are VM administrators. Provisioning requires a root-owned,
|
||||||
|
setuid `/usr/bin/sudo` and `/etc/sudoers`, and appends a user-scoped `PASSWD: ALL`
|
||||||
|
rule with root/target/runas-password defaults disabled. Sudo authenticates with
|
||||||
|
the login user's password; root's separate shadow entry is not changed. Global
|
||||||
|
sshd directives precede managed and existing `Match` blocks so standard Ubuntu
|
||||||
|
`UsePAM`/`Subsystem` settings remain valid.
|
||||||
|
|
||||||
UVM replaces conventional RSA, ECDSA, and Ed25519 host keys in each private
|
UVM replaces conventional RSA, ECDSA, and Ed25519 host keys in each private
|
||||||
disk and removes the known public login key from Firecracker's bionic image.
|
disk and removes the known public login key from Firecracker's bionic image.
|
||||||
A VM must never rely on reusable SSH private keys from a shared template.
|
A VM must never rely on reusable SSH private keys from a shared template.
|
||||||
|
|
||||||
|
### Guest Network Profile
|
||||||
|
|
||||||
|
`GUEST_NETWORK_TEMPLATE` in `images.py` is shared with the Ubuntu builder. It
|
||||||
|
uses `KeepConfiguration=yes`: in Ubuntu 22.04's systemd-networkd, `static`
|
||||||
|
retains permanent addresses and `proto static` routes, but the kernel's
|
||||||
|
`ip=`-configured default route uses `proto boot` and is otherwise removed.
|
||||||
|
This can leave SSH to the private IP working while all off-subnet destinations
|
||||||
|
fail locally with `Network is unreachable`.
|
||||||
|
|
||||||
|
`ImageStore.configure_guest_network()` fills in the assigned address/prefix and
|
||||||
|
gateway during creation and stopped-disk resume. It only specializes the exact
|
||||||
|
shipped profile from recognized UVM Jammy v1/v2/v3 images. Missing, customized,
|
||||||
|
symlinked, hard-linked, or unexpectedly owned/mode-set profiles are left intact.
|
||||||
|
An already-specialized profile is a no-op. Custom images retain responsibility
|
||||||
|
for their own guest networking.
|
||||||
|
|
||||||
|
For migration, the image layer sparse-copies the private disk and runs
|
||||||
|
`e2fsck -f -p` on the copy. It rechecks the profile after journal replay so
|
||||||
|
committed guest edits cannot be overwritten based on stale offline reads.
|
||||||
|
The updated profile is read back and the filesystem checked before fsync and
|
||||||
|
atomic disk replacement. Failed staging keeps the original disk available for
|
||||||
|
retry. The built template's recipe marker is v3; cached older templates are
|
||||||
|
also handled by per-VM provisioning without requiring an installation refresh.
|
||||||
|
|
||||||
### Integrity Model
|
### Integrity Model
|
||||||
|
|
||||||
[`integrity.py`](uvm/integrity.py) has two concepts:
|
[`integrity.py`](uvm/integrity.py) has two concepts:
|
||||||
@@ -557,14 +608,19 @@ A VM must never rely on reusable SSH private keys from a shared template.
|
|||||||
- A local `integrity.json` manifest recording artifact hashes and whether the
|
- A local `integrity.json` manifest recording artifact hashes and whether the
|
||||||
installation was verified.
|
installation was verified.
|
||||||
|
|
||||||
Strict installation requires all of these values:
|
Strict installation requires these values for downloaded artifacts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
UVM_FIRECRACKER_SHA256
|
UVM_FIRECRACKER_SHA256
|
||||||
UVM_KERNEL_SHA256
|
UVM_KERNEL_SHA256
|
||||||
UVM_ROOTFS_SHA256
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The default rootfs is built from signature-verified Ubuntu packages and its
|
||||||
|
resulting digest is recorded in the same manifest. `UVM_ROOTFS_URL` explicitly
|
||||||
|
selects a prebuilt ext4 download and additionally requires `UVM_ROOTFS_SHA256`.
|
||||||
|
A rootfs checksum without a URL is rejected during installation to avoid using
|
||||||
|
a stale Bionic-image checksum for a local build.
|
||||||
|
|
||||||
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` is a local-development escape hatch. It
|
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` is a local-development escape hatch. It
|
||||||
writes a manifest marked `verified: false`; a later strict run will not treat
|
writes a manifest marked `verified: false`; a later strict run will not treat
|
||||||
that manifest as publisher trust. Use `install --force` with trusted digests to
|
that manifest as publisher trust. Use `install --force` with trusted digests to
|
||||||
@@ -592,7 +648,7 @@ Linux bridge: uvm0 (10.42.0.1/24 by default)
|
|||||||
|
|
|
|
||||||
iptables forwarding + MASQUERADE
|
iptables forwarding + MASQUERADE
|
||||||
|
|
|
|
||||||
host default uplink
|
host IPv4 route for the destination (current uplink)
|
||||||
|
|
|
|
||||||
internet
|
internet
|
||||||
```
|
```
|
||||||
@@ -607,7 +663,7 @@ internet
|
|||||||
- Bridge creation or validation that an existing interface is actually a
|
- Bridge creation or validation that an existing interface is actually a
|
||||||
Linux bridge.
|
Linux bridge.
|
||||||
- Gateway address setup and IPv4 forwarding.
|
- Gateway address setup and IPv4 forwarding.
|
||||||
- One egress NAT rule and forward/return rules for the default uplink.
|
- Egress NAT and forward/return rules scoped to the guest subnet/bridge.
|
||||||
- TAP ownership aliases (`uvm:<id>`), preparation while down, activation,
|
- TAP ownership aliases (`uvm:<id>`), preparation while down, activation,
|
||||||
deactivation, cleanup, and error reporting.
|
deactivation, cleanup, and error reporting.
|
||||||
|
|
||||||
@@ -616,6 +672,23 @@ it may belong to another VM. A create-time name collision is an error. Cleanup
|
|||||||
checks the TAP kind and ownership alias, with a narrow unaliased-TAP allowance
|
checks the TAP kind and ownership alias, with a narrow unaliased-TAP allowance
|
||||||
for legacy records; it does not treat an arbitrary interface name as ownership.
|
for legacy records; it does not treat an arbitrary interface name as ownership.
|
||||||
|
|
||||||
|
Internet sharing is configured even with no host default route. Rules never
|
||||||
|
pin an uplink interface: Linux routing selects it, and MASQUERADE uses that
|
||||||
|
interface's address. Offline-to-online transitions, reconnects and route changes
|
||||||
|
therefore work without another UVM command or a monitoring daemon. Existing
|
||||||
|
connections may need to reconnect after the upstream address changes. Guest
|
||||||
|
DNS in the Ubuntu template uses the public resolvers documented in README.
|
||||||
|
|
||||||
|
`uvm-egress`-commented iptables rules are checked before insertion, with a bounded
|
||||||
|
xtables-lock wait. Scoped exceptions are inserted at the start of the host chains
|
||||||
|
so pre-existing terminal drops cannot shadow them. Outbound forwarding requires
|
||||||
|
the UVM bridge and source subnet; routed replies require the destination bridge,
|
||||||
|
subnet and established/related conntrack state. Native bridge policy still checks
|
||||||
|
identity, service ports and permitted protocols. Outbound forwarding also covers
|
||||||
|
peer traffic if the host already uses `br_netfilter`, while MASQUERADE excludes
|
||||||
|
the UVM bridge to retain private peer source addresses. Global chain policies
|
||||||
|
and unrelated rules remain administrator-owned.
|
||||||
|
|
||||||
### Native Bridge Policy
|
### Native Bridge Policy
|
||||||
|
|
||||||
The supported path is a **software Linux bridge** with STP disabled. Each
|
The supported path is a **software Linux bridge** with STP disabled. Each
|
||||||
@@ -672,17 +745,19 @@ including on conntrack-tools 1.4.8, where `--list` is not a supported option.
|
|||||||
|
|
||||||
### Coexistence and Recovery
|
### Coexistence and Recovery
|
||||||
|
|
||||||
Existing `iptables` egress MASQUERADE/forwarding remains unchanged. The new
|
Shared `iptables` egress MASQUERADE/forwarding is separate from private bridge
|
||||||
policy adds no DNAT, public listener, or host-port mapping and does not replace
|
policy. The policy adds no DNAT, public listener, or host-port mapping and does
|
||||||
the host firewall manager. Public connectivity, DNS, guest reverse proxies/TLS,
|
not replace the host firewall manager. Public inbound connectivity, DNS
|
||||||
and integration with UFW/firewalld/Docker/libvirt remain out of scope. An
|
publication, guest reverse proxies/TLS, and integration with
|
||||||
|
UFW/firewalld/Docker/libvirt remain out of scope. An
|
||||||
administrator's external routing/NAT can still affect reachability; do not
|
administrator's external routing/NAT can still affect reachability; do not
|
||||||
infer source-CIDR restrictions or complete public-exposure protection from the
|
infer source-CIDR restrictions or complete public-exposure protection from the
|
||||||
use of private addresses. Operators must choose a non-conflicting topology.
|
use of private addresses. Operators must choose a non-conflicting topology.
|
||||||
|
|
||||||
If an external actor loses or changes UVM rules, recovery is explicit
|
If an external actor loses or changes private policy, recovery is explicit
|
||||||
`ports sync`, not a promised daemon repair. An `enforced` snapshot cannot
|
`ports sync`; `start` also restores shared networking for a running VM. An
|
||||||
prevent later root-level changes or prove that other rules allow traffic.
|
`enforced` snapshot cannot prevent later root-level changes or prove that other
|
||||||
|
rules allow traffic.
|
||||||
[FIREWALL.md](FIREWALL.md) retains the original proposal/backend evaluation;
|
[FIREWALL.md](FIREWALL.md) retains the original proposal/backend evaluation;
|
||||||
do not import its unimplemented topology checks into current documentation.
|
do not import its unimplemented topology checks into current documentation.
|
||||||
|
|
||||||
@@ -694,12 +769,49 @@ handling, downloads, and integrity-manifest publication.
|
|||||||
`Settings` detects and normalizes the host architecture at construction time
|
`Settings` detects and normalizes the host architecture at construction time
|
||||||
(`amd64` to `x86_64`, `arm64` to `aarch64`) and rejects unsupported architectures.
|
(`amd64` to `x86_64`, `arm64` to `aarch64`) and rejects unsupported architectures.
|
||||||
The same architecture selects the Firecracker release archive, guest kernel,
|
The same architecture selects the Firecracker release archive, guest kernel,
|
||||||
rootfs, and architecture-specific boot arguments. Default guest URLs use
|
native rootfs packages, and architecture-specific boot arguments. The default
|
||||||
Firecracker's `img/quickstart_guide/<architecture>/` assets: `kernels/vmlinux.bin`
|
kernel URL is pinned to Firecracker's published
|
||||||
and `rootfs/bionic.rootfs.ext4`. Kernel and rootfs URL overrides are independent;
|
`firecracker-ci/v1.15/<architecture>/vmlinux-6.1.155` artifacts. The CI artifact
|
||||||
custom assets and trusted digests must match the host architecture. ARM64
|
series is independent of the VMM release. Both architecture configs include
|
||||||
|
`CONFIG_HW_RANDOM=y` and `CONFIG_HW_RANDOM_VIRTIO=y`; custom kernels need these
|
||||||
|
built-in drivers for UVM's entropy device. The previous ARM quickstart image
|
||||||
|
was Linux 4.14.174 without the built-in virtio RNG driver. Updating the device
|
||||||
|
configuration alone cannot repair that kernel; existing installs need a forced
|
||||||
|
asset refresh with a matching new trusted kernel checksum, then a fresh VM boot.
|
||||||
|
An empty `Settings.rootfs_url` selects the built Ubuntu 22.04 (Jammy) template.
|
||||||
|
Explicit kernel/rootfs URLs and trusted digests must match the host architecture. ARM64
|
||||||
uses an uncompressed Linux `Image` (PE format) and an ARM64 ext4 rootfs.
|
uses an uncompressed Linux `Image` (PE format) and an ARM64 ext4 rootfs.
|
||||||
|
|
||||||
|
The default rootfs builder installs `debootstrap` and `ubuntu-keyring`, uses
|
||||||
|
`--force-check-gpg`, and bootstraps only the base release's main component with
|
||||||
|
CA certificates and the Ubuntu keyring. Do not pass updates/security as
|
||||||
|
`--extra-suites`: debootstrap can download base archives but import newer
|
||||||
|
pre-dependency metadata, producing an empty `dpkg --install` invocation.
|
||||||
|
After bootstrap, apt sees main/universe plus updates/security, runs update
|
||||||
|
with `APT::Update::Error-Mode=any`, upgrades the base, and installs guest packages.
|
||||||
|
amd64 uses `archive.ubuntu.com/ubuntu`; arm64 uses `ports.ubuntu.com/ubuntu-ports`.
|
||||||
|
Both phases execute in private mount/PID namespaces. The apt phase has private
|
||||||
|
dev/proc and read-only sys mounts, and recreates `policy-rc.d` to suppress service
|
||||||
|
startup after Ubuntu's debootstrap script removes it. Cleanup tolerates an
|
||||||
|
already-removed policy file. No mounts survive the build process.
|
||||||
|
The staged tree retains `/var/lib/dpkg` and apt metadata, validates installed
|
||||||
|
apt/sudo/sshd/systemd packages, enables SSH/networkd, preserves kernel-provided
|
||||||
|
static networking, and supplies guest DNS. Template host keys and machine IDs
|
||||||
|
are cleared for per-VM initialization. `mkfs.ext4 -d` creates a sparse 2 GiB
|
||||||
|
template with a guest-kernel-compatible feature set; `e2fsck` and fsync precede
|
||||||
|
atomic publication. Create still grows each private disk to at least 30 GiB.
|
||||||
|
|
||||||
|
Only a checksum-verified manifest and matching `/etc/uvm-image` recipe marker
|
||||||
|
permit reuse of a built template. Old demo images and unverified caches are
|
||||||
|
rebuilt, and `install --force` always rebuilds. A failed staging build retains
|
||||||
|
the old template. Installation holds the existing operation lock throughout;
|
||||||
|
existing VM disks are never upgraded or reprovisioned by installation.
|
||||||
|
Stage command output and debootstrap's detailed log are atomically saved to
|
||||||
|
`images/ubuntu-build.log` (mode `0600`) on success or failure before deleting
|
||||||
|
staging files. Errors name that durable log; the last lines contain the latest
|
||||||
|
stage's diagnostics and failure summary. The recipe marker is bumped whenever
|
||||||
|
the build workflow changes so an earlier cached template cannot skip it.
|
||||||
|
|
||||||
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
|
||||||
@@ -744,6 +856,7 @@ or secret manager when that matters.
|
|||||||
| `GET` | `/` | `server.py` | Redirect to `/dashboard` |
|
| `GET` | `/` | `server.py` | Redirect to `/dashboard` |
|
||||||
| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell |
|
| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell |
|
||||||
| `GET` | `/health` | `health.py` | Unauthenticated liveness response |
|
| `GET` | `/health` | `health.py` | Unauthenticated liveness response |
|
||||||
|
| `POST` | `/exit` | `exit.py` | Request graceful Uvicorn shutdown |
|
||||||
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
|
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
|
||||||
| `POST` | `/install` | `installation.py` | `{ "force": false }` body |
|
| `POST` | `/install` | `installation.py` | `{ "force": false }` body |
|
||||||
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
|
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
|
||||||
@@ -765,6 +878,12 @@ All management routes use `get_authorized_application()` and require:
|
|||||||
X-UVM-Token: <UVM_API_TOKEN>
|
X-UVM-Token: <UVM_API_TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`POST /exit` is a management route. `run_server()` creates the Uvicorn server,
|
||||||
|
stores it on the FastAPI application state, and runs it in-process. The route
|
||||||
|
returns `202` before a background task sets `should_exit`, allowing Uvicorn to
|
||||||
|
deliver the response and perform its normal graceful shutdown. It does not
|
||||||
|
stop or delete any managed VM.
|
||||||
|
|
||||||
Request schemas reject unexpected fields. `UvmError` instances are rendered as
|
Request schemas reject unexpected fields. `UvmError` instances are rendered as
|
||||||
JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal
|
JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal
|
||||||
422 response format. Create accepts `name: str | null`, `ports: list | null`,
|
422 response format. Create accepts `name: str | null`, `ports: list | null`,
|
||||||
@@ -892,8 +1011,18 @@ sparse ext4 images when the tools are installed: default 30 GiB creation,
|
|||||||
growth, shrinking, geometry, and retained data/password-hash/host-key fixtures.
|
growth, shrinking, geometry, and retained data/password-hash/host-key fixtures.
|
||||||
`test_images.py` uses temporary ext4 images to verify custom-user creation,
|
`test_images.py` uses temporary ext4 images to verify custom-user creation,
|
||||||
UID/GID allocation, account/home metadata, existing accounts, and password hashes.
|
UID/GID allocation, account/home metadata, existing accounts, and password hashes.
|
||||||
|
`test_install.py` simulates package bootstrap and exercises real ext4 formatting
|
||||||
|
and readback for retained dpkg metadata, sudo modes, template configuration,
|
||||||
|
staging failures, durable build diagnostics, and checksum provenance. The apt
|
||||||
|
shell script runs against stub mount/chroot commands to check argument handling
|
||||||
|
and stop-on-error behavior. Tests simulate Ubuntu removing `policy-rc.d` after
|
||||||
|
bootstrap. They do not run debootstrap or boot a VM.
|
||||||
Lifecycle resize tests use fake processes/networking to verify ordering,
|
Lifecycle resize tests use fake processes/networking to verify ordering,
|
||||||
single-lock ownership, identity preservation, pending intent, and failure recovery.
|
single-lock ownership, identity preservation, pending intent, and failure recovery.
|
||||||
|
`test_guest_network.py` exercises real offline ext4 profiles, explicit guest
|
||||||
|
addresses/gateways, custom subnets, idempotent migration, journal-replay
|
||||||
|
rechecks, and retention of guest data, credentials and keys on failures.
|
||||||
|
Lifecycle tests require confirmed process exit before network-profile repair.
|
||||||
|
|
||||||
### Isolated Packet Tests
|
### Isolated Packet Tests
|
||||||
|
|
||||||
@@ -901,17 +1030,18 @@ The opt-in packet harness must run **only in disposable isolated user/network
|
|||||||
namespaces**. It verifies namespace separation before creating links or
|
namespaces**. It verifies namespace separation before creating links or
|
||||||
changing rules, and never falls back to the parent host namespace. It may
|
changing rules, and never falls back to the parent host namespace. It may
|
||||||
require privileges (or enabled unprivileged user namespaces) plus `unshare`,
|
require privileges (or enabled unprivileged user namespaces) plus `unshare`,
|
||||||
`ip`, `bridge`, `nft`, `conntrack`, and `sysctl`:
|
`ip`, `bridge`, `nft`, `iptables`, `conntrack`, and `sysctl`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
|
UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
|
||||||
```
|
```
|
||||||
|
|
||||||
The harness uses bridge-connected veths in place of guest TAPs and routed
|
The harness uses bridge-connected veths in place of guest TAPs and two simulated
|
||||||
egress without NAT. It exercises host/peer service traffic, both-direction
|
uplinks. It exercises NAT with a pre-existing terminal FORWARD drop, offline
|
||||||
close of existing streams while preserving conntrack/egress, IPv6/spoof
|
startup, uplink changes and reconnects for host/guest TCP and guest UDP/53.
|
||||||
blocking, quarantine, and offline cleanup. It does not boot a microVM or
|
It also checks host/peer service traffic, both-direction close of existing
|
||||||
validate real TAP/NAT deployment.
|
streams while preserving conntrack/egress, IPv6/spoof blocking, quarantine,
|
||||||
|
and offline cleanup. It does not boot a microVM or validate a real TAP deployment.
|
||||||
|
|
||||||
The packet test skipped in this workspace because namespace creation was
|
The packet test skipped in this workspace because namespace creation was
|
||||||
denied (`Operation not permitted`). Document that permission limitation, not
|
denied (`Operation not permitted`). Document that permission limitation, not
|
||||||
@@ -925,8 +1055,10 @@ on an isolated Linux host:
|
|||||||
|
|
||||||
1. Strict checked installation.
|
1. Strict checked installation.
|
||||||
2. `create` and successful guest boot.
|
2. `create` and successful guest boot.
|
||||||
3. Guest IP reachability and egress.
|
3. Guest IP reachability and egress, including the guest's default route after
|
||||||
4. SSH with unique guest host keys.
|
systemd-networkd startup and reconfiguration.
|
||||||
|
4. SSH with unique guest host keys, `sudo -i` for a custom login, and
|
||||||
|
`sudo apt update && sudo apt install htop` in a newly built Ubuntu guest.
|
||||||
5. Stop/start/restart with retained guest data, SSH keys, and credentials.
|
5. Stop/start/restart with retained guest data, SSH keys, and credentials.
|
||||||
6. Live private-port close in both directions, empty policy, and explicit sync
|
6. Live private-port close in both directions, empty policy, and explicit sync
|
||||||
after deliberately lost rules inside the isolated setup.
|
after deliberately lost rules inside the isolated setup.
|
||||||
@@ -961,7 +1093,8 @@ on an isolated Linux host:
|
|||||||
| Firecracker exits early | VM `firecracker.log`, VM config, `/dev/kvm` access |
|
| Firecracker exits early | VM `firecracker.log`, VM config, `/dev/kvm` access |
|
||||||
| VM is `dead` in `list` | PID/start token in state, `/proc/<pid>`, VM log |
|
| VM is `dead` in `list` | PID/start token in state, `/proc/<pid>`, VM log |
|
||||||
| Cannot stop legacy VM | Missing start/boot identity or failed protected binary/socket match; never guess a PID |
|
| Cannot stop legacy VM | Missing start/boot identity or failed protected binary/socket match; never guess a PID |
|
||||||
| Guest has no egress | `ip route show default`, bridge address, TAP state, iptables rules |
|
| Guest has no egress | Host connectivity/routes, bridge/TAP state, `uvm-egress` iptables rules; `start` refreshes networking on a running VM |
|
||||||
|
| Guest says `Network is unreachable` | Guest `ip -4 route`; the default route must use the UVM bridge gateway. Restart once with updated code to migrate an original Ubuntu profile. |
|
||||||
| Policy missing/drift/unknown | Actual nft JSON and ownership, STP/group mask, permissions; explicit sync after fixing cause |
|
| Policy missing/drift/unknown | Actual nft JSON and ownership, STP/group mask, permissions; explicit sync after fixing cause |
|
||||||
| Retained-disk start fails | `disk_ready`, safe private path, kernel/VMM integrity, process identity, retained network, pending operation |
|
| Retained-disk start fails | `disk_ready`, safe private path, kernel/VMM integrity, process identity, retained network, pending operation |
|
||||||
| API server refuses to start | `UVM_API_TOKEN`, requested host, TLS cert/key files |
|
| API server refuses to start | `UVM_API_TOKEN`, requested host, TLS cert/key files |
|
||||||
@@ -985,9 +1118,10 @@ The code is intentionally honest about current limits:
|
|||||||
- Guest health is inferred from VMM process state, not application readiness.
|
- Guest health is inferred from VMM process state, not application readiness.
|
||||||
- No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy.
|
- No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy.
|
||||||
- No guest snapshots, extra disks, production image builder, or SSH-key injection.
|
- No guest snapshots, extra disks, production image builder, or SSH-key injection.
|
||||||
- Per-VM private TCP ingress policy exists, but public connectivity, DNS,
|
- Per-VM private TCP ingress policy exists, but public inbound connectivity,
|
||||||
guest reverse proxy/TLS, and external firewall integration remain out of scope.
|
DNS publication, guest reverse proxy/TLS, and external firewall integration
|
||||||
- Existing egress `iptables` rules are incremental and unchanged; only the
|
remain out of scope.
|
||||||
|
- Shared egress `iptables` rules are incremental and idempotent; only the
|
||||||
private bridge policy is atomically rendered per VM.
|
private bridge policy is atomically rendered per VM.
|
||||||
- No autostart, background reconciliation, or guest-coordinated shutdown.
|
- No autostart, background reconciliation, or guest-coordinated shutdown.
|
||||||
- The JSON inventory is suitable for one host, not distributed coordination.
|
- The JSON inventory is suitable for one host, not distributed coordination.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
`uvm` is a small, local Firecracker microVM command-line manager for Ubuntu.
|
`uvm` is a small, local Firecracker microVM command-line manager for Ubuntu.
|
||||||
It creates one Firecracker process per VM, attaches it to a host TAP device,
|
It creates one Firecracker process per VM, attaches it to a host TAP device,
|
||||||
and keeps a private disk and local registry for named VMs. The CLI supports
|
and keeps a private disk and local registry for named VMs. The CLI supports
|
||||||
create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics, and
|
create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics,
|
||||||
enforced private-IPv4 TCP port allowlists.
|
host-shared IPv4 internet access, and enforced private-IPv4 TCP port allowlists.
|
||||||
|
|
||||||
This directory is a self-contained project. All UVM code, tests, packaging
|
This directory is a self-contained project. All UVM code, tests, packaging
|
||||||
metadata, launcher scripts, and documentation live here.
|
metadata, launcher scripts, and documentation live here.
|
||||||
@@ -48,12 +48,25 @@ 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
|
Firecracker and the guest kernel are selected automatically for the host
|
||||||
for the host architecture. On NVIDIA DGX Spark, UVM uses the ARM64 assets.
|
architecture. The default rootfs is built locally from Ubuntu 22.04 (Jammy)
|
||||||
|
packages with OpenSSH, sudo, and a complete apt/dpkg database. On NVIDIA DGX
|
||||||
|
Spark, UVM uses ARM64 assets and packages.
|
||||||
`arm64` and `amd64` architecture names are normalized to `aarch64` and
|
`arm64` and `amd64` architecture names are normalized to `aarch64` and
|
||||||
`x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides
|
`x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides
|
||||||
must point to assets compatible with the host architecture.
|
must point to assets compatible with the host architecture.
|
||||||
|
|
||||||
|
The default guest kernel is Linux 6.1.155 from Firecracker's published `v1.15`
|
||||||
|
CI artifacts, with built-in `virtio-rng` support. UVM attaches a host-backed
|
||||||
|
entropy device before each guest boot so services such as SSH can initialize
|
||||||
|
their cryptographic random generator. Custom kernels must include
|
||||||
|
`CONFIG_HW_RANDOM=y` and `CONFIG_HW_RANDOM_VIRTIO=y` for this device.
|
||||||
|
|
||||||
|
Older installs used a Linux 4.14 ARM demo kernel and supplied no entropy
|
||||||
|
device. If SSH stays at **Starting OpenBSD Secure Shell server** while ping
|
||||||
|
works, see [SSH startup and guest entropy](USAGE.md#ssh-startup-and-guest-entropy)
|
||||||
|
for the kernel-refresh and retained-VM recovery steps.
|
||||||
|
|
||||||
## 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
|
||||||
@@ -64,21 +77,42 @@ the privileged installer:
|
|||||||
sudo env \
|
sudo env \
|
||||||
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
||||||
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
||||||
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
|
|
||||||
./uvm.py install
|
./uvm.py install
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--force` to redownload Firecracker and guest assets when replacing an
|
The Ubuntu build uses `debootstrap`, authenticated Ubuntu archive signatures,
|
||||||
image or refreshing artifacts:
|
and native packages. It bootstraps the base release first, then uses apt to
|
||||||
|
apply updates/security fixes and install the guest packages. UVM installs the
|
||||||
|
builder prerequisites and records the built ext4 image's SHA-256 in the
|
||||||
|
integrity manifest. Building requires root
|
||||||
|
with mount/PID namespace support and internet access to Ubuntu mirrors.
|
||||||
|
|
||||||
|
Use `--force` to refresh downloads and rebuild the Ubuntu template:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo env \
|
sudo env \
|
||||||
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
||||||
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
||||||
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
|
|
||||||
./uvm.py install --force
|
./uvm.py install --force
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To use a prebuilt ext4 image instead, set both `UVM_ROOTFS_URL` and its trusted
|
||||||
|
`UVM_ROOTFS_SHA256`. Leave both unset for the default Ubuntu build.
|
||||||
|
|
||||||
|
### Upgrade from the Stripped Bionic Demo Image
|
||||||
|
|
||||||
|
Run `install --force` with the updated code and with `UVM_ROOTFS_URL` and
|
||||||
|
`UVM_ROOTFS_SHA256` unset, then create a new VM. The replacement template
|
||||||
|
includes sudo and the package database that the old demo image lacked.
|
||||||
|
Existing VM disks retain their original OS and credentials; installation
|
||||||
|
updates the template used for future VMs. Restart a running API server to load
|
||||||
|
the updated provisioner before creating VMs through the dashboard.
|
||||||
|
|
||||||
|
If the build fails, its detailed bootstrap/package output is retained in
|
||||||
|
`/var/lib/uvm/images/ubuntu-build.log` (mode `0600`). Correct the build failure
|
||||||
|
and rerun installation before creating a custom-user VM; a failed build leaves
|
||||||
|
the previous template installed.
|
||||||
|
|
||||||
For local experimentation only, the old unverified quick-start behavior is
|
For local experimentation only, the old unverified quick-start behavior is
|
||||||
available through an explicit opt-out:
|
available through an explicit opt-out:
|
||||||
|
|
||||||
@@ -232,8 +266,8 @@ well as new connections. This is packet dropping, not a guaranteed TCP RST or
|
|||||||
immediate application disconnect.
|
immediate application disconnect.
|
||||||
|
|
||||||
These rules govern access to the **guest private IP**. They do not start a
|
These rules govern access to the **guest private IP**. They do not start a
|
||||||
guest service or provide public connectivity, host-port mappings, DNS,
|
guest service or provide public inbound connectivity, host-port mappings,
|
||||||
reverse proxies, or guest-service TLS. UDP and IPv6 service policies are not
|
DNS publication, reverse proxies, or guest-service TLS. UDP and IPv6 service policies are not
|
||||||
yet supported. Guest-initiated IPv4 TCP/UDP egress, its replies, and required
|
yet supported. Guest-initiated IPv4 TCP/UDP egress, its replies, and required
|
||||||
ARP/ICMP traffic remain allowed; managed TAPs block IPv6.
|
ARP/ICMP traffic remain allowed; managed TAPs block IPv6.
|
||||||
|
|
||||||
@@ -280,6 +314,7 @@ The server exposes these routes:
|
|||||||
| `GET` | `/` | Redirect to the local dashboard |
|
| `GET` | `/` | Redirect to the local dashboard |
|
||||||
| `GET` | `/dashboard` | Serve the local management dashboard |
|
| `GET` | `/dashboard` | Serve the local management dashboard |
|
||||||
| `GET` | `/health` | Unauthenticated liveness response |
|
| `GET` | `/health` | Unauthenticated liveness response |
|
||||||
|
| `POST` | `/exit` | Gracefully stop the UVM API server |
|
||||||
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
|
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
|
||||||
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
||||||
| `GET` | `/vms` | List VM records and observed status |
|
| `GET` | `/vms` | List VM records and observed status |
|
||||||
@@ -341,6 +376,18 @@ curl \
|
|||||||
http://127.0.0.1:8000/vms
|
http://127.0.0.1:8000/vms
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Stop the API server without stopping its managed VMs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
http://127.0.0.1:8000/exit
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint returns `202 {"status":"stopping"}` and then gracefully terminates
|
||||||
|
the UVM API process. It is a protected management endpoint; it does not stop or
|
||||||
|
delete managed VMs.
|
||||||
|
|
||||||
Do not expose this server directly to an untrusted network. For remote access,
|
Do not expose this server directly to an untrusted network. For remote access,
|
||||||
keep TLS enabled and consider placing it behind an additional authenticated
|
keep TLS enabled and consider placing it behind an additional authenticated
|
||||||
reverse proxy.
|
reverse proxy.
|
||||||
@@ -360,13 +407,41 @@ defaults are username `root` and password `root`. A custom image must provide:
|
|||||||
- `sshd`
|
- `sshd`
|
||||||
- `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config`
|
- `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config`
|
||||||
- `/etc/group` and `/bin/sh` when creating a new user
|
- `/etc/group` and `/bin/sh` when creating a new user
|
||||||
|
- A root-owned, setuid `/usr/bin/sudo` and `/etc/sudoers` for custom login users
|
||||||
|
|
||||||
|
Custom login users receive password-authenticated sudo access. Inside a new VM:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -i # Enter the same password used for this user's SSH login.
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install htop
|
||||||
|
```
|
||||||
|
|
||||||
|
`su -` asks for root's separate password; creating a custom login does not set
|
||||||
|
root's password. Use `sudo -i` for an administrative shell. The default Ubuntu
|
||||||
|
template retains apt's package metadata and enables `main` and `universe`,
|
||||||
|
including updates/security repositories. Guest DNS defaults to `1.1.1.1` and
|
||||||
|
`8.8.8.8` in `/etc/resolv.conf`.
|
||||||
|
|
||||||
|
UVM specializes the Ubuntu guest's `/etc/systemd/network/10-uvm.network` with
|
||||||
|
its assigned `Address=` and `Gateway=` before boot. `KeepConfiguration=yes`
|
||||||
|
also retains the kernel-created default route while systemd-networkd starts.
|
||||||
|
Older templates used `KeepConfiguration=static`, which could retain the guest
|
||||||
|
IP but remove the default route, producing `ping: connect: Network is unreachable`.
|
||||||
|
|
||||||
|
The updated CLI repairs an older VM's original, unedited UVM profile on its
|
||||||
|
next stopped-disk boot, including `restart`. The repair is prepared and checked
|
||||||
|
on a sparse copy before publication; guest-edited profiles and custom images
|
||||||
|
are preserved. New VMs also receive the fix when using a cached older Ubuntu
|
||||||
|
template. See [guest route recovery](USAGE.md#guest-reports-network-is-unreachable)
|
||||||
|
for immediate recovery inside a running guest.
|
||||||
|
|
||||||
Existing accounts retain their UID, groups, home directory, and shell. A new
|
Existing accounts retain their UID, groups, home directory, and shell. A new
|
||||||
user reuses a same-named group if present; otherwise UVM creates one with an
|
user reuses a same-named group if present; otherwise UVM creates one with an
|
||||||
unused GID. `/etc/gshadow` is updated when present. The default `root` account
|
unused GID. `/etc/gshadow` is updated when present. The default `root` account
|
||||||
must already exist in the image.
|
must already exist in the image.
|
||||||
|
|
||||||
The known public demo key bundled in Firecracker's default bionic image is
|
The known public demo key bundled in Firecracker's older Bionic image is
|
||||||
removed from each private disk. UVM also replaces the conventional RSA, ECDSA,
|
removed from each private disk. UVM also replaces the conventional RSA, ECDSA,
|
||||||
and Ed25519 SSH host keys in every private disk so cloned VMs have distinct
|
and Ed25519 SSH host keys in every private disk so cloned VMs have distinct
|
||||||
host identities. Never rely on SSH private keys baked into a shared template.
|
host identities. Never rely on SSH private keys baked into a shared template.
|
||||||
@@ -382,13 +457,35 @@ Bridge: uvm0
|
|||||||
TAPs: uvm-<unique suffix>
|
TAPs: uvm-<unique suffix>
|
||||||
```
|
```
|
||||||
|
|
||||||
It creates the bridge when absent, validates an existing bridge before using
|
VMs share the host's IPv4 internet connection by default. When the host has
|
||||||
it, enables IPv4 forwarding, adds an egress masquerade rule, and adds matching
|
internet access, guests can initiate outbound connections through it. When
|
||||||
forward rules for return traffic. Guest IPs, gateway, bridge name, and guest
|
the host loses connectivity, guest internet access is lost too; the private
|
||||||
boot netmask derive from the configured CIDR.
|
VM network remains usable. Reconnecting the host restores guest egress.
|
||||||
|
|
||||||
The existing `iptables` egress NAT/forwarding behavior is unchanged. Private
|
UVM creates the bridge when absent, validates an existing bridge before using
|
||||||
VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`,
|
it, enables IPv4 forwarding, and installs subnet-scoped `iptables` masquerade
|
||||||
|
and forward/return rules. These rules are installed even while the host is
|
||||||
|
offline and follow its current routing table, so switching uplinks does not
|
||||||
|
require restarting VMs. Existing connections may need application retries
|
||||||
|
after an outage or upstream address change. Guest IPs, gateway, bridge name,
|
||||||
|
and guest boot netmask derive from the configured CIDR.
|
||||||
|
|
||||||
|
Forwarding exceptions are inserted ahead of existing host-chain rules and
|
||||||
|
allow guest-originated traffic and its replies. Masquerading excludes traffic
|
||||||
|
back onto the UVM bridge, preserving private peer addresses.
|
||||||
|
|
||||||
|
To apply updated sharing rules to an existing running VM, use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./uvm.py start web
|
||||||
|
```
|
||||||
|
|
||||||
|
On a running VM, `start` refreshes its private policy and shared host networking
|
||||||
|
without rebooting it. New VM boots set up the same networking automatically.
|
||||||
|
For an older guest missing its default route, use `restart` once to apply the
|
||||||
|
offline guest-profile repair, or follow the guest route recovery steps above.
|
||||||
|
|
||||||
|
Private VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`,
|
||||||
`172.16.0.0/12`, or `192.168.0.0/16`). Retained VMs prevent incompatible
|
`172.16.0.0/12`, or `192.168.0.0/16`). Retained VMs prevent incompatible
|
||||||
gateway, subnet, or bridge changes within the same registry. Private
|
gateway, subnet, or bridge changes within the same registry. Private
|
||||||
port enforcement uses separate per-VM native nftables `bridge` tables, not
|
port enforcement uses separate per-VM native nftables `bridge` tables, not
|
||||||
@@ -405,8 +502,9 @@ firewalld, Docker, or other external firewall managers remains deferred.
|
|||||||
Do not offload traffic around the software bridge hooks.
|
Do not offload traffic around the software bridge hooks.
|
||||||
|
|
||||||
There is no autostart service or daemon reconciliation. After externally lost
|
There is no autostart service or daemon reconciliation. After externally lost
|
||||||
or changed UVM rules, inspect policy status and explicitly run `ports sync`.
|
or changed private policy, inspect its status and explicitly run `ports sync`.
|
||||||
Read-only commands do not repair rules; sync does not boot stopped VMs.
|
Use `start` to restore shared bridge/NAT/forwarding rules as well. Read-only
|
||||||
|
commands do not repair rules; port sync does not boot stopped VMs.
|
||||||
|
|
||||||
Use one authoritative registry per bridge/subnet. Independent `UVM_BASE`
|
Use one authoritative registry per bridge/subnet. Independent `UVM_BASE`
|
||||||
directories do not coordinate IP or MAC allocation with each other.
|
directories do not coordinate IP or MAC allocation with each other.
|
||||||
@@ -433,7 +531,7 @@ is mode `0600`. Treat it as a secret, do not include it in bug reports, and
|
|||||||
replace the default `root` password immediately.
|
replace the default `root` password immediately.
|
||||||
|
|
||||||
Credential provisioning applies only to VMs created by this version. Existing
|
Credential provisioning applies only to VMs created by this version. Existing
|
||||||
VM disks are not modified automatically.
|
guest accounts, passwords, and SSH host keys are retained on subsequent boots.
|
||||||
|
|
||||||
Older registries are read and migrated on the next write. A missing/null
|
Older registries are read and migrated on the next write. A missing/null
|
||||||
`ports` policy remains `None`/`unmanaged`, not an empty allowlist or an
|
`ports` policy remains `None`/`unmanaged`, not an empty allowlist or an
|
||||||
@@ -466,10 +564,10 @@ Host and API settings can be overridden through environment variables:
|
|||||||
| `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` |
|
| `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` |
|
||||||
| `UVM_KNOWN_HOSTS_FILES` | Override local SSH cache files cleaned on deletion; colon-separated absolute paths |
|
| `UVM_KNOWN_HOSTS_FILES` | Override local SSH cache files cleaned on deletion; colon-separated absolute paths |
|
||||||
| `UVM_KERNEL_URL` | Override the host-architecture default guest kernel URL |
|
| `UVM_KERNEL_URL` | Override the host-architecture default guest kernel URL |
|
||||||
| `UVM_ROOTFS_URL` | Override the host-architecture default guest rootfs URL |
|
| `UVM_ROOTFS_URL` | Optional prebuilt ext4 image URL; unset to build Ubuntu 22.04 locally |
|
||||||
| `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 prebuilt rootfs SHA-256; use with `UVM_ROOTFS_URL` |
|
||||||
| `UVM_FIRECRACKER_BINARY_SHA256` | Optional trusted local Firecracker binary SHA-256 |
|
| `UVM_FIRECRACKER_BINARY_SHA256` | Optional trusted local Firecracker binary SHA-256 |
|
||||||
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for unverified local development |
|
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for unverified local development |
|
||||||
| `UVM_API_TOKEN` | Required for every API server; sent as `X-UVM-Token` |
|
| `UVM_API_TOKEN` | Required for every API server; sent as `X-UVM-Token` |
|
||||||
@@ -530,8 +628,10 @@ UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration
|
|||||||
|
|
||||||
The packet test was skipped in this workspace because namespace creation was
|
The packet test was skipped in this workspace because namespace creation was
|
||||||
denied (`Operation not permitted`). A skip is not packet-path or real-host
|
denied (`Operation not permitted`). A skip is not packet-path or real-host
|
||||||
validation. The harness uses veths in place of TAPs and does not boot a real VM;
|
validation. The harness uses veths in place of TAPs and simulated upstream
|
||||||
validate KVM, TAP, NAT, SSH, and guest connectivity on an isolated Linux host.
|
networks to check NAT, offline startup, reconnects, and uplink changes alongside
|
||||||
|
private port policies. It does not boot a real VM; validate KVM, TAP, SSH, and
|
||||||
|
guest connectivity on an isolated Linux host.
|
||||||
|
|
||||||
## Current Boundaries
|
## Current Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ Bridge: uvm0
|
|||||||
Guest pool: 10.42.0.2 through 10.42.0.254
|
Guest pool: 10.42.0.2 through 10.42.0.254
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Guest IPv4 internet access shares the host's connection automatically. Host
|
||||||
|
offline means guest internet is offline; reconnecting the host restores guest
|
||||||
|
egress. UVM installs sharing rules even when booting offline, and follows the
|
||||||
|
host's current routes when uplinks change. The private network remains usable
|
||||||
|
without internet. Applications may need to reconnect after a network interruption.
|
||||||
|
|
||||||
The default locations on the host are under `/var/lib/uvm`. Stop/start and
|
The default locations on the host are under `/var/lib/uvm`. Stop/start and
|
||||||
restart reuse the private disk; delete removes it. UVM is a local CLI, not an
|
restart reuse the private disk; delete removes it. UVM is a local CLI, not an
|
||||||
autostart service or a daemon that continuously repairs VM or firewall state.
|
autostart service or a daemon that continuously repairs VM or firewall state.
|
||||||
@@ -135,29 +141,51 @@ go to stderr. VM JSON omits passwords. Lifecycle and sync results are arrays of
|
|||||||
`changes`). `ports list --json` returns an array even for one VM. JSON does
|
`changes`). `ports list --json` returns an array even for one VM. JSON does
|
||||||
not disable confirmations, and errors may still be plain text on stderr.
|
not disable confirmations, and errors may still be plain text on stderr.
|
||||||
|
|
||||||
|
### Stop the Management API
|
||||||
|
|
||||||
|
The API can be stopped remotely through its authenticated shutdown endpoint.
|
||||||
|
This stops only the UVM API process; it does not stop or delete any VM:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
http://127.0.0.1:8000/exit
|
||||||
|
```
|
||||||
|
|
||||||
|
The server first returns `202 {"status":"stopping"}`, then performs Uvicorn's
|
||||||
|
graceful shutdown. Use the actual API host and port when they differ from the
|
||||||
|
defaults. `GET /exit` is intentionally not supported.
|
||||||
|
|
||||||
## 4. Install UVM Assets
|
## 4. Install UVM Assets
|
||||||
|
|
||||||
UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest
|
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. The default rootfs is built locally from authenticated Ubuntu
|
||||||
artifacts that will be used by a privileged process.
|
22.04 packages, including OpenSSH, sudo, and complete apt/dpkg metadata. The
|
||||||
|
downloaded Firecracker archive and kernel require trusted SHA-256 checksums.
|
||||||
|
|
||||||
### ARM64 Hosts (NVIDIA DGX Spark)
|
### ARM64 Hosts (NVIDIA DGX Spark)
|
||||||
|
|
||||||
UVM detects the host architecture and selects matching Firecracker, kernel,
|
UVM detects the host architecture and selects matching Firecracker and kernel
|
||||||
and rootfs downloads. NVIDIA DGX Spark uses the `aarch64` assets; the `arm64`
|
downloads and native Ubuntu packages. NVIDIA DGX Spark uses `aarch64` artifacts
|
||||||
architecture alias is also accepted. The default ARM64 guest downloads are:
|
and `arm64` packages; the `arm64` architecture alias is also accepted:
|
||||||
|
|
||||||
- Kernel: `https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64/kernels/vmlinux.bin`
|
- Kernel: `https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/aarch64/vmlinux-6.1.155`
|
||||||
- Rootfs: `https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64/rootfs/bionic.rootfs.ext4`
|
- Rootfs: Ubuntu 22.04 built with `debootstrap` from `ports.ubuntu.com/ubuntu-ports`.
|
||||||
|
|
||||||
Use the installation commands below with checksums for those ARM64 artifacts
|
Use the installation commands below with checksums for the ARM64 kernel
|
||||||
and the ARM64 Firecracker release archive. `UVM_KERNEL_URL` and
|
and Firecracker release archive. `UVM_KERNEL_URL` overrides the kernel download;
|
||||||
`UVM_ROOTFS_URL` can override either download independently. Custom images must
|
`UVM_ROOTFS_URL` selects a prebuilt ext4 image instead of the local build. Custom images must
|
||||||
match the host architecture and the [guest image contract](README.md#guest-image-contract).
|
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),
|
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
|
and the rootfs must be an ARM64 ext4 filesystem. UVM adds `keep_bootcon` to the
|
||||||
guest boot arguments for ARM64 console output.
|
guest boot arguments for ARM64 console output.
|
||||||
|
|
||||||
|
Both architectures default to the pinned Linux 6.1.155 kernel from the published
|
||||||
|
`v1.15` CI artifact series, independently of the Firecracker binary version.
|
||||||
|
These kernels have `CONFIG_HW_RANDOM=y` and `CONFIG_HW_RANDOM_VIRTIO=y` built in.
|
||||||
|
UVM configures Firecracker's entropy device before boot; custom kernels also
|
||||||
|
need these drivers to receive host-backed randomness.
|
||||||
|
|
||||||
### Scenario: Strict First Installation
|
### Scenario: Strict First Installation
|
||||||
|
|
||||||
Obtain trusted checksums independently, then run:
|
Obtain trusted checksums independently, then run:
|
||||||
@@ -166,7 +194,6 @@ Obtain trusted checksums independently, then run:
|
|||||||
sudo env \
|
sudo env \
|
||||||
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
||||||
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
||||||
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
|
|
||||||
./uvm.py install
|
./uvm.py install
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -174,6 +201,13 @@ Installation prints the Firecracker, kernel, and rootfs paths under
|
|||||||
`/var/lib/uvm`. Checksums and verification provenance are recorded in
|
`/var/lib/uvm`. Checksums and verification provenance are recorded in
|
||||||
`/var/lib/uvm/integrity.json`.
|
`/var/lib/uvm/integrity.json`.
|
||||||
|
|
||||||
|
The default build installs `debootstrap` and `ubuntu-keyring` on the host,
|
||||||
|
checks Ubuntu archive signatures, bootstraps the base release, then uses apt
|
||||||
|
to apply updates and install the guest packages. It builds inside private
|
||||||
|
mount/PID namespaces and requires native root execution with namespace support.
|
||||||
|
Leave `UVM_ROOTFS_URL` and `UVM_ROOTFS_SHA256` unset for this mode. To use a prebuilt
|
||||||
|
ext4 image, supply both its URL and SHA-256 instead.
|
||||||
|
|
||||||
### Scenario: Refresh Firecracker or Guest Assets
|
### Scenario: Refresh Firecracker or Guest Assets
|
||||||
|
|
||||||
Use `--force` after changing a URL, replacing an image, or rotating checksums:
|
Use `--force` after changing a URL, replacing an image, or rotating checksums:
|
||||||
@@ -182,12 +216,17 @@ Use `--force` after changing a URL, replacing an image, or rotating checksums:
|
|||||||
sudo env \
|
sudo env \
|
||||||
UVM_FIRECRACKER_SHA256='<new-firecracker-archive-sha256>' \
|
UVM_FIRECRACKER_SHA256='<new-firecracker-archive-sha256>' \
|
||||||
UVM_KERNEL_SHA256='<new-kernel-sha256>' \
|
UVM_KERNEL_SHA256='<new-kernel-sha256>' \
|
||||||
UVM_ROOTFS_SHA256='<new-rootfs-sha256>' \
|
|
||||||
./uvm.py install --force
|
./uvm.py install --force
|
||||||
```
|
```
|
||||||
|
|
||||||
`--force` downloads to temporary files and only replaces the existing artifact
|
`--force` stages downloads and builds in temporary paths. Downloaded artifacts
|
||||||
after checksum verification succeeds.
|
are checksum-verified; the built rootfs is checked for installed packages,
|
||||||
|
sudo permissions, and filesystem consistency before replacement.
|
||||||
|
|
||||||
|
To upgrade from the old stripped Bionic demo rootfs, run this refresh with
|
||||||
|
`UVM_ROOTFS_URL` and `UVM_ROOTFS_SHA256` unset, then create a new VM. Restart a
|
||||||
|
running API server to load the new provisioner. Existing VM disks retain their
|
||||||
|
installed OS, files, and credentials.
|
||||||
|
|
||||||
### Scenario: Local Experimentation Without Checksums
|
### Scenario: Local Experimentation Without Checksums
|
||||||
|
|
||||||
@@ -215,6 +254,19 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py start VM
|
|||||||
| `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 an x86_64 or ARM64 Linux host. |
|
| `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. |
|
||||||
|
| `unshare: Operation not permitted` | Run the builder as root on a host permitting private mount/PID namespaces. |
|
||||||
|
| `Ubuntu rootfs build failed` | Read `images/ubuntu-build.log` under `UVM_BASE`, fix the reported bootstrap/apt error, and rerun installation. |
|
||||||
|
|
||||||
|
The latest image-build log is retained after temporary build directories are
|
||||||
|
cleaned up, including detailed debootstrap and apt output:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo tail -n 100 /var/lib/uvm/images/ubuntu-build.log
|
||||||
|
```
|
||||||
|
|
||||||
|
A failed build leaves the previous rootfs template installed. If creation then
|
||||||
|
reports missing sudo, complete the image installation successfully before
|
||||||
|
retrying creation. The username itself does not cause that error.
|
||||||
|
|
||||||
## 5. Create VMs
|
## 5. Create VMs
|
||||||
|
|
||||||
@@ -245,7 +297,8 @@ create DNS records, and identifiers are not matched by ID prefix.
|
|||||||
|
|
||||||
`--username` selects the guest login account. If a custom username is missing,
|
`--username` selects the guest login account. If a custom username is missing,
|
||||||
UVM creates it on the private disk with a primary group, `/home/USERNAME`, and
|
UVM creates it on the private disk with a primary group, `/home/USERNAME`, and
|
||||||
the `/bin/sh` login shell. Existing accounts keep their identity and home.
|
the `/bin/sh` login shell. Custom logins receive sudo access using their guest
|
||||||
|
password. Existing accounts keep their identity and home.
|
||||||
|
|
||||||
A `--password` value is visible in process arguments and may enter shell
|
A `--password` value is visible in process arguments and may enter shell
|
||||||
history. Prefer `--password-stdin`, which reads one line, strips the trailing
|
history. Prefer `--password-stdin`, which reads one line, strips the trailing
|
||||||
@@ -415,6 +468,20 @@ guest image must include:
|
|||||||
- An SSH server.
|
- An SSH server.
|
||||||
- Standard passwd and shadow files, plus `/etc/ssh/sshd_config`.
|
- Standard passwd and shadow files, plus `/etc/ssh/sshd_config`.
|
||||||
- `/etc/group` and `/bin/sh` for new users; the default `root` account must exist.
|
- `/etc/group` and `/bin/sh` for new users; the default `root` account must exist.
|
||||||
|
- Root-owned setuid `/usr/bin/sudo` and `/etc/sudoers` for a custom login user.
|
||||||
|
|
||||||
|
For a custom login, run these commands inside the VM:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -i # Authenticate with the custom user's SSH password.
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install htop
|
||||||
|
```
|
||||||
|
|
||||||
|
`su -` requests root's password, which is separate from the custom user's
|
||||||
|
password. Use `sudo -i` for administrative access. The default Ubuntu image
|
||||||
|
includes an intact `/var/lib/dpkg` database; an old Bionic demo image missing
|
||||||
|
that database needs a replacement template and a new VM for package management.
|
||||||
|
|
||||||
UVM replaces conventional RSA, ECDSA, and Ed25519 SSH host keys in the private
|
UVM replaces conventional RSA, ECDSA, and Ed25519 SSH host keys in the private
|
||||||
disk during creation and removes Firecracker's publicly known demo login key.
|
disk during creation and removes Firecracker's publicly known demo login key.
|
||||||
@@ -1040,7 +1107,6 @@ sudo env \
|
|||||||
UVM_BRIDGE='uvm50' \
|
UVM_BRIDGE='uvm50' \
|
||||||
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
||||||
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
||||||
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
|
|
||||||
./uvm.py install
|
./uvm.py install
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1059,10 +1125,10 @@ automatically detects every conflict with host routes or other managers.
|
|||||||
| `UVM_BRIDGE` | Linux bridge name; default `uvm0` |
|
| `UVM_BRIDGE` | Linux bridge name; default `uvm0` |
|
||||||
| `UVM_KNOWN_HOSTS_FILES` | Override deletion's local SSH cache files; colon-separated absolute paths |
|
| `UVM_KNOWN_HOSTS_FILES` | Override deletion's local SSH cache files; colon-separated absolute paths |
|
||||||
| `UVM_KERNEL_URL` | Override the host-architecture default kernel URL |
|
| `UVM_KERNEL_URL` | Override the host-architecture default kernel URL |
|
||||||
| `UVM_ROOTFS_URL` | Override the host-architecture default rootfs URL |
|
| `UVM_ROOTFS_URL` | Optional prebuilt ext4 URL; unset to build Ubuntu 22.04 locally |
|
||||||
| `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 prebuilt rootfs checksum, used with `UVM_ROOTFS_URL` |
|
||||||
| `UVM_FIRECRACKER_BINARY_SHA256` | Optional local binary checksum |
|
| `UVM_FIRECRACKER_BINARY_SHA256` | Optional local binary checksum |
|
||||||
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for local development |
|
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for local development |
|
||||||
| `UVM_API_TOKEN` | Required API token |
|
| `UVM_API_TOKEN` | Required API token |
|
||||||
@@ -1095,7 +1161,10 @@ Firecracker logs may include guest/application secrets; redact them before shari
|
|||||||
|
|
||||||
## 15. Networking Troubleshooting
|
## 15. Networking Troubleshooting
|
||||||
|
|
||||||
The existing `iptables` egress MASQUERADE and forwarding rules are unchanged.
|
UVM enables IPv4 forwarding and installs `uvm-egress`-commented iptables
|
||||||
|
MASQUERADE and forward/return rules. These match the private subnet and bridge
|
||||||
|
without fixing an uplink, so guest traffic follows the host's current routes.
|
||||||
|
Scoped forwarding exceptions are inserted ahead of existing host-chain rules.
|
||||||
Private port filtering is a separate native nftables `bridge` policy, not
|
Private port filtering is a separate native nftables `bridge` policy, not
|
||||||
DNAT. It must be installed and read back before the TAP is active. The bridge
|
DNAT. It must be installed and read back before the TAP is active. The bridge
|
||||||
must have STP disabled and each TAP must have bridge-slave `group_fwd_mask`
|
must have STP disabled and each TAP must have bridge-slave `group_fwd_mask`
|
||||||
@@ -1123,11 +1192,73 @@ sudo iptables -t nat -S POSTROUTING
|
|||||||
sudo iptables -S FORWARD
|
sudo iptables -S FORWARD
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After upgrading UVM, apply the sharing rules to an already-running VM with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./uvm.py start web
|
||||||
|
```
|
||||||
|
|
||||||
|
`start` on a running VM refreshes its private policy and shared host networking
|
||||||
|
without a reboot. Use the updated CLI checkout, or restart the API server to
|
||||||
|
load the updated code before using its start endpoint. Subsequent host
|
||||||
|
disconnects, reconnects, and route changes require no UVM command.
|
||||||
|
|
||||||
|
### Guest Reports `Network is Unreachable`
|
||||||
|
|
||||||
|
An immediate `ping: connect: Network is unreachable` inside the VM indicates
|
||||||
|
that the guest has no usable route to that destination. Inspect the guest's
|
||||||
|
networking from its SSH shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ip -4 address show dev eth0
|
||||||
|
ip -4 route
|
||||||
|
```
|
||||||
|
|
||||||
|
For the default UVM subnet, the route must include `default via 10.42.0.1 dev
|
||||||
|
eth0`. The guest gateway is the **UVM bridge address**, even when the host's
|
||||||
|
own uplink uses a different gateway.
|
||||||
|
|
||||||
|
Older UVM Ubuntu images used `KeepConfiguration=static`. Ubuntu's network
|
||||||
|
manager could preserve the guest address but remove its kernel-created
|
||||||
|
`proto boot` default route. Inside the running guest, restore it immediately:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ip -4 route replace default via 10.42.0.1 dev eth0 proto static
|
||||||
|
ping -c 3 1.1.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
For a persistent repair, run the updated CLI **on the host**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./uvm.py restart 10.42.0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
This stops the VM, repairs its original UVM network profile on the retained
|
||||||
|
disk, and boots it with explicit address/gateway settings. A stopped VM's
|
||||||
|
`start` applies the same repair; `start` on an already-running VM refreshes
|
||||||
|
host rules only. Per-VM provisioning also repairs cached older Ubuntu
|
||||||
|
templates when creating new VMs. Use the configured `UVM_GATEWAY` and guest IP
|
||||||
|
in place of these defaults for a custom subnet.
|
||||||
|
|
||||||
|
Guest-edited profiles and custom images are preserved. For those, persist the
|
||||||
|
correct `Address=` and `Gateway=` in the guest's own network configuration.
|
||||||
|
|
||||||
|
### Verify Guest Internet Access
|
||||||
|
|
||||||
|
Check DNS and HTTPS from the guest:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./uvm.py ssh web -- getent ahostsv4 example.com
|
||||||
|
sudo ./uvm.py ssh web -- curl -4 --connect-timeout 5 --max-time 15 -I https://example.com
|
||||||
|
```
|
||||||
|
|
||||||
Common issues:
|
Common issues:
|
||||||
|
|
||||||
| Symptom | First checks |
|
| Symptom | First checks |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Guest cannot reach the internet | Bridge is up, TAP is up, host has default route, NAT and FORWARD rules exist. |
|
| Guest cannot reach the internet | Confirm the host can reach the same destination, bridge/TAP are up, and `uvm-egress` NAT/FORWARD rules exist; use `start VM` to refresh sharing. |
|
||||||
|
| Guest immediately reports `Network is unreachable` | Inspect the guest's default route and apply the guest route recovery steps above. |
|
||||||
|
| IP connectivity works but DNS fails | Check guest `/etc/resolv.conf` and DNS reachability. The Ubuntu template uses `1.1.1.1` and `8.8.8.8`; host loopback resolvers such as `127.0.0.53` are not reachable as guest loopback addresses. |
|
||||||
| Requested guest IP is rejected | Confirm it is inside the configured subnet and not used, gateway, network, or broadcast. |
|
| Requested guest IP is rejected | Confirm it is inside the configured subnet and not used, gateway, network, or broadcast. |
|
||||||
| Create says TAP already exists | Inspect the interface; do not delete it unless you know it is stale UVM state. |
|
| Create says TAP already exists | Inspect the interface; do not delete it unless you know it is stale UVM state. |
|
||||||
| Existing bridge is rejected | Verify it is a software Linux bridge with STP disabled. |
|
| Existing bridge is rejected | Verify it is a software Linux bridge with STP disabled. |
|
||||||
@@ -1140,6 +1271,58 @@ External firewall integration (including UFW, firewalld, Docker, and libvirt)
|
|||||||
is deferred. A UVM policy readback does not prove that external rules permit
|
is deferred. A UVM policy readback does not prove that external rules permit
|
||||||
traffic, or that they will not later remove UVM's rules.
|
traffic, or that they will not later remove UVM's rules.
|
||||||
|
|
||||||
|
### SSH Startup and Guest Entropy
|
||||||
|
|
||||||
|
`running` checks the Firecracker process, and `enforced` checks the private
|
||||||
|
firewall policy. Neither probes the guest's SSH listener. If ping works but
|
||||||
|
TCP/22 returns `Connection refused`, inspect the guest boot log:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./uvm.py logs VM --tail 200
|
||||||
|
sudo ./uvm.py inspect VM --json
|
||||||
|
```
|
||||||
|
|
||||||
|
An SSH service stuck at `Starting OpenBSD Secure Shell server...`, together
|
||||||
|
with an unfinished `Load/Save Random Seed` service and no `random: crng init done`,
|
||||||
|
strongly suggests entropy starvation. A login prompt can still appear while
|
||||||
|
SSH is waiting for its cryptographic random generator. On the old ARM demo
|
||||||
|
kernel, `random: fast init done` is not full cryptographic RNG initialization.
|
||||||
|
|
||||||
|
Earlier UVM versions downloaded a Linux 4.14.174 ARM quickstart kernel without
|
||||||
|
the built-in virtio RNG driver and did not attach an entropy device. Recovery
|
||||||
|
requires **both** the updated UVM boot code and an RNG-capable kernel. A restart
|
||||||
|
with the old cached kernel is insufficient.
|
||||||
|
|
||||||
|
From the updated checkout, stop the affected VM before refreshing assets.
|
||||||
|
Stop any other VMs using the same installed Firecracker binary before the
|
||||||
|
forced reinstall as well. Use the Firecracker archive checksum for the configured
|
||||||
|
release and a new trusted checksum for the selected architecture's Linux 6.1.155
|
||||||
|
kernel; the old kernel checksum will not match:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ./uvm.py stop VM
|
||||||
|
sudo env -u UVM_KERNEL_URL \
|
||||||
|
UVM_FIRECRACKER_SHA256='<trusted-firecracker-archive-sha256>' \
|
||||||
|
UVM_KERNEL_SHA256='<trusted-new-kernel-sha256>' \
|
||||||
|
./uvm.py install --force
|
||||||
|
sudo ./uvm.py start VM
|
||||||
|
sudo ./uvm.py logs VM --tail 100
|
||||||
|
sudo ./uvm.py ssh VM
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses the default kernel URL and boots the retained private disk with the
|
||||||
|
updated device configuration. The VM's identity, files, credentials, and SSH
|
||||||
|
host keys are retained. `install --force` also refreshes the shared rootfs
|
||||||
|
template for future creates. If using a custom kernel, supply its URL and
|
||||||
|
matching checksum with the required drivers instead. For an explicitly
|
||||||
|
unverified development installation, retain the existing
|
||||||
|
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` setting as described in the installation section.
|
||||||
|
|
||||||
|
Restart a running UVM API server to load the updated code before using dashboard
|
||||||
|
start/restart actions. Look for `Started OpenBSD Secure Shell server` in the new
|
||||||
|
boot log and confirm an actual SSH connection. `start` on an already-running
|
||||||
|
VM only refreshes networking; use a stopped VM or `restart` to attach the device.
|
||||||
|
|
||||||
## 16. Important Limitations
|
## 16. Important Limitations
|
||||||
|
|
||||||
Keep these constraints in mind when deciding whether UVM fits a scenario:
|
Keep these constraints in mind when deciding whether UVM fits a scenario:
|
||||||
@@ -1148,7 +1331,7 @@ Keep these constraints in mind when deciding whether UVM fits a scenario:
|
|||||||
- No quotas or cgroup CPU, memory, I/O, or process limits are applied.
|
- No quotas or cgroup CPU, memory, I/O, or process limits are applied.
|
||||||
- Fractional CPU requests are advisory only.
|
- Fractional CPU requests are advisory only.
|
||||||
- No autostart, background lifecycle/firewall reconciliation, or guest-coordinated stop.
|
- No autostart, background lifecycle/firewall reconciliation, or guest-coordinated stop.
|
||||||
- No UDP/IPv6 service policies, public connectivity setup, host-port mapping,
|
- No UDP/IPv6 service policies, public inbound connectivity setup, host-port mapping,
|
||||||
DNS publication, guest reverse proxy/TLS setup, DHCP, load balancing,
|
DNS publication, guest reverse proxy/TLS setup, DHCP, load balancing,
|
||||||
snapshots, multi-network tenancy, or multi-host scheduling.
|
snapshots, multi-network tenancy, or multi-host scheduling.
|
||||||
- The API uses one shared bearer token, not individual users, roles, or tenants.
|
- The API uses one shared bearer token, not individual users, roles, or tenants.
|
||||||
@@ -1181,7 +1364,7 @@ python3 -B -m unittest discover -s tests -v
|
|||||||
|
|
||||||
The opt-in packet test runs **only in disposable isolated user/network
|
The opt-in packet test runs **only in disposable isolated user/network
|
||||||
namespaces**, which may require privileges. It needs `unshare`, `ip`,
|
namespaces**, which may require privileges. It needs `unshare`, `ip`,
|
||||||
`bridge`, `nft`, `conntrack`, and `sysctl`:
|
`bridge`, `nft`, `iptables`, `conntrack`, and `sysctl`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
|
UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
|
||||||
@@ -1189,10 +1372,10 @@ UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration
|
|||||||
|
|
||||||
Namespace creation was denied in this workspace (`Operation not permitted`),
|
Namespace creation was denied in this workspace (`Operation not permitted`),
|
||||||
so the packet test skipped. That is a permission limitation, not real-packet
|
so the packet test skipped. That is a permission limitation, not real-packet
|
||||||
or real-host validation. The harness uses veths as TAP stand-ins and routed
|
or real-host validation. The harness uses veths as TAP stand-ins and simulated
|
||||||
egress without NAT; it does not boot Firecracker or validate the host's real
|
upstreams to check NAT, offline startup, reconnects and uplink changes. It does
|
||||||
TAP/NAT setup. Run actual VM/SSH/egress checks on an isolated Linux host before
|
not boot Firecracker or validate the host's real TAP setup. Run actual
|
||||||
relying on the deployment.
|
VM/SSH/egress checks on an isolated Linux host before relying on the deployment.
|
||||||
|
|
||||||
## 19. Get Help
|
## 19. Get Help
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
Run: UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
|
Run: UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
|
||||||
|
|
||||||
Needs Linux, nft, ip, bridge, conntrack, sysctl and permission to create network
|
Needs Linux, nft, iptables, ip, bridge, conntrack, sysctl and permission to create
|
||||||
namespaces (root, or unprivileged user namespaces). No sudo, installation,
|
network namespaces (root, or unprivileged user namespaces). No sudo, installation,
|
||||||
/run/netns mounts, host firewall changes or br_netfilter sysctls are used.
|
/run/netns mounts, host firewall changes or br_netfilter sysctls are used.
|
||||||
Bridge-connected veths stand in for guest TAPs. A third, routed namespace tests
|
Bridge-connected veths stand in for guest TAPs. A third namespace with two
|
||||||
guest egress without NAT. Ordinary test runs skip without executing commands.
|
uplinks tests NAT, offline startup, reconnects and host route changes without
|
||||||
|
using the real internet. Ordinary test runs skip without executing commands.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import errno
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -62,6 +64,13 @@ class SocketEndpoint:
|
|||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
sock.close()
|
sock.close()
|
||||||
return False
|
return False
|
||||||
|
except OSError as error:
|
||||||
|
sock.close()
|
||||||
|
if operation == "connect" and error.errno in {
|
||||||
|
errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN, errno.ECONNREFUSED,
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
raise
|
||||||
except BaseException:
|
except BaseException:
|
||||||
sock.close()
|
sock.close()
|
||||||
raise
|
raise
|
||||||
@@ -206,6 +215,7 @@ def _sandbox(parent_fd):
|
|||||||
from uvm.domain import PortRule, VmRecord
|
from uvm.domain import PortRule, VmRecord
|
||||||
from uvm.errors import UvmError
|
from uvm.errors import UvmError
|
||||||
from uvm.firewall import FirewallManager
|
from uvm.firewall import FirewallManager
|
||||||
|
from uvm.network import NetworkManager
|
||||||
from uvm.system import CommandRunner
|
from uvm.system import CommandRunner
|
||||||
|
|
||||||
runner = CommandRunner(emit=None)
|
runner = CommandRunner(emit=None)
|
||||||
@@ -220,11 +230,12 @@ def _sandbox(parent_fd):
|
|||||||
manager = FirewallManager(settings, runner)
|
manager = FirewallManager(settings, runner)
|
||||||
manager.check_available()
|
manager.check_available()
|
||||||
run("ip", "link", "set", "lo", "up")
|
run("ip", "link", "set", "lo", "up")
|
||||||
run("ip", "link", "add", settings.bridge, "type", "bridge", "stp_state", "0")
|
# All changes are inside the freshly verified namespace. A pre-existing
|
||||||
run("ip", "addr", "add", f"{settings.gateway}/24", "dev", settings.bridge)
|
# terminal drop catches regressions that append ineffective forward rules.
|
||||||
run("ip", "link", "set", settings.bridge, "up")
|
run("iptables", "-P", "FORWARD", "DROP")
|
||||||
# This IPv4 sysctl belongs only to the freshly verified network namespace.
|
run("iptables", "-A", "FORWARD", "-j", "DROP")
|
||||||
run("sysctl", "-w", "net.ipv4.ip_forward=1")
|
assert not run("ip", "route", "show", "default").strip()
|
||||||
|
NetworkManager(settings, runner).ensure_bridge()
|
||||||
|
|
||||||
metadata = dict(cpu=1, ram_mib=128, gateway=str(settings.gateway),
|
metadata = dict(cpu=1, ram_mib=128, gateway=str(settings.gateway),
|
||||||
socket="", config="", log="", network=str(settings.network), bridge=settings.bridge)
|
socket="", config="", log="", network=str(settings.network), bridge=settings.bridge)
|
||||||
@@ -263,6 +274,16 @@ def _sandbox(parent_fd):
|
|||||||
run("ip", "link", "set", tap, "type", "bridge_slave", "group_fwd_mask", "0x4000")
|
run("ip", "link", "set", tap, "type", "bridge_slave", "group_fwd_mask", "0x4000")
|
||||||
endpoint.request("run", command=["ip", "route", "add", "default", "via", str(settings.gateway)])
|
endpoint.request("run", command=["ip", "route", "add", "default", "via", str(settings.gateway)])
|
||||||
|
|
||||||
|
run("ip", "link", "add", "fw-up2", "type", "veth", "peer", "name", "fw-end2")
|
||||||
|
run("ip", "link", "set", "fw-end2", "netns", str(outside.process.pid))
|
||||||
|
run("ip", "addr", "add", "172.30.74.1/24", "dev", "fw-up2")
|
||||||
|
run("ip", "link", "set", "fw-up2", "up")
|
||||||
|
outside.request("run", command=["ip", "link", "set", "fw-end2", "name", "eth1"])
|
||||||
|
outside.request("run", command=["ip", "addr", "add", "172.30.74.2/24", "dev", "eth1"])
|
||||||
|
outside.request("run", command=["ip", "link", "set", "eth1", "up"])
|
||||||
|
internet_ip = "203.0.113.73"
|
||||||
|
outside.request("run", command=["ip", "addr", "add", f"{internet_ip}/32", "dev", "lo"])
|
||||||
|
|
||||||
manager.apply(vm)
|
manager.apply(vm)
|
||||||
manager.apply(peer_vm)
|
manager.apply(peer_vm)
|
||||||
for tap in (vm.tap, peer_vm.tap):
|
for tap in (vm.tap, peer_vm.tap):
|
||||||
@@ -328,6 +349,7 @@ def _sandbox(parent_fd):
|
|||||||
guest.request("udp_send", name="dns", ip="172.30.73.2", port=53, data="dns-query")
|
guest.request("udp_send", name="dns", ip="172.30.73.2", port=53, data="dns-query")
|
||||||
query = outside.request("udp_recv", name="dns")
|
query = outside.request("udp_recv", name="dns")
|
||||||
assert query[0] == "dns-query"
|
assert query[0] == "dns-query"
|
||||||
|
assert query[1][0] == "172.30.73.1", "guest egress must use the host uplink's address"
|
||||||
outside.request("udp_send", name="dns", ip=query[1][0], port=query[1][1], data="dns-reply")
|
outside.request("udp_send", name="dns", ip=query[1][0], port=query[1][1], data="dns-reply")
|
||||||
assert guest.request("udp_recv", name="dns")[0] == "dns-reply"
|
assert guest.request("udp_recv", name="dns")[0] == "dns-reply"
|
||||||
guest.request("udp_bind", name="unsolicited", ip=vm.guest_ip, port=40054)
|
guest.request("udp_bind", name="unsolicited", ip=vm.guest_ip, port=40054)
|
||||||
@@ -338,6 +360,44 @@ def _sandbox(parent_fd):
|
|||||||
assert endpoint.request("ping", ip=target), (endpoint, target)
|
assert endpoint.request("ping", ip=target), (endpoint, target)
|
||||||
print("PASS: independent peer policy, outbound UDP/DNS and ICMP; unsolicited UDP blocked", flush=True)
|
print("PASS: independent peer policy, outbound UDP/DNS and ICMP; unsolicited UDP blocked", flush=True)
|
||||||
|
|
||||||
|
# No UVM reconfiguration after this point: the kernel must follow the
|
||||||
|
# host routes for both TCP and UDP/53, including after offline creation.
|
||||||
|
outside.request("listen", name="internet", ip=internet_ip, port=9443)
|
||||||
|
outside.request("udp_bind", name="internet-dns", ip=internet_ip, port=53)
|
||||||
|
|
||||||
|
def offline(label):
|
||||||
|
assert not host.request("connect", name=f"host-{label}", ip=internet_ip, port=9443)
|
||||||
|
assert not guest.request("connect", name=f"guest-{label}", ip=internet_ip, port=9443)
|
||||||
|
assert guest.request("ping", ip=str(settings.gateway)), "local networking must still work offline"
|
||||||
|
|
||||||
|
def online(label, source):
|
||||||
|
for endpoint, role in ((host, "host"), (guest, "guest")):
|
||||||
|
name = f"{role}-{label}"
|
||||||
|
assert endpoint.request("connect", name=name, ip=internet_ip, port=9443)
|
||||||
|
assert outside.request("accept", listener="internet", name=name)
|
||||||
|
transfer(endpoint, outside, name, "request")
|
||||||
|
transfer(outside, endpoint, name, "response")
|
||||||
|
guest.request("udp_bind", name=label, ip=vm.guest_ip, port=0)
|
||||||
|
guest.request("udp_send", name=label, ip=internet_ip, port=53, data=label)
|
||||||
|
query = outside.request("udp_recv", name="internet-dns")
|
||||||
|
assert query[0] == label and query[1][0] == source, query
|
||||||
|
outside.request("udp_send", name="internet-dns", ip=source, port=query[1][1], data="dns-reply")
|
||||||
|
assert guest.request("udp_recv", name=label)[0] == "dns-reply"
|
||||||
|
|
||||||
|
offline("initially-offline")
|
||||||
|
run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up")
|
||||||
|
online("first-uplink", "172.30.73.1")
|
||||||
|
run("ip", "route", "replace", "default", "via", "172.30.74.2", "dev", "fw-up2")
|
||||||
|
online("changed-uplink", "172.30.74.1")
|
||||||
|
run("ip", "route", "del", "default")
|
||||||
|
run("ip", "link", "set", "fw-up", "down")
|
||||||
|
run("ip", "link", "set", "fw-up2", "down")
|
||||||
|
offline("disconnected")
|
||||||
|
run("ip", "link", "set", "fw-up", "up")
|
||||||
|
run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up")
|
||||||
|
online("reconnected", "172.30.73.1")
|
||||||
|
print("PASS: host/guest NAT egress follows offline/online, uplink changes and reconnects", flush=True)
|
||||||
|
|
||||||
# Spoofed source MACs must not reach FDB learning, including the local
|
# Spoofed source MACs must not reach FDB learning, including the local
|
||||||
# link-local path which can skip bridge prerouting entirely.
|
# link-local path which can skip bridge prerouting entirely.
|
||||||
spoof = bytes.fromhex("02aabbccddee")
|
spoof = bytes.fromhex("02aabbccddee")
|
||||||
@@ -393,7 +453,7 @@ class PrivatePortNamespaceTests(unittest.TestCase):
|
|||||||
probe = subprocess.run((*prefix, "true"), text=True, capture_output=True, timeout=5)
|
probe = subprocess.run((*prefix, "true"), text=True, capture_output=True, timeout=5)
|
||||||
if probe.returncode:
|
if probe.returncode:
|
||||||
self.skipTest(f"isolated namespace permission unavailable: {probe.stderr.strip()}")
|
self.skipTest(f"isolated namespace permission unavailable: {probe.stderr.strip()}")
|
||||||
missing = [name for name in ("ip", "bridge", "nft", "conntrack", "sysctl") if shutil.which(name) is None]
|
missing = [name for name in ("ip", "bridge", "nft", "iptables", "conntrack", "sysctl") if shutil.which(name) is None]
|
||||||
if missing:
|
if missing:
|
||||||
self.skipTest(f"integration tools unavailable: {', '.join(missing)}")
|
self.skipTest(f"integration tools unavailable: {', '.join(missing)}")
|
||||||
with open("/proc/self/ns/net", "rb") as parent:
|
with open("/proc/self/ns/net", "rb") as parent:
|
||||||
|
|||||||
@@ -27,17 +27,17 @@ class ArchitectureSettingsTests(unittest.TestCase):
|
|||||||
settings = factory()
|
settings = factory()
|
||||||
|
|
||||||
base = (
|
base = (
|
||||||
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
"https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/"
|
||||||
f"{architecture}"
|
f"{architecture}"
|
||||||
)
|
)
|
||||||
self.assertEqual(settings.architecture, architecture)
|
self.assertEqual(settings.architecture, architecture)
|
||||||
self.assertEqual(settings.kernel_url, f"{base}/kernels/vmlinux.bin")
|
self.assertEqual(settings.kernel_url, f"{base}/vmlinux-6.1.155")
|
||||||
self.assertEqual(settings.rootfs_url, f"{base}/rootfs/bionic.rootfs.ext4")
|
self.assertEqual(settings.rootfs_url, "")
|
||||||
|
|
||||||
def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None:
|
def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None:
|
||||||
kernel = "https://example.com/custom-arm64-kernel"
|
kernel = "https://example.com/custom-arm64-kernel"
|
||||||
rootfs = "https://example.com/custom-arm64-rootfs.ext4"
|
rootfs = "https://example.com/custom-arm64-rootfs.ext4"
|
||||||
base = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64"
|
base = "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/aarch64"
|
||||||
for overrides in (
|
for overrides in (
|
||||||
{"UVM_KERNEL_URL": kernel},
|
{"UVM_KERNEL_URL": kernel},
|
||||||
{"UVM_ROOTFS_URL": rootfs},
|
{"UVM_ROOTFS_URL": rootfs},
|
||||||
@@ -52,11 +52,11 @@ class ArchitectureSettingsTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
settings.kernel_url,
|
settings.kernel_url,
|
||||||
overrides.get("UVM_KERNEL_URL", f"{base}/kernels/vmlinux.bin"),
|
overrides.get("UVM_KERNEL_URL", f"{base}/vmlinux-6.1.155"),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
settings.rootfs_url,
|
settings.rootfs_url,
|
||||||
overrides.get("UVM_ROOTFS_URL", f"{base}/rootfs/bionic.rootfs.ext4"),
|
overrides.get("UVM_ROOTFS_URL", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_rejects_unsupported_hosts_even_with_custom_guest_assets(self) -> None:
|
def test_rejects_unsupported_hosts_even_with_custom_guest_assets(self) -> None:
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from ipaddress import IPv4Address, IPv4Network
|
from ipaddress import IPv4Address, IPv4Network
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from uvm.config import Settings
|
from uvm.config import Settings
|
||||||
from uvm.domain import VmRecord
|
from uvm.domain import VmRecord
|
||||||
|
from uvm.errors import FirecrackerError
|
||||||
from uvm.firecracker.api import FirecrackerClient
|
from uvm.firecracker.api import FirecrackerClient
|
||||||
from uvm.firecracker.config import build_config, vcpu_count
|
from uvm.firecracker.config import build_config, vcpu_count
|
||||||
|
|
||||||
@@ -45,6 +47,10 @@ class FirecrackerConfigTests(unittest.TestCase):
|
|||||||
self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64")
|
self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64")
|
||||||
self.assertIn("console=ttyS0", arguments)
|
self.assertIn("console=ttyS0", arguments)
|
||||||
self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments)
|
self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments)
|
||||||
|
client = RecordingFirecrackerClient()
|
||||||
|
client.configure_and_start(config)
|
||||||
|
self.assertEqual(client.calls[-2], ("PUT", "/entropy", {}))
|
||||||
|
self.assertEqual(client.calls[-1], ("PUT", "/actions", {"action_type": "InstanceStart"}))
|
||||||
|
|
||||||
def test_builds_a_writable_vm_disk_configuration(self) -> None:
|
def test_builds_a_writable_vm_disk_configuration(self) -> None:
|
||||||
vm = VmRecord(
|
vm = VmRecord(
|
||||||
@@ -101,6 +107,7 @@ class FirecrackerConfigTests(unittest.TestCase):
|
|||||||
"/boot-source",
|
"/boot-source",
|
||||||
"/drives/rootfs",
|
"/drives/rootfs",
|
||||||
"/network-interfaces/eth0",
|
"/network-interfaces/eth0",
|
||||||
|
"/entropy",
|
||||||
"/actions",
|
"/actions",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -110,6 +117,36 @@ class FirecrackerConfigTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(client.calls[-1][2], {"action_type": "InstanceStart"})
|
self.assertEqual(client.calls[-1][2], {"action_type": "InstanceStart"})
|
||||||
|
|
||||||
|
def test_entropy_failure_prevents_guest_boot(self) -> None:
|
||||||
|
config = {
|
||||||
|
"machine-config": {"vcpu_count": 1, "mem_size_mib": 512},
|
||||||
|
"boot-source": {},
|
||||||
|
"drives": [{}],
|
||||||
|
"network-interfaces": [{}],
|
||||||
|
"entropy": {},
|
||||||
|
}
|
||||||
|
client = RecordingFirecrackerClient()
|
||||||
|
|
||||||
|
def request(method: str, path: str, body: object) -> None:
|
||||||
|
client.calls.append((method, path, body))
|
||||||
|
if path == "/entropy":
|
||||||
|
raise FirecrackerError("entropy device unavailable")
|
||||||
|
|
||||||
|
with patch.object(client, "_request", side_effect=request):
|
||||||
|
with self.assertRaisesRegex(FirecrackerError, "entropy device unavailable"):
|
||||||
|
client.configure_and_start(config)
|
||||||
|
|
||||||
|
self.assertEqual(client.calls[-1], ("PUT", "/entropy", {}))
|
||||||
|
self.assertNotIn("/actions", [path for _method, path, _body in client.calls])
|
||||||
|
|
||||||
|
for entropy in (None, [], False):
|
||||||
|
with self.subTest(entropy=entropy):
|
||||||
|
client.calls.clear()
|
||||||
|
config["entropy"] = entropy
|
||||||
|
with self.assertRaisesRegex(FirecrackerError, "no valid entropy device"):
|
||||||
|
client.configure_and_start(config)
|
||||||
|
self.assertEqual(client.calls, [])
|
||||||
|
|
||||||
def test_uses_the_configured_network_netmask_in_guest_boot_arguments(self) -> None:
|
def test_uses_the_configured_network_netmask_in_guest_boot_arguments(self) -> None:
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
network=IPv4Network("10.50.0.0/16"),
|
network=IPv4Network("10.50.0.0/16"),
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
from ipaddress import IPv4Address, IPv4Network
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from uvm.config import Settings
|
||||||
|
from uvm.errors import UvmError, ValidationError
|
||||||
|
from uvm.images import GUEST_NETWORK_TEMPLATE, ImageStore
|
||||||
|
from uvm.system import CommandResult, CommandRunner
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = "/etc/systemd/network/10-uvm.network"
|
||||||
|
# The shipped v1/v2 profile that retained the IP but lost the proto-boot route.
|
||||||
|
LEGACY_PROFILE = (
|
||||||
|
"[Match]\nName=eth0\n\n[Network]\n"
|
||||||
|
"KeepConfiguration=static\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GuestNetworkValidationTests(unittest.TestCase):
|
||||||
|
def test_invalid_guest_addresses_fail_before_disk_access(self) -> None:
|
||||||
|
runner = Mock(spec=CommandRunner)
|
||||||
|
store = ImageStore(Settings(), runner)
|
||||||
|
for address in ("invalid", "::1", "192.0.2.2", "10.42.0.0", "10.42.0.255", "10.42.0.1"):
|
||||||
|
with self.subTest(address=address), self.assertRaises(ValidationError):
|
||||||
|
store.configure_guest_network(Path("/nonexistent/rootfs.ext4"), address)
|
||||||
|
runner.run.assert_not_called()
|
||||||
|
|
||||||
|
def test_shared_or_linked_disks_are_rejected_before_guest_commands(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as name:
|
||||||
|
settings = Settings(base=Path(name))
|
||||||
|
settings.images_dir.mkdir()
|
||||||
|
settings.rootfs_image.write_bytes(b"shared template")
|
||||||
|
disk = settings.base / "rootfs.ext4"
|
||||||
|
disk.write_bytes(b"private disk")
|
||||||
|
linked = settings.base / "linked.ext4"
|
||||||
|
linked.hardlink_to(disk)
|
||||||
|
symlink = settings.base / "symlink.ext4"
|
||||||
|
symlink.symlink_to(disk)
|
||||||
|
runner = Mock(spec=CommandRunner)
|
||||||
|
store = ImageStore(settings, runner)
|
||||||
|
for candidate in (settings.rootfs_image, disk, linked, symlink):
|
||||||
|
with self.subTest(candidate=candidate), self.assertRaisesRegex(UvmError, "unsafe"):
|
||||||
|
store.configure_guest_network(candidate, "10.42.0.2")
|
||||||
|
runner.run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp")),
|
||||||
|
"e2fsprogs and coreutils are required for offline guest networking checks")
|
||||||
|
class OfflineGuestNetworkTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.base = Path(temporary.name)
|
||||||
|
self.settings = Settings(base=self.base / "uvm")
|
||||||
|
self.settings.images_dir.mkdir(parents=True)
|
||||||
|
self.disk = self.base / "VM with spaces" / "rootfs.ext4"
|
||||||
|
self.disk.parent.mkdir()
|
||||||
|
self.runner = CommandRunner(emit=None)
|
||||||
|
self.store = ImageStore(self.settings, self.runner)
|
||||||
|
self.retained = {
|
||||||
|
"/etc/shadow": "adm:$6$original-password-hash:1:0:99999:7:::\n",
|
||||||
|
"/etc/ssh/ssh_host_ed25519_key": "original private host key\n",
|
||||||
|
"/home/adm/data": "retained guest application data\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
def make_disk(self, *, marker="uvm-ubuntu-jammy-v2\n", profile=LEGACY_PROFILE, symlink=False) -> None:
|
||||||
|
root = self.base / "tree"
|
||||||
|
if root.exists():
|
||||||
|
shutil.rmtree(root)
|
||||||
|
files = dict(self.retained)
|
||||||
|
if marker is not None:
|
||||||
|
files["/etc/uvm-image"] = marker
|
||||||
|
if profile is not None:
|
||||||
|
files["/etc/alternate.network" if symlink else PROFILE] = profile
|
||||||
|
for path, contents in files.items():
|
||||||
|
local = root / path.lstrip("/")
|
||||||
|
local.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local.write_text(contents, encoding="utf-8")
|
||||||
|
local.chmod(0o644)
|
||||||
|
if symlink:
|
||||||
|
local = root / PROFILE.lstrip("/")
|
||||||
|
local.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local.symlink_to("/etc/alternate.network")
|
||||||
|
with self.settings.rootfs_image.open("wb") as image:
|
||||||
|
image.truncate(32 * 1024 * 1024)
|
||||||
|
self.runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-d", root, self.settings.rootfs_image), capture=True)
|
||||||
|
if profile is not None:
|
||||||
|
for field in ("uid", "gid"):
|
||||||
|
self.runner.run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0",
|
||||||
|
self.settings.rootfs_image), capture=True)
|
||||||
|
self.runner.run(("cp", "--sparse=always", self.settings.rootfs_image, self.disk), capture=True)
|
||||||
|
self.disk.chmod(0o600)
|
||||||
|
|
||||||
|
def guest_file(self, path: str) -> str:
|
||||||
|
return self.runner.run(("debugfs", "-R", f"cat {path}", self.disk), capture=True).stdout
|
||||||
|
|
||||||
|
def snapshot(self) -> tuple[int, bytes]:
|
||||||
|
return self.disk.stat().st_ino, hashlib.sha256(self.disk.read_bytes()).digest()
|
||||||
|
|
||||||
|
def test_templates_gain_an_explicit_address_gateway_and_boot_route_preservation(self) -> None:
|
||||||
|
for marker, profile in (("uvm-ubuntu-jammy-v1\n", LEGACY_PROFILE),
|
||||||
|
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE),
|
||||||
|
("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE)):
|
||||||
|
with self.subTest(marker=marker):
|
||||||
|
self.make_disk(marker=marker, profile=profile)
|
||||||
|
original_template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
|
||||||
|
self.assertTrue(self.store.configure_guest_network(self.disk, "10.42.0.2"))
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read_string(self.guest_file(PROFILE))
|
||||||
|
self.assertEqual(config["Match"]["Name"], "eth0")
|
||||||
|
self.assertEqual(config["Network"]["Address"], "10.42.0.2/24")
|
||||||
|
self.assertEqual(config["Network"]["Gateway"], "10.42.0.1")
|
||||||
|
self.assertTrue(config["Network"].getboolean("KeepConfiguration"))
|
||||||
|
self.assertFalse(config["Network"].getboolean("DHCP"))
|
||||||
|
for path, contents in self.retained.items():
|
||||||
|
self.assertEqual(self.guest_file(path), contents)
|
||||||
|
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), original_template)
|
||||||
|
self.assertEqual(self.disk.stat().st_size, 32 * 1024 * 1024)
|
||||||
|
self.assertEqual(self.disk.stat().st_mode & 0o777, 0o600)
|
||||||
|
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
|
||||||
|
|
||||||
|
before = self.snapshot()
|
||||||
|
with patch.object(self.store, "_copy_sparse") as copy:
|
||||||
|
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
|
||||||
|
copy.assert_not_called()
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
|
||||||
|
def test_custom_subnets_and_gateways_are_used_in_the_guest_profile(self) -> None:
|
||||||
|
settings = replace(self.settings, network=IPv4Network("10.50.0.0/16"), gateway=IPv4Address("10.50.1.1"))
|
||||||
|
self.make_disk()
|
||||||
|
self.assertTrue(ImageStore(settings, self.runner).configure_guest_network(self.disk, "10.50.8.9"))
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read_string(self.guest_file(PROFILE))
|
||||||
|
self.assertEqual(config["Network"]["Address"], "10.50.8.9/16")
|
||||||
|
self.assertEqual(config["Network"]["Gateway"], "10.50.1.1")
|
||||||
|
|
||||||
|
def test_custom_images_missing_profiles_and_guest_edits_are_left_intact(self) -> None:
|
||||||
|
for marker, profile, symlink in (
|
||||||
|
(None, LEGACY_PROFILE, False), ("custom-image\n", LEGACY_PROFILE, False),
|
||||||
|
("uvm-ubuntu-jammy-v2\n", None, False),
|
||||||
|
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "# guest customization\n", False),
|
||||||
|
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "Gateway=10.42.0.9\n", False),
|
||||||
|
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE, True),
|
||||||
|
):
|
||||||
|
with self.subTest(marker=marker, profile=profile, symlink=symlink):
|
||||||
|
self.make_disk(marker=marker, profile=profile, symlink=symlink)
|
||||||
|
before = self.snapshot()
|
||||||
|
with patch.object(self.store, "_copy_sparse") as copy:
|
||||||
|
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
|
||||||
|
copy.assert_not_called()
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
|
||||||
|
def test_a_failed_or_interrupted_profile_write_keeps_the_original_disk(self) -> None:
|
||||||
|
for error in (UvmError("profile readback failed"), KeyboardInterrupt()):
|
||||||
|
with self.subTest(error=type(error).__name__):
|
||||||
|
self.make_disk()
|
||||||
|
before = self.snapshot()
|
||||||
|
write = self.store._write_guest_file
|
||||||
|
|
||||||
|
def fail(candidate, path, contents):
|
||||||
|
self.assertNotEqual(candidate, self.disk)
|
||||||
|
write(candidate, path, contents)
|
||||||
|
raise error
|
||||||
|
|
||||||
|
with patch.object(self.store, "_write_guest_file", side_effect=fail), self.assertRaises(type(error)):
|
||||||
|
self.store.configure_guest_network(self.disk, "10.42.0.2")
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
|
||||||
|
|
||||||
|
def test_filesystem_check_failure_prevents_publication(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
before = self.snapshot()
|
||||||
|
run = self.runner.run
|
||||||
|
|
||||||
|
def check_failure(command, **options):
|
||||||
|
if command[0] == "e2fsck":
|
||||||
|
return CommandResult(tuple(map(str, command)), 4, stderr="unrepaired filesystem errors")
|
||||||
|
return run(command, **options)
|
||||||
|
|
||||||
|
with patch.object(self.runner, "run", side_effect=check_failure):
|
||||||
|
with self.assertRaisesRegex(UvmError, "unrepaired filesystem errors"):
|
||||||
|
self.store.configure_guest_network(self.disk, "10.42.0.2")
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
|
||||||
|
|
||||||
|
def test_guest_edits_revealed_after_journal_replay_prevent_migration(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
before = self.snapshot()
|
||||||
|
edited = self.base / "edited.network"
|
||||||
|
edited.write_text(LEGACY_PROFILE + "Gateway=10.42.0.9\n", encoding="utf-8")
|
||||||
|
run = self.runner.run
|
||||||
|
|
||||||
|
def replay(command, **options):
|
||||||
|
result = run(command, **options)
|
||||||
|
if command[:3] == ("e2fsck", "-f", "-p"):
|
||||||
|
run(("debugfs", "-w", "-R", f"rm {PROFILE}", command[-1]), capture=True)
|
||||||
|
run(("debugfs", "-w", "-R", f"write {edited} {PROFILE}", command[-1]), capture=True)
|
||||||
|
for field in ("uid", "gid"):
|
||||||
|
run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0", command[-1]), capture=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(self.runner, "run", side_effect=replay):
|
||||||
|
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
|
||||||
+72
-1
@@ -130,6 +130,44 @@ class ImageStoreTests(unittest.TestCase):
|
|||||||
self.assertIn("Match User service\n PasswordAuthentication yes", updated)
|
self.assertIn("Match User service\n PasswordAuthentication yes", updated)
|
||||||
self.assertNotIn("ChallengeResponseAuthentication yes", updated)
|
self.assertNotIn("ChallengeResponseAuthentication yes", updated)
|
||||||
|
|
||||||
|
def test_full_ubuntu_sshd_global_directives_stay_outside_match_blocks(self) -> None:
|
||||||
|
updated = _enable_ssh_password_authentication(
|
||||||
|
"Include /etc/ssh/sshd_config.d/*.conf\n"
|
||||||
|
"UsePAM yes\nSubsystem sftp /usr/lib/openssh/sftp-server\n"
|
||||||
|
"Match Group restricted\n X11Forwarding no\n PasswordAuthentication no\n",
|
||||||
|
"codemate-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertLess(updated.index("UsePAM yes"), updated.index("Match User codemate-user"))
|
||||||
|
self.assertLess(updated.index("Subsystem sftp"), updated.index("Match User codemate-user"))
|
||||||
|
self.assertLess(updated.index("Match User codemate-user"), updated.index("Match Group restricted"))
|
||||||
|
self.assertIn("Match Group restricted\n X11Forwarding no", updated)
|
||||||
|
|
||||||
|
@unittest.skipUnless(
|
||||||
|
shutil.which("sshd") and shutil.which("ssh-keygen") and Path("/run/sshd").is_dir(),
|
||||||
|
"OpenSSH server tools and the privilege-separation directory are required",
|
||||||
|
)
|
||||||
|
def test_openssh_accepts_the_full_config_and_limits_password_auth_to_the_guest_user(self) -> None:
|
||||||
|
runner = CommandRunner(emit=None)
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
key = Path(temporary) / "host_key"
|
||||||
|
runner.run(("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key), capture=True)
|
||||||
|
contents = _enable_ssh_password_authentication(
|
||||||
|
"UsePAM yes\nSubsystem sftp internal-sftp\nPermitRootLogin no\n",
|
||||||
|
"codemate-user",
|
||||||
|
)
|
||||||
|
for key_type in ("rsa", "ecdsa", "ed25519"):
|
||||||
|
contents = contents.replace(f"/etc/ssh/ssh_host_{key_type}_key", str(key))
|
||||||
|
config = Path(temporary) / "sshd_config"
|
||||||
|
config.write_text(contents, encoding="utf-8")
|
||||||
|
for username, expected in (("codemate-user", "yes"), ("root", "no")):
|
||||||
|
with self.subTest(username=username):
|
||||||
|
result = runner.run((
|
||||||
|
shutil.which("sshd"), "-T", "-f", config,
|
||||||
|
"-C", f"user={username},host=localhost,addr=127.0.0.1",
|
||||||
|
), capture=True)
|
||||||
|
self.assertIn(f"passwordauthentication {expected}\n", result.stdout)
|
||||||
|
|
||||||
def test_removes_only_the_public_firecracker_demo_key(self) -> None:
|
def test_removes_only_the_public_firecracker_demo_key(self) -> None:
|
||||||
own_key = "ssh-ed25519 AAAA-own-key developer@example"
|
own_key = "ssh-ed25519 AAAA-own-key developer@example"
|
||||||
|
|
||||||
@@ -241,6 +279,8 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
"/etc/group": "root:x:0:\nshadow:x:42:\nubuntu:x:1000:\nreserved:x:1001:\nnogroup:x:65534:\n",
|
"/etc/group": "root:x:0:\nshadow:x:42:\nubuntu:x:1000:\nreserved:x:1001:\nnogroup:x:65534:\n",
|
||||||
"/etc/gshadow": "root:*::\nshadow:*::\nubuntu:!::\nreserved:!::\nnogroup:*::\n",
|
"/etc/gshadow": "root:*::\nshadow:*::\nubuntu:!::\nreserved:!::\nnogroup:*::\n",
|
||||||
|
"/etc/sudoers": "Defaults env_reset\nroot ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n",
|
||||||
|
"/usr/bin/sudo": "sudo binary fixture\n",
|
||||||
"/etc/ssh/sshd_config": "PasswordAuthentication no\nPermitRootLogin no\n",
|
"/etc/ssh/sshd_config": "PasswordAuthentication no\nPermitRootLogin no\n",
|
||||||
"/root/.ssh/authorized_keys": f"{_FIRECRACKER_DEMO_PUBLIC_KEY} demo\nssh-ed25519 AAAA-own-key\n",
|
"/root/.ssh/authorized_keys": f"{_FIRECRACKER_DEMO_PUBLIC_KEY} demo\nssh-ed25519 AAAA-own-key\n",
|
||||||
}
|
}
|
||||||
@@ -251,7 +291,8 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
|
|||||||
path = root / guest_path.lstrip("/")
|
path = root / guest_path.lstrip("/")
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(contents, encoding="utf-8")
|
path.write_text(contents, encoding="utf-8")
|
||||||
path.chmod(0o640 if guest_path in ("/etc/shadow", "/etc/gshadow") else 0o644)
|
mode = {"/etc/shadow": 0o640, "/etc/gshadow": 0o640, "/etc/sudoers": 0o440, "/usr/bin/sudo": 0o4755}
|
||||||
|
path.chmod(mode.get(guest_path, 0o644))
|
||||||
with self.template.open("wb") as image:
|
with self.template.open("wb") as image:
|
||||||
image.truncate(16 * 1024 * 1024)
|
image.truncate(16 * 1024 * 1024)
|
||||||
self.runner.run(("mkfs.ext4", "-q", "-F", "-d", root, self.template), capture=True)
|
self.runner.run(("mkfs.ext4", "-q", "-F", "-d", root, self.template), capture=True)
|
||||||
@@ -305,6 +346,15 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
|
|||||||
self.assertLessEqual(int(shadow[2]), int(time.time() // 86400))
|
self.assertLessEqual(int(shadow[2]), int(time.time() // 86400))
|
||||||
self.assertEqual(shadow[3:], ["0", "99999", "7", "", "", ""])
|
self.assertEqual(shadow[3:], ["0", "99999", "7", "", "", ""])
|
||||||
self.assert_password("codemate-user", "custom-password")
|
self.assert_password("codemate-user", "custom-password")
|
||||||
|
self.assertIn("codemate-user ALL=(ALL:ALL) PASSWD: ALL\n", self.contents("/etc/sudoers"))
|
||||||
|
self.assertIn("Defaults:codemate-user !rootpw, !runaspw, !targetpw\n", self.contents("/etc/sudoers"))
|
||||||
|
self.assert_metadata("/etc/sudoers", 0o440, 0, 0)
|
||||||
|
self.assert_metadata("/usr/bin/sudo", 0o4755, 0, 0)
|
||||||
|
if shutil.which("visudo"):
|
||||||
|
sudoers = self.base / "sudoers"
|
||||||
|
sudoers.write_text(self.contents("/etc/sudoers"), encoding="utf-8")
|
||||||
|
sudoers.chmod(0o440)
|
||||||
|
self.runner.run(("visudo", "-c", "-f", sudoers), capture=True)
|
||||||
for guest_path in ("/etc/passwd", "/etc/group"):
|
for guest_path in ("/etc/passwd", "/etc/group"):
|
||||||
self.assert_metadata(guest_path, 0o644, 0, 0)
|
self.assert_metadata(guest_path, 0o644, 0, 0)
|
||||||
for guest_path in ("/etc/shadow", "/etc/gshadow"):
|
for guest_path in ("/etc/shadow", "/etc/gshadow"):
|
||||||
@@ -332,6 +382,10 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.contents(guest_path), self.files[guest_path])
|
self.assertEqual(self.contents(guest_path), self.files[guest_path])
|
||||||
self.assertEqual(self.entry("/etc/shadow", username)[2:], original_shadow[2:])
|
self.assertEqual(self.entry("/etc/shadow", username)[2:], original_shadow[2:])
|
||||||
self.assert_password(username, "changed-password")
|
self.assert_password(username, "changed-password")
|
||||||
|
if username != "root":
|
||||||
|
self.assertIn(f"{username} ALL=(ALL:ALL) PASSWD: ALL\n", self.contents("/etc/sudoers"))
|
||||||
|
else:
|
||||||
|
self.assertEqual(self.contents("/etc/sudoers"), self.files["/etc/sudoers"])
|
||||||
for line in self.files["/etc/shadow"].splitlines():
|
for line in self.files["/etc/shadow"].splitlines():
|
||||||
if line.split(":", 1)[0] != username:
|
if line.split(":", 1)[0] != username:
|
||||||
self.assertIn(line + "\n", self.contents("/etc/shadow"))
|
self.assertIn(line + "\n", self.contents("/etc/shadow"))
|
||||||
@@ -375,3 +429,20 @@ class OfflineGuestCredentialsTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.contents("/home/codemate-user"), self.files["/home/codemate-user"])
|
self.assertEqual(self.contents("/home/codemate-user"), self.files["/home/codemate-user"])
|
||||||
self.assertEqual(self.contents("/etc/passwd"), self.files["/etc/passwd"])
|
self.assertEqual(self.contents("/etc/passwd"), self.files["/etc/passwd"])
|
||||||
self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"])
|
self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"])
|
||||||
|
|
||||||
|
def test_custom_users_fail_with_an_actionable_error_on_stripped_images(self) -> None:
|
||||||
|
del self.files["/usr/bin/sudo"]
|
||||||
|
del self.files["/etc/sudoers"]
|
||||||
|
self.make_disk()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(UvmError, "guest image needs setuid-root sudo.*uvm install --force"):
|
||||||
|
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
|
||||||
|
|
||||||
|
self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"])
|
||||||
|
|
||||||
|
def test_sudo_without_setuid_permission_does_not_produce_a_broken_administrator(self) -> None:
|
||||||
|
self.make_disk()
|
||||||
|
self.runner.run(("debugfs", "-w", "-R", "set_inode_field /usr/bin/sudo mode 0100755", self.disk), capture=True)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(UvmError, "guest image needs setuid-root sudo"):
|
||||||
|
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
|
||||||
|
|||||||
+260
-4
@@ -2,7 +2,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -13,10 +16,11 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
from uvm.config import Settings
|
from uvm.config import Settings
|
||||||
from uvm.errors import UvmError
|
from uvm.errors import UvmError
|
||||||
from uvm.install import Installer
|
from uvm.images import ImageStore
|
||||||
from uvm.integrity import load_manifest
|
from uvm.install import Installer, _GUEST_APT_SCRIPT, _ROOTFS_RECIPE
|
||||||
|
from uvm.integrity import load_manifest, write_manifest
|
||||||
from uvm.state import StateStore
|
from uvm.state import StateStore
|
||||||
from uvm.system import CommandRunner
|
from uvm.system import CommandResult, CommandRunner
|
||||||
|
|
||||||
|
|
||||||
class InstallerArchitectureTests(unittest.TestCase):
|
class InstallerArchitectureTests(unittest.TestCase):
|
||||||
@@ -56,6 +60,10 @@ class InstallerArchitectureTests(unittest.TestCase):
|
|||||||
firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(),
|
firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(),
|
||||||
kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(),
|
kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(),
|
||||||
rootfs_sha256=hashlib.sha256(rootfs_bytes).hexdigest(),
|
rootfs_sha256=hashlib.sha256(rootfs_bytes).hexdigest(),
|
||||||
|
rootfs_url=(
|
||||||
|
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
||||||
|
f"{architecture}/rootfs/bionic.rootfs.ext4"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
guest_base = (
|
guest_base = (
|
||||||
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
||||||
@@ -64,7 +72,8 @@ class InstallerArchitectureTests(unittest.TestCase):
|
|||||||
payloads = {
|
payloads = {
|
||||||
"https://github.com/firecracker-microvm/firecracker/releases/download/"
|
"https://github.com/firecracker-microvm/firecracker/releases/download/"
|
||||||
f"{version}/firecracker-{version}-{architecture}.tgz": archive_bytes,
|
f"{version}/firecracker-{version}-{architecture}.tgz": archive_bytes,
|
||||||
f"{guest_base}/kernels/vmlinux.bin": kernel_bytes,
|
"https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/"
|
||||||
|
f"{architecture}/vmlinux-6.1.155": kernel_bytes,
|
||||||
f"{guest_base}/rootfs/bionic.rootfs.ext4": rootfs_bytes,
|
f"{guest_base}/rootfs/bionic.rootfs.ext4": rootfs_bytes,
|
||||||
}
|
}
|
||||||
runner = Mock(spec=CommandRunner)
|
runner = Mock(spec=CommandRunner)
|
||||||
@@ -111,3 +120,250 @@ class InstallerArchitectureTests(unittest.TestCase):
|
|||||||
check_kvm.assert_not_called()
|
check_kvm.assert_not_called()
|
||||||
download.assert_not_called()
|
download.assert_not_called()
|
||||||
self.assertFalse(settings.integrity_manifest_path.exists())
|
self.assertFalse(settings.integrity_manifest_path.exists())
|
||||||
|
|
||||||
|
def test_default_build_records_the_authenticated_local_image_digest(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
settings = Settings(
|
||||||
|
base=Path(temporary), firecracker_sha256="1" * 64,
|
||||||
|
kernel_sha256=hashlib.sha256(b"kernel").hexdigest(),
|
||||||
|
)
|
||||||
|
settings.bin_dir.mkdir()
|
||||||
|
settings.firecracker_binary.write_bytes(b"firecracker")
|
||||||
|
settings.jailer_binary.write_bytes(b"jailer")
|
||||||
|
installer = Installer(settings, Mock(spec=CommandRunner), StateStore(settings), emit=None)
|
||||||
|
with (
|
||||||
|
patch("uvm.install.require_root"), patch("uvm.install.check_kvm"),
|
||||||
|
patch.object(installer, "install_apt_packages"),
|
||||||
|
patch.object(installer, "install_firecracker_binary", return_value=settings.firecracker_binary),
|
||||||
|
patch.object(installer, "download", side_effect=lambda _url, path, **_kwargs: path.write_bytes(b"kernel")),
|
||||||
|
patch.object(installer, "_build_ubuntu_rootfs", side_effect=lambda path: path.write_bytes(b"full Ubuntu")) as build,
|
||||||
|
):
|
||||||
|
installer.install()
|
||||||
|
|
||||||
|
build.assert_called_once_with(settings.rootfs_image)
|
||||||
|
manifest = load_manifest(settings.integrity_manifest_path)
|
||||||
|
self.assertTrue(manifest.verified)
|
||||||
|
self.assertEqual(manifest.checksums["rootfs"], hashlib.sha256(b"full Ubuntu").hexdigest())
|
||||||
|
self.assertEqual(ImageStore(settings).installed_assets().rootfs, settings.rootfs_image)
|
||||||
|
|
||||||
|
def test_old_rootfs_checksum_requires_an_explicit_prebuilt_url_before_mutations(self) -> None:
|
||||||
|
settings = Settings(firecracker_sha256="1" * 64, kernel_sha256="2" * 64, rootfs_sha256="3" * 64)
|
||||||
|
runner = Mock(spec=CommandRunner)
|
||||||
|
installer = Installer(settings, runner, Mock(spec=StateStore), emit=None)
|
||||||
|
with self.assertRaisesRegex(UvmError, "unset UVM_ROOTFS_SHA256"):
|
||||||
|
installer._validate_integrity_policy()
|
||||||
|
runner.run.assert_not_called()
|
||||||
|
|
||||||
|
def test_only_verified_current_recipe_images_are_reused(self) -> None:
|
||||||
|
for verified, marker, force in (
|
||||||
|
(True, _ROOTFS_RECIPE, False), (False, _ROOTFS_RECIPE, False),
|
||||||
|
(True, "", False), (True, "uvm-ubuntu-jammy-v1\n", False), (True, _ROOTFS_RECIPE, True),
|
||||||
|
(True, "uvm-ubuntu-jammy-v2\n", False),
|
||||||
|
):
|
||||||
|
with self.subTest(verified=verified, marker=marker, force=force), tempfile.TemporaryDirectory() as temporary:
|
||||||
|
settings = Settings(base=Path(temporary))
|
||||||
|
settings.images_dir.mkdir()
|
||||||
|
settings.rootfs_image.write_bytes(b"image")
|
||||||
|
write_manifest(settings.integrity_manifest_path,
|
||||||
|
{"rootfs": hashlib.sha256(b"image").hexdigest()}, verified=verified)
|
||||||
|
runner = Mock(spec=CommandRunner)
|
||||||
|
runner.run.return_value = CommandResult(("debugfs",), 0, stdout=marker)
|
||||||
|
installer = Installer(settings, runner, StateStore(settings), emit=None)
|
||||||
|
with patch.object(installer, "_build_ubuntu_rootfs") as build:
|
||||||
|
installer._install_ubuntu_rootfs(settings.rootfs_image, force=force)
|
||||||
|
self.assertEqual(build.called, not (verified and marker == _ROOTFS_RECIPE and not force))
|
||||||
|
|
||||||
|
|
||||||
|
class UbuntuBuildRunner:
|
||||||
|
"""Simulate package installation; exercise real ext4 formatting and readback."""
|
||||||
|
|
||||||
|
def __init__(self, *, failure: str | None = None) -> None:
|
||||||
|
self.calls: list[tuple[str, ...]] = []
|
||||||
|
self.failure = failure
|
||||||
|
self.runner = CommandRunner(emit=None)
|
||||||
|
|
||||||
|
def run(self, command, **options) -> CommandResult:
|
||||||
|
args = tuple(map(str, command))
|
||||||
|
self.calls.append(args)
|
||||||
|
if "debootstrap" in args:
|
||||||
|
root = Path(args[-2])
|
||||||
|
if self.failure == "bootstrap":
|
||||||
|
self.write_files(root, {"debootstrap/debootstrap.log": "dpkg: --install needs at least one package archive file argument\n"})
|
||||||
|
return CommandResult(args, 1, stderr="W: Failure while unpacking the base system\n")
|
||||||
|
self.write_files(root, {
|
||||||
|
"etc/passwd": "root:x:0:0::/root:/bin/bash\n",
|
||||||
|
"etc/shadow": "root:*:1:0:99999:7:::\n", "etc/group": "root:x:0:\n",
|
||||||
|
"etc/apt/sources.list": "deb https://ports.ubuntu.com/ubuntu-ports jammy main\n",
|
||||||
|
"usr/bin/apt-get": "apt binary",
|
||||||
|
"var/lib/dpkg/status": "Package: apt\nStatus: install ok installed\nVersion: 1.0\n",
|
||||||
|
"var/lib/dpkg/info/apt.list": "/usr/bin/apt-get\n",
|
||||||
|
"var/log/bootstrap.log": "Base system installed successfully.\n",
|
||||||
|
})
|
||||||
|
for relative in ("dev", "proc", "sys", "usr/sbin"):
|
||||||
|
(root / relative).mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf")
|
||||||
|
# Ubuntu's real script removes this, even if it existed before bootstrap.
|
||||||
|
(root / "usr/sbin/policy-rc.d").unlink(missing_ok=True)
|
||||||
|
return CommandResult(args, 0, stdout="I: Base system installed successfully.\n")
|
||||||
|
if "uvm-guest-packages" in args:
|
||||||
|
root = Path(args[args.index("uvm-guest-packages") + 1])
|
||||||
|
if (root / "usr/sbin/policy-rc.d").read_text() != "#!/bin/sh\nexit 101\n":
|
||||||
|
raise AssertionError("guest services must be disabled during apt provisioning")
|
||||||
|
sources = (root / "etc/apt/sources.list").read_text()
|
||||||
|
if "jammy-updates main universe" not in sources or "jammy-security main universe" not in sources:
|
||||||
|
raise AssertionError("apt must see updates and security before installing guest packages")
|
||||||
|
if self.failure == "packages":
|
||||||
|
return CommandResult(args, 100, stdout="Reading package lists...\n", stderr="E: unmet guest dependencies\n")
|
||||||
|
status = "\n\n".join(
|
||||||
|
f"Package: {package}\nStatus: install ok installed\nVersion: 1.0"
|
||||||
|
for package in ("apt", "sudo", "openssh-server", "systemd-sysv")
|
||||||
|
) + "\n"
|
||||||
|
files = {
|
||||||
|
"etc/sudoers": "root ALL=(ALL:ALL) ALL\n",
|
||||||
|
"etc/ssh/sshd_config": "UsePAM yes\n",
|
||||||
|
"etc/ssh/ssh_host_rsa_key": "template host key",
|
||||||
|
"usr/bin/sudo": "sudo binary",
|
||||||
|
"usr/sbin/sshd": "sshd binary", "usr/lib/systemd/systemd": "init binary",
|
||||||
|
"var/lib/dpkg/status": status,
|
||||||
|
"var/lib/dpkg/info/sudo.list": "/usr/bin/sudo\n",
|
||||||
|
}
|
||||||
|
if self.failure == "metadata":
|
||||||
|
files["var/lib/dpkg/status"] = ""
|
||||||
|
self.write_files(root, files)
|
||||||
|
(root / "usr/bin/sudo").chmod(0o4755)
|
||||||
|
return CommandResult(args, 0, stdout="Configured guest packages.\n")
|
||||||
|
if args[0] == "systemctl":
|
||||||
|
return CommandResult(args, 0)
|
||||||
|
return self.runner.run(command, **options)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_files(root: Path, files: dict[str, str]) -> None:
|
||||||
|
for relative, contents in files.items():
|
||||||
|
path = root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(contents, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class UbuntuAptStageTests(unittest.TestCase):
|
||||||
|
def test_apt_script_preserves_arguments_and_stops_after_an_update_failure(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
base = Path(temporary)
|
||||||
|
root = base / "guest with spaces"
|
||||||
|
policy = root / "usr/sbin/policy-rc.d"
|
||||||
|
policy.parent.mkdir(parents=True)
|
||||||
|
policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8")
|
||||||
|
tools = base / "tools"
|
||||||
|
tools.mkdir()
|
||||||
|
for name in ("mount", "chroot"):
|
||||||
|
tool = tools / name
|
||||||
|
tool.write_text(
|
||||||
|
f"#!{sys.executable}\n"
|
||||||
|
"import json, os, sys\nfrom pathlib import Path\n"
|
||||||
|
"name = Path(sys.argv[0]).name\n"
|
||||||
|
"print(json.dumps([name, *sys.argv[1:]]))\n"
|
||||||
|
"if name == 'chroot':\n"
|
||||||
|
" assert (Path(sys.argv[1]) / 'usr/sbin/policy-rc.d').is_file()\n"
|
||||||
|
" if os.environ.get('FAIL_UPDATE') == '1' and sys.argv[-1] == 'update':\n"
|
||||||
|
" sys.exit(100)\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tool.chmod(0o755)
|
||||||
|
runner = CommandRunner(emit=None)
|
||||||
|
for fail in (False, True):
|
||||||
|
with self.subTest(fail=fail):
|
||||||
|
result = runner.run((
|
||||||
|
"env", f"PATH={tools}", f"FAIL_UPDATE={int(fail)}",
|
||||||
|
"/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT,
|
||||||
|
"uvm-guest-packages", root, "sudo", "openssh-server",
|
||||||
|
), check=False, capture=True)
|
||||||
|
events = [json.loads(line) for line in result.stdout.splitlines()]
|
||||||
|
mounts = [event for event in events if event[0] == "mount"]
|
||||||
|
self.assertEqual([event[-1] for event in mounts], [
|
||||||
|
str(root / "dev"), str(root / "dev"), str(root / "proc"), str(root / "sys"),
|
||||||
|
])
|
||||||
|
calls = [event for event in events if event[0] == "chroot"]
|
||||||
|
self.assertEqual(result.returncode, 100 if fail else 0)
|
||||||
|
self.assertEqual(len(calls), 1 if fail else 3)
|
||||||
|
self.assertEqual(calls[0][-3:], ["-o", "APT::Update::Error-Mode=any", "update"])
|
||||||
|
for call in calls:
|
||||||
|
self.assertEqual(call[1:6], [str(root), "/usr/bin/env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get"])
|
||||||
|
if not fail:
|
||||||
|
self.assertEqual(calls[1][-1], "dist-upgrade")
|
||||||
|
self.assertEqual(calls[2][-3:], ["install", "sudo", "openssh-server"])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "debugfs")),
|
||||||
|
"e2fsprogs is required for offline Ubuntu image checks")
|
||||||
|
class UbuntuRootfsBuildTests(unittest.TestCase):
|
||||||
|
def test_build_keeps_package_metadata_and_sudo_permissions_with_native_signed_bootstrap(self) -> None:
|
||||||
|
for architecture, deb_arch, mirror in (
|
||||||
|
("aarch64", "arm64", "https://ports.ubuntu.com/ubuntu-ports"),
|
||||||
|
("x86_64", "amd64", "https://archive.ubuntu.com/ubuntu"),
|
||||||
|
):
|
||||||
|
with self.subTest(architecture=architecture), tempfile.TemporaryDirectory() as temporary:
|
||||||
|
settings = Settings(base=Path(temporary), architecture=architecture)
|
||||||
|
runner = UbuntuBuildRunner()
|
||||||
|
installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type]
|
||||||
|
installer._build_ubuntu_rootfs(settings.rootfs_image)
|
||||||
|
|
||||||
|
bootstrap = next(call for call in runner.calls if "debootstrap" in call)
|
||||||
|
self.assertIn(f"--arch={deb_arch}", bootstrap)
|
||||||
|
self.assertEqual(bootstrap[-1], mirror)
|
||||||
|
self.assertIn("--force-check-gpg", bootstrap)
|
||||||
|
self.assertIn("--components=main", bootstrap)
|
||||||
|
self.assertIn("--include=ca-certificates,ubuntu-keyring", bootstrap)
|
||||||
|
self.assertFalse(any(part.startswith("--extra-suites") for part in bootstrap))
|
||||||
|
self.assertIn("--mount-proc", bootstrap)
|
||||||
|
self.assertIn("--kill-child", bootstrap)
|
||||||
|
packages = next(call for call in runner.calls if "uvm-guest-packages" in call)
|
||||||
|
self.assertLess(runner.calls.index(bootstrap), runner.calls.index(packages))
|
||||||
|
self.assertIn("sudo", packages)
|
||||||
|
self.assertIn("openssh-server", packages)
|
||||||
|
self.assertIn("--mount-proc", packages)
|
||||||
|
self.assertIn("--kill-child", packages)
|
||||||
|
self.assertEqual(settings.rootfs_image.stat().st_mode & 0o777, 0o600)
|
||||||
|
log = settings.images_dir / "ubuntu-build.log"
|
||||||
|
self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log})
|
||||||
|
self.assertEqual(log.stat().st_mode & 0o777, 0o600)
|
||||||
|
self.assertIn("Base system installed successfully.", log.read_text())
|
||||||
|
self.assertIn("Configured guest packages.", log.read_text())
|
||||||
|
|
||||||
|
def guest(command):
|
||||||
|
return runner.runner.run(("debugfs", "-R", command, settings.rootfs_image), capture=True)
|
||||||
|
|
||||||
|
self.assertIn("Package: apt\nStatus: install ok installed", guest("cat /var/lib/dpkg/status").stdout)
|
||||||
|
self.assertEqual(guest("cat /var/lib/dpkg/info/sudo.list").stdout, "/usr/bin/sudo\n")
|
||||||
|
self.assertRegex(guest("stat /usr/bin/sudo").stdout, r"Mode:\s+0?4755")
|
||||||
|
self.assertEqual(guest("cat /etc/uvm-image").stdout, _ROOTFS_RECIPE)
|
||||||
|
self.assertIn("nameserver 1.1.1.1", guest("cat /etc/resolv.conf").stdout)
|
||||||
|
self.assertIn("KeepConfiguration=yes", guest("cat /etc/systemd/network/10-uvm.network").stdout)
|
||||||
|
self.assertIn("jammy-security main universe", guest("cat /etc/apt/sources.list").stdout)
|
||||||
|
for path in ("/usr/sbin/policy-rc.d", "/etc/ssh/ssh_host_rsa_key"):
|
||||||
|
self.assertIn("File not found", guest(f"stat {path}").stderr)
|
||||||
|
|
||||||
|
def test_failed_build_preserves_the_old_template_and_retains_diagnostics(self) -> None:
|
||||||
|
for failure in ("bootstrap", "packages", "metadata"):
|
||||||
|
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary:
|
||||||
|
settings = Settings(base=Path(temporary))
|
||||||
|
settings.images_dir.mkdir()
|
||||||
|
settings.rootfs_image.write_bytes(b"old template")
|
||||||
|
runner = UbuntuBuildRunner(failure=failure)
|
||||||
|
installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type]
|
||||||
|
log = settings.images_dir / "ubuntu-build.log"
|
||||||
|
with self.assertRaisesRegex(UvmError, "details saved to .*ubuntu-build.log"):
|
||||||
|
installer._build_ubuntu_rootfs(settings.rootfs_image)
|
||||||
|
self.assertEqual(settings.rootfs_image.read_bytes(), b"old template")
|
||||||
|
self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log})
|
||||||
|
self.assertEqual(log.stat().st_mode & 0o777, 0o600)
|
||||||
|
details = log.read_text()
|
||||||
|
if failure == "bootstrap":
|
||||||
|
self.assertIn("--install needs at least one package archive", details)
|
||||||
|
self.assertIn("Failure while unpacking", details)
|
||||||
|
self.assertFalse(any("uvm-guest-packages" in call for call in runner.calls))
|
||||||
|
elif failure == "packages":
|
||||||
|
self.assertIn("Reading package lists...", details)
|
||||||
|
self.assertIn("E: unmet guest dependencies", details)
|
||||||
|
self.assertLess(details.index("Base system installed successfully."), details.index("E: unmet guest dependencies"))
|
||||||
|
else:
|
||||||
|
self.assertIn("incomplete apt/dpkg package metadata", details)
|
||||||
|
self.assertFalse(any(call[0] == "mkfs.ext4" for call in runner.calls))
|
||||||
|
|||||||
+52
-1
@@ -307,6 +307,9 @@ class LifecycleTestCase(unittest.TestCase):
|
|||||||
self.images, "resize_vm_disk", side_effect=self.resize_vm_disk,
|
self.images, "resize_vm_disk", side_effect=self.resize_vm_disk,
|
||||||
))
|
))
|
||||||
self.provision = self.enterContext(patch.object(self.images, "provision_credentials"))
|
self.provision = self.enterContext(patch.object(self.images, "provision_credentials"))
|
||||||
|
self.configure_network = self.enterContext(patch.object(
|
||||||
|
self.images, "configure_guest_network", side_effect=self.configure_guest_network,
|
||||||
|
))
|
||||||
self.copy_disk = self.enterContext(patch.object(
|
self.copy_disk = self.enterContext(patch.object(
|
||||||
self.images, "create_vm_disk", wraps=self.images.create_vm_disk,
|
self.images, "create_vm_disk", wraps=self.images.create_vm_disk,
|
||||||
))
|
))
|
||||||
@@ -340,6 +343,14 @@ class LifecycleTestCase(unittest.TestCase):
|
|||||||
raise AssertionError("cannot resize a running or ambiguous VM")
|
raise AssertionError("cannot resize a running or ambiguous VM")
|
||||||
self.disk_sizes[disk] = storage_mib * 1024 * 1024
|
self.disk_sizes[disk] = storage_mib * 1024 * 1024
|
||||||
|
|
||||||
|
def configure_guest_network(self, disk: Path, guest_ip: str) -> None:
|
||||||
|
vm = self.store.get(disk.parent.name)
|
||||||
|
self.host.call("images.configure_network", vm.id)
|
||||||
|
if self.process.observe(vm) != "gone":
|
||||||
|
raise AssertionError("cannot configure a running or ambiguous VM disk")
|
||||||
|
if guest_ip != vm.guest_ip:
|
||||||
|
raise AssertionError("guest network must use the VM's retained address")
|
||||||
|
|
||||||
def create_vm(self, **kwargs) -> VmRecord:
|
def create_vm(self, **kwargs) -> VmRecord:
|
||||||
return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs))
|
return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs))
|
||||||
|
|
||||||
@@ -481,7 +492,7 @@ class LifecycleTests(LifecycleTestCase):
|
|||||||
def test_boot_persists_intent_and_identity_with_policy_before_tap_and_vmm(self) -> None:
|
def test_boot_persists_intent_and_identity_with_policy_before_tap_and_vmm(self) -> None:
|
||||||
vm = self.create_vm()
|
vm = self.create_vm()
|
||||||
self.assert_call_order(
|
self.assert_call_order(
|
||||||
vm, "network.prepare_tap", "firewall.apply", "firewall.readback",
|
vm, "images.configure_network", "network.prepare_tap", "firewall.apply", "firewall.readback",
|
||||||
"network.activate_tap", "process.start", "process.spawn", "process.ready",
|
"network.activate_tap", "process.start", "process.spawn", "process.ready",
|
||||||
"client.configure", "firewall.status",
|
"client.configure", "firewall.status",
|
||||||
)
|
)
|
||||||
@@ -499,6 +510,46 @@ class LifecycleTests(LifecycleTestCase):
|
|||||||
self.assertEqual(spawned.operation, "configuring")
|
self.assertEqual(spawned.operation, "configuring")
|
||||||
self.assertEqual(self.host.records["client.configure", vm.id].pid, vm.pid)
|
self.assertEqual(self.host.records["client.configure", vm.id].pid, vm.pid)
|
||||||
|
|
||||||
|
def test_guest_network_configuration_failure_rolls_back_before_marking_the_disk_ready(self) -> None:
|
||||||
|
self.host.failures["images.configure_network", None] = UvmError("guest network verification failed")
|
||||||
|
with self.assertRaisesRegex(UvmError, "guest network verification failed"):
|
||||||
|
self.create_vm()
|
||||||
|
self.assertEqual(self.store.load().vms, {})
|
||||||
|
self.assertFalse(self.network.created_taps)
|
||||||
|
self.assertFalse(self.process.started)
|
||||||
|
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
|
||||||
|
|
||||||
|
def test_retained_guest_network_is_configured_after_exit_and_before_boot(self) -> None:
|
||||||
|
vm = self.create_vm()
|
||||||
|
self.host.events.clear()
|
||||||
|
self.service.restart(vm.id)
|
||||||
|
self.assert_call_order(vm, "images.configure_network", "process.start", "client.configure")
|
||||||
|
actions = self.host.actions(vm.id)
|
||||||
|
self.assertLess(max(index for index, action in enumerate(actions) if action == "process.gone"),
|
||||||
|
actions.index("images.configure_network"))
|
||||||
|
self.configure_network.assert_called_with(Path(vm.disk), vm.guest_ip)
|
||||||
|
self.assertEqual(self.provision.call_count, 1)
|
||||||
|
|
||||||
|
def test_live_start_does_not_modify_the_guest_disk(self) -> None:
|
||||||
|
vm = self.create_vm()
|
||||||
|
self.configure_network.reset_mock()
|
||||||
|
self.service.start(vm.id)
|
||||||
|
self.configure_network.assert_not_called()
|
||||||
|
|
||||||
|
def test_failed_retained_network_configuration_keeps_disk_for_retry(self) -> None:
|
||||||
|
vm = self.create_vm()
|
||||||
|
self.service.stop(vm.id)
|
||||||
|
disk = Path(vm.disk)
|
||||||
|
disk.write_bytes(b"retained data and credentials")
|
||||||
|
self.host.failures["images.configure_network", vm.id] = UvmError("network verification failed")
|
||||||
|
with self.assertRaisesRegex(UvmError, "network verification failed"):
|
||||||
|
self.service.start(vm.id)
|
||||||
|
self.assertEqual(disk.read_bytes(), b"retained data and credentials")
|
||||||
|
self.assertEqual(self.process.observe(self.store.get(vm.id)), "gone")
|
||||||
|
self.assertEqual(len(self.process.started), 1)
|
||||||
|
self.assertEqual(self.service.start(vm.id).status, "running")
|
||||||
|
self.assertEqual(self.provision.call_count, 1)
|
||||||
|
|
||||||
def test_failed_initial_policy_never_activates_tap_or_starts_vmm(self) -> None:
|
def test_failed_initial_policy_never_activates_tap_or_starts_vmm(self) -> None:
|
||||||
self.host.failures["firewall.apply", None] = UvmError("policy unavailable")
|
self.host.failures["firewall.apply", None] = UvmError("policy unavailable")
|
||||||
with self.assertRaisesRegex(UvmError, "policy unavailable"):
|
with self.assertRaisesRegex(UvmError, "policy unavailable"):
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ class LifecyclePortTests(LifecycleTestCase):
|
|||||||
self.assertEqual(self.settings.state_path.read_bytes(), before)
|
self.assertEqual(self.settings.state_path.read_bytes(), before)
|
||||||
self.assertEqual(self.host.events, [])
|
self.assertEqual(self.host.events, [])
|
||||||
|
|
||||||
def test_start_of_running_vm_repairs_policy_without_reboot_or_reprovision(self) -> None:
|
def test_start_of_running_vm_repairs_policy_and_egress_without_reboot_or_reprovision(self) -> None:
|
||||||
vm = self.create_vm()
|
vm = self.create_vm()
|
||||||
del self.network.firewall.tables[vm.id]
|
del self.network.firewall.tables[vm.id]
|
||||||
self.host.events.clear()
|
self.host.events.clear()
|
||||||
@@ -395,9 +395,34 @@ class LifecyclePortTests(LifecycleTestCase):
|
|||||||
self.assertNotIn("process.start", self.host.actions(vm.id))
|
self.assertNotIn("process.start", self.host.actions(vm.id))
|
||||||
self.assertNotIn("process.terminate", self.host.actions(vm.id))
|
self.assertNotIn("process.terminate", self.host.actions(vm.id))
|
||||||
self.assertNotIn("client.configure", self.host.actions(vm.id))
|
self.assertNotIn("client.configure", self.host.actions(vm.id))
|
||||||
|
self.assertIn(("network.ensure_bridge", None), self.host.events)
|
||||||
|
self.assertLess(self.host.events.index(("firewall.apply", vm.id)),
|
||||||
|
self.host.events.index(("network.ensure_bridge", None)))
|
||||||
self.assertEqual(self.provision.call_count, 1)
|
self.assertEqual(self.provision.call_count, 1)
|
||||||
self.assertEqual(self.copy_disk.call_count, 1)
|
self.assertEqual(self.copy_disk.call_count, 1)
|
||||||
|
|
||||||
|
def test_live_start_rejects_changed_network_settings_before_repairing_network(self) -> None:
|
||||||
|
vm = self.create_vm()
|
||||||
|
self.service._settings = replace(self.settings, bridge="another-bridge")
|
||||||
|
before = self.settings.state_path.read_bytes()
|
||||||
|
self.host.events.clear()
|
||||||
|
with self.assertRaisesRegex(ConflictError, "retained network"):
|
||||||
|
self.service.start(vm.id)
|
||||||
|
self.assertEqual(self.settings.state_path.read_bytes(), before)
|
||||||
|
self.assertNotIn(("network.ensure_bridge", None), self.host.events)
|
||||||
|
self.assertNotIn("firewall.apply", self.host.actions(vm.id))
|
||||||
|
|
||||||
|
def test_live_start_can_retry_an_egress_setup_failure_with_the_same_process(self) -> None:
|
||||||
|
vm = self.create_vm()
|
||||||
|
self.host.failures["network.ensure_bridge", None] = UvmError("egress setup failed")
|
||||||
|
with self.assertRaisesRegex(UvmError, "egress setup failed"):
|
||||||
|
self.service.start(vm.id)
|
||||||
|
self.assertEqual(self.process.observe(self.store.get(vm.id)), "alive")
|
||||||
|
self.assertEqual(self.network.firewall.status(vm), "enforced")
|
||||||
|
self.assertEqual(self.service.start(vm.id).pid, vm.pid)
|
||||||
|
self.assertEqual(len(self.process.started), 1)
|
||||||
|
self.assertEqual(self.provision.call_count, 1)
|
||||||
|
|
||||||
|
|
||||||
class LifecycleBulkTests(LifecycleTestCase):
|
class LifecycleBulkTests(LifecycleTestCase):
|
||||||
def test_all_bulk_actions_take_one_outer_lock_and_use_only_selected_ids(self) -> None:
|
def test_all_bulk_actions_take_one_outer_lock_and_use_only_selected_ids(self) -> None:
|
||||||
|
|||||||
+75
-39
@@ -5,6 +5,7 @@ import unittest
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from ipaddress import IPv4Address, IPv4Network
|
from ipaddress import IPv4Address, IPv4Network
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from uvm.config import Settings
|
from uvm.config import Settings
|
||||||
from uvm.domain import PortRule, VmRecord
|
from uvm.domain import PortRule, VmRecord
|
||||||
@@ -22,6 +23,10 @@ class FakeRunner:
|
|||||||
self.links: dict[str, dict] = {}
|
self.links: dict[str, dict] = {}
|
||||||
self.inventory: str | None = None
|
self.inventory: str | None = None
|
||||||
self.failures: dict[tuple[str, ...], BaseException] = {}
|
self.failures: dict[tuple[str, ...], BaseException] = {}
|
||||||
|
self.default_routes = "default via 192.0.2.1 dev eth0\n"
|
||||||
|
self.rules: dict[tuple[str, str], list[tuple[str, ...]]] = {
|
||||||
|
("nat", "POSTROUTING"): [], ("filter", "FORWARD"): [],
|
||||||
|
}
|
||||||
|
|
||||||
def run(self, command, *, check=True, capture=False, timeout=None) -> CommandResult:
|
def run(self, command, *, check=True, capture=False, timeout=None) -> CommandResult:
|
||||||
del capture, timeout
|
del capture, timeout
|
||||||
@@ -68,11 +73,21 @@ class FakeRunner:
|
|||||||
elif args[:3] == ("ip", "addr", "replace") or args[:2] == ("sysctl", "-w"):
|
elif args[:3] == ("ip", "addr", "replace") or args[:2] == ("sysctl", "-w"):
|
||||||
pass
|
pass
|
||||||
elif args[0] == "iptables":
|
elif args[0] == "iptables":
|
||||||
return CommandResult(args=args, returncode=1 if "-C" in args else 0)
|
if args[1:4] != ("--wait", "5", "-t"):
|
||||||
|
raise AssertionError(f"unexpected iptables options: {args}")
|
||||||
|
table, operation, chain, *rule = args[4:]
|
||||||
|
rules = self.rules[table, chain]
|
||||||
|
if operation == "-C":
|
||||||
|
return CommandResult(args=args, returncode=0 if tuple(rule) in rules else 1)
|
||||||
|
if operation == "-I":
|
||||||
|
position, *rule = rule
|
||||||
|
rules.insert(int(position) - 1, tuple(rule))
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"unexpected iptables mutation: {args}")
|
||||||
elif args != ("ip", "route", "show", "default"):
|
elif args != ("ip", "route", "show", "default"):
|
||||||
raise AssertionError(f"unexpected host command: {args}")
|
raise AssertionError(f"unexpected host command: {args}")
|
||||||
if args == ("ip", "route", "show", "default"):
|
if args == ("ip", "route", "show", "default"):
|
||||||
return CommandResult(args=args, returncode=0, stdout="default via 192.0.2.1 dev eth0\n")
|
return CommandResult(args=args, returncode=0, stdout=self.default_routes)
|
||||||
return CommandResult(args=args, returncode=0)
|
return CommandResult(args=args, returncode=0)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,44 +142,61 @@ class NetworkManagerTests(unittest.TestCase):
|
|||||||
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
|
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
|
||||||
self.runner.calls,
|
self.runner.calls,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(("sysctl", "-w", "net.ipv4.ip_forward=1"), self.runner.calls)
|
||||||
(
|
nat, = self.runner.rules["nat", "POSTROUTING"]
|
||||||
"iptables",
|
self.assertEqual(nat[:6], ("-s", "10.42.0.0/24", "!", "-o", "uvm0", "-m"))
|
||||||
"-A",
|
self.assertEqual(nat[-2:], ("-j", "MASQUERADE"))
|
||||||
"FORWARD",
|
inbound, outbound = self.runner.rules["filter", "FORWARD"]
|
||||||
"-i",
|
self.assertEqual(outbound[:4], ("-i", "uvm0", "-s", "10.42.0.0/24"))
|
||||||
"uvm0",
|
self.assertEqual(inbound[:8], (
|
||||||
"-o",
|
"-o", "uvm0", "-d", "10.42.0.0/24", "-m", "conntrack",
|
||||||
"eth0",
|
"--ctstate", "ESTABLISHED,RELATED",
|
||||||
"-s",
|
))
|
||||||
"10.42.0.0/24",
|
self.assertEqual(inbound[-2:], ("-j", "ACCEPT"))
|
||||||
"-j",
|
self.assertEqual(outbound[-2:], ("-j", "ACCEPT"))
|
||||||
"ACCEPT",
|
|
||||||
),
|
def test_egress_is_installed_offline_and_survives_uplink_changes_without_extra_rules(self) -> None:
|
||||||
self.runner.calls,
|
self.runner.default_routes = ""
|
||||||
)
|
self.network.ensure_bridge()
|
||||||
checks = [
|
offline_rules = deepcopy(self.runner.rules)
|
||||||
call
|
self.assertEqual([len(rules) for rules in offline_rules.values()], [1, 2])
|
||||||
for call in self.runner.calls
|
|
||||||
if call[:5] == ("iptables", "-t", "nat", "-C", "POSTROUTING")
|
for route in (
|
||||||
|
"default via 192.0.2.1 dev eth0\n",
|
||||||
|
"default via 198.51.100.1 dev wlan0\n",
|
||||||
|
"default dev tun0\ndefault via 198.51.100.1 dev wlan0 metric 600\n",
|
||||||
|
"",
|
||||||
|
):
|
||||||
|
with self.subTest(route=route):
|
||||||
|
self.runner.default_routes = route
|
||||||
|
self.runner.calls.clear()
|
||||||
|
self.network.ensure_bridge()
|
||||||
|
self.assertEqual(self.runner.rules, offline_rules)
|
||||||
|
self.assertFalse(any("-I" in call for call in self.runner.calls))
|
||||||
|
self.assertFalse(any("eth0" in rule or "wlan0" in rule or "tun0" in rule
|
||||||
|
for rules in offline_rules.values() for rule in rules))
|
||||||
|
|
||||||
|
def test_egress_exceptions_precede_existing_drops_and_preserve_foreign_and_legacy_rules(self) -> None:
|
||||||
|
existing = [
|
||||||
|
("-j", "HOST-FIREWALL"), ("-j", "DROP"),
|
||||||
|
("-i", "uvm0", "-o", "eth0", "-s", "10.42.0.0/24", "-j", "ACCEPT"),
|
||||||
]
|
]
|
||||||
self.assertEqual(len(checks), 1)
|
self.runner.rules["filter", "FORWARD"] = list(existing)
|
||||||
self.assertIn(
|
self.network.ensure_bridge()
|
||||||
(
|
rules = list(self.runner.rules["filter", "FORWARD"])
|
||||||
"iptables",
|
self.assertEqual(rules[2:], existing)
|
||||||
"-t",
|
self.assertTrue(all("uvm-egress" in rule and rule[-1] == "ACCEPT" for rule in rules[:2]))
|
||||||
"nat",
|
self.network.ensure_bridge()
|
||||||
"-A",
|
self.assertEqual(self.runner.rules["filter", "FORWARD"], rules)
|
||||||
"POSTROUTING",
|
|
||||||
"-s",
|
def test_iptables_inspection_errors_are_not_treated_as_missing_rules(self) -> None:
|
||||||
"10.42.0.0/24",
|
check = ("iptables", "--wait", "5", "-t", "nat", "-C", "POSTROUTING",
|
||||||
"-o",
|
"-s", "10.42.0.0/24", "!", "-o", "uvm0",
|
||||||
"eth0",
|
"-m", "comment", "--comment", "uvm-egress", "-j", "MASQUERADE")
|
||||||
"-j",
|
with patch.object(self.runner, "run", return_value=CommandResult(check, 4, stderr="permission denied")) as run:
|
||||||
"MASQUERADE",
|
with self.assertRaisesRegex(CommandError, "could not inspect.*permission denied"):
|
||||||
),
|
self.network._ensure_masquerade()
|
||||||
self.runner.calls,
|
self.assertEqual(run.call_count, 1)
|
||||||
)
|
|
||||||
|
|
||||||
def test_bridge_and_guest_network_support_non_default_prefixes(self) -> None:
|
def test_bridge_and_guest_network_support_non_default_prefixes(self) -> None:
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
@@ -178,6 +210,10 @@ class NetworkManagerTests(unittest.TestCase):
|
|||||||
("ip", "addr", "replace", "10.50.0.1/16", "dev", "uvm0"),
|
("ip", "addr", "replace", "10.50.0.1/16", "dev", "uvm0"),
|
||||||
runner.calls,
|
runner.calls,
|
||||||
)
|
)
|
||||||
|
for rules in runner.rules.values():
|
||||||
|
for rule in rules:
|
||||||
|
self.assertIn("10.50.0.0/16", rule)
|
||||||
|
self.assertNotIn("10.42.0.0/24", rule)
|
||||||
|
|
||||||
def test_refuses_to_adopt_an_existing_tap_device(self) -> None:
|
def test_refuses_to_adopt_an_existing_tap_device(self) -> None:
|
||||||
for alias in (None, "foreign-owner", f"uvm:{self.vm.id}"):
|
for alias in (None, "foreign-owner", f"uvm:{self.vm.id}"):
|
||||||
|
|||||||
+39
-8
@@ -7,7 +7,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from uvm.cli import build_parser, main
|
from uvm.cli import build_parser, main
|
||||||
from uvm.config import Settings
|
from uvm.config import Settings
|
||||||
@@ -99,19 +99,31 @@ class ServerCliTests(unittest.TestCase):
|
|||||||
application = fake_application(api_token="test-token")
|
application = fake_application(api_token="test-token")
|
||||||
application.settings.api_tls_cert = certificate
|
application.settings.api_tls_cert = certificate
|
||||||
application.settings.api_tls_key = key
|
application.settings.api_tls_key = key
|
||||||
fake_uvicorn = SimpleNamespace(run=lambda *_args, **_kwargs: None)
|
api = SimpleNamespace(state=SimpleNamespace())
|
||||||
|
server = SimpleNamespace(run=Mock())
|
||||||
|
config = object()
|
||||||
|
fake_uvicorn = SimpleNamespace(
|
||||||
|
Config=Mock(return_value=config),
|
||||||
|
Server=Mock(return_value=server),
|
||||||
|
)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("uvm.server._validate_server_settings"),
|
patch("uvm.server._validate_server_settings"),
|
||||||
patch("uvm.server.create_api", return_value=object()),
|
patch("uvm.server.create_api", return_value=api),
|
||||||
patch.dict(sys.modules, {"uvicorn": fake_uvicorn}),
|
patch.dict(sys.modules, {"uvicorn": fake_uvicorn}),
|
||||||
patch.object(fake_uvicorn, "run") as run_mock,
|
|
||||||
):
|
):
|
||||||
run_server(application, host="0.0.0.0", port=8443)
|
run_server(application, host="0.0.0.0", port=8443)
|
||||||
|
|
||||||
run_mock.assert_called_once()
|
fake_uvicorn.Config.assert_called_once_with(
|
||||||
self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate))
|
api,
|
||||||
self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key))
|
host="0.0.0.0",
|
||||||
|
port=8443,
|
||||||
|
ssl_certfile=str(certificate),
|
||||||
|
ssl_keyfile=str(key),
|
||||||
|
)
|
||||||
|
fake_uvicorn.Server.assert_called_once_with(config)
|
||||||
|
self.assertIs(api.state.uvicorn_server, server)
|
||||||
|
server.run.assert_called_once_with()
|
||||||
|
|
||||||
def test_api_token_rejects_non_ascii_or_whitespace(self) -> None:
|
def test_api_token_rejects_non_ascii_or_whitespace(self) -> None:
|
||||||
for token in ("s\u00e9cret", "contains space", ""):
|
for token in ("s\u00e9cret", "contains space", ""):
|
||||||
@@ -300,7 +312,8 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
application.state_store = SimpleNamespace(initialize=lambda: None)
|
application.state_store = SimpleNamespace(initialize=lambda: None)
|
||||||
self.headers = {"X-UVM-Token": "test-token"}
|
self.headers = {"X-UVM-Token": "test-token"}
|
||||||
self.client = TestClient(create_api(application, host="127.0.0.1"))
|
self.api = create_api(application, host="127.0.0.1")
|
||||||
|
self.client = TestClient(self.api)
|
||||||
self.addCleanup(self.client.close)
|
self.addCleanup(self.client.close)
|
||||||
|
|
||||||
def test_health_is_available_without_credentials(self) -> None:
|
def test_health_is_available_without_credentials(self) -> None:
|
||||||
@@ -339,6 +352,7 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
("POST", "/vms/test-vm/ports", {}),
|
("POST", "/vms/test-vm/ports", {}),
|
||||||
("POST", "/vms/test-vm/ports/sync", {}),
|
("POST", "/vms/test-vm/ports/sync", {}),
|
||||||
("GET", "/diagnostics", {}),
|
("GET", "/diagnostics", {}),
|
||||||
|
("POST", "/exit", {}),
|
||||||
("POST", "/install", {}),
|
("POST", "/install", {}),
|
||||||
):
|
):
|
||||||
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
|
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
|
||||||
@@ -353,6 +367,22 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(authorized.json()[0]["id"], "vm-test")
|
self.assertEqual(authorized.json()[0]["id"], "vm-test")
|
||||||
self.assertNotIn("password", authorized.json()[0])
|
self.assertNotIn("password", authorized.json()[0])
|
||||||
|
|
||||||
|
def test_exit_requests_a_graceful_authenticated_server_shutdown(self) -> None:
|
||||||
|
server = SimpleNamespace(should_exit=False)
|
||||||
|
self.api.state.uvicorn_server = server
|
||||||
|
|
||||||
|
response = self.client.post("/exit", headers=self.headers)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
self.assertEqual(response.json(), {"status": "stopping"})
|
||||||
|
self.assertTrue(server.should_exit)
|
||||||
|
|
||||||
|
def test_exit_requires_a_running_uvicorn_server(self) -> None:
|
||||||
|
response = self.client.post("/exit", headers=self.headers)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 503)
|
||||||
|
self.assertEqual(response.json(), {"detail": "server shutdown is unavailable outside uvm --serve"})
|
||||||
|
|
||||||
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
|
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
|
||||||
created = self.client.post(
|
created = self.client.post(
|
||||||
"/vms",
|
"/vms",
|
||||||
@@ -752,6 +782,7 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
("/", "get"),
|
("/", "get"),
|
||||||
("/dashboard", "get"),
|
("/dashboard", "get"),
|
||||||
("/health", "get"),
|
("/health", "get"),
|
||||||
|
("/exit", "post"),
|
||||||
("/install", "post"),
|
("/install", "post"),
|
||||||
("/vms", "get"),
|
("/vms", "get"),
|
||||||
("/vms", "post"),
|
("/vms", "post"),
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class HealthResponse(BaseModel):
|
|||||||
status: str = "ok"
|
status: str = "ok"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerExitResponse(BaseModel):
|
||||||
|
status: str = "stopping"
|
||||||
|
|
||||||
|
|
||||||
class InstallRequest(BaseModel):
|
class InstallRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -74,8 +74,9 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
|||||||
install = subcommands.add_parser(
|
install = subcommands.add_parser(
|
||||||
"install",
|
"install",
|
||||||
description=(
|
description=(
|
||||||
"Set UVM_FIRECRACKER_SHA256, UVM_KERNEL_SHA256, and UVM_ROOTFS_SHA256 "
|
"Set UVM_FIRECRACKER_SHA256 and UVM_KERNEL_SHA256 to trusted digests. "
|
||||||
"to trusted digests. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit "
|
"The default Ubuntu rootfs is built from signed packages. A custom "
|
||||||
|
"UVM_ROOTFS_URL also requires UVM_ROOTFS_SHA256. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit "
|
||||||
"development-only opt-out."
|
"development-only opt-out."
|
||||||
),
|
),
|
||||||
help="install dependencies and assets; SHA-256 values are required by default",
|
help="install dependencies and assets; SHA-256 values are required by default",
|
||||||
@@ -83,7 +84,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
|||||||
install.add_argument(
|
install.add_argument(
|
||||||
"--force",
|
"--force",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="redownload Firecracker, kernel, and rootfs even when local assets already exist",
|
help="refresh downloads and rebuild the default Ubuntu rootfs even when assets exist",
|
||||||
)
|
)
|
||||||
|
|
||||||
create = subcommands.add_parser("create", help="create and boot a microVM detached")
|
create = subcommands.add_parser("create", help="create and boot a microVM detached")
|
||||||
@@ -111,7 +112,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
|||||||
create.add_argument(
|
create.add_argument(
|
||||||
"--username",
|
"--username",
|
||||||
default=application.settings.default_ssh_user,
|
default=application.settings.default_ssh_user,
|
||||||
help="guest login account; created if missing (defaults to root)",
|
help="guest administrator account; created if missing, with sudo access (defaults to root)",
|
||||||
)
|
)
|
||||||
credentials = create.add_mutually_exclusive_group()
|
credentials = create.add_mutually_exclusive_group()
|
||||||
credentials.add_argument(
|
credentials.add_argument(
|
||||||
|
|||||||
+5
-5
@@ -10,7 +10,9 @@ from pathlib import Path
|
|||||||
from .errors import ConfigurationError
|
from .errors import ConfigurationError
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide"
|
# Pin published kernels with built-in virtio-rng support. The artifact series
|
||||||
|
# is independent of the VMM release; the old ARM quickstart kernel lacks this driver.
|
||||||
|
DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15"
|
||||||
MIN_STORAGE_MIB = 30 * 1024
|
MIN_STORAGE_MIB = 30 * 1024
|
||||||
MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
|
MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ class Settings:
|
|||||||
default_vcpu: float = 1
|
default_vcpu: float = 1
|
||||||
default_ssh_user: str = "root"
|
default_ssh_user: str = "root"
|
||||||
kernel_url: str = ""
|
kernel_url: str = ""
|
||||||
rootfs_url: str = ""
|
rootfs_url: str = "" # Empty selects the locally built, package-managed Ubuntu image.
|
||||||
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
|
||||||
@@ -55,9 +57,7 @@ class Settings:
|
|||||||
# Resolve defaults at construction time, while preserving individual URL overrides.
|
# Resolve defaults at construction time, while preserving individual URL overrides.
|
||||||
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
|
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
|
||||||
if not self.kernel_url:
|
if not self.kernel_url:
|
||||||
object.__setattr__(self, "kernel_url", f"{asset_base}/kernels/vmlinux.bin")
|
object.__setattr__(self, "kernel_url", f"{asset_base}/vmlinux-6.1.155")
|
||||||
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(
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
<form id="create-form" class="create-layout">
|
<form id="create-form" class="create-layout">
|
||||||
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
|
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
|
||||||
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
|
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
|
||||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory. Credentials are applied to the machine’s private disk.</p></section>
|
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory and sudo access using their guest password. Credentials are applied to the machine’s private disk.</p></section>
|
||||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
|
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
|
||||||
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
|
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
|
||||||
</form>
|
</form>
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
<section id="page-system" class="page" aria-labelledby="system-heading" hidden>
|
<section id="page-system" class="page" aria-labelledby="system-heading" hidden>
|
||||||
<div class="page-heading"><div><span class="subtle-label">Behind every little machine</span><h1 id="system-heading" tabindex="-1">A look at your host<span class="heading-period">.</span></h1></div><button id="diagnostics-refresh-btn" class="button" type="button" data-action="diagnostics"><svg class="icon"><use href="#i-refresh"/></svg>Run checks</button></div>
|
<div class="page-heading"><div><span class="subtle-label">Behind every little machine</span><h1 id="system-heading" tabindex="-1">A look at your host<span class="heading-period">.</span></h1></div><button id="diagnostics-refresh-btn" class="button" type="button" data-action="diagnostics"><svg class="icon"><use href="#i-refresh"/></svg>Run checks</button></div>
|
||||||
<div id="health-summary" class="compact-metrics"></div>
|
<div id="health-summary" class="compact-metrics"></div>
|
||||||
<div class="health-layout"><article class="soft-panel diagnostics-panel"><header class="section-heading"><div><h2>The essentials, checked</h2><span>Readiness checks from your local host.</span></div><span id="diagnostics-count" class="tag">Checking</span></header><div id="diagnostics-list" class="diagnostics-list"><div class="loading-state">Checking in with your host…</div></div></article><div class="health-side"><article class="soft-panel install-panel"><span class="round-icon"><svg class="icon"><use href="#i-download"/></svg></span><h2>A foundation for your fleet</h2><p>Install the Firecracker binary, guest kernel, and root filesystem, ready for your next machine.</p><div class="install-assets"><span><svg class="icon"><use href="#i-cpu"/></svg>Firecracker</span><span><svg class="icon"><use href="#i-terminal"/></svg>Guest kernel</span><span><svg class="icon"><use href="#i-server"/></svg>Root filesystem</span></div><label class="toggle-field"><span><strong>Refresh existing assets</strong><small>Reinstall with force enabled.</small></span><input id="install-force" type="checkbox" /><span class="toggle-track" aria-hidden="true"></span></label><button id="install-btn" class="button button-primary button-wide" type="button" data-action="install"><span>Install assets</span><svg class="icon"><use href="#i-arrow"/></svg></button><pre id="install-result" class="install-result" role="status" hidden></pre></article><article class="soft-panel server-info"><header class="section-heading"><h2>Close to home</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl class="detail-list"><div><dt>API origin</dt><dd id="info-api-url"></dd></div><div><dt>Last host check</dt><dd id="info-observed">Not checked yet</dd></div><div><dt>Connection</dt><dd>Session token</dd></div></dl><a href="/docs" target="_blank" rel="noopener" class="quiet-link">Open API reference <svg class="icon"><use href="#i-arrow"/></svg></a></article></div></div>
|
<div class="health-layout"><article class="soft-panel diagnostics-panel"><header class="section-heading"><div><h2>The essentials, checked</h2><span>Readiness checks from your local host.</span></div><span id="diagnostics-count" class="tag">Checking</span></header><div id="diagnostics-list" class="diagnostics-list"><div class="loading-state">Checking in with your host…</div></div></article><div class="health-side"><article class="soft-panel install-panel"><span class="round-icon"><svg class="icon"><use href="#i-download"/></svg></span><h2>A foundation for your fleet</h2><p>Install Firecracker, a guest kernel, and a root filesystem. The default Ubuntu 22.04 image includes SSH, sudo, and apt.</p><div class="install-assets"><span><svg class="icon"><use href="#i-cpu"/></svg>Firecracker</span><span><svg class="icon"><use href="#i-terminal"/></svg>Guest kernel</span><span><svg class="icon"><use href="#i-server"/></svg>Root filesystem</span></div><label class="toggle-field"><span><strong>Refresh existing assets</strong><small>Refresh downloads and rebuild the Ubuntu image.</small></span><input id="install-force" type="checkbox" /><span class="toggle-track" aria-hidden="true"></span></label><button id="install-btn" class="button button-primary button-wide" type="button" data-action="install"><span>Install assets</span><svg class="icon"><use href="#i-arrow"/></svg></button><pre id="install-result" class="install-result" role="status" hidden></pre></article><article class="soft-panel server-info"><header class="section-heading"><h2>Close to home</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl class="detail-list"><div><dt>API origin</dt><dd id="info-api-url"></dd></div><div><dt>Last host check</dt><dd id="info-observed">Not checked yet</dd></div><div><dt>Connection</dt><dd>Session token</dd></div></dl><a href="/docs" target="_blank" rel="noopener" class="quiet-link">Open API reference <svg class="icon"><use href="#i-arrow"/></svg></a></article></div></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,12 +35,15 @@ class FirecrackerClient:
|
|||||||
boot = config["boot-source"]
|
boot = config["boot-source"]
|
||||||
drives = config["drives"]
|
drives = config["drives"]
|
||||||
interfaces = config["network-interfaces"]
|
interfaces = config["network-interfaces"]
|
||||||
|
entropy = config.get("entropy")
|
||||||
if not isinstance(machine, Mapping) or not isinstance(boot, Mapping):
|
if not isinstance(machine, Mapping) or not isinstance(boot, Mapping):
|
||||||
raise FirecrackerError("invalid Firecracker configuration")
|
raise FirecrackerError("invalid Firecracker configuration")
|
||||||
if not isinstance(drives, list) or not drives:
|
if not isinstance(drives, list) or not drives:
|
||||||
raise FirecrackerError("Firecracker configuration has no root drive")
|
raise FirecrackerError("Firecracker configuration has no root drive")
|
||||||
if not isinstance(interfaces, list) or not interfaces:
|
if not isinstance(interfaces, list) or not interfaces:
|
||||||
raise FirecrackerError("Firecracker configuration has no network interface")
|
raise FirecrackerError("Firecracker configuration has no network interface")
|
||||||
|
if not isinstance(entropy, Mapping):
|
||||||
|
raise FirecrackerError("Firecracker configuration has no valid entropy device")
|
||||||
|
|
||||||
self._request(
|
self._request(
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -54,6 +57,7 @@ class FirecrackerClient:
|
|||||||
self._request("PUT", "/boot-source", boot)
|
self._request("PUT", "/boot-source", boot)
|
||||||
self._request("PUT", "/drives/rootfs", drives[0])
|
self._request("PUT", "/drives/rootfs", drives[0])
|
||||||
self._request("PUT", "/network-interfaces/eth0", interfaces[0])
|
self._request("PUT", "/network-interfaces/eth0", interfaces[0])
|
||||||
|
self._request("PUT", "/entropy", entropy)
|
||||||
self._request("PUT", "/actions", {"action_type": "InstanceStart"})
|
self._request("PUT", "/actions", {"action_type": "InstanceStart"})
|
||||||
|
|
||||||
def _request(self, method: str, path: str, body: object) -> None:
|
def _request(self, method: str, path: str, body: object) -> None:
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ def build_config(
|
|||||||
"host_dev_name": vm.tap,
|
"host_dev_name": vm.tap,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
# Supply host-backed randomness before guest services such as sshd start.
|
||||||
|
"entropy": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+139
-3
@@ -8,6 +8,7 @@ import stat
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from ipaddress import IPv4Address
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
||||||
@@ -35,6 +36,16 @@ _HOST_KEY_TYPES = (
|
|||||||
("ed25519", None),
|
("ed25519", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Keep the kernel's proto-boot default route until networkd takes over. On
|
||||||
|
# Ubuntu 22.04, KeepConfiguration=static preserves addresses but drops that route.
|
||||||
|
GUEST_NETWORK_TEMPLATE = (
|
||||||
|
"[Match]\nName=eth0\n\n[Network]\n"
|
||||||
|
"KeepConfiguration=yes\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
|
||||||
|
)
|
||||||
|
_LEGACY_GUEST_NETWORK_TEMPLATE = GUEST_NETWORK_TEMPLATE.replace(
|
||||||
|
"KeepConfiguration=yes", "KeepConfiguration=static",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class GuestAssets:
|
class GuestAssets:
|
||||||
@@ -150,6 +161,91 @@ class ImageStore:
|
|||||||
finally:
|
finally:
|
||||||
temporary.unlink(missing_ok=True)
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def configure_guest_network(self, disk: Path, guest_ip: str) -> bool:
|
||||||
|
"""Specialize an unedited UVM profile on a proven-offline private disk.
|
||||||
|
|
||||||
|
Lifecycle owns the operation lock and confirms process exit. Stage and
|
||||||
|
check a sparse copy so failed migrations retain the original disk. A
|
||||||
|
completed migration, custom image, or guest-edited profile is a no-op.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
address = IPv4Address(guest_ip)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValidationError("guest network configuration requires a valid IPv4 address") from error
|
||||||
|
if address not in self._settings.network or address in {
|
||||||
|
self._settings.network.network_address,
|
||||||
|
self._settings.network.broadcast_address,
|
||||||
|
self._settings.gateway,
|
||||||
|
}:
|
||||||
|
raise ValidationError("guest network configuration requires a usable IP inside the UVM subnet")
|
||||||
|
|
||||||
|
try:
|
||||||
|
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 configure: {disk}")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="uvm-guest-network-") as name:
|
||||||
|
files = Path(name)
|
||||||
|
if self._unconfigured_guest_network(disk, files) is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-network-", suffix=".ext4", dir=disk.parent)
|
||||||
|
os.close(descriptor)
|
||||||
|
temporary = Path(candidate)
|
||||||
|
try:
|
||||||
|
self._copy_sparse(disk, temporary)
|
||||||
|
temporary.chmod(0o600)
|
||||||
|
checked = self._runner.run(("e2fsck", "-f", "-p", temporary), 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 guest network configuration: {detail}")
|
||||||
|
# Journal replay may reveal guest edits not visible to the
|
||||||
|
# initial debugfs read. Recheck ownership on the recovered copy.
|
||||||
|
profile = self._unconfigured_guest_network(temporary, files)
|
||||||
|
if profile is None:
|
||||||
|
return False
|
||||||
|
profile.path.write_text(
|
||||||
|
GUEST_NETWORK_TEMPLATE
|
||||||
|
+ f"Address={address}/{self._settings.network.prefixlen}\n"
|
||||||
|
+ f"Gateway={self._settings.gateway}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self._write_guest_file(temporary, "/etc/systemd/network/10-uvm.network", profile)
|
||||||
|
self._runner.run(("e2fsck", "-f", "-n", temporary), capture=True)
|
||||||
|
with temporary.open("rb") as image:
|
||||||
|
os.fsync(image.fileno())
|
||||||
|
os.replace(temporary, disk)
|
||||||
|
directory = os.open(disk.parent, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return True
|
||||||
|
except (OSError, UnicodeError) as error:
|
||||||
|
raise UvmError(f"could not configure guest networking: {error}") from error
|
||||||
|
|
||||||
|
def _unconfigured_guest_network(self, disk: Path, temporary: Path) -> _GuestFile | None:
|
||||||
|
marker = self._read_guest_file(
|
||||||
|
disk, "/etc/uvm-image", temporary / "marker", required=False, regular_only=True,
|
||||||
|
)
|
||||||
|
if marker is None or marker.path.read_text(encoding="utf-8") not in {
|
||||||
|
"uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v2\n", "uvm-ubuntu-jammy-v3\n",
|
||||||
|
}:
|
||||||
|
return None
|
||||||
|
profile = self._read_guest_file(
|
||||||
|
disk, "/etc/systemd/network/10-uvm.network", temporary / "network", required=False, regular_only=True,
|
||||||
|
)
|
||||||
|
if (profile is None or (profile.uid, profile.gid, profile.mode, profile.links) != (0, 0, 0o644, 1)
|
||||||
|
or profile.has_extended_attributes):
|
||||||
|
return None
|
||||||
|
if profile.path.read_text(encoding="utf-8") not in {
|
||||||
|
_LEGACY_GUEST_NETWORK_TEMPLATE, GUEST_NETWORK_TEMPLATE,
|
||||||
|
}:
|
||||||
|
return None
|
||||||
|
return profile
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_storage(storage_mib: int) -> None:
|
def _validate_storage(storage_mib: int) -> None:
|
||||||
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
|
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
|
||||||
@@ -226,6 +322,8 @@ class ImageStore:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
shadow.path.chmod(shadow.mode)
|
shadow.path.chmod(shadow.mode)
|
||||||
|
if username != "root":
|
||||||
|
self._provision_guest_sudo(disk, username, temporary)
|
||||||
|
|
||||||
sshd_config.path.write_text(
|
sshd_config.path.write_text(
|
||||||
_enable_ssh_password_authentication(
|
_enable_ssh_password_authentication(
|
||||||
@@ -244,6 +342,33 @@ class ImageStore:
|
|||||||
except (OSError, UnicodeError) as error:
|
except (OSError, UnicodeError) as error:
|
||||||
raise UvmError(f"could not provision guest credentials: {error}") from error
|
raise UvmError(f"could not provision guest credentials: {error}") from error
|
||||||
|
|
||||||
|
def _provision_guest_sudo(self, disk: Path, username: str, temporary: Path) -> None:
|
||||||
|
sudoers = self._read_guest_file(disk, "/etc/sudoers", temporary / "sudoers", required=False)
|
||||||
|
sudo = self._runner.run(("debugfs", "-R", "stat /usr/bin/sudo", disk), capture=True)
|
||||||
|
mode = _MODE_PATTERN.search(sudo.stdout)
|
||||||
|
owner = _OWNER_PATTERN.search(sudo.stdout)
|
||||||
|
if (
|
||||||
|
sudoers is None
|
||||||
|
or sudoers.uid != 0 or sudoers.mode & 0o022
|
||||||
|
or re.search(r"Type:\s+regular\b", sudo.stdout) is None
|
||||||
|
or mode is None or not int(mode.group(1), 8) & 0o4000
|
||||||
|
or not int(mode.group(1), 8) & 0o111
|
||||||
|
or owner is None or int(owner.group(1)) != 0
|
||||||
|
):
|
||||||
|
raise UvmError(
|
||||||
|
"guest image needs setuid-root sudo and /etc/sudoers for a custom administrator; "
|
||||||
|
"run uvm install --force to build the complete Ubuntu image"
|
||||||
|
)
|
||||||
|
contents = sudoers.path.read_text(encoding="utf-8").rstrip()
|
||||||
|
sudoers.path.write_text(
|
||||||
|
contents + "\n\n# Managed by uvm: guest administrator\n"
|
||||||
|
+ f"Defaults:{username} !rootpw, !runaspw, !targetpw\n"
|
||||||
|
+ f"{username} ALL=(ALL:ALL) PASSWD: ALL\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
sudoers.path.chmod(sudoers.mode)
|
||||||
|
self._write_guest_file(disk, "/etc/sudoers", sudoers)
|
||||||
|
|
||||||
def _create_guest_user(
|
def _create_guest_user(
|
||||||
self,
|
self,
|
||||||
disk: Path,
|
disk: Path,
|
||||||
@@ -368,12 +493,16 @@ class ImageStore:
|
|||||||
destination: Path,
|
destination: Path,
|
||||||
*,
|
*,
|
||||||
required: bool = True,
|
required: bool = True,
|
||||||
|
regular_only: bool = False,
|
||||||
) -> _GuestFile | None:
|
) -> _GuestFile | None:
|
||||||
stat_result = self._runner.run(
|
stat_result = self._runner.run(
|
||||||
("debugfs", "-R", f"stat {guest_path}", disk),
|
("debugfs", "-R", f"stat {guest_path}", disk),
|
||||||
check=False,
|
check=False,
|
||||||
capture=True,
|
capture=True,
|
||||||
)
|
)
|
||||||
|
file_type = re.search(r"Type:\s+(\S+)", stat_result.stdout)
|
||||||
|
if regular_only and file_type is not None and file_type.group(1) != "regular":
|
||||||
|
return None
|
||||||
mode_match = _MODE_PATTERN.search(stat_result.stdout)
|
mode_match = _MODE_PATTERN.search(stat_result.stdout)
|
||||||
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
|
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
|
||||||
flags_match = _FLAGS_PATTERN.search(stat_result.stdout)
|
flags_match = _FLAGS_PATTERN.search(stat_result.stdout)
|
||||||
@@ -562,12 +691,16 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
|||||||
}
|
}
|
||||||
if username == "root":
|
if username == "root":
|
||||||
managed_directives.add("permitrootlogin")
|
managed_directives.add("permitrootlogin")
|
||||||
unmanaged_lines = []
|
global_lines = []
|
||||||
|
match_lines = []
|
||||||
|
in_match = False
|
||||||
for line in contents.splitlines():
|
for line in contents.splitlines():
|
||||||
stripped = line.lstrip()
|
stripped = line.lstrip()
|
||||||
directive = stripped.split(None, 1)[0].lower() if stripped else ""
|
directive = stripped.split(None, 1)[0].lower() if stripped else ""
|
||||||
|
if directive == "match":
|
||||||
|
in_match = True
|
||||||
if stripped.startswith("#") or directive not in managed_directives:
|
if stripped.startswith("#") or directive not in managed_directives:
|
||||||
unmanaged_lines.append(line)
|
(match_lines if in_match else global_lines).append(line)
|
||||||
|
|
||||||
directives = [
|
directives = [
|
||||||
"HostKey /etc/ssh/ssh_host_rsa_key",
|
"HostKey /etc/ssh/ssh_host_rsa_key",
|
||||||
@@ -579,13 +712,16 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
|||||||
]
|
]
|
||||||
if username == "root":
|
if username == "root":
|
||||||
directives.append("PermitRootLogin yes")
|
directives.append("PermitRootLogin yes")
|
||||||
|
# Global-only directives (e.g. UsePAM and Subsystem) must precede every Match block.
|
||||||
|
globals_text = "\n".join(global_lines).strip("\n")
|
||||||
return (
|
return (
|
||||||
"# Managed by uvm\n"
|
"# Managed by uvm\n"
|
||||||
+ "\n".join(directives)
|
+ "\n".join(directives)
|
||||||
|
+ (f"\n{globals_text}" if globals_text else "")
|
||||||
+ f"\nMatch User {username}\n"
|
+ f"\nMatch User {username}\n"
|
||||||
+ " PasswordAuthentication yes\n"
|
+ " PasswordAuthentication yes\n"
|
||||||
+ "Match all\n"
|
+ "Match all\n"
|
||||||
+ "\n".join(unmanaged_lines).rstrip()
|
+ "\n".join(match_lines).rstrip()
|
||||||
+ "\n"
|
+ "\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+236
-24
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -13,12 +14,43 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .errors import UvmError
|
from .errors import UvmError
|
||||||
from .images import GuestAssets
|
from .images import GUEST_NETWORK_TEMPLATE, GuestAssets
|
||||||
from .integrity import require_checksum, sha256_file, verify_file, write_manifest
|
from .integrity import load_manifest, require_checksum, sha256_file, verify_file, write_manifest
|
||||||
from .state import StateStore
|
from .state import StateStore
|
||||||
from .system import CommandRunner, check_kvm, ensure_data_directories, require_root
|
from .system import CommandRunner, check_kvm, ensure_data_directories, require_root
|
||||||
|
|
||||||
|
|
||||||
|
_UBUNTU_RELEASE = "jammy"
|
||||||
|
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v3\n"
|
||||||
|
_GUEST_PACKAGES = (
|
||||||
|
"ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev",
|
||||||
|
"dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl",
|
||||||
|
)
|
||||||
|
# Keep the image mountable by the older Firecracker quickstart guest kernels.
|
||||||
|
_EXT4_FEATURES = (
|
||||||
|
"none,has_journal,ext_attr,resize_inode,dir_index,filetype,extent,64bit,"
|
||||||
|
"flex_bg,sparse_super,large_file,huge_file,dir_nlink,extra_isize,metadata_csum"
|
||||||
|
)
|
||||||
|
_ROOTFS_NAMESPACE = (
|
||||||
|
"unshare", "--mount", "--propagation", "private", "--pid", "--fork",
|
||||||
|
"--kill-child", "--mount-proc",
|
||||||
|
)
|
||||||
|
_GUEST_APT_SCRIPT = """\
|
||||||
|
root=$1
|
||||||
|
shift
|
||||||
|
mount --rbind /dev "$root/dev"
|
||||||
|
mount --make-rslave "$root/dev"
|
||||||
|
mount -t proc proc "$root/proc"
|
||||||
|
mount -t sysfs -o ro sysfs "$root/sys"
|
||||||
|
chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\
|
||||||
|
apt-get -o APT::Update::Error-Mode=any update
|
||||||
|
chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\
|
||||||
|
apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade
|
||||||
|
chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\
|
||||||
|
apt-get -y --no-install-recommends install "$@"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Installer:
|
class Installer:
|
||||||
"""Install exactly the local host dependencies used by the uvm CLI."""
|
"""Install exactly the local host dependencies used by the uvm CLI."""
|
||||||
|
|
||||||
@@ -68,12 +100,18 @@ class Installer:
|
|||||||
"UVM_KERNEL_SHA256",
|
"UVM_KERNEL_SHA256",
|
||||||
allow_unverified=self._settings.allow_unverified_downloads,
|
allow_unverified=self._settings.allow_unverified_downloads,
|
||||||
)
|
)
|
||||||
require_checksum(
|
if self._settings.rootfs_url:
|
||||||
self._settings.rootfs_sha256,
|
require_checksum(
|
||||||
"guest root filesystem",
|
self._settings.rootfs_sha256,
|
||||||
"UVM_ROOTFS_SHA256",
|
"guest root filesystem",
|
||||||
allow_unverified=self._settings.allow_unverified_downloads,
|
"UVM_ROOTFS_SHA256",
|
||||||
)
|
allow_unverified=self._settings.allow_unverified_downloads,
|
||||||
|
)
|
||||||
|
elif self._settings.rootfs_sha256:
|
||||||
|
raise UvmError(
|
||||||
|
"UVM_ROOTFS_SHA256 requires UVM_ROOTFS_URL for a prebuilt image; "
|
||||||
|
"unset UVM_ROOTFS_SHA256 to build the default Ubuntu rootfs"
|
||||||
|
)
|
||||||
|
|
||||||
def _write_integrity_manifest(self, firecracker: Path, assets: GuestAssets) -> None:
|
def _write_integrity_manifest(self, firecracker: Path, assets: GuestAssets) -> None:
|
||||||
write_manifest(
|
write_manifest(
|
||||||
@@ -88,7 +126,7 @@ class Installer:
|
|||||||
(
|
(
|
||||||
self._settings.firecracker_sha256,
|
self._settings.firecracker_sha256,
|
||||||
self._settings.kernel_sha256,
|
self._settings.kernel_sha256,
|
||||||
self._settings.rootfs_sha256,
|
self._settings.rootfs_sha256 if self._settings.rootfs_url else True,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -108,6 +146,8 @@ class Installer:
|
|||||||
"openssl",
|
"openssl",
|
||||||
"openssh-client",
|
"openssh-client",
|
||||||
)
|
)
|
||||||
|
if not self._settings.rootfs_url:
|
||||||
|
packages += ("debootstrap", "ubuntu-keyring")
|
||||||
self._runner.run(("apt-get", "update"))
|
self._runner.run(("apt-get", "update"))
|
||||||
self._runner.run(("apt-get", "install", "-y", *packages))
|
self._runner.run(("apt-get", "install", "-y", *packages))
|
||||||
|
|
||||||
@@ -183,14 +223,6 @@ class Installer:
|
|||||||
label="guest kernel",
|
label="guest kernel",
|
||||||
environment_name="UVM_KERNEL_SHA256",
|
environment_name="UVM_KERNEL_SHA256",
|
||||||
)
|
)
|
||||||
if force or not rootfs.exists():
|
|
||||||
self.download(
|
|
||||||
self._settings.rootfs_url,
|
|
||||||
rootfs,
|
|
||||||
expected_sha256=self._settings.rootfs_sha256,
|
|
||||||
label="guest root filesystem",
|
|
||||||
environment_name="UVM_ROOTFS_SHA256",
|
|
||||||
)
|
|
||||||
verify_file(
|
verify_file(
|
||||||
kernel,
|
kernel,
|
||||||
self._settings.kernel_sha256,
|
self._settings.kernel_sha256,
|
||||||
@@ -198,15 +230,195 @@ class Installer:
|
|||||||
"UVM_KERNEL_SHA256",
|
"UVM_KERNEL_SHA256",
|
||||||
allow_unverified=self._settings.allow_unverified_downloads,
|
allow_unverified=self._settings.allow_unverified_downloads,
|
||||||
)
|
)
|
||||||
verify_file(
|
if not self._settings.rootfs_url:
|
||||||
rootfs,
|
self._install_ubuntu_rootfs(rootfs, force=force)
|
||||||
self._settings.rootfs_sha256,
|
elif force or not rootfs.exists():
|
||||||
"guest root filesystem",
|
self.download(
|
||||||
"UVM_ROOTFS_SHA256",
|
self._settings.rootfs_url,
|
||||||
allow_unverified=self._settings.allow_unverified_downloads,
|
rootfs,
|
||||||
)
|
expected_sha256=self._settings.rootfs_sha256,
|
||||||
|
label="guest root filesystem",
|
||||||
|
environment_name="UVM_ROOTFS_SHA256",
|
||||||
|
)
|
||||||
|
if self._settings.rootfs_url:
|
||||||
|
verify_file(
|
||||||
|
rootfs,
|
||||||
|
self._settings.rootfs_sha256,
|
||||||
|
"guest root filesystem",
|
||||||
|
"UVM_ROOTFS_SHA256",
|
||||||
|
allow_unverified=self._settings.allow_unverified_downloads,
|
||||||
|
)
|
||||||
return GuestAssets(kernel=kernel, rootfs=rootfs)
|
return GuestAssets(kernel=kernel, rootfs=rootfs)
|
||||||
|
|
||||||
|
def _install_ubuntu_rootfs(self, destination: Path, *, force: bool) -> None:
|
||||||
|
if destination.is_file() and not force:
|
||||||
|
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||||
|
checksum = manifest.checksums.get("rootfs")
|
||||||
|
if manifest.verified and checksum:
|
||||||
|
verify_file(destination, checksum, "Ubuntu rootfs", "integrity manifest", allow_unverified=False)
|
||||||
|
marker = self._runner.run(
|
||||||
|
("debugfs", "-R", "cat /etc/uvm-image", destination), capture=True,
|
||||||
|
)
|
||||||
|
if marker.stdout == _ROOTFS_RECIPE:
|
||||||
|
return
|
||||||
|
# Rebuild old demo images and unverified caches rather than promoting their trust.
|
||||||
|
self._build_ubuntu_rootfs(destination)
|
||||||
|
|
||||||
|
def _build_ubuntu_rootfs(self, destination: Path) -> None:
|
||||||
|
architecture = "arm64" if self._settings.architecture == "aarch64" else "amd64"
|
||||||
|
mirror = (
|
||||||
|
"https://ports.ubuntu.com/ubuntu-ports" if architecture == "arm64"
|
||||||
|
else "https://archive.ubuntu.com/ubuntu"
|
||||||
|
)
|
||||||
|
if self._emit is not None:
|
||||||
|
self._emit("Building Ubuntu 22.04 rootfs with sudo, OpenSSH, and apt/dpkg metadata")
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".ubuntu-build-", dir=destination.parent) as name:
|
||||||
|
temporary = Path(name)
|
||||||
|
root = temporary / "root"
|
||||||
|
build_log = temporary / "build.log"
|
||||||
|
try:
|
||||||
|
# Bootstrap one consistent release; apt resolves updated package versions later.
|
||||||
|
self._run_rootfs_stage("Ubuntu bootstrap", (
|
||||||
|
*_ROOTFS_NAMESPACE, "env", "DEBIAN_FRONTEND=noninteractive",
|
||||||
|
"debootstrap", f"--arch={architecture}", "--variant=minbase", "--merged-usr",
|
||||||
|
"--components=main", "--include=ca-certificates,ubuntu-keyring",
|
||||||
|
"--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg",
|
||||||
|
_UBUNTU_RELEASE, root, mirror,
|
||||||
|
), build_log)
|
||||||
|
self._install_ubuntu_packages(root, mirror, build_log)
|
||||||
|
self._configure_ubuntu_rootfs(root)
|
||||||
|
self._validate_ubuntu_rootfs(root)
|
||||||
|
|
||||||
|
disk = temporary / "rootfs.ext4"
|
||||||
|
with disk.open("wb") as image:
|
||||||
|
image.truncate(2 * 1024 * 1024 * 1024)
|
||||||
|
disk.chmod(0o600)
|
||||||
|
self._run_rootfs_stage("Ubuntu ext4 formatting", (
|
||||||
|
"mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES,
|
||||||
|
"-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk,
|
||||||
|
), build_log)
|
||||||
|
self._run_rootfs_stage("Ubuntu filesystem check", ("e2fsck", "-f", "-n", disk), build_log)
|
||||||
|
except (UvmError, OSError, UnicodeError) as error:
|
||||||
|
try:
|
||||||
|
log = self._save_rootfs_build_log(destination, root, build_log, error=error)
|
||||||
|
except OSError as log_error:
|
||||||
|
raise UvmError(f"Ubuntu rootfs build failed: {error}; could not save diagnostics: {log_error}") from error
|
||||||
|
raise UvmError(f"Ubuntu rootfs build failed: {error}; details saved to {log}") from error
|
||||||
|
self._save_rootfs_build_log(destination, root, build_log)
|
||||||
|
with disk.open("rb") as image:
|
||||||
|
os.fsync(image.fileno())
|
||||||
|
os.replace(disk, destination)
|
||||||
|
directory = os.open(destination.parent, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
except (OSError, UnicodeError) as error:
|
||||||
|
raise UvmError(f"could not build Ubuntu guest rootfs: {error}") from error
|
||||||
|
|
||||||
|
def _install_ubuntu_packages(self, root: Path, mirror: str, build_log: Path) -> None:
|
||||||
|
sources = root / "etc/apt/sources.list"
|
||||||
|
sources.write_text("".join(
|
||||||
|
f"deb {mirror} {suite} main universe\n"
|
||||||
|
for suite in (_UBUNTU_RELEASE, f"{_UBUNTU_RELEASE}-updates", f"{_UBUNTU_RELEASE}-security")
|
||||||
|
), encoding="utf-8")
|
||||||
|
sources.chmod(0o644)
|
||||||
|
# Ubuntu's debootstrap script removes its own policy-rc.d on success.
|
||||||
|
policy = root / "usr/sbin/policy-rc.d"
|
||||||
|
policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8")
|
||||||
|
policy.chmod(0o755)
|
||||||
|
try:
|
||||||
|
self._run_rootfs_stage("Ubuntu guest package installation", (
|
||||||
|
*_ROOTFS_NAMESPACE, "/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT,
|
||||||
|
"uvm-guest-packages", root, *_GUEST_PACKAGES,
|
||||||
|
), build_log)
|
||||||
|
finally:
|
||||||
|
policy.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _run_rootfs_stage(self, stage: str, command: tuple[str | Path, ...], log: Path) -> None:
|
||||||
|
if self._emit is not None:
|
||||||
|
self._emit(stage)
|
||||||
|
with log.open("a", encoding="utf-8") as output:
|
||||||
|
output.write(f"\n=== {stage} ===\n+ {shlex.join(map(str, command))}\n")
|
||||||
|
result = self._runner.run(command, check=False, capture=True)
|
||||||
|
output.write(result.stdout)
|
||||||
|
output.write(result.stderr)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise UvmError(f"{stage} failed (exit {result.returncode})")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _save_rootfs_build_log(destination: Path, root: Path, build_log: Path, *, error: Exception | None = None) -> Path:
|
||||||
|
log = destination.with_name("ubuntu-build.log")
|
||||||
|
descriptor, name = tempfile.mkstemp(prefix=".ubuntu-log-", dir=destination.parent)
|
||||||
|
temporary = Path(name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb") as output:
|
||||||
|
# Completed bootstrap details precede apt output; failed bootstrap details follow it.
|
||||||
|
for source in (root / "var/log/bootstrap.log", build_log, root / "debootstrap/debootstrap.log"):
|
||||||
|
if source.is_file():
|
||||||
|
output.write(f"\n=== {source.name} ===\n".encode("utf-8"))
|
||||||
|
with source.open("rb") as content:
|
||||||
|
shutil.copyfileobj(content, output)
|
||||||
|
if error is not None:
|
||||||
|
output.write(f"\nUbuntu rootfs build failed: {error}\n".encode("utf-8", errors="replace"))
|
||||||
|
output.flush()
|
||||||
|
os.fsync(output.fileno())
|
||||||
|
os.replace(temporary, log)
|
||||||
|
directory = os.open(log.parent, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return log
|
||||||
|
|
||||||
|
def _configure_ubuntu_rootfs(self, root: Path) -> None:
|
||||||
|
files = {
|
||||||
|
"etc/fstab": "/dev/vda / ext4 defaults 0 1\n",
|
||||||
|
"etc/hostname": "uvm\n",
|
||||||
|
"etc/hosts": "127.0.0.1 localhost\n127.0.1.1 uvm\n",
|
||||||
|
"etc/resolv.conf": "nameserver 1.1.1.1\nnameserver 8.8.8.8\n",
|
||||||
|
"etc/machine-id": "",
|
||||||
|
"etc/uvm-image": _ROOTFS_RECIPE,
|
||||||
|
"etc/systemd/network/10-uvm.network": GUEST_NETWORK_TEMPLATE,
|
||||||
|
}
|
||||||
|
for relative, contents in files.items():
|
||||||
|
path = root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.unlink(missing_ok=True) # In particular, replace a resolver symlink with guest DNS.
|
||||||
|
path.write_text(contents, encoding="utf-8")
|
||||||
|
path.chmod(0o644)
|
||||||
|
machine_id = root / "var/lib/dbus/machine-id"
|
||||||
|
machine_id.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
machine_id.unlink(missing_ok=True)
|
||||||
|
machine_id.symlink_to("../../../etc/machine-id")
|
||||||
|
for key in (root / "etc/ssh").glob("ssh_host_*_key*"):
|
||||||
|
key.unlink()
|
||||||
|
self._runner.run(("systemctl", "--root", root, "enable", "ssh.service", "systemd-networkd.service"), capture=True)
|
||||||
|
self._runner.run(("systemctl", "--root", root, "set-default", "multi-user.target"), capture=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_ubuntu_rootfs(root: Path) -> None:
|
||||||
|
for relative in (
|
||||||
|
"etc/passwd", "etc/shadow", "etc/group", "etc/sudoers", "etc/ssh/sshd_config",
|
||||||
|
"usr/bin/sudo", "usr/bin/apt-get", "usr/sbin/sshd", "usr/lib/systemd/systemd",
|
||||||
|
"var/lib/dpkg/status", "var/lib/dpkg/info/apt.list", "var/lib/dpkg/info/sudo.list",
|
||||||
|
):
|
||||||
|
if not (root / relative).is_file():
|
||||||
|
raise UvmError(f"built Ubuntu rootfs is missing /{relative}")
|
||||||
|
installed = set()
|
||||||
|
for paragraph in (root / "var/lib/dpkg/status").read_text(encoding="utf-8").split("\n\n"):
|
||||||
|
fields = dict(line.split(": ", 1) for line in paragraph.splitlines() if ": " in line)
|
||||||
|
if fields.get("Status") == "install ok installed":
|
||||||
|
installed.add(fields.get("Package"))
|
||||||
|
if not {"apt", "sudo", "openssh-server", "systemd-sysv"}.issubset(installed):
|
||||||
|
raise UvmError("built Ubuntu rootfs has incomplete apt/dpkg package metadata")
|
||||||
|
if not (root / "usr/bin/sudo").stat().st_mode & 0o4000:
|
||||||
|
raise UvmError("built Ubuntu rootfs sudo is missing its setuid permission")
|
||||||
|
|
||||||
def download(
|
def download(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
|
|||||||
+9
-1
@@ -89,6 +89,7 @@ class LifecycleService:
|
|||||||
self._save(vm)
|
self._save(vm)
|
||||||
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk), storage_mib=spec.storage_mib)
|
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)
|
||||||
|
self._images.configure_guest_network(disk, vm.guest_ip)
|
||||||
vm.disk_ready = True
|
vm.disk_ready = True
|
||||||
self._save(vm)
|
self._save(vm)
|
||||||
self._boot(vm, assets.kernel)
|
self._boot(vm, assets.kernel)
|
||||||
@@ -177,7 +178,10 @@ class LifecycleService:
|
|||||||
raise ConflictError(f"resize is pending for {vm.id}; retry resize or stop it before starting")
|
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)
|
self._validate_retained_network(vm)
|
||||||
|
vm = self._sync_ports_locked(vm)
|
||||||
|
self._network.ensure_bridge()
|
||||||
|
return vm
|
||||||
self._validate_resume(vm)
|
self._validate_resume(vm)
|
||||||
if kernel is None:
|
if kernel is None:
|
||||||
self._check_boot()
|
self._check_boot()
|
||||||
@@ -185,6 +189,7 @@ class LifecycleService:
|
|||||||
try:
|
try:
|
||||||
self._cleanup_runtime(vm)
|
self._cleanup_runtime(vm)
|
||||||
self._apply_pending_resize(vm)
|
self._apply_pending_resize(vm)
|
||||||
|
self._images.configure_guest_network(Path(vm.disk), vm.guest_ip)
|
||||||
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
|
||||||
@@ -276,6 +281,9 @@ class LifecycleService:
|
|||||||
disk = Path(vm.disk)
|
disk = Path(vm.disk)
|
||||||
if not vm.disk or not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
|
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}")
|
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
|
||||||
|
self._validate_retained_network(vm)
|
||||||
|
|
||||||
|
def _validate_retained_network(self, vm: VmRecord) -> None:
|
||||||
if (vm.gateway != str(self._settings.gateway)
|
if (vm.gateway != str(self._settings.gateway)
|
||||||
or vm.network not in {None, str(self._settings.network)}
|
or vm.network not in {None, str(self._settings.network)}
|
||||||
or vm.bridge not in {None, self._settings.bridge}):
|
or vm.bridge not in {None, self._settings.bridge}):
|
||||||
|
|||||||
+29
-102
@@ -9,7 +9,7 @@ from ipaddress import IPv4Address
|
|||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .domain import VmRecord
|
from .domain import VmRecord
|
||||||
from .errors import ConflictError, UvmError, ValidationError
|
from .errors import CommandError, ConflictError, UvmError, ValidationError
|
||||||
from .firewall import FirewallManager
|
from .firewall import FirewallManager
|
||||||
from .system import CommandRunner
|
from .system import CommandRunner
|
||||||
|
|
||||||
@@ -178,111 +178,38 @@ class NetworkManager:
|
|||||||
raise UvmError(f"could not delete TAP device {name}{suffix}")
|
raise UvmError(f"could not delete TAP device {name}{suffix}")
|
||||||
|
|
||||||
def _ensure_masquerade(self) -> None:
|
def _ensure_masquerade(self) -> None:
|
||||||
route = self._runner.run(("ip", "route", "show", "default"), capture=True)
|
# Match the guest network, not a snapshot of the host's default uplink.
|
||||||
match = re.search(r"\bdev\s+(\S+)", route.stdout)
|
# Routing selects the current interface for each destination, even after
|
||||||
if match is None:
|
# an offline boot, a reconnect, or a switch between Wi-Fi/Ethernet/VPN.
|
||||||
return
|
bridge = self._settings.bridge
|
||||||
uplink = match.group(1)
|
network = str(self._settings.network)
|
||||||
rule = (
|
owner = ("-m", "comment", "--comment", "uvm-egress")
|
||||||
"iptables",
|
|
||||||
"-t",
|
|
||||||
"nat",
|
|
||||||
"-C",
|
|
||||||
"POSTROUTING",
|
|
||||||
"-s",
|
|
||||||
str(self._settings.network),
|
|
||||||
"-o",
|
|
||||||
uplink,
|
|
||||||
"-j",
|
|
||||||
"MASQUERADE",
|
|
||||||
)
|
|
||||||
present = self._runner.run(rule, check=False)
|
|
||||||
if present.returncode != 0:
|
|
||||||
self._runner.run(
|
|
||||||
(
|
|
||||||
"iptables",
|
|
||||||
"-t",
|
|
||||||
"nat",
|
|
||||||
"-A",
|
|
||||||
"POSTROUTING",
|
|
||||||
"-s",
|
|
||||||
str(self._settings.network),
|
|
||||||
"-o",
|
|
||||||
uplink,
|
|
||||||
"-j",
|
|
||||||
"MASQUERADE",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._ensure_iptables_rule(
|
self._ensure_iptables_rule(
|
||||||
(
|
"nat", "POSTROUTING",
|
||||||
"iptables",
|
("-s", network, "!", "-o", bridge, *owner, "-j", "MASQUERADE"),
|
||||||
"-C",
|
)
|
||||||
"FORWARD",
|
# Native bridge policy still checks guest identity and private ports.
|
||||||
"-i",
|
# This also permits peer traffic when br_netfilter is already enabled.
|
||||||
self._settings.bridge,
|
self._ensure_iptables_rule(
|
||||||
"-o",
|
"filter", "FORWARD",
|
||||||
uplink,
|
("-i", bridge, "-s", network, *owner, "-j", "ACCEPT"),
|
||||||
"-s",
|
|
||||||
str(self._settings.network),
|
|
||||||
"-j",
|
|
||||||
"ACCEPT",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"iptables",
|
|
||||||
"-A",
|
|
||||||
"FORWARD",
|
|
||||||
"-i",
|
|
||||||
self._settings.bridge,
|
|
||||||
"-o",
|
|
||||||
uplink,
|
|
||||||
"-s",
|
|
||||||
str(self._settings.network),
|
|
||||||
"-j",
|
|
||||||
"ACCEPT",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
self._ensure_iptables_rule(
|
self._ensure_iptables_rule(
|
||||||
(
|
"filter", "FORWARD",
|
||||||
"iptables",
|
("-o", bridge, "-d", network, "-m", "conntrack",
|
||||||
"-C",
|
"--ctstate", "ESTABLISHED,RELATED", *owner, "-j", "ACCEPT"),
|
||||||
"FORWARD",
|
|
||||||
"-i",
|
|
||||||
uplink,
|
|
||||||
"-o",
|
|
||||||
self._settings.bridge,
|
|
||||||
"-d",
|
|
||||||
str(self._settings.network),
|
|
||||||
"-m",
|
|
||||||
"conntrack",
|
|
||||||
"--ctstate",
|
|
||||||
"ESTABLISHED,RELATED",
|
|
||||||
"-j",
|
|
||||||
"ACCEPT",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"iptables",
|
|
||||||
"-A",
|
|
||||||
"FORWARD",
|
|
||||||
"-i",
|
|
||||||
uplink,
|
|
||||||
"-o",
|
|
||||||
self._settings.bridge,
|
|
||||||
"-d",
|
|
||||||
str(self._settings.network),
|
|
||||||
"-m",
|
|
||||||
"conntrack",
|
|
||||||
"--ctstate",
|
|
||||||
"ESTABLISHED,RELATED",
|
|
||||||
"-j",
|
|
||||||
"ACCEPT",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_iptables_rule(
|
def _ensure_iptables_rule(
|
||||||
self,
|
self, table: str, chain: str, rule: tuple[str, ...],
|
||||||
check_rule: tuple[str, ...],
|
|
||||||
add_rule: tuple[str, ...],
|
|
||||||
) -> None:
|
) -> None:
|
||||||
present = self._runner.run(check_rule, check=False)
|
command = ("iptables", "--wait", "5", "-t", table)
|
||||||
if present.returncode != 0:
|
present = self._runner.run((*command, "-C", chain, *rule), check=False, capture=True)
|
||||||
self._runner.run(add_rule)
|
if present.returncode == 0:
|
||||||
|
return
|
||||||
|
if present.returncode != 1:
|
||||||
|
detail = present.stderr.strip() or present.stdout.strip()
|
||||||
|
raise CommandError(f"could not inspect UVM {table}/{chain} rule ({present.returncode}): {detail}")
|
||||||
|
# Scoped exceptions must precede terminal drops in the host's chains.
|
||||||
|
# Keep unrelated rules and the global forwarding policy intact.
|
||||||
|
self._runner.run((*command, "-I", chain, "1", *rule), capture=True)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Authenticated graceful shutdown endpoint for the UVM API server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from ..api_models import ServerExitResponse
|
||||||
|
from ..app import Application
|
||||||
|
from .dependencies import get_authorized_application
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["server"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/exit", response_model=ServerExitResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
def exit_server(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
request: Request,
|
||||||
|
_application: Application = Depends(get_authorized_application),
|
||||||
|
) -> ServerExitResponse:
|
||||||
|
"""Stop the Uvicorn process after this response has been delivered."""
|
||||||
|
server = getattr(request.app.state, "uvicorn_server", None)
|
||||||
|
if server is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="server shutdown is unavailable outside uvm --serve",
|
||||||
|
)
|
||||||
|
background_tasks.add_task(_request_server_exit, server)
|
||||||
|
return ServerExitResponse()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_server_exit(server: Any) -> None:
|
||||||
|
server.should_exit = True
|
||||||
+6
-1
@@ -35,6 +35,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
) from error
|
) from error
|
||||||
|
|
||||||
from .routers.health import router as health_router
|
from .routers.health import router as health_router
|
||||||
|
from .routers.exit import router as exit_router
|
||||||
from .routers.installation import router as installation_router
|
from .routers.installation import router as installation_router
|
||||||
from .routers.vms import router as vms_router
|
from .routers.vms import router as vms_router
|
||||||
from .routers.diagnostics import router as diagnostics_router
|
from .routers.diagnostics import router as diagnostics_router
|
||||||
@@ -55,6 +56,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
api.include_router(health_router)
|
api.include_router(health_router)
|
||||||
|
api.include_router(exit_router)
|
||||||
api.include_router(installation_router)
|
api.include_router(installation_router)
|
||||||
api.include_router(vms_router)
|
api.include_router(vms_router)
|
||||||
api.include_router(diagnostics_router)
|
api.include_router(diagnostics_router)
|
||||||
@@ -91,7 +93,10 @@ def run_server(application: Application, *, host: str, port: int) -> None:
|
|||||||
if settings.api_tls_cert is not None:
|
if settings.api_tls_cert is not None:
|
||||||
options["ssl_certfile"] = str(settings.api_tls_cert)
|
options["ssl_certfile"] = str(settings.api_tls_cert)
|
||||||
options["ssl_keyfile"] = str(settings.api_tls_key)
|
options["ssl_keyfile"] = str(settings.api_tls_key)
|
||||||
uvicorn.run(create_api(application, host=host), **options)
|
api = create_api(application, host=host)
|
||||||
|
server = uvicorn.Server(uvicorn.Config(api, **options))
|
||||||
|
api.state.uvicorn_server = server
|
||||||
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
def _is_loopback_host(host: str) -> bool:
|
def _is_loopback_host(host: str) -> bool:
|
||||||
|
|||||||
Reference in New Issue
Block a user