Dev #4
+41
-7
@@ -545,6 +545,13 @@ 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.
|
||||||
@@ -557,14 +564,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
|
||||||
@@ -694,12 +706,30 @@ 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 uses Firecracker's `img/quickstart_guide/<architecture>/kernels/vmlinux.bin`.
|
||||||
and `rootfs/bionic.rootfs.ext4`. Kernel and rootfs URL overrides are independent;
|
An empty `Settings.rootfs_url` selects the built Ubuntu 22.04 (Jammy) template.
|
||||||
custom assets and trusted digests must match the host architecture. ARM64
|
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 includes main/universe plus updates/security suites.
|
||||||
|
amd64 uses `archive.ubuntu.com/ubuntu`; arm64 uses `ports.ubuntu.com/ubuntu-ports`.
|
||||||
|
Package scripts execute in private mount/PID namespaces with a temporary
|
||||||
|
`policy-rc.d` suppressing service startup. 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.
|
||||||
|
|
||||||
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
|
||||||
@@ -892,6 +922,9 @@ 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, and checksum provenance. It does 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.
|
||||||
|
|
||||||
@@ -926,7 +959,8 @@ 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.
|
||||||
4. SSH with unique guest host keys.
|
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.
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ 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.
|
||||||
@@ -64,21 +66,35 @@ 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. 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.
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -360,13 +376,28 @@ 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`.
|
||||||
|
|
||||||
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.
|
||||||
@@ -466,10 +497,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` |
|
||||||
|
|||||||
@@ -138,21 +138,22 @@ not disable confirmations, and errors may still be plain text on stderr.
|
|||||||
## 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/img/quickstart_guide/aarch64/kernels/vmlinux.bin`
|
||||||
- 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
|
||||||
@@ -166,7 +167,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 +174,12 @@ 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, and builds inside private mount/PID
|
||||||
|
namespaces. It 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 +188,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 +226,7 @@ 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. |
|
||||||
|
|
||||||
## 5. Create VMs
|
## 5. Create VMs
|
||||||
|
|
||||||
@@ -245,7 +257,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 +428,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.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class ArchitectureSettingsTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
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}/kernels/vmlinux.bin")
|
||||||
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"
|
||||||
@@ -56,7 +56,7 @@ class ArchitectureSettingsTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
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:
|
||||||
|
|||||||
+42
-1
@@ -130,6 +130,19 @@ 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)
|
||||||
|
|
||||||
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 +254,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 +266,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 +321,10 @@ 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)
|
||||||
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 +352,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 +399,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")
|
||||||
|
|||||||
+158
-4
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -12,11 +13,12 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import Mock, patch
|
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 CommandError, UvmError
|
||||||
from uvm.install import Installer
|
from uvm.images import ImageStore
|
||||||
from uvm.integrity import load_manifest
|
from uvm.install import Installer, _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 +58,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/"
|
||||||
@@ -111,3 +117,151 @@ 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, _ROOTFS_RECIPE, True),
|
||||||
|
):
|
||||||
|
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":
|
||||||
|
raise CommandError("bootstrap interrupted")
|
||||||
|
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/passwd": "root:x:0:0::/root:/bin/bash\n",
|
||||||
|
"etc/shadow": "root:*:1:0:99999:7:::\n", "etc/group": "root:x:0:\n",
|
||||||
|
"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/bin/apt-get": "apt binary",
|
||||||
|
"usr/sbin/sshd": "sshd binary", "usr/lib/systemd/systemd": "init binary",
|
||||||
|
"var/lib/dpkg/status": status,
|
||||||
|
"var/lib/dpkg/info/apt.list": "/usr/bin/apt-get\n",
|
||||||
|
"var/lib/dpkg/info/sudo.list": "/usr/bin/sudo\n",
|
||||||
|
}
|
||||||
|
if self.failure == "metadata":
|
||||||
|
files["var/lib/dpkg/status"] = ""
|
||||||
|
for relative, contents in files.items():
|
||||||
|
path = root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(contents, encoding="utf-8")
|
||||||
|
(root / "usr/bin/sudo").chmod(0o4755)
|
||||||
|
(root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf")
|
||||||
|
return CommandResult(args, 0)
|
||||||
|
if args[0] == "systemctl":
|
||||||
|
return CommandResult(args, 0)
|
||||||
|
return self.runner.run(command, **options)
|
||||||
|
|
||||||
|
|
||||||
|
@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("--extra-suites=jammy-updates,jammy-security", bootstrap)
|
||||||
|
self.assertIn("--mount-proc", bootstrap)
|
||||||
|
self.assertIn("--kill-child", bootstrap)
|
||||||
|
self.assertEqual(settings.rootfs_image.stat().st_mode & 0o777, 0o600)
|
||||||
|
self.assertEqual(list(settings.images_dir.iterdir()), [settings.rootfs_image])
|
||||||
|
|
||||||
|
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=static", 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_bootstrap_or_missing_package_metadata_preserves_the_old_template(self) -> None:
|
||||||
|
for failure in ("bootstrap", "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]
|
||||||
|
with self.assertRaises(UvmError):
|
||||||
|
installer._build_ubuntu_rootfs(settings.rootfs_image)
|
||||||
|
self.assertEqual(settings.rootfs_image.read_bytes(), b"old template")
|
||||||
|
self.assertEqual(list(settings.images_dir.iterdir()), [settings.rootfs_image])
|
||||||
|
self.assertFalse(any(call[0] == "mkfs.ext4" for call in runner.calls))
|
||||||
|
|||||||
+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(
|
||||||
|
|||||||
+1
-3
@@ -31,7 +31,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
|
||||||
@@ -56,8 +56,6 @@ class Settings:
|
|||||||
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}/kernels/vmlinux.bin")
|
||||||
if not self.rootfs_url:
|
|
||||||
object.__setattr__(self, "rootfs_url", f"{asset_base}/rootfs/bionic.rootfs.ext4")
|
|
||||||
|
|
||||||
if self.gateway not in self.network:
|
if self.gateway not in self.network:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
|
|||||||
+37
-3
@@ -226,6 +226,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 +246,31 @@ 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 re.search(r"Type:\s+regular\b", sudo.stdout) is None
|
||||||
|
or mode is None or not int(mode.group(1), 8) & 0o4000
|
||||||
|
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,
|
||||||
@@ -562,12 +589,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 +610,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+143
-3
@@ -14,11 +14,24 @@ 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 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-v1\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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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 +81,18 @@ class Installer:
|
|||||||
"UVM_KERNEL_SHA256",
|
"UVM_KERNEL_SHA256",
|
||||||
allow_unverified=self._settings.allow_unverified_downloads,
|
allow_unverified=self._settings.allow_unverified_downloads,
|
||||||
)
|
)
|
||||||
|
if self._settings.rootfs_url:
|
||||||
require_checksum(
|
require_checksum(
|
||||||
self._settings.rootfs_sha256,
|
self._settings.rootfs_sha256,
|
||||||
"guest root filesystem",
|
"guest root filesystem",
|
||||||
"UVM_ROOTFS_SHA256",
|
"UVM_ROOTFS_SHA256",
|
||||||
allow_unverified=self._settings.allow_unverified_downloads,
|
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 +107,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 +127,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,7 +204,9 @@ class Installer:
|
|||||||
label="guest kernel",
|
label="guest kernel",
|
||||||
environment_name="UVM_KERNEL_SHA256",
|
environment_name="UVM_KERNEL_SHA256",
|
||||||
)
|
)
|
||||||
if force or not rootfs.exists():
|
if not self._settings.rootfs_url:
|
||||||
|
self._install_ubuntu_rootfs(rootfs, force=force)
|
||||||
|
elif force or not rootfs.exists():
|
||||||
self.download(
|
self.download(
|
||||||
self._settings.rootfs_url,
|
self._settings.rootfs_url,
|
||||||
rootfs,
|
rootfs,
|
||||||
@@ -198,6 +221,7 @@ class Installer:
|
|||||||
"UVM_KERNEL_SHA256",
|
"UVM_KERNEL_SHA256",
|
||||||
allow_unverified=self._settings.allow_unverified_downloads,
|
allow_unverified=self._settings.allow_unverified_downloads,
|
||||||
)
|
)
|
||||||
|
if self._settings.rootfs_url:
|
||||||
verify_file(
|
verify_file(
|
||||||
rootfs,
|
rootfs,
|
||||||
self._settings.rootfs_sha256,
|
self._settings.rootfs_sha256,
|
||||||
@@ -207,6 +231,122 @@ class Installer:
|
|||||||
)
|
)
|
||||||
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"
|
||||||
|
policy = root / "usr/sbin/policy-rc.d"
|
||||||
|
policy.parent.mkdir(parents=True)
|
||||||
|
policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8")
|
||||||
|
policy.chmod(0o755)
|
||||||
|
# Package scripts run natively, with private mounts/PIDs and service startup disabled.
|
||||||
|
self._runner.run((
|
||||||
|
"unshare", "--mount", "--propagation", "private", "--pid", "--fork",
|
||||||
|
"--kill-child", "--mount-proc", "env", "DEBIAN_FRONTEND=noninteractive",
|
||||||
|
"debootstrap", f"--arch={architecture}", "--variant=minbase", "--merged-usr",
|
||||||
|
"--components=main,universe", f"--include={','.join(_GUEST_PACKAGES)}",
|
||||||
|
f"--extra-suites={_UBUNTU_RELEASE}-updates,{_UBUNTU_RELEASE}-security",
|
||||||
|
"--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg",
|
||||||
|
_UBUNTU_RELEASE, root, mirror,
|
||||||
|
))
|
||||||
|
policy.unlink()
|
||||||
|
self._configure_ubuntu_rootfs(root, mirror)
|
||||||
|
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._runner.run((
|
||||||
|
"mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES,
|
||||||
|
"-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk,
|
||||||
|
), capture=True)
|
||||||
|
self._runner.run(("e2fsck", "-f", "-n", disk), capture=True)
|
||||||
|
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 _configure_ubuntu_rootfs(self, root: Path, mirror: str) -> None:
|
||||||
|
files = {
|
||||||
|
"etc/apt/sources.list": "".join(
|
||||||
|
f"deb {mirror} {suite} main universe\n"
|
||||||
|
for suite in (_UBUNTU_RELEASE, f"{_UBUNTU_RELEASE}-updates", f"{_UBUNTU_RELEASE}-security")
|
||||||
|
),
|
||||||
|
"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": (
|
||||||
|
"[Match]\nName=eth0\n\n[Network]\n"
|
||||||
|
"KeepConfiguration=static\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user