Add experimental Void guest workflow and vsock agent

Make iterating on a Firecracker-friendly Void guest practical without replacing the Debian default image path.

Add local Void rootfs build/register/verify plumbing, a language-agnostic dev package baseline, and guest SSH/work-disk hardening so new images use the runtime bundle key, keep a normal root bash environment, and repair stale nested /root layouts on restart.

Replace the guest PING/PONG responder with an HTTP /healthz agent over vsock, rename the runtime bundle and config surface from ping helper to agent while still accepting the legacy keys, and route the post-SSH reminder through the new vm.health path.

Validated with GOCACHE=/tmp/banger-gocache go test ./..., make build, bash -n customize.sh make-rootfs-void.sh, and git diff --check.
This commit is contained in:
Thales Maciel 2026-03-19 14:51:25 -03:00
parent c8d9a122f9
commit 3ed78fdcfc
No known key found for this signature in database
GPG key ID: 33112E6833C34679
42 changed files with 2222 additions and 388 deletions

View file

@ -9,15 +9,18 @@
- The daemon keeps state under XDG directories rather than the old repo-local `state/` layout. - The daemon keeps state under XDG directories rather than the old repo-local `state/` layout.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `make build` builds `./banger`, `./bangerd`, and the bundled `./runtime/banger-vsock-pingd` guest helper. - `make build` builds `./banger`, `./bangerd`, and the bundled `./runtime/banger-vsock-agent` guest helper.
- `make bench-create` benchmarks `vm create` and first-SSH readiness on the current host. - `make bench-create` benchmarks `vm create` and first-SSH readiness on the current host.
- `make runtime-bundle` bootstraps `./runtime/` from the archive referenced by `RUNTIME_MANIFEST`; the checked-in `runtime-bundle.toml` is only a template. - `make runtime-bundle` bootstraps `./runtime/` from the archive referenced by `RUNTIME_MANIFEST`; the checked-in `runtime-bundle.toml` is only a template.
- `make rootfs-void` builds an experimental local-only `x86_64-glibc` Void rootfs plus work-seed under `./runtime/`; it does not replace the default Debian path or teach `banger image build` about Void.
- `make verify-void` registers `void-exp` and runs the normal smoke test against that image.
- `banger` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set. - `banger` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set.
- `./banger vm create --name testbox` creates and starts a VM. - `./banger vm create --name testbox` creates and starts a VM.
- `./banger vm ssh testbox` connects to a running guest and reminds the user if the VM is still running when the session exits. - `./banger vm ssh testbox` connects to a running guest using the runtime bundle SSH key and reminds the user if the VM is still running when the session exits.
- `./banger vm stop testbox` stops a VM while preserving its disks. - `./banger vm stop testbox` stops a VM while preserving its disks.
- `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI. - `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI.
- `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon. - `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon.
- `./banger image register --name local --rootfs /abs/path/rootfs.ext4` creates or updates an unmanaged image record without changing the default image config; use it for experimental guest iteration paths such as Void.
- `./banger tui` launches the terminal UI. - `./banger tui` launches the terminal UI.
- `make test` runs `go test ./...`. - `make test` runs `go test ./...`.
- `./verify.sh` runs the smoke test for the Go VM workflow. - `./verify.sh` runs the smoke test for the Go VM workflow.
@ -32,7 +35,8 @@
- Primary automated coverage is `go test ./...`. - Primary automated coverage is `go test ./...`.
- Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM. - Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM.
- For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke. - For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke.
- Rebuilt images now include `mise`, `opencode`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-pingd` service used by the SSH reminder path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up. - Rebuilt images now include `mise`, `opencode`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up.
- The experimental Void rootfs path is intentionally lean: keep it limited to boot, SSH, the vsock HTTP health agent, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed unless the user explicitly wants more baked in.
- Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. If you touch `/root` provisioning, verify both the rootfs and the work-seed output. - Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. If you touch `/root` provisioning, verify both the rootfs and the work-seed output.
- The daemon may keep idle TAP devices in a pool for faster creates. Smoke tests should treat `tap-pool-*` devices as reusable capacity, not cleanup leaks. - The daemon may keep idle TAP devices in a pool for faster creates. Smoke tests should treat `tap-pool-*` devices as reusable capacity, not cleanup leaks.
- If you add a new operational workflow, document how to exercise it in `README.md`. - If you add a new operational workflow, document how to exercise it in `README.md`.

View file

@ -12,17 +12,19 @@ RUNTIME_MANIFEST ?= runtime-bundle.toml
RUNTIME_SOURCE_DIR ?= runtime RUNTIME_SOURCE_DIR ?= runtime
RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz
BINARIES := banger bangerd BINARIES := banger bangerd
RUNTIME_HELPERS := $(RUNTIME_SOURCE_DIR)/banger-vsock-pingd RUNTIME_HELPERS := $(RUNTIME_SOURCE_DIR)/banger-vsock-agent
GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort) GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort)
RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen banger-vsock-pingd RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen banger-vsock-agent
RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4 RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4
RUNTIME_OPTIONAL_DATA_FILES := rootfs.ext4 rootfs-docker.work-seed.ext4 bundle.json RUNTIME_OPTIONAL_DATA_FILES := rootfs.ext4 rootfs-docker.work-seed.ext4 bundle.json
RUNTIME_BOOT_FILES := wtf/root/boot/vmlinux-6.8.0-94-generic wtf/root/boot/initrd.img-6.8.0-94-generic RUNTIME_BOOT_FILES := wtf/root/boot/vmlinux-6.8.0-94-generic wtf/root/boot/initrd.img-6.8.0-94-generic
RUNTIME_MODULES_DIR := wtf/root/lib/modules/6.8.0-94-generic RUNTIME_MODULES_DIR := wtf/root/lib/modules/6.8.0-94-generic
VOID_IMAGE_NAME ?= void-exp
VOID_VM_NAME ?= void-dev
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help build banger bangerd test fmt tidy clean rootfs install runtime-bundle runtime-package check-runtime bench-create .PHONY: help build banger bangerd test fmt tidy clean rootfs rootfs-void void-register void-vm verify-void install runtime-bundle runtime-package check-runtime bench-create
help: help:
@printf '%s\n' \ @printf '%s\n' \
@ -36,7 +38,11 @@ help:
' make fmt Format Go sources under cmd/ and internal/' \ ' make fmt Format Go sources under cmd/ and internal/' \
' make tidy Run go mod tidy' \ ' make tidy Run go mod tidy' \
' make clean Remove built Go binaries' \ ' make clean Remove built Go binaries' \
' make rootfs Rebuild the source-checkout default rootfs image in ./runtime' ' make rootfs Rebuild the source-checkout default Debian rootfs image in ./runtime' \
' make rootfs-void Build an experimental Void Linux rootfs and work-seed in ./runtime' \
' make void-register Register or update the experimental Void image as $(VOID_IMAGE_NAME)' \
' make void-vm Register the experimental Void image and create a VM named $(VOID_VM_NAME)' \
' make verify-void Register the experimental Void image and run verify.sh against it'
build: $(BINARIES) $(RUNTIME_HELPERS) build: $(BINARIES) $(RUNTIME_HELPERS)
@ -46,9 +52,9 @@ banger: $(GO_SOURCES) go.mod go.sum
bangerd: $(GO_SOURCES) go.mod go.sum bangerd: $(GO_SOURCES) go.mod go.sum
$(GO) build -o ./bangerd ./cmd/bangerd $(GO) build -o ./bangerd ./cmd/bangerd
$(RUNTIME_SOURCE_DIR)/banger-vsock-pingd: $(GO_SOURCES) go.mod go.sum $(RUNTIME_SOURCE_DIR)/banger-vsock-agent: $(GO_SOURCES) go.mod go.sum
mkdir -p "$(RUNTIME_SOURCE_DIR)" mkdir -p "$(RUNTIME_SOURCE_DIR)"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -o "$(RUNTIME_SOURCE_DIR)/banger-vsock-pingd" ./cmd/banger-vsock-pingd CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -o "$(RUNTIME_SOURCE_DIR)/banger-vsock-agent" ./cmd/banger-vsock-agent
test: test:
$(GO) test ./... $(GO) test ./...
@ -100,3 +106,15 @@ install: build check-runtime
rootfs: rootfs:
BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs.sh BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs.sh
rootfs-void:
BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs-void.sh
void-register: build
./banger image register --name "$(VOID_IMAGE_NAME)" --rootfs "$(abspath $(RUNTIME_SOURCE_DIR))/rootfs-void.ext4" --work-seed "$(abspath $(RUNTIME_SOURCE_DIR))/rootfs-void.work-seed.ext4" --packages "$(abspath packages.void)"
void-vm: void-register
./banger vm create --image "$(VOID_IMAGE_NAME)" --name "$(VOID_VM_NAME)"
verify-void: void-register
./verify.sh --image "$(VOID_IMAGE_NAME)"

101
README.md
View file

@ -22,7 +22,7 @@ generated `./runtime/` bundle, while installed binaries use
The bundle contains: The bundle contains:
- `firecracker` - `firecracker`
- `banger-vsock-pingd` for the guest-side SSH reminder responder - `banger-vsock-agent` for the guest-side vsock HTTP health agent and SSH reminder checks
- `bundle.json` with the bundle's default kernel/initrd/modules/rootfs paths - `bundle.json` with the bundle's default kernel/initrd/modules/rootfs paths
- a kernel, initrd, and modules tree referenced by `bundle.json` - a kernel, initrd, and modules tree referenced by `bundle.json`
- `rootfs-docker.ext4` - `rootfs-docker.ext4`
@ -69,7 +69,7 @@ make build
``` ```
Run `make build` after `./runtime/` has been bootstrapped. It also rebuilds the Run `make build` after `./runtime/` has been bootstrapped. It also rebuilds the
bundled `banger-vsock-pingd` guest helper in `./runtime/`. bundled `banger-vsock-agent` guest helper in `./runtime/`.
Install into `~/.local/bin` by default, with the runtime bundle under Install into `~/.local/bin` by default, with the runtime bundle under
`~/.local/lib/banger`: `~/.local/lib/banger`:
@ -166,10 +166,9 @@ Useful config keys:
- `runtime_dir` - `runtime_dir`
- `tap_pool_size` - `tap_pool_size`
- `firecracker_bin` - `firecracker_bin`
- `ssh_key_path`
- `namegen_path` - `namegen_path`
- `customize_script` (manual helper compatibility; `banger image build` is Go-native) - `customize_script` (manual helper compatibility; `banger image build` is Go-native)
- `vsock_ping_helper_path` - `vsock_agent_path`
- `default_rootfs` - `default_rootfs`
- `default_work_seed` - `default_work_seed`
- `default_base_rootfs` - `default_base_rootfs`
@ -178,6 +177,10 @@ Useful config keys:
- `default_modules_dir` - `default_modules_dir`
- `default_packages_file` - `default_packages_file`
Guest SSH access always uses the private key shipped in the resolved runtime
bundle. `ssh_key_path` is no longer a supported override for `banger vm ssh`,
VM start key injection, or daemon guest provisioning.
## Doctor ## Doctor
`banger doctor` runs the same readiness checks the Go control plane uses for VM `banger doctor` runs the same readiness checks the Go control plane uses for VM
start, host-integrated features, and image builds. It reports runtime bundle start, host-integrated features, and image builds. It reports runtime bundle
@ -211,7 +214,8 @@ Rebuilt images install a pinned `mise` at `/usr/local/bin/mise`, activate it
for bash login and interactive shells, install `opencode` through `mise`, for bash login and interactive shells, install `opencode` through `mise`,
configure `tmux-resurrect` plus `tmux-continuum` for `root` with periodic configure `tmux-resurrect` plus `tmux-continuum` for `root` with periodic
autosaves and manual-only restore by default, and bake in the autosaves and manual-only restore by default, and bake in the
`banger-vsock-pingd` systemd service used by the post-SSH reminder path. They `banger-vsock-agent` systemd service used by the post-SSH reminder path and
guest health checks. They
also emit a `work-seed.ext4` sidecar that lets new VMs clone a prepared `/root` also emit a `work-seed.ext4` sidecar that lets new VMs clone a prepared `/root`
work disk instead of rebuilding it from scratch on every create. work disk instead of rebuilding it from scratch on every create.
@ -293,6 +297,93 @@ is not available, pass an explicit `--base-rootfs` to `./make-rootfs.sh`.
Existing VMs keep using their current image and disks; rebuilds only affect VMs Existing VMs keep using their current image and disks; rebuilds only affect VMs
created from the rebuilt image afterward. created from the rebuilt image afterward.
## Experimental Void Rootfs
There is also a separate, opt-in builder for an experimental Void Linux guest
path:
```bash
make rootfs-void
```
That writes:
- `./runtime/rootfs-void.ext4`
- `./runtime/rootfs-void.work-seed.ext4`
This path is intentionally local-only and does not change the default Debian
image flow. It reuses the current runtime bundle kernel, initrd, and modules,
but builds a lean `x86_64-glibc` Void userspace with:
- `bash` installed for interactive/admin use
- `openssh` enabled under runit
- the bundled `banger-vsock-agent` health agent enabled under runit
- `root` normalized to `/bin/bash` while keeping `/bin/sh` as the distro's system shell
- a generated `/root` work-seed for fast creates
It does not install the Debian-oriented extras from rebuilt default images:
- no Docker
- no `mise`
- no `opencode`
- no tmux plugin defaults
The builder fetches official static XBPS tools and packages from the Void
mirror during the build. It currently supports only `x86_64-glibc`.
The package set comes from [`packages.void`](/home/thales/projects/personal/banger/packages.void).
You can override the mirror, size, or output path directly:
```bash
./make-rootfs-void.sh --mirror https://repo-default.voidlinux.org --size 2G
```
The fastest local iteration loop does not require changing your default image
config at all:
```bash
make rootfs-void
make void-register
./banger vm create --image void-exp --name void-dev
./banger vm ssh void-dev
```
There is also a smoke path for the experimental image:
```bash
make verify-void
```
`make void-register` uses the unmanaged image registration path to create or
update a `void-exp` image record in place, so repeated rebuilds do not require
editing `~/.config/banger/config.toml`.
There is also a one-step helper target:
```bash
make void-vm VOID_VM_NAME=void-a
```
If you really want the Void image to become your default for `vm create`
without `--image`, use the checked-in override template at
[`examples/void-exp.config.toml`](/home/thales/projects/personal/banger/examples/void-exp.config.toml)
and merge its four settings into `~/.config/banger/config.toml`.
`banger image build` remains Debian-only in this pass. Do not point
`default_base_rootfs` at the Void artifact yet.
## Registering Unmanaged Images
You can also register any local rootfs as an unmanaged image record without
changing global defaults:
```bash
banger image register --name local-test --rootfs /abs/path/rootfs.ext4
```
Optional paths let you point at an existing work seed, kernel, initrd, modules,
and package manifest:
```bash
banger image register \
--name void-exp \
--rootfs ./runtime/rootfs-void.ext4 \
--work-seed ./runtime/rootfs-void.work-seed.ext4 \
--packages ./packages.void
```
If an unmanaged image with the same name already exists, `image register`
updates it in place so future `vm create --image <name>` calls pick up the new
artifacts immediately.
## Maintaining The Runtime Bundle ## Maintaining The Runtime Bundle
The checked-in [`runtime-bundle.toml`](/home/thales/projects/personal/banger/runtime-bundle.toml) The checked-in [`runtime-bundle.toml`](/home/thales/projects/personal/banger/runtime-bundle.toml)
is a template. Keep `bundle_metadata` accurate there, but use a separate local is a template. Keep `bundle_metadata` accurate there, but use a separate local

View file

@ -0,0 +1,58 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"time"
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
"github.com/sirupsen/logrus"
"banger/internal/vsockagent"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
logger := logrus.New()
logger.SetOutput(io.Discard)
listener, err := sdkvsock.Listener(ctx, logrus.NewEntry(logger), vsockagent.Port)
if err != nil {
fmt.Fprintf(os.Stderr, "banger-vsock-agent: %v\n", err)
os.Exit(1)
}
defer listener.Close()
server := &http.Server{
Handler: vsockagent.NewHandler(),
ReadHeaderTimeout: 3 * time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(listener)
}()
select {
case <-ctx.Done():
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintf(os.Stderr, "banger-vsock-agent: %v\n", err)
os.Exit(1)
}
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintf(os.Stderr, "banger-vsock-agent: %v\n", err)
os.Exit(1)
}
}
}

View file

@ -1,49 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/signal"
"syscall"
"time"
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
"github.com/sirupsen/logrus"
"banger/internal/vsockping"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
logger := logrus.New()
logger.SetOutput(io.Discard)
listener, err := sdkvsock.Listener(ctx, logrus.NewEntry(logger), vsockping.Port)
if err != nil {
fmt.Fprintf(os.Stderr, "banger-vsock-pingd: %v\n", err)
os.Exit(1)
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
return
}
fmt.Fprintf(os.Stderr, "banger-vsock-pingd: accept: %v\n", err)
time.Sleep(200 * time.Millisecond)
continue
}
go func(conn net.Conn) {
if err := vsockping.ServeConn(conn); err != nil {
fmt.Fprintf(os.Stderr, "banger-vsock-pingd: %v\n", err)
}
}(conn)
}
}

View file

@ -68,7 +68,10 @@ FC_BIN="$RUNTIME_DIR/firecracker"
KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")" KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")"
INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")" INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")"
SSH_KEY="$RUNTIME_DIR/id_ed25519" SSH_KEY="$RUNTIME_DIR/id_ed25519"
VSOCK_PING_HELPER="$(bundle_path vsock_ping_helper_path "$RUNTIME_DIR/banger-vsock-pingd")" VSOCK_AGENT="$(bundle_path vsock_agent_path "$RUNTIME_DIR/banger-vsock-agent")"
if [[ "$VSOCK_AGENT" == "$RUNTIME_DIR/banger-vsock-agent" && ! -x "$VSOCK_AGENT" ]]; then
VSOCK_AGENT="$(bundle_path vsock_ping_helper_path "$RUNTIME_DIR/banger-vsock-pingd")"
fi
BR_DEV="br-fc" BR_DEV="br-fc"
BR_IP="172.16.0.1" BR_IP="172.16.0.1"
@ -213,8 +216,8 @@ if [[ ! -f "$PACKAGES_FILE" ]]; then
log "package manifest not found: $PACKAGES_FILE" log "package manifest not found: $PACKAGES_FILE"
exit 1 exit 1
fi fi
if [[ ! -x "$VSOCK_PING_HELPER" ]]; then if [[ ! -x "$VSOCK_AGENT" ]]; then
log "vsock ping helper not found or not executable: $VSOCK_PING_HELPER" log "vsock agent not found or not executable: $VSOCK_AGENT"
log "run 'make build' or refresh the runtime bundle" log "run 'make build' or refresh the runtime bundle"
exit 1 exit 1
fi fi
@ -393,9 +396,9 @@ if [[ "$SSH_READY" -ne 1 ]]; then
fi fi
log "configuring guest" log "configuring guest"
log "installing vsock ping helper" log "installing vsock agent"
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ scp -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"$VSOCK_PING_HELPER" "root@${GUEST_IP}:/usr/local/bin/banger-vsock-pingd" >/dev/null "$VSOCK_AGENT" "root@${GUEST_IP}:/usr/local/bin/banger-vsock-agent" >/dev/null
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"root@${GUEST_IP}" bash -lc "set -e "root@${GUEST_IP}" bash -lc "set -e
@ -436,31 +439,31 @@ if [[ \"$INSTALL_DOCKER\" == \"1\" ]]; then
fi fi
fi fi
rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh
chmod 0755 /usr/local/bin/banger-vsock-pingd chmod 0755 /usr/local/bin/banger-vsock-agent
mkdir -p /etc/modules-load.d /etc/systemd/system mkdir -p /etc/modules-load.d /etc/systemd/system
cat > /etc/modules-load.d/banger-vsock.conf <<'EOF' cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'
vsock vsock
vmw_vsock_virtio_transport vmw_vsock_virtio_transport
EOF EOF
chmod 0644 /etc/modules-load.d/banger-vsock.conf chmod 0644 /etc/modules-load.d/banger-vsock.conf
cat > /etc/systemd/system/banger-vsock-pingd.service <<'EOF' cat > /etc/systemd/system/banger-vsock-agent.service <<'EOF'
[Unit] [Unit]
Description=Banger vsock ping responder Description=Banger vsock agent
After=network.target After=network.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/banger-vsock-pingd ExecStart=/usr/local/bin/banger-vsock-agent
Restart=on-failure Restart=on-failure
RestartSec=1 RestartSec=1
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
chmod 0644 /etc/systemd/system/banger-vsock-pingd.service chmod 0644 /etc/systemd/system/banger-vsock-agent.service
if command -v systemctl >/dev/null 2>&1; then if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true systemctl daemon-reload || true
systemctl enable --now banger-vsock-pingd.service || true systemctl enable --now banger-vsock-agent.service || true
fi fi
git config --system init.defaultBranch main git config --system init.defaultBranch main
" "

View file

@ -0,0 +1,10 @@
# Experimental Void Linux guest profile for local testing.
#
# Copy the values you want into ~/.config/banger/config.toml and replace
# /abs/path/to/banger with your checkout path. Do not set default_base_rootfs
# to the Void image yet; banger image build still assumes the Debian flow.
runtime_dir = "/abs/path/to/banger/runtime"
default_image_name = "void-exp"
default_rootfs = "/abs/path/to/banger/runtime/rootfs-void.ext4"
default_work_seed = "/abs/path/to/banger/runtime/rootfs-void.work-seed.ext4"

View file

@ -63,6 +63,11 @@ type VMSSHResult struct {
GuestIP string `json:"guest_ip"` GuestIP string `json:"guest_ip"`
} }
type VMHealthResult struct {
Name string `json:"name"`
Healthy bool `json:"healthy"`
}
type VMPingResult struct { type VMPingResult struct {
Name string `json:"name"` Name string `json:"name"`
Alive bool `json:"alive"` Alive bool `json:"alive"`
@ -78,6 +83,17 @@ type ImageBuildParams struct {
Docker bool `json:"docker,omitempty"` Docker bool `json:"docker,omitempty"`
} }
type ImageRegisterParams struct {
Name string `json:"name,omitempty"`
RootfsPath string `json:"rootfs_path,omitempty"`
WorkSeedPath string `json:"work_seed_path,omitempty"`
KernelPath string `json:"kernel_path,omitempty"`
InitrdPath string `json:"initrd_path,omitempty"`
ModulesDir string `json:"modules_dir,omitempty"`
PackagesPath string `json:"packages_path,omitempty"`
Docker bool `json:"docker,omitempty"`
}
type ImageRefParams struct { type ImageRefParams struct {
IDOrName string `json:"id_or_name"` IDOrName string `json:"id_or_name"`
} }

View file

@ -24,7 +24,7 @@ import (
"banger/internal/rpc" "banger/internal/rpc"
"banger/internal/system" "banger/internal/system"
"banger/internal/vmdns" "banger/internal/vmdns"
"banger/internal/vsockping" "banger/internal/vsockagent"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -42,8 +42,8 @@ var (
sshCmd.Stdin = stdin sshCmd.Stdin = stdin
return sshCmd.Run() return sshCmd.Run()
} }
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
return rpc.Call[api.VMPingResult](ctx, socketPath, "vm.ping", api.VMRefParams{IDOrName: idOrName}) return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName})
} }
) )
@ -550,6 +550,7 @@ func newImageCommand() *cobra.Command {
} }
cmd.AddCommand( cmd.AddCommand(
newImageBuildCommand(), newImageBuildCommand(),
newImageRegisterCommand(),
newImageListCommand(), newImageListCommand(),
newImageShowCommand(), newImageShowCommand(),
newImageDeleteCommand(), newImageDeleteCommand(),
@ -591,6 +592,41 @@ func newImageBuildCommand() *cobra.Command {
return cmd return cmd
} }
func newImageRegisterCommand() *cobra.Command {
var params api.ImageRegisterParams
cmd := &cobra.Command{
Use: "register",
Short: "Register or update an unmanaged image",
Args: noArgsUsage("usage: banger image register --name <name> --rootfs <path> [--work-seed <path>] [--kernel <path>] [--initrd <path>] [--modules <dir>] [--packages <path>]"),
RunE: func(cmd *cobra.Command, args []string) error {
if err := absolutizeImageRegisterPaths(&params); err != nil {
return err
}
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.register", params)
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
cmd.Flags().StringVar(&params.Name, "name", "", "image name")
cmd.Flags().StringVar(&params.RootfsPath, "rootfs", "", "rootfs path")
cmd.Flags().StringVar(&params.WorkSeedPath, "work-seed", "", "work-seed path")
cmd.Flags().StringVar(&params.KernelPath, "kernel", "", "kernel path")
cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path")
cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir")
cmd.Flags().StringVar(&params.PackagesPath, "packages", "", "packages manifest path")
cmd.Flags().BoolVar(&params.Docker, "docker", false, "mark image as docker-prepared")
return cmd
}
func newImageListCommand() *cobra.Command { func newImageListCommand() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "list", Use: "list",
@ -995,17 +1031,17 @@ func runSSHSession(ctx context.Context, socketPath, vmRef string, stdin io.Reade
} }
pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
ping, err := vmPingFunc(pingCtx, socketPath, vmRef) health, err := vmHealthFunc(pingCtx, socketPath, vmRef)
if err != nil { if err != nil {
_, _ = fmt.Fprintln(stderr, vsockping.WarningMessage(vmRef, err)) _, _ = fmt.Fprintln(stderr, vsockagent.WarningMessage(vmRef, err))
return sshErr return sshErr
} }
if ping.Alive { if health.Healthy {
name := ping.Name name := health.Name
if strings.TrimSpace(name) == "" { if strings.TrimSpace(name) == "" {
name = vmRef name = vmRef
} }
_, _ = fmt.Fprintln(stderr, vsockping.ReminderMessage(name)) _, _ = fmt.Fprintln(stderr, vsockagent.ReminderMessage(name))
} }
return sshErr return sshErr
} }
@ -1015,7 +1051,10 @@ func shouldCheckSSHReminder(err error) bool {
return true return true
} }
var exitErr *exec.ExitError var exitErr *exec.ExitError
return errors.As(err, &exitErr) if !errors.As(err, &exitErr) {
return false
}
return exitErr.ExitCode() != 255
} }
func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) { func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) {
@ -1023,10 +1062,21 @@ func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]s
return nil, errors.New("vm has no guest IP") return nil, errors.New("vm has no guest IP")
} }
args := []string{} args := []string{}
args = append(args, "-F", "/dev/null")
if cfg.SSHKeyPath != "" { if cfg.SSHKeyPath != "" {
args = append(args, "-i", cfg.SSHKeyPath) args = append(args, "-i", cfg.SSHKeyPath)
} }
args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "root@"+guestIP) args = append(
args,
"-o", "IdentitiesOnly=yes",
"-o", "BatchMode=yes",
"-o", "PreferredAuthentications=publickey",
"-o", "PasswordAuthentication=no",
"-o", "KbdInteractiveAuthentication=no",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"root@"+guestIP,
)
args = append(args, extra...) args = append(args, extra...)
return args, nil return args, nil
} }
@ -1035,14 +1085,29 @@ func validateSSHPrereqs(cfg model.DaemonConfig) error {
checks := system.NewPreflight() checks := system.NewPreflight()
checks.RequireCommand("ssh", "install openssh-client") checks.RequireCommand("ssh", "install openssh-client")
if strings.TrimSpace(cfg.SSHKeyPath) != "" { if strings.TrimSpace(cfg.SSHKeyPath) != "" {
checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or refresh the runtime bundle`) checks.RequireFile(cfg.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`)
} }
return checks.Err("ssh preflight failed") return checks.Err("ssh preflight failed")
} }
func absolutizeImageBuildPaths(params *api.ImageBuildParams) error { func absolutizeImageBuildPaths(params *api.ImageBuildParams) error {
return absolutizePaths(&params.BaseRootfs, &params.KernelPath, &params.InitrdPath, &params.ModulesDir)
}
func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error {
return absolutizePaths(
&params.RootfsPath,
&params.WorkSeedPath,
&params.KernelPath,
&params.InitrdPath,
&params.ModulesDir,
&params.PackagesPath,
)
}
func absolutizePaths(values ...*string) error {
var err error var err error
for _, value := range []*string{&params.BaseRootfs, &params.KernelPath, &params.InitrdPath, &params.ModulesDir} { for _, value := range values {
if *value == "" || filepath.IsAbs(*value) { if *value == "" || filepath.IsAbs(*value) {
continue continue
} }

View file

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"os/exec" "os/exec"
@ -118,6 +119,23 @@ func TestVMCreateFlagsExist(t *testing.T) {
} }
} }
func TestImageRegisterFlagsExist(t *testing.T) {
root := NewBangerCommand()
image, _, err := root.Find([]string{"image"})
if err != nil {
t.Fatalf("find image: %v", err)
}
register, _, err := image.Find([]string{"register"})
if err != nil {
t.Fatalf("find register: %v", err)
}
for _, flagName := range []string{"name", "rootfs", "work-seed", "kernel", "initrd", "modules", "packages", "docker"} {
if register.Flags().Lookup(flagName) == nil {
t.Fatalf("missing flag %q", flagName)
}
}
}
func TestVMKillFlagsExist(t *testing.T) { func TestVMKillFlagsExist(t *testing.T) {
root := NewBangerCommand() root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"}) vm, _, err := root.Find([]string{"vm"})
@ -211,19 +229,58 @@ func TestVMSetParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
} }
} }
func TestRunSSHSessionPrintsReminderWhenPingAlive(t *testing.T) { func TestAbsolutizeImageRegisterPaths(t *testing.T) {
tmp := t.TempDir()
params := api.ImageRegisterParams{
RootfsPath: filepath.Join(".", "runtime", "rootfs-void.ext4"),
WorkSeedPath: filepath.Join(".", "runtime", "rootfs-void.work-seed.ext4"),
KernelPath: filepath.Join(".", "runtime", "vmlinux"),
InitrdPath: filepath.Join(".", "runtime", "initrd.img"),
ModulesDir: filepath.Join(".", "runtime", "modules"),
PackagesPath: filepath.Join(".", "packages.void"),
}
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(%s): %v", tmp, err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := absolutizeImageRegisterPaths(&params); err != nil {
t.Fatalf("absolutizeImageRegisterPaths: %v", err)
}
for _, value := range []string{
params.RootfsPath,
params.WorkSeedPath,
params.KernelPath,
params.InitrdPath,
params.ModulesDir,
params.PackagesPath,
} {
if !filepath.IsAbs(value) {
t.Fatalf("path %q is not absolute", value)
}
}
}
func TestRunSSHSessionPrintsReminderWhenHealthCheckPasses(t *testing.T) {
origSSHExec := sshExecFunc origSSHExec := sshExecFunc
origPing := vmPingFunc origHealth := vmHealthFunc
t.Cleanup(func() { t.Cleanup(func() {
sshExecFunc = origSSHExec sshExecFunc = origSSHExec
vmPingFunc = origPing vmHealthFunc = origHealth
}) })
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
return nil return nil
} }
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
return api.VMPingResult{Name: "devbox", Alive: true}, nil return api.VMHealthResult{Name: "devbox", Healthy: true}, nil
} }
var stderr bytes.Buffer var stderr bytes.Buffer
@ -235,19 +292,19 @@ func TestRunSSHSessionPrintsReminderWhenPingAlive(t *testing.T) {
} }
} }
func TestRunSSHSessionPreservesSSHExitStatusOnPingWarning(t *testing.T) { func TestRunSSHSessionPreservesSSHExitStatusOnHealthWarning(t *testing.T) {
origSSHExec := sshExecFunc origSSHExec := sshExecFunc
origPing := vmPingFunc origHealth := vmHealthFunc
t.Cleanup(func() { t.Cleanup(func() {
sshExecFunc = origSSHExec sshExecFunc = origSSHExec
vmPingFunc = origPing vmHealthFunc = origHealth
}) })
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
return &exec.ExitError{} return exitErrorWithCode(t, 1)
} }
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
return api.VMPingResult{}, errors.New("dial failed") return api.VMHealthResult{}, errors.New("dial failed")
} }
var stderr bytes.Buffer var stderr bytes.Buffer
@ -261,6 +318,37 @@ func TestRunSSHSessionPreservesSSHExitStatusOnPingWarning(t *testing.T) {
} }
} }
func TestRunSSHSessionSkipsReminderOnSSHAuthFailure(t *testing.T) {
origSSHExec := sshExecFunc
origHealth := vmHealthFunc
t.Cleanup(func() {
sshExecFunc = origSSHExec
vmHealthFunc = origHealth
})
healthCalled := false
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
return exitErrorWithCode(t, 255)
}
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
healthCalled = true
return api.VMHealthResult{Name: "devbox", Healthy: true}, nil
}
var stderr bytes.Buffer
err := runSSHSession(context.Background(), "/tmp/bangerd.sock", "devbox", strings.NewReader(""), &bytes.Buffer{}, &stderr, []string{"root@127.0.0.1"})
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 255 {
t.Fatalf("runSSHSession error = %v, want exit 255", err)
}
if healthCalled {
t.Fatal("vm health should not run after ssh auth failure")
}
if strings.Contains(stderr.String(), "still running") {
t.Fatalf("stderr = %q, should not contain reminder", stderr.String())
}
}
func TestResolveVMTargetsDeduplicatesAndReportsErrors(t *testing.T) { func TestResolveVMTargetsDeduplicatesAndReportsErrors(t *testing.T) {
vms := []model.VMRecord{ vms := []model.VMRecord{
testCLIResolvedVM("alpha-id", "alpha"), testCLIResolvedVM("alpha-id", "alpha"),
@ -358,7 +446,13 @@ func TestSSHCommandArgs(t *testing.T) {
t.Fatalf("sshCommandArgs: %v", err) t.Fatalf("sshCommandArgs: %v", err)
} }
want := []string{ want := []string{
"-F", "/dev/null",
"-i", "/bundle/id_ed25519", "-i", "/bundle/id_ed25519",
"-o", "IdentitiesOnly=yes",
"-o", "BatchMode=yes",
"-o", "PreferredAuthentications=publickey",
"-o", "PasswordAuthentication=no",
"-o", "KbdInteractiveAuthentication=no",
"-o", "StrictHostKeyChecking=no", "-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null", "-o", "UserKnownHostsFile=/dev/null",
"root@172.16.0.2", "root@172.16.0.2",
@ -381,6 +475,17 @@ func TestValidateSSHPrereqs(t *testing.T) {
} }
} }
func exitErrorWithCode(t *testing.T, code int) *exec.ExitError {
t.Helper()
cmd := exec.Command("bash", "-lc", fmt.Sprintf("exit %d", code))
err := cmd.Run()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("exitErrorWithCode(%d) error = %v, want exit error", code, err)
}
return exitErr
}
func TestValidateSSHPrereqsFailsForMissingKey(t *testing.T) { func TestValidateSSHPrereqsFailsForMissingKey(t *testing.T) {
err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: "/does/not/exist"}) err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: "/does/not/exist"})
if err == nil || !strings.Contains(err.Error(), "ssh private key") { if err == nil || !strings.Contains(err.Error(), "ssh private key") {

View file

@ -16,7 +16,7 @@ import (
"banger/internal/paths" "banger/internal/paths"
"banger/internal/rpc" "banger/internal/rpc"
"banger/internal/system" "banger/internal/system"
"banger/internal/vsockping" "banger/internal/vsockagent"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@ -1466,22 +1466,22 @@ func sshDoneMsg(layout paths.Layout, action actionRequest, name string, execErr
} }
pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
ping, err := vmPingFunc(pingCtx, layout.SocketPath, name) health, err := vmHealthFunc(pingCtx, layout.SocketPath, name)
if err != nil { if err != nil {
return actionResultMsg{ return actionResultMsg{
action: action, action: action,
status: vsockping.WarningMessage(name, err), status: vsockagent.WarningMessage(name, err),
refresh: true, refresh: true,
focusID: action.id, focusID: action.id,
} }
} }
if ping.Alive { if health.Healthy {
if strings.TrimSpace(ping.Name) != "" { if strings.TrimSpace(health.Name) != "" {
name = ping.Name name = health.Name
} }
return actionResultMsg{ return actionResultMsg{
action: action, action: action,
status: vsockping.ReminderMessage(name), status: vsockagent.ReminderMessage(name),
refresh: true, refresh: true,
focusID: action.id, focusID: action.id,
} }

View file

@ -238,13 +238,13 @@ func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) {
} }
} }
func TestSSHDoneMsgShowsReminderWhenPingAlive(t *testing.T) { func TestSSHDoneMsgShowsReminderWhenHealthCheckPasses(t *testing.T) {
origPing := vmPingFunc origHealth := vmHealthFunc
t.Cleanup(func() { t.Cleanup(func() {
vmPingFunc = origPing vmHealthFunc = origHealth
}) })
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
return api.VMPingResult{Name: "devbox", Alive: true}, nil return api.VMHealthResult{Name: "devbox", Healthy: true}, nil
} }
msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil) msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil)
@ -257,13 +257,13 @@ func TestSSHDoneMsgShowsReminderWhenPingAlive(t *testing.T) {
} }
} }
func TestSSHDoneMsgShowsWarningWhenPingFails(t *testing.T) { func TestSSHDoneMsgShowsWarningWhenHealthCheckFails(t *testing.T) {
origPing := vmPingFunc origHealth := vmHealthFunc
t.Cleanup(func() { t.Cleanup(func() {
vmPingFunc = origPing vmHealthFunc = origHealth
}) })
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
return api.VMPingResult{}, errors.New("dial failed") return api.VMHealthResult{}, errors.New("dial failed")
} }
msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil) msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil)

View file

@ -22,6 +22,7 @@ type fileConfig struct {
SSHKeyPath string `toml:"ssh_key_path"` SSHKeyPath string `toml:"ssh_key_path"`
NamegenPath string `toml:"namegen_path"` NamegenPath string `toml:"namegen_path"`
CustomizeScript string `toml:"customize_script"` CustomizeScript string `toml:"customize_script"`
VSockAgent string `toml:"vsock_agent_path"`
VSockPingHelper string `toml:"vsock_ping_helper_path"` VSockPingHelper string `toml:"vsock_ping_helper_path"`
DefaultWorkSeed string `toml:"default_work_seed"` DefaultWorkSeed string `toml:"default_work_seed"`
DefaultImageName string `toml:"default_image_name"` DefaultImageName string `toml:"default_image_name"`
@ -83,17 +84,16 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) {
if file.LogLevel != "" { if file.LogLevel != "" {
cfg.LogLevel = file.LogLevel cfg.LogLevel = file.LogLevel
} }
if file.SSHKeyPath != "" {
cfg.SSHKeyPath = file.SSHKeyPath
}
if file.NamegenPath != "" { if file.NamegenPath != "" {
cfg.NamegenPath = file.NamegenPath cfg.NamegenPath = file.NamegenPath
} }
if file.CustomizeScript != "" { if file.CustomizeScript != "" {
cfg.CustomizeScript = file.CustomizeScript cfg.CustomizeScript = file.CustomizeScript
} }
if file.VSockPingHelper != "" { if file.VSockAgent != "" {
cfg.VSockPingHelperPath = file.VSockPingHelper cfg.VSockAgentPath = file.VSockAgent
} else if file.VSockPingHelper != "" {
cfg.VSockAgentPath = file.VSockPingHelper
} }
if file.DefaultWorkSeed != "" { if file.DefaultWorkSeed != "" {
cfg.DefaultWorkSeed = file.DefaultWorkSeed cfg.DefaultWorkSeed = file.DefaultWorkSeed
@ -197,7 +197,7 @@ func applyBundleMetadataDefaults(cfg *model.DaemonConfig, runtimeDir string, met
cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, runtimeDir, meta.SSHKeyPath) cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, runtimeDir, meta.SSHKeyPath)
cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, runtimeDir, meta.NamegenPath) cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, runtimeDir, meta.NamegenPath)
cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, runtimeDir, meta.CustomizeScript) cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, runtimeDir, meta.CustomizeScript)
cfg.VSockPingHelperPath = defaultRuntimePath(cfg.VSockPingHelperPath, runtimeDir, meta.VSockPingHelperPath) cfg.VSockAgentPath = defaultRuntimePath(cfg.VSockAgentPath, runtimeDir, meta.VSockAgentPath)
cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, runtimeDir, meta.DefaultWorkSeed) cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, runtimeDir, meta.DefaultWorkSeed)
cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, runtimeDir, meta.DefaultKernel) cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, runtimeDir, meta.DefaultKernel)
cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, runtimeDir, meta.DefaultInitrd) cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, runtimeDir, meta.DefaultInitrd)
@ -212,7 +212,10 @@ func applyLegacyRuntimeDefaults(cfg *model.DaemonConfig) {
cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, cfg.RuntimeDir, "id_ed25519") cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, cfg.RuntimeDir, "id_ed25519")
cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, cfg.RuntimeDir, "namegen") cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, cfg.RuntimeDir, "namegen")
cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, cfg.RuntimeDir, "customize.sh") cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, cfg.RuntimeDir, "customize.sh")
cfg.VSockPingHelperPath = defaultRuntimePath(cfg.VSockPingHelperPath, cfg.RuntimeDir, "banger-vsock-pingd") cfg.VSockAgentPath = firstExistingRuntimePath(
defaultRuntimePath(cfg.VSockAgentPath, cfg.RuntimeDir, "banger-vsock-agent"),
filepath.Join(cfg.RuntimeDir, "banger-vsock-pingd"),
)
cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, cfg.RuntimeDir, "rootfs-docker.work-seed.ext4") cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, cfg.RuntimeDir, "rootfs-docker.work-seed.ext4")
cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, cfg.RuntimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, cfg.RuntimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic")
cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, cfg.RuntimeDir, "wtf/root/boot/initrd.img-6.8.0-94-generic") cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, cfg.RuntimeDir, "wtf/root/boot/initrd.img-6.8.0-94-generic")

View file

@ -13,24 +13,24 @@ import (
func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) { func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) {
runtimeDir := t.TempDir() runtimeDir := t.TempDir()
meta := runtimebundle.BundleMetadata{ meta := runtimebundle.BundleMetadata{
FirecrackerBin: "bin/firecracker", FirecrackerBin: "bin/firecracker",
SSHKeyPath: "keys/id_ed25519", SSHKeyPath: "keys/id_ed25519",
NamegenPath: "bin/namegen", NamegenPath: "bin/namegen",
CustomizeScript: "scripts/customize.sh", CustomizeScript: "scripts/customize.sh",
VSockPingHelperPath: "bin/banger-vsock-pingd", VSockAgentPath: "bin/banger-vsock-agent",
DefaultPackages: "config/packages.apt", DefaultPackages: "config/packages.apt",
DefaultRootfs: "images/rootfs-docker.ext4", DefaultRootfs: "images/rootfs-docker.ext4",
DefaultWorkSeed: "images/rootfs-docker.work-seed.ext4", DefaultWorkSeed: "images/rootfs-docker.work-seed.ext4",
DefaultKernel: "kernels/vmlinux", DefaultKernel: "kernels/vmlinux",
DefaultInitrd: "kernels/initrd.img", DefaultInitrd: "kernels/initrd.img",
DefaultModulesDir: "modules/current", DefaultModulesDir: "modules/current",
} }
for _, rel := range []string{ for _, rel := range []string{
meta.FirecrackerBin, meta.FirecrackerBin,
meta.SSHKeyPath, meta.SSHKeyPath,
meta.NamegenPath, meta.NamegenPath,
meta.CustomizeScript, meta.CustomizeScript,
meta.VSockPingHelperPath, meta.VSockAgentPath,
meta.DefaultPackages, meta.DefaultPackages,
meta.DefaultRootfs, meta.DefaultRootfs,
meta.DefaultWorkSeed, meta.DefaultWorkSeed,
@ -75,8 +75,8 @@ func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) {
if cfg.CustomizeScript != filepath.Join(runtimeDir, meta.CustomizeScript) { if cfg.CustomizeScript != filepath.Join(runtimeDir, meta.CustomizeScript) {
t.Fatalf("CustomizeScript = %q", cfg.CustomizeScript) t.Fatalf("CustomizeScript = %q", cfg.CustomizeScript)
} }
if cfg.VSockPingHelperPath != filepath.Join(runtimeDir, meta.VSockPingHelperPath) { if cfg.VSockAgentPath != filepath.Join(runtimeDir, meta.VSockAgentPath) {
t.Fatalf("VSockPingHelperPath = %q", cfg.VSockPingHelperPath) t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath)
} }
if cfg.DefaultRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) { if cfg.DefaultRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) {
t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs) t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs)
@ -108,7 +108,7 @@ func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) {
"id_ed25519", "id_ed25519",
"namegen", "namegen",
"customize.sh", "customize.sh",
"banger-vsock-pingd", "banger-vsock-agent",
"packages.apt", "packages.apt",
"rootfs-docker.ext4", "rootfs-docker.ext4",
"rootfs-docker.work-seed.ext4", "rootfs-docker.work-seed.ext4",
@ -134,8 +134,8 @@ func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) {
if cfg.FirecrackerBin != filepath.Join(runtimeDir, "firecracker") { if cfg.FirecrackerBin != filepath.Join(runtimeDir, "firecracker") {
t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin)
} }
if cfg.VSockPingHelperPath != filepath.Join(runtimeDir, "banger-vsock-pingd") { if cfg.VSockAgentPath != filepath.Join(runtimeDir, "banger-vsock-agent") {
t.Fatalf("VSockPingHelperPath = %q", cfg.VSockPingHelperPath) t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath)
} }
if cfg.DefaultWorkSeed != filepath.Join(runtimeDir, "rootfs-docker.work-seed.ext4") { if cfg.DefaultWorkSeed != filepath.Join(runtimeDir, "rootfs-docker.work-seed.ext4") {
t.Fatalf("DefaultWorkSeed = %q", cfg.DefaultWorkSeed) t.Fatalf("DefaultWorkSeed = %q", cfg.DefaultWorkSeed)
@ -167,3 +167,125 @@ func TestLoadDefaultsLogLevelToInfo(t *testing.T) {
t.Fatalf("LogLevel = %q, want info", cfg.LogLevel) t.Fatalf("LogLevel = %q, want info", cfg.LogLevel)
} }
} }
func TestLoadIgnoresConfigSSHKeyOverrideForGuestAccess(t *testing.T) {
runtimeDir := t.TempDir()
meta := runtimebundle.BundleMetadata{
FirecrackerBin: "bin/firecracker",
SSHKeyPath: "keys/id_ed25519",
NamegenPath: "bin/namegen",
CustomizeScript: "scripts/customize.sh",
VSockAgentPath: "bin/banger-vsock-agent",
DefaultPackages: "config/packages.apt",
DefaultRootfs: "images/rootfs.ext4",
DefaultWorkSeed: "images/rootfs.work-seed.ext4",
DefaultKernel: "kernels/vmlinux",
DefaultModulesDir: "modules/current",
}
for _, rel := range []string{
meta.FirecrackerBin,
meta.SSHKeyPath,
meta.NamegenPath,
meta.CustomizeScript,
meta.VSockAgentPath,
meta.DefaultPackages,
meta.DefaultRootfs,
meta.DefaultWorkSeed,
meta.DefaultKernel,
filepath.Join(meta.DefaultModulesDir, "modules.dep"),
} {
path := filepath.Join(runtimeDir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
data, err := json.Marshal(meta)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil {
t.Fatalf("write bundle metadata: %v", err)
}
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("ssh_key_path = \"/tmp/override-key\"\n"), 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
t.Setenv("BANGER_RUNTIME_DIR", runtimeDir)
cfg, err := Load(paths.Layout{ConfigDir: configDir})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join(runtimeDir, meta.SSHKeyPath)
if cfg.SSHKeyPath != want {
t.Fatalf("SSHKeyPath = %q, want runtime key %q", cfg.SSHKeyPath, want)
}
}
func TestLoadAcceptsLegacyBundleVsockPingHelperPath(t *testing.T) {
runtimeDir := t.TempDir()
meta := runtimebundle.BundleMetadata{
FirecrackerBin: "bin/firecracker",
SSHKeyPath: "keys/id_ed25519",
NamegenPath: "bin/namegen",
CustomizeScript: "scripts/customize.sh",
VSockPingHelperPath: "bin/banger-vsock-pingd",
DefaultPackages: "config/packages.apt",
DefaultRootfs: "images/rootfs.ext4",
DefaultKernel: "kernels/vmlinux",
}
for _, rel := range []string{
meta.FirecrackerBin,
meta.SSHKeyPath,
meta.NamegenPath,
meta.CustomizeScript,
meta.VSockPingHelperPath,
meta.DefaultPackages,
meta.DefaultRootfs,
meta.DefaultKernel,
} {
path := filepath.Join(runtimeDir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
data, err := json.Marshal(meta)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil {
t.Fatalf("write bundle metadata: %v", err)
}
t.Setenv("BANGER_RUNTIME_DIR", runtimeDir)
cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.VSockAgentPath != filepath.Join(runtimeDir, meta.VSockPingHelperPath) {
t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath)
}
}
func TestLoadAcceptsLegacyConfigVsockPingHelperPath(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("vsock_ping_helper_path = \"/tmp/legacy-agent\"\n"), 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.VSockAgentPath != "/tmp/legacy-agent" {
t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath)
}
}

View file

@ -191,7 +191,10 @@ func (workDiskCapability) ContributeMachine(cfg *firecracker.MachineConfig, vm m
} }
func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.VMRecord, image model.Image) error { func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.VMRecord, image model.Image) error {
return d.ensureWorkDisk(ctx, vm, image) if err := d.ensureWorkDisk(ctx, vm, image); err != nil {
return err
}
return d.ensureAuthorizedKeyOnWorkDisk(ctx, vm)
} }
func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) { func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) {

View file

@ -331,6 +331,13 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
return rpc.NewError("not_running", fmt.Sprintf("vm %s is not running", vm.Name)) return rpc.NewError("not_running", fmt.Sprintf("vm %s is not running", vm.Name))
} }
return marshalResultOrError(api.VMSSHResult{Name: vm.Name, GuestIP: vm.Runtime.GuestIP}, nil) return marshalResultOrError(api.VMSSHResult{Name: vm.Name, GuestIP: vm.Runtime.GuestIP}, nil)
case "vm.health":
params, err := rpc.DecodeParams[api.VMRefParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
result, err := d.HealthVM(ctx, params.IDOrName)
return marshalResultOrError(result, err)
case "vm.ping": case "vm.ping":
params, err := rpc.DecodeParams[api.VMRefParams](req) params, err := rpc.DecodeParams[api.VMRefParams](req)
if err != nil { if err != nil {
@ -355,6 +362,13 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
} }
image, err := d.BuildImage(ctx, params) image, err := d.BuildImage(ctx, params)
return marshalResultOrError(api.ImageShowResult{Image: image}, err) return marshalResultOrError(api.ImageShowResult{Image: image}, err)
case "image.register":
params, err := rpc.DecodeParams[api.ImageRegisterParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
image, err := d.RegisterImage(ctx, params)
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
case "image.delete": case "image.delete":
params, err := rpc.DecodeParams[api.ImageRefParams](req) params, err := rpc.DecodeParams[api.ImageRefParams](req)
if err != nil { if err != nil {

View file

@ -246,6 +246,128 @@ func TestEnsureDefaultImageSkipsRewriteWhenCurrentArtifactsMissing(t *testing.T)
} }
} }
func TestRegisterImageCreatesUnmanagedImage(t *testing.T) {
dir := t.TempDir()
rootfs, kernel, initrd, modulesDir, _ := writeDefaultImageArtifacts(t, dir)
workSeed := filepath.Join(dir, "rootfs-void.work-seed.ext4")
packages := filepath.Join(dir, "packages.void")
if err := os.WriteFile(workSeed, []byte("seed"), 0o644); err != nil {
t.Fatalf("WriteFile(workSeed): %v", err)
}
if err := os.WriteFile(packages, []byte("base-minimal\nopenssh\n"), 0o644); err != nil {
t.Fatalf("WriteFile(packages): %v", err)
}
db := openDefaultImageStore(t, dir)
d := &Daemon{
config: model.DaemonConfig{
DefaultKernel: kernel,
DefaultInitrd: initrd,
DefaultModulesDir: modulesDir,
},
store: db,
}
image, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "void-exp",
RootfsPath: rootfs,
WorkSeedPath: workSeed,
PackagesPath: packages,
})
if err != nil {
t.Fatalf("RegisterImage: %v", err)
}
if image.Managed {
t.Fatal("registered image should be unmanaged")
}
if image.Name != "void-exp" || image.RootfsPath != rootfs || image.WorkSeedPath != workSeed || image.KernelPath != kernel {
t.Fatalf("registered image = %+v", image)
}
}
func TestRegisterImageUpdatesExistingUnmanagedImageInPlace(t *testing.T) {
dir := t.TempDir()
_, kernel, initrd, modulesDir, _ := writeDefaultImageArtifacts(t, dir)
newRootfs := filepath.Join(dir, "rootfs-void-next.ext4")
newWorkSeed := filepath.Join(dir, "rootfs-void-next.work-seed.ext4")
packages := filepath.Join(dir, "packages.void")
for _, path := range []string{newRootfs, newWorkSeed} {
if err := os.WriteFile(path, []byte("next"), 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
if err := os.WriteFile(packages, []byte("base-minimal\n"), 0o644); err != nil {
t.Fatalf("WriteFile(packages): %v", err)
}
db := openDefaultImageStore(t, dir)
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
existing := model.Image{
ID: "void-image-id",
Name: "void-exp",
Managed: false,
RootfsPath: filepath.Join(dir, "old-rootfs.ext4"),
KernelPath: kernel,
InitrdPath: initrd,
ModulesDir: modulesDir,
PackagesPath: packages,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.UpsertImage(context.Background(), existing); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
d := &Daemon{
config: model.DaemonConfig{
DefaultKernel: kernel,
DefaultInitrd: initrd,
DefaultModulesDir: modulesDir,
},
store: db,
}
image, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "void-exp",
RootfsPath: newRootfs,
WorkSeedPath: newWorkSeed,
PackagesPath: packages,
})
if err != nil {
t.Fatalf("RegisterImage: %v", err)
}
if image.ID != existing.ID || !image.CreatedAt.Equal(existing.CreatedAt) {
t.Fatalf("updated image identity changed: %+v", image)
}
if image.RootfsPath != newRootfs || image.WorkSeedPath != newWorkSeed {
t.Fatalf("updated image paths not applied: %+v", image)
}
}
func TestRegisterImageRejectsManagedOverwrite(t *testing.T) {
dir := t.TempDir()
rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir)
db := openDefaultImageStore(t, dir)
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
if err := db.UpsertImage(context.Background(), model.Image{
ID: "managed-id",
Name: "void-exp",
Managed: true,
RootfsPath: rootfs,
KernelPath: kernel,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
d := &Daemon{config: model.DaemonConfig{DefaultKernel: kernel}, store: db}
_, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "void-exp",
RootfsPath: rootfs,
})
if err == nil || !strings.Contains(err.Error(), "cannot be updated via register") {
t.Fatalf("RegisterImage(managed) error = %v", err)
}
}
func openDefaultImageStore(t *testing.T, dir string) *store.Store { func openDefaultImageStore(t *testing.T, dir string) *store.Store {
t.Helper() t.Helper()
db, err := store.Open(filepath.Join(dir, "state.db")) db, err := store.Open(filepath.Join(dir, "state.db"))

View file

@ -33,7 +33,7 @@ func (d *Daemon) doctorReport(ctx context.Context) system.Report {
report.AddPreflight("runtime bundle", d.runtimeBundleChecks(), runtimeBundleStatus(d.config)) report.AddPreflight("runtime bundle", d.runtimeBundleChecks(), runtimeBundleStatus(d.config))
report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available") report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available")
report.AddPreflight("vsock ssh reminder", d.vsockChecks(), "vsock reminder prerequisites available") report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock agent prerequisites available")
d.addCapabilityDoctorChecks(ctx, &report) d.addCapabilityDoctorChecks(ctx, &report)
report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available") report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available")
@ -44,8 +44,8 @@ func (d *Daemon) runtimeBundleChecks() *system.Preflight {
checks := system.NewPreflight() checks := system.NewPreflight()
hint := paths.RuntimeBundleHint() hint := paths.RuntimeBundleHint()
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or refresh the runtime bundle`) checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`)
checks.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`) checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
checks.RequireFile(d.config.DefaultRootfs, "default rootfs image", `set "default_rootfs" or refresh the runtime bundle`) checks.RequireFile(d.config.DefaultRootfs, "default rootfs image", `set "default_rootfs" or refresh the runtime bundle`)
checks.RequireFile(d.config.DefaultKernel, "kernel image", `set "default_kernel" or refresh the runtime bundle`) checks.RequireFile(d.config.DefaultKernel, "kernel image", `set "default_kernel" or refresh the runtime bundle`)
if strings.TrimSpace(d.config.DefaultInitrd) != "" { if strings.TrimSpace(d.config.DefaultInitrd) != "" {
@ -79,7 +79,7 @@ func (d *Daemon) imageBuildChecks(ctx context.Context) *system.Preflight {
func (d *Daemon) vsockChecks() *system.Preflight { func (d *Daemon) vsockChecks() *system.Preflight {
checks := system.NewPreflight() checks := system.NewPreflight()
checks.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`) checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host") checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host")
return checks return checks
} }

View file

@ -17,7 +17,7 @@ import (
"banger/internal/hostnat" "banger/internal/hostnat"
"banger/internal/model" "banger/internal/model"
"banger/internal/system" "banger/internal/system"
"banger/internal/vsockping" "banger/internal/vsockagent"
) )
const ( const (
@ -104,14 +104,14 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (
} }
defer client.Close() defer client.Close()
helperBytes, err := os.ReadFile(d.config.VSockPingHelperPath) helperBytes, err := os.ReadFile(d.config.VSockAgentPath)
if err != nil { if err != nil {
return err return err
} }
if err := writeBuildLog(spec.BuildLog, "installing vsock ping helper"); err != nil { if err := writeBuildLog(spec.BuildLog, "installing vsock agent"); err != nil {
return err return err
} }
if err := client.UploadFile(ctx, vsockping.GuestInstallPath, 0o755, helperBytes, spec.BuildLog); err != nil { if err := client.UploadFile(ctx, vsockagent.GuestInstallPath, 0o755, helperBytes, spec.BuildLog); err != nil {
return err return err
} }
if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil { if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil {
@ -333,14 +333,14 @@ func appendTmuxSetup(script *bytes.Buffer) {
func appendVSockPingSetup(script *bytes.Buffer) { func appendVSockPingSetup(script *bytes.Buffer) {
script.WriteString("mkdir -p /etc/modules-load.d /etc/systemd/system\n") script.WriteString("mkdir -p /etc/modules-load.d /etc/systemd/system\n")
script.WriteString("cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'\n") script.WriteString("cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'\n")
script.WriteString(vsockping.ModulesLoadConfig()) script.WriteString(vsockagent.ModulesLoadConfig())
script.WriteString("EOF\n") script.WriteString("EOF\n")
script.WriteString("chmod 0644 /etc/modules-load.d/banger-vsock.conf\n") script.WriteString("chmod 0644 /etc/modules-load.d/banger-vsock.conf\n")
script.WriteString("cat > /etc/systemd/system/" + vsockping.ServiceName + " <<'EOF'\n") script.WriteString("cat > /etc/systemd/system/" + vsockagent.ServiceName + " <<'EOF'\n")
script.WriteString(vsockping.ServiceUnit()) script.WriteString(vsockagent.ServiceUnit())
script.WriteString("EOF\n") script.WriteString("EOF\n")
script.WriteString("chmod 0644 /etc/systemd/system/" + vsockping.ServiceName + "\n") script.WriteString("chmod 0644 /etc/systemd/system/" + vsockagent.ServiceName + "\n")
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + vsockping.ServiceName + " || true; fi\n") script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + vsockagent.ServiceName + " || true; fi\n")
} }
func appendGitRepo(script *bytes.Buffer, dir, repo string) { func appendGitRepo(script *bytes.Buffer, dir, repo string) {

View file

@ -28,9 +28,9 @@ func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) {
"run '~/.tmux/plugins/tpm/tpm'", "run '~/.tmux/plugins/tpm/tpm'",
"cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'", "cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'",
"vmw_vsock_virtio_transport", "vmw_vsock_virtio_transport",
"cat > /etc/systemd/system/banger-vsock-pingd.service <<'EOF'", "cat > /etc/systemd/system/banger-vsock-agent.service <<'EOF'",
"ExecStart=/usr/local/bin/banger-vsock-pingd", "ExecStart=/usr/local/bin/banger-vsock-agent",
"systemctl enable --now banger-vsock-pingd.service || true", "systemctl enable --now banger-vsock-agent.service || true",
"rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh", "rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh",
} { } {
if !strings.Contains(script, snippet) { if !strings.Contains(script, snippet) {

View file

@ -2,9 +2,12 @@ package daemon
import ( import (
"context" "context"
"database/sql"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"banger/internal/api" "banger/internal/api"
"banger/internal/model" "banger/internal/model"
@ -132,6 +135,110 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i
return image, nil return image, nil
} }
func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterParams) (image model.Image, err error) {
d.mu.Lock()
defer d.mu.Unlock()
name := strings.TrimSpace(params.Name)
if name == "" {
return model.Image{}, fmt.Errorf("image name is required")
}
rootfsPath := strings.TrimSpace(params.RootfsPath)
if rootfsPath == "" {
return model.Image{}, fmt.Errorf("rootfs path is required")
}
workSeedPath := strings.TrimSpace(params.WorkSeedPath)
if workSeedPath == "" {
candidate := system.WorkSeedPath(rootfsPath)
if candidate != "" {
if _, statErr := os.Stat(candidate); statErr == nil {
workSeedPath = candidate
}
}
}
kernelPath := strings.TrimSpace(params.KernelPath)
if kernelPath == "" {
kernelPath = d.config.DefaultKernel
}
initrdPath := strings.TrimSpace(params.InitrdPath)
if initrdPath == "" {
initrdPath = d.config.DefaultInitrd
}
modulesDir := strings.TrimSpace(params.ModulesDir)
if modulesDir == "" {
modulesDir = d.config.DefaultModulesDir
}
packagesPath := strings.TrimSpace(params.PackagesPath)
if err := validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath); err != nil {
return model.Image{}, err
}
now := model.Now()
existing, lookupErr := d.store.GetImageByName(ctx, name)
switch {
case lookupErr == nil:
if existing.Managed {
return model.Image{}, fmt.Errorf("managed image %s cannot be updated via register", name)
}
image = existing
image.RootfsPath = rootfsPath
image.WorkSeedPath = workSeedPath
image.KernelPath = kernelPath
image.InitrdPath = initrdPath
image.ModulesDir = modulesDir
image.PackagesPath = packagesPath
image.Docker = params.Docker
image.UpdatedAt = now
case errors.Is(lookupErr, sql.ErrNoRows):
id, idErr := model.NewID()
if idErr != nil {
return model.Image{}, idErr
}
image = model.Image{
ID: id,
Name: name,
Managed: false,
RootfsPath: rootfsPath,
WorkSeedPath: workSeedPath,
KernelPath: kernelPath,
InitrdPath: initrdPath,
ModulesDir: modulesDir,
PackagesPath: packagesPath,
Docker: params.Docker,
CreatedAt: now,
UpdatedAt: now,
}
default:
return model.Image{}, lookupErr
}
if err := d.store.UpsertImage(ctx, image); err != nil {
return model.Image{}, err
}
return image, nil
}
func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath string) error {
checks := system.NewPreflight()
checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs <path>`)
checks.RequireFile(kernelPath, "kernel image", `pass --kernel <path> or set "default_kernel"`)
if workSeedPath != "" {
checks.RequireFile(workSeedPath, "work-seed image", `pass --work-seed <path> or rebuild the image with a work seed`)
}
if initrdPath != "" {
checks.RequireFile(initrdPath, "initrd image", `pass --initrd <path> or set "default_initrd"`)
}
if modulesDir != "" {
checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules <dir> or set "default_modules_dir"`)
}
if packagesPath != "" {
checks.RequireFile(packagesPath, "packages manifest", `pass --packages <path>`)
}
return checks.Err("image register failed")
}
func writePackagesMetadata(rootfsPath, packagesPath string) error { func writePackagesMetadata(rootfsPath, packagesPath string) error {
if rootfsPath == "" || packagesPath == "" { if rootfsPath == "" || packagesPath == "" {
return nil return nil

View file

@ -59,7 +59,7 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
t.Setenv("PATH", binDir) t.Setenv("PATH", binDir)
firecrackerBin := filepath.Join(t.TempDir(), "firecracker") firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-pingd") vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent")
if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write firecracker: %v", err) t.Fatalf("write firecracker: %v", err)
} }
@ -105,12 +105,12 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
d := &Daemon{ d := &Daemon{
layout: paths.Layout{RuntimeDir: filepath.Join(t.TempDir(), "runtime")}, layout: paths.Layout{RuntimeDir: filepath.Join(t.TempDir(), "runtime")},
config: model.DaemonConfig{ config: model.DaemonConfig{
BridgeName: "br-fc", BridgeName: "br-fc",
BridgeIP: model.DefaultBridgeIP, BridgeIP: model.DefaultBridgeIP,
DefaultDNS: model.DefaultDNS, DefaultDNS: model.DefaultDNS,
FirecrackerBin: firecrackerBin, FirecrackerBin: firecrackerBin,
VSockPingHelperPath: vsockHelper, VSockAgentPath: vsockHelper,
StatsPollInterval: model.DefaultStatsPollInterval, StatsPollInterval: model.DefaultStatsPollInterval,
}, },
runner: runner, runner: runner,
logger: logger, logger: logger,
@ -151,7 +151,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
packagesPath := filepath.Join(t.TempDir(), "packages.apt") packagesPath := filepath.Join(t.TempDir(), "packages.apt")
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519") sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
firecrackerBin := filepath.Join(t.TempDir(), "firecracker") firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-pingd") vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent")
for _, path := range []string{baseRootfs, kernelPath, packagesPath, sshKeyPath} { for _, path := range []string{baseRootfs, kernelPath, packagesPath, sshKeyPath} {
if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil { if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err) t.Fatalf("write %s: %v", path, err)
@ -186,7 +186,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
DefaultPackagesFile: packagesPath, DefaultPackagesFile: packagesPath,
SSHKeyPath: sshKeyPath, SSHKeyPath: sshKeyPath,
FirecrackerBin: firecrackerBin, FirecrackerBin: firecrackerBin,
VSockPingHelperPath: vsockHelper, VSockAgentPath: vsockHelper,
}, },
store: store, store: store,
runner: runner, runner: runner,

View file

@ -54,7 +54,7 @@ func (d *Daemon) addBaseStartPrereqs(checks *system.Preflight, image model.Image
d.addBaseStartCommandPrereqs(checks) d.addBaseStartCommandPrereqs(checks)
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
checks.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`) checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host") checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host")
checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid image or rebuild the runtime bundle") checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid image or rebuild the runtime bundle")
checks.RequireFile(image.KernelPath, "kernel image", `set "default_kernel" or refresh the runtime bundle`) checks.RequireFile(image.KernelPath, "kernel image", `set "default_kernel" or refresh the runtime bundle`)
@ -79,8 +79,8 @@ func (d *Daemon) addImageBuildPrereqs(ctx context.Context, checks *system.Prefli
checks.RequireCommand(command, toolHint(command)) checks.RequireCommand(command, toolHint(command))
} }
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or refresh the runtime bundle`) checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`)
checks.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`) checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
checks.RequireFile(baseRootfs, "base rootfs image", `pass --base-rootfs or set "default_base_rootfs"`) checks.RequireFile(baseRootfs, "base rootfs image", `pass --base-rootfs or set "default_base_rootfs"`)
checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`) checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`)
checks.RequireFile(d.config.DefaultPackagesFile, "package manifest", `set "default_packages_file" or refresh the runtime bundle`) checks.RequireFile(d.config.DefaultPackagesFile, "package manifest", `set "default_packages_file" or refresh the runtime bundle`)

View file

@ -13,11 +13,13 @@ import (
"banger/internal/api" "banger/internal/api"
"banger/internal/firecracker" "banger/internal/firecracker"
"banger/internal/guest"
"banger/internal/guestconfig" "banger/internal/guestconfig"
"banger/internal/model" "banger/internal/model"
"banger/internal/paths" "banger/internal/paths"
"banger/internal/system" "banger/internal/system"
"banger/internal/vmdns" "banger/internal/vmdns"
"banger/internal/vsockagent"
) )
var ( var (
@ -582,11 +584,11 @@ func (d *Daemon) GetVMStats(ctx context.Context, idOrName string) (model.VMRecor
return vm, vm.Stats, nil return vm, vm.Stats, nil
} }
func (d *Daemon) PingVM(ctx context.Context, idOrName string) (result api.VMPingResult, err error) { func (d *Daemon) HealthVM(ctx context.Context, idOrName string) (result api.VMHealthResult, err error) {
_, err = d.withVMLockByRef(ctx, idOrName, func(vm model.VMRecord) (model.VMRecord, error) { _, err = d.withVMLockByRef(ctx, idOrName, func(vm model.VMRecord) (model.VMRecord, error) {
result.Name = vm.Name result.Name = vm.Name
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) { if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
result.Alive = false result.Healthy = false
return vm, nil return vm, nil
} }
if strings.TrimSpace(vm.Runtime.VSockPath) == "" { if strings.TrimSpace(vm.Runtime.VSockPath) == "" {
@ -600,15 +602,23 @@ func (d *Daemon) PingVM(ctx context.Context, idOrName string) (result api.VMPing
} }
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second) pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() defer cancel()
if err := firecracker.PingVSock(pingCtx, d.logger, vm.Runtime.VSockPath); err != nil { if err := vsockagent.Health(pingCtx, d.logger, vm.Runtime.VSockPath); err != nil {
return model.VMRecord{}, err return model.VMRecord{}, err
} }
result.Alive = true result.Healthy = true
return vm, nil return vm, nil
}) })
return result, err return result, err
} }
func (d *Daemon) PingVM(ctx context.Context, idOrName string) (result api.VMPingResult, err error) {
health, err := d.HealthVM(ctx, idOrName)
if err != nil {
return api.VMPingResult{}, err
}
return api.VMPingResult{Name: health.Name, Alive: health.Healthy}, nil
}
func (d *Daemon) getVMStatsLocked(ctx context.Context, vm model.VMRecord) (model.VMRecord, error) { func (d *Daemon) getVMStatsLocked(ctx context.Context, vm model.VMRecord) (model.VMRecord, error) {
stats, err := d.collectStats(ctx, vm) stats, err := d.collectStats(ctx, vm)
if err == nil { if err == nil {
@ -814,6 +824,84 @@ func (d *Daemon) ensureWorkDisk(ctx context.Context, vm *model.VMRecord, image m
return nil return nil
} }
func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
publicKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath)
if err != nil {
return fmt.Errorf("derive authorized ssh key: %w", err)
}
workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false)
if err != nil {
return err
}
defer cleanupWork()
if err := d.flattenNestedWorkHome(ctx, workMount); err != nil {
return err
}
sshDir := filepath.Join(workMount, ".ssh")
if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", sshDir); err != nil {
return err
}
if _, err := d.runner.RunSudo(ctx, "chmod", "700", sshDir); err != nil {
return err
}
authorizedKeysPath := filepath.Join(sshDir, "authorized_keys")
existing, err := d.runner.RunSudo(ctx, "cat", authorizedKeysPath)
if err != nil {
existing = nil
}
merged := mergeAuthorizedKey(existing, publicKey)
tmpFile, err := os.CreateTemp("", "banger-authorized-keys-*")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write(merged); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return err
}
if err := tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return err
}
defer os.Remove(tmpPath)
if _, err := d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil {
return err
}
return nil
}
func mergeAuthorizedKey(existing, managed []byte) []byte {
managedLine := strings.TrimSpace(string(managed))
if managedLine == "" {
return append([]byte(nil), existing...)
}
lines := strings.Split(strings.ReplaceAll(string(existing), "\r\n", "\n"), "\n")
out := make([]string, 0, len(lines)+1)
found := false
for _, line := range lines {
line = strings.TrimRight(line, "\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if trimmed == managedLine {
found = true
}
out = append(out, line)
}
if !found {
out = append(out, managedLine)
}
return []byte(strings.Join(out, "\n") + "\n")
}
func (d *Daemon) flattenNestedWorkHome(ctx context.Context, workMount string) error { func (d *Daemon) flattenNestedWorkHome(ctx context.Context, workMount string) error {
nestedHome := filepath.Join(workMount, "root") nestedHome := filepath.Join(workMount, "root")
if !exists(nestedHome) { if !exists(nestedHome) {

View file

@ -2,6 +2,10 @@ package daemon
import ( import (
"context" "context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@ -253,7 +257,7 @@ func TestSetVMRejectsStoppedOnlyChangesForRunningVM(t *testing.T) {
} }
} }
func TestPingVMReturnsAliveForRunningGuest(t *testing.T) { func TestHealthVMReturnsHealthyForRunningGuest(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
@ -296,16 +300,24 @@ func TestPingVMReturnsAliveForRunningGuest(t *testing.T) {
serverDone <- err serverDone <- err
return return
} }
n, err = conn.Read(buf) reqBuf := make([]byte, 0, 512)
if err != nil { reqBuf = append(reqBuf, buf[:0]...)
serverDone <- err for {
n, err = conn.Read(buf)
if err != nil {
serverDone <- err
return
}
reqBuf = append(reqBuf, buf[:n]...)
if strings.Contains(string(reqBuf), "\r\n\r\n") {
break
}
}
if got := string(reqBuf); !strings.Contains(got, "GET /healthz HTTP/1.1\r\n") {
serverDone <- fmt.Errorf("unexpected health payload %q", got)
return return
} }
if got := string(buf[:n]); got != "PING\n" { _, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
serverDone <- fmt.Errorf("unexpected ping payload %q", got)
return
}
_, err = conn.Write([]byte("PONG\n"))
serverDone <- err serverDone <- err
}() }()
@ -326,12 +338,12 @@ func TestPingVMReturnsAliveForRunningGuest(t *testing.T) {
}, },
} }
d := &Daemon{store: db, runner: runner} d := &Daemon{store: db, runner: runner}
result, err := d.PingVM(ctx, vm.Name) result, err := d.HealthVM(ctx, vm.Name)
if err != nil { if err != nil {
t.Fatalf("PingVM: %v", err) t.Fatalf("HealthVM: %v", err)
} }
if !result.Alive || result.Name != vm.Name { if !result.Healthy || result.Name != vm.Name {
t.Fatalf("PingVM result = %+v, want alive %s", result, vm.Name) t.Fatalf("HealthVM result = %+v, want healthy %s", result, vm.Name)
} }
runner.assertExhausted() runner.assertExhausted()
if err := <-serverDone; err != nil { if err := <-serverDone; err != nil {
@ -339,7 +351,65 @@ func TestPingVMReturnsAliveForRunningGuest(t *testing.T) {
} }
} }
func TestPingVMReturnsFalseForStoppedVM(t *testing.T) { func TestPingVMAliasReturnsAliveForHealthyVM(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
apiSock := filepath.Join(t.TempDir(), "fc.sock")
fake := startFakeFirecrackerProcess(t, apiSock)
t.Cleanup(func() {
_ = fake.Process.Kill()
_ = fake.Wait()
})
vsockSock := filepath.Join(t.TempDir(), "fc.vsock")
listener, err := net.Listen("unix", vsockSock)
if err != nil {
t.Fatalf("listen vsock: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
_ = os.Remove(vsockSock)
})
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
buf := make([]byte, 512)
_, _ = conn.Read(buf)
_, _ = conn.Write([]byte("OK 1\n"))
_, _ = conn.Read(buf)
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
}()
vm := testVM("healthy-ping", "image-healthy", "172.16.0.42")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = fake.Process.Pid
vm.Runtime.APISockPath = apiSock
vm.Runtime.VSockPath = vsockSock
vm.Runtime.VSockCID = 10042
upsertDaemonVM(t, ctx, db, vm)
runner := &scriptedRunner{
t: t,
steps: []runnerStep{
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
sudoStep("", nil, "chmod", "600", vsockSock),
},
}
d := &Daemon{store: db, runner: runner}
result, err := d.PingVM(ctx, vm.Name)
if err != nil {
t.Fatalf("PingVM: %v", err)
}
if !result.Alive {
t.Fatalf("PingVM result = %+v, want alive", result)
}
}
func TestHealthVMReturnsFalseForStoppedVM(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
@ -348,12 +418,12 @@ func TestPingVMReturnsFalseForStoppedVM(t *testing.T) {
upsertDaemonVM(t, ctx, db, vm) upsertDaemonVM(t, ctx, db, vm)
d := &Daemon{store: db} d := &Daemon{store: db}
result, err := d.PingVM(ctx, vm.Name) result, err := d.HealthVM(ctx, vm.Name)
if err != nil { if err != nil {
t.Fatalf("PingVM: %v", err) t.Fatalf("HealthVM: %v", err)
} }
if result.Alive { if result.Healthy {
t.Fatalf("PingVM result = %+v, want not alive", result) t.Fatalf("HealthVM result = %+v, want not healthy", result)
} }
} }
@ -406,6 +476,64 @@ func TestFlattenNestedWorkHomeCopiesEntriesIndividually(t *testing.T) {
runner.assertExhausted() runner.assertExhausted()
} }
func TestEnsureAuthorizedKeyOnWorkDiskRepairsNestedRootLayout(t *testing.T) {
t.Parallel()
workDiskDir := t.TempDir()
nestedHome := filepath.Join(workDiskDir, "root")
if err := os.MkdirAll(filepath.Join(nestedHome, ".ssh"), 0o700); err != nil {
t.Fatalf("MkdirAll(.ssh): %v", err)
}
if err := os.WriteFile(filepath.Join(nestedHome, ".bashrc"), []byte("export TEST_PROMPT=1\n"), 0o644); err != nil {
t.Fatalf("WriteFile(.bashrc): %v", err)
}
legacyKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILEgacykey legacy@test\n"
if err := os.WriteFile(filepath.Join(nestedHome, ".ssh", "authorized_keys"), []byte(legacyKey), 0o600); err != nil {
t.Fatalf("WriteFile(authorized_keys): %v", err)
}
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
sshKeyPath := filepath.Join(t.TempDir(), "id_rsa")
if err := os.WriteFile(sshKeyPath, privateKeyPEM, 0o600); err != nil {
t.Fatalf("WriteFile(private key): %v", err)
}
d := &Daemon{
runner: &filesystemRunner{t: t},
config: model.DaemonConfig{SSHKeyPath: sshKeyPath},
}
vm := testVM("seed-repair", "image-seed-repair", "172.16.0.61")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ensureAuthorizedKeyOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensureAuthorizedKeyOnWorkDisk: %v", err)
}
if _, err := os.Stat(filepath.Join(workDiskDir, "root")); !os.IsNotExist(err) {
t.Fatalf("nested root still exists: %v", err)
}
if _, err := os.Stat(filepath.Join(workDiskDir, ".bashrc")); err != nil {
t.Fatalf(".bashrc missing at top level: %v", err)
}
data, err := os.ReadFile(filepath.Join(workDiskDir, ".ssh", "authorized_keys"))
if err != nil {
t.Fatalf("ReadFile(authorized_keys): %v", err)
}
content := string(data)
if !strings.Contains(content, strings.TrimSpace(legacyKey)) {
t.Fatalf("authorized_keys missing legacy key: %q", content)
}
if !strings.Contains(content, "ssh-rsa ") {
t.Fatalf("authorized_keys missing managed key: %q", content)
}
}
func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) { func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) {
d := &Daemon{} d := &Daemon{}
if _, err := d.CreateVM(context.Background(), api.VMCreateParams{VCPUCount: ptr(0)}); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") { if _, err := d.CreateVM(context.Background(), api.VMCreateParams{VCPUCount: ptr(0)}); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
@ -824,6 +952,29 @@ func testImage(name string) model.Image {
} }
} }
func TestMergeAuthorizedKey(t *testing.T) {
t.Parallel()
managed := []byte("ssh-ed25519 AAAATESTKEY banger\n")
existing := []byte("ssh-ed25519 AAAAOTHER other\n")
merged := mergeAuthorizedKey(existing, managed)
got := string(merged)
if !strings.Contains(got, "ssh-ed25519 AAAAOTHER other") {
t.Fatalf("merged keys dropped existing entry: %q", got)
}
if !strings.Contains(got, "ssh-ed25519 AAAATESTKEY banger") {
t.Fatalf("merged keys missing managed entry: %q", got)
}
if strings.Count(got, "ssh-ed25519 AAAATESTKEY banger") != 1 {
t.Fatalf("managed key duplicated in %q", got)
}
merged = mergeAuthorizedKey(merged, managed)
if strings.Count(string(merged), "ssh-ed25519 AAAATESTKEY banger") != 1 {
t.Fatalf("managed key duplicated after second merge: %q", string(merged))
}
}
func startFakeFirecrackerProcess(t *testing.T, apiSock string) *exec.Cmd { func startFakeFirecrackerProcess(t *testing.T, apiSock string) *exec.Cmd {
t.Helper() t.Helper()
@ -878,6 +1029,117 @@ type processKillingRunner struct {
proc *exec.Cmd proc *exec.Cmd
} }
type filesystemRunner struct {
t *testing.T
}
func (r *filesystemRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
r.t.Helper()
return nil, fmt.Errorf("unexpected Run call: %s %v", name, args)
}
func (r *filesystemRunner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
r.t.Helper()
if len(args) == 0 {
return nil, errors.New("missing sudo command")
}
switch args[0] {
case "mount":
if len(args) != 3 {
return nil, fmt.Errorf("unexpected mount args: %v", args)
}
source, mountDir := args[1], args[2]
if err := os.Remove(mountDir); err != nil {
return nil, err
}
if err := os.Symlink(source, mountDir); err != nil {
return nil, err
}
return nil, nil
case "umount":
return nil, nil
case "chmod":
if len(args) != 3 {
return nil, fmt.Errorf("unexpected chmod args: %v", args)
}
mode, err := strconv.ParseUint(args[1], 8, 32)
if err != nil {
return nil, err
}
return nil, os.Chmod(args[2], os.FileMode(mode))
case "cp":
if len(args) != 4 || args[1] != "-a" {
return nil, fmt.Errorf("unexpected cp args: %v", args)
}
return nil, copyIntoDir(args[2], args[3])
case "rm":
if len(args) != 3 || args[1] != "-rf" {
return nil, fmt.Errorf("unexpected rm args: %v", args)
}
return nil, os.RemoveAll(args[2])
case "mkdir":
if len(args) != 3 || args[1] != "-p" {
return nil, fmt.Errorf("unexpected mkdir args: %v", args)
}
return nil, os.MkdirAll(args[2], 0o755)
case "cat":
if len(args) != 2 {
return nil, fmt.Errorf("unexpected cat args: %v", args)
}
return os.ReadFile(args[1])
case "install":
if len(args) != 5 || args[1] != "-m" {
return nil, fmt.Errorf("unexpected install args: %v", args)
}
mode, err := strconv.ParseUint(args[2], 8, 32)
if err != nil {
return nil, err
}
data, err := os.ReadFile(args[3])
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(args[4]), 0o755); err != nil {
return nil, err
}
return nil, os.WriteFile(args[4], data, os.FileMode(mode))
default:
return nil, fmt.Errorf("unexpected sudo command: %v", args)
}
}
func copyIntoDir(sourcePath, targetDir string) error {
targetDir = strings.TrimSuffix(targetDir, "/")
info, err := os.Stat(sourcePath)
if err != nil {
return err
}
destPath := filepath.Join(targetDir, filepath.Base(sourcePath))
if info.IsDir() {
if err := os.MkdirAll(destPath, info.Mode().Perm()); err != nil {
return err
}
entries, err := os.ReadDir(sourcePath)
if err != nil {
return err
}
for _, entry := range entries {
if err := copyIntoDir(filepath.Join(sourcePath, entry.Name()), destPath); err != nil {
return err
}
}
return os.Chmod(destPath, info.Mode().Perm())
}
data, err := os.ReadFile(sourcePath)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return err
}
return os.WriteFile(destPath, data, info.Mode().Perm())
}
func (r *processKillingRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { func (r *processKillingRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
return r.scriptedRunner.Run(ctx, name, args...) return r.scriptedRunner.Run(ctx, name, args...)
} }

View file

@ -1,23 +1,19 @@
package firecracker package firecracker
import ( import (
"bufio"
"context" "context"
"fmt"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"sync" "sync"
"time"
sdk "github.com/firecracker-microvm/firecracker-go-sdk" sdk "github.com/firecracker-microvm/firecracker-go-sdk"
models "github.com/firecracker-microvm/firecracker-go-sdk/client/models" models "github.com/firecracker-microvm/firecracker-go-sdk/client/models"
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"banger/internal/vsockping" "banger/internal/vsockagent"
) )
type MachineConfig struct { type MachineConfig struct {
@ -212,37 +208,12 @@ func newLogger(base *slog.Logger) *logrus.Entry {
return logrus.NewEntry(logger) return logrus.NewEntry(logger)
} }
func HealthVSock(ctx context.Context, logger *slog.Logger, socketPath string) error {
return vsockagent.Health(ctx, logger, socketPath)
}
func PingVSock(ctx context.Context, logger *slog.Logger, socketPath string) error { func PingVSock(ctx context.Context, logger *slog.Logger, socketPath string) error {
conn, err := sdkvsock.DialContext( return HealthVSock(ctx, logger, socketPath)
ctx,
socketPath,
vsockping.Port,
sdkvsock.WithRetryTimeout(3*time.Second),
sdkvsock.WithRetryInterval(100*time.Millisecond),
sdkvsock.WithLogger(newLogger(logger)),
)
if err != nil {
return err
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
} else {
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
}
if _, err := io.WriteString(conn, vsockping.RequestLine); err != nil {
return err
}
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
return err
}
if strings.TrimSpace(line) != strings.TrimSpace(vsockping.ResponseLine) {
return fmt.Errorf("unexpected vsock response %q", strings.TrimSpace(line))
}
return nil
} }
type slogHook struct { type slogHook struct {

View file

@ -128,7 +128,7 @@ func TestSDKLoggerBridgeSuppressesDebugAtInfoLevel(t *testing.T) {
} }
} }
func TestPingVSock(t *testing.T) { func TestHealthVSock(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
socketPath := filepath.Join(dir, "fc.vsock") socketPath := filepath.Join(dir, "fc.vsock")
listener, err := net.Listen("unix", socketPath) listener, err := net.Listen("unix", socketPath)
@ -174,22 +174,22 @@ func TestPingVSock(t *testing.T) {
return return
} }
buf = append(buf, tmp[:n]...) buf = append(buf, tmp[:n]...)
if strings.Contains(string(buf), "\n") { if strings.Contains(string(buf), "\r\n\r\n") {
break break
} }
} }
if got := string(buf); got != "PING\n" { if got := string(buf); !strings.Contains(got, "GET /healthz HTTP/1.1\r\n") {
done <- errUnexpectedString(got) done <- errUnexpectedString(got)
return return
} }
_, err = conn.Write([]byte("PONG\n")) _, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
done <- err done <- err
}() }()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if err := PingVSock(ctx, nil, socketPath); err != nil { if err := HealthVSock(ctx, nil, socketPath); err != nil {
t.Fatalf("PingVSock: %v", err) t.Fatalf("HealthVSock: %v", err)
} }
if err := <-done; err != nil { if err := <-done; err != nil {
t.Fatalf("server: %v", err) t.Fatalf("server: %v", err)

View file

@ -129,6 +129,14 @@ func privateKeySigner(path string) (ssh.Signer, error) {
return ssh.ParsePrivateKey(data) return ssh.ParsePrivateKey(data)
} }
func AuthorizedPublicKey(path string) ([]byte, error) {
signer, err := privateKeySigner(path)
if err != nil {
return nil, err
}
return ssh.MarshalAuthorizedKey(signer.PublicKey()), nil
}
func shellQuote(value string) string { func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
} }

View file

@ -3,10 +3,16 @@ package guest
import ( import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"golang.org/x/crypto/ssh"
) )
func TestWriteTarArchiveKeepsTopLevelDirectory(t *testing.T) { func TestWriteTarArchiveKeepsTopLevelDirectory(t *testing.T) {
@ -56,3 +62,32 @@ func TestWriteTarArchiveKeepsTopLevelDirectory(t *testing.T) {
} }
} }
} }
func TestAuthorizedPublicKey(t *testing.T) {
t.Parallel()
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
keyPath := filepath.Join(t.TempDir(), "id_rsa")
if err := os.WriteFile(keyPath, privateKeyPEM, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
publicKey, err := AuthorizedPublicKey(keyPath)
if err != nil {
t.Fatalf("AuthorizedPublicKey: %v", err)
}
parsed, _, _, _, err := ssh.ParseAuthorizedKey(publicKey)
if err != nil {
t.Fatalf("ParseAuthorizedKey: %v", err)
}
if parsed.Type() != ssh.KeyAlgoRSA {
t.Fatalf("key type = %q, want %q", parsed.Type(), ssh.KeyAlgoRSA)
}
}

View file

@ -41,7 +41,7 @@ type DaemonConfig struct {
SSHKeyPath string SSHKeyPath string
NamegenPath string NamegenPath string
CustomizeScript string CustomizeScript string
VSockPingHelperPath string VSockAgentPath string
DefaultWorkSeed string DefaultWorkSeed string
AutoStopStaleAfter time.Duration AutoStopStaleAfter time.Duration
StatsPollInterval time.Duration StatsPollInterval time.Duration

View file

@ -56,21 +56,21 @@ func TestResolveRuntimeDirUsesSourceCheckoutRuntimeSubdir(t *testing.T) {
func createRuntimeBundle(t *testing.T, runtimeDir string) { func createRuntimeBundle(t *testing.T, runtimeDir string) {
t.Helper() t.Helper()
metadata := runtimebundle.BundleMetadata{ metadata := runtimebundle.BundleMetadata{
FirecrackerBin: "bin/firecracker", FirecrackerBin: "bin/firecracker",
SSHKeyPath: "keys/id_ed25519", SSHKeyPath: "keys/id_ed25519",
NamegenPath: "bin/namegen", NamegenPath: "bin/namegen",
CustomizeScript: "scripts/customize.sh", CustomizeScript: "scripts/customize.sh",
VSockPingHelperPath: "bin/banger-vsock-pingd", VSockAgentPath: "bin/banger-vsock-agent",
DefaultPackages: "config/packages.apt", DefaultPackages: "config/packages.apt",
DefaultRootfs: "images/rootfs-docker.ext4", DefaultRootfs: "images/rootfs-docker.ext4",
DefaultKernel: "kernels/vmlinux", DefaultKernel: "kernels/vmlinux",
} }
for _, rel := range []string{ for _, rel := range []string{
metadata.FirecrackerBin, metadata.FirecrackerBin,
metadata.SSHKeyPath, metadata.SSHKeyPath,
metadata.NamegenPath, metadata.NamegenPath,
metadata.CustomizeScript, metadata.CustomizeScript,
metadata.VSockPingHelperPath, metadata.VSockAgentPath,
metadata.DefaultPackages, metadata.DefaultPackages,
metadata.DefaultRootfs, metadata.DefaultRootfs,
metadata.DefaultKernel, metadata.DefaultKernel,

View file

@ -34,7 +34,8 @@ type BundleMetadata struct {
SSHKeyPath string `json:"ssh_key_path" toml:"ssh_key_path"` SSHKeyPath string `json:"ssh_key_path" toml:"ssh_key_path"`
NamegenPath string `json:"namegen_path" toml:"namegen_path"` NamegenPath string `json:"namegen_path" toml:"namegen_path"`
CustomizeScript string `json:"customize_script" toml:"customize_script"` CustomizeScript string `json:"customize_script" toml:"customize_script"`
VSockPingHelperPath string `json:"vsock_ping_helper_path" toml:"vsock_ping_helper_path"` VSockAgentPath string `json:"vsock_agent_path,omitempty" toml:"vsock_agent_path"`
VSockPingHelperPath string `json:"vsock_ping_helper_path,omitempty" toml:"vsock_ping_helper_path"`
DefaultPackages string `json:"default_packages_file" toml:"default_packages_file"` DefaultPackages string `json:"default_packages_file" toml:"default_packages_file"`
DefaultRootfs string `json:"default_rootfs" toml:"default_rootfs"` DefaultRootfs string `json:"default_rootfs" toml:"default_rootfs"`
DefaultBaseRootfs string `json:"default_base_rootfs,omitempty" toml:"default_base_rootfs"` DefaultBaseRootfs string `json:"default_base_rootfs,omitempty" toml:"default_base_rootfs"`
@ -211,7 +212,7 @@ func validateBundleMetadata(runtimeDir string, meta BundleMetadata) error {
{meta.SSHKeyPath, "ssh_key_path"}, {meta.SSHKeyPath, "ssh_key_path"},
{meta.NamegenPath, "namegen_path"}, {meta.NamegenPath, "namegen_path"},
{meta.CustomizeScript, "customize_script"}, {meta.CustomizeScript, "customize_script"},
{meta.VSockPingHelperPath, "vsock_ping_helper_path"}, {meta.VSockAgentPath, "vsock_agent_path"},
{meta.DefaultPackages, "default_packages_file"}, {meta.DefaultPackages, "default_packages_file"},
{meta.DefaultRootfs, "default_rootfs"}, {meta.DefaultRootfs, "default_rootfs"},
{meta.DefaultKernel, "default_kernel"}, {meta.DefaultKernel, "default_kernel"},
@ -230,7 +231,7 @@ func validateBundleMetadata(runtimeDir string, meta BundleMetadata) error {
{meta.SSHKeyPath, "ssh_key_path", true}, {meta.SSHKeyPath, "ssh_key_path", true},
{meta.NamegenPath, "namegen_path", true}, {meta.NamegenPath, "namegen_path", true},
{meta.CustomizeScript, "customize_script", true}, {meta.CustomizeScript, "customize_script", true},
{meta.VSockPingHelperPath, "vsock_ping_helper_path", true}, {meta.VSockAgentPath, "vsock_agent_path", true},
{meta.DefaultPackages, "default_packages_file", true}, {meta.DefaultPackages, "default_packages_file", true},
{meta.DefaultRootfs, "default_rootfs", true}, {meta.DefaultRootfs, "default_rootfs", true},
{meta.DefaultBaseRootfs, "default_base_rootfs", false}, {meta.DefaultBaseRootfs, "default_base_rootfs", false},
@ -269,7 +270,7 @@ func metadataArchiveBytes(runtimeDir string, meta BundleMetadata) ([]byte, error
strings.TrimSpace(meta.SSHKeyPath) == "" && strings.TrimSpace(meta.SSHKeyPath) == "" &&
strings.TrimSpace(meta.NamegenPath) == "" && strings.TrimSpace(meta.NamegenPath) == "" &&
strings.TrimSpace(meta.CustomizeScript) == "" && strings.TrimSpace(meta.CustomizeScript) == "" &&
strings.TrimSpace(meta.VSockPingHelperPath) == "" && strings.TrimSpace(meta.VSockAgentPath) == "" &&
strings.TrimSpace(meta.DefaultPackages) == "" && strings.TrimSpace(meta.DefaultPackages) == "" &&
strings.TrimSpace(meta.DefaultRootfs) == "" && strings.TrimSpace(meta.DefaultRootfs) == "" &&
strings.TrimSpace(meta.DefaultBaseRootfs) == "" && strings.TrimSpace(meta.DefaultBaseRootfs) == "" &&
@ -290,7 +291,11 @@ func normalizeBundleMetadata(meta BundleMetadata) BundleMetadata {
meta.SSHKeyPath = strings.TrimSpace(meta.SSHKeyPath) meta.SSHKeyPath = strings.TrimSpace(meta.SSHKeyPath)
meta.NamegenPath = strings.TrimSpace(meta.NamegenPath) meta.NamegenPath = strings.TrimSpace(meta.NamegenPath)
meta.CustomizeScript = strings.TrimSpace(meta.CustomizeScript) meta.CustomizeScript = strings.TrimSpace(meta.CustomizeScript)
meta.VSockAgentPath = strings.TrimSpace(meta.VSockAgentPath)
meta.VSockPingHelperPath = strings.TrimSpace(meta.VSockPingHelperPath) meta.VSockPingHelperPath = strings.TrimSpace(meta.VSockPingHelperPath)
if meta.VSockAgentPath == "" {
meta.VSockAgentPath = meta.VSockPingHelperPath
}
meta.DefaultPackages = strings.TrimSpace(meta.DefaultPackages) meta.DefaultPackages = strings.TrimSpace(meta.DefaultPackages)
meta.DefaultRootfs = strings.TrimSpace(meta.DefaultRootfs) meta.DefaultRootfs = strings.TrimSpace(meta.DefaultRootfs)
meta.DefaultBaseRootfs = strings.TrimSpace(meta.DefaultBaseRootfs) meta.DefaultBaseRootfs = strings.TrimSpace(meta.DefaultBaseRootfs)

View file

@ -20,7 +20,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) {
"runtime/firecracker": "fc", "runtime/firecracker": "fc",
"runtime/id_ed25519": "key", "runtime/id_ed25519": "key",
"runtime/namegen": "namegen", "runtime/namegen": "namegen",
"runtime/banger-vsock-pingd": "pingd", "runtime/banger-vsock-agent": "agent",
"runtime/customize.sh": "#!/bin/bash\n", "runtime/customize.sh": "#!/bin/bash\n",
"runtime/packages.sh": "#!/bin/bash\n", "runtime/packages.sh": "#!/bin/bash\n",
"runtime/packages.apt": "vim\n", "runtime/packages.apt": "vim\n",
@ -28,7 +28,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) {
"runtime/wtf/root/boot/vmlinux-6.8.0-94-generic": "kernel", "runtime/wtf/root/boot/vmlinux-6.8.0-94-generic": "kernel",
"runtime/wtf/root/boot/initrd.img-6.8.0-94-generic": "initrd", "runtime/wtf/root/boot/initrd.img-6.8.0-94-generic": "initrd",
"runtime/wtf/root/lib/modules/6.8.0-94-generic/modules.dep": "dep", "runtime/wtf/root/lib/modules/6.8.0-94-generic/modules.dep": "dep",
"runtime/bundle.json": mustJSON(t, BundleMetadata{FirecrackerBin: "firecracker", SSHKeyPath: "id_ed25519", NamegenPath: "namegen", CustomizeScript: "customize.sh", VSockPingHelperPath: "banger-vsock-pingd", DefaultPackages: "packages.apt", DefaultRootfs: "rootfs-docker.ext4", DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic", DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic"}), "runtime/bundle.json": mustJSON(t, BundleMetadata{FirecrackerBin: "firecracker", SSHKeyPath: "id_ed25519", NamegenPath: "namegen", CustomizeScript: "customize.sh", VSockAgentPath: "banger-vsock-agent", DefaultPackages: "packages.apt", DefaultRootfs: "rootfs-docker.ext4", DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic", DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic"}),
}) })
archivePath := filepath.Join(manifestDir, "bundle.tar.gz") archivePath := filepath.Join(manifestDir, "bundle.tar.gz")
if err := os.WriteFile(archivePath, bundleData, 0o644); err != nil { if err := os.WriteFile(archivePath, bundleData, 0o644); err != nil {
@ -39,7 +39,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) {
URL: "./bundle.tar.gz", URL: "./bundle.tar.gz",
SHA256: sha256Hex(bundleData), SHA256: sha256Hex(bundleData),
BundleRoot: "runtime", BundleRoot: "runtime",
RequiredPaths: []string{"firecracker", "banger-vsock-pingd", "customize.sh", "packages.apt", "rootfs-docker.ext4", "wtf/root/boot/vmlinux-6.8.0-94-generic", "wtf/root/lib/modules/6.8.0-94-generic"}, RequiredPaths: []string{"firecracker", "banger-vsock-agent", "customize.sh", "packages.apt", "rootfs-docker.ext4", "wtf/root/boot/vmlinux-6.8.0-94-generic", "wtf/root/lib/modules/6.8.0-94-generic"},
} }
outDir := filepath.Join(t.TempDir(), "runtime") outDir := filepath.Join(t.TempDir(), "runtime")
if err := Bootstrap(context.Background(), manifest, filepath.Join(manifestDir, "runtime-bundle.toml"), outDir); err != nil { if err := Bootstrap(context.Background(), manifest, filepath.Join(manifestDir, "runtime-bundle.toml"), outDir); err != nil {
@ -100,7 +100,7 @@ func TestPackageWritesArchive(t *testing.T) {
"firecracker", "firecracker",
"id_ed25519", "id_ed25519",
"namegen", "namegen",
"banger-vsock-pingd", "banger-vsock-agent",
"customize.sh", "customize.sh",
"packages.apt", "packages.apt",
"rootfs-docker.ext4", "rootfs-docker.ext4",
@ -128,22 +128,22 @@ func TestPackageWritesArchive(t *testing.T) {
manifest := Manifest{ manifest := Manifest{
BundleRoot: "runtime", BundleRoot: "runtime",
BundleMeta: BundleMetadata{ BundleMeta: BundleMetadata{
FirecrackerBin: "firecracker", FirecrackerBin: "firecracker",
SSHKeyPath: "id_ed25519", SSHKeyPath: "id_ed25519",
NamegenPath: "namegen", NamegenPath: "namegen",
CustomizeScript: "customize.sh", CustomizeScript: "customize.sh",
VSockPingHelperPath: "banger-vsock-pingd", VSockAgentPath: "banger-vsock-agent",
DefaultPackages: "packages.apt", DefaultPackages: "packages.apt",
DefaultRootfs: "rootfs-docker.ext4", DefaultRootfs: "rootfs-docker.ext4",
DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic",
DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic", DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic",
DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic", DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic",
}, },
RequiredPaths: []string{ RequiredPaths: []string{
"firecracker", "firecracker",
"id_ed25519", "id_ed25519",
"namegen", "namegen",
"banger-vsock-pingd", "banger-vsock-agent",
"customize.sh", "customize.sh",
"packages.apt", "packages.apt",
"rootfs-docker.ext4", "rootfs-docker.ext4",
@ -186,7 +186,36 @@ func TestPackageWritesArchive(t *testing.T) {
func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) { func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) {
runtimeDir := t.TempDir() runtimeDir := t.TempDir()
for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "banger-vsock-pingd", "customize.sh", "packages.apt", "rootfs-docker.ext4"} { for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "banger-vsock-agent", "customize.sh", "packages.apt", "rootfs-docker.ext4"} {
path := filepath.Join(runtimeDir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(path, []byte(rel), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
data := mustJSON(t, BundleMetadata{
FirecrackerBin: "firecracker",
SSHKeyPath: "id_ed25519",
NamegenPath: "namegen",
CustomizeScript: "customize.sh",
VSockAgentPath: "banger-vsock-agent",
DefaultPackages: "packages.apt",
DefaultRootfs: "rootfs-docker.ext4",
DefaultKernel: "missing-kernel",
})
if err := os.WriteFile(filepath.Join(runtimeDir, BundleMetadataFile), []byte(data), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, err := LoadBundleMetadata(runtimeDir); err == nil || !strings.Contains(err.Error(), "default_kernel") {
t.Fatalf("LoadBundleMetadata() error = %v, want default_kernel failure", err)
}
}
func TestLoadBundleMetadataAcceptsLegacyVsockPingHelperPath(t *testing.T) {
runtimeDir := t.TempDir()
for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "banger-vsock-pingd", "customize.sh", "packages.apt", "rootfs-docker.ext4", "wtf/root/boot/vmlinux-6.8.0-94-generic"} {
path := filepath.Join(runtimeDir, rel) path := filepath.Join(runtimeDir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err) t.Fatalf("MkdirAll: %v", err)
@ -203,13 +232,17 @@ func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) {
VSockPingHelperPath: "banger-vsock-pingd", VSockPingHelperPath: "banger-vsock-pingd",
DefaultPackages: "packages.apt", DefaultPackages: "packages.apt",
DefaultRootfs: "rootfs-docker.ext4", DefaultRootfs: "rootfs-docker.ext4",
DefaultKernel: "missing-kernel", DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic",
}) })
if err := os.WriteFile(filepath.Join(runtimeDir, BundleMetadataFile), []byte(data), 0o644); err != nil { if err := os.WriteFile(filepath.Join(runtimeDir, BundleMetadataFile), []byte(data), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
if _, err := LoadBundleMetadata(runtimeDir); err == nil || !strings.Contains(err.Error(), "default_kernel") { meta, err := LoadBundleMetadata(runtimeDir)
t.Fatalf("LoadBundleMetadata() error = %v, want default_kernel failure", err) if err != nil {
t.Fatalf("LoadBundleMetadata: %v", err)
}
if meta.VSockAgentPath != "banger-vsock-pingd" {
t.Fatalf("VSockAgentPath = %q", meta.VSockAgentPath)
} }
} }

View file

@ -6,6 +6,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
@ -75,7 +76,7 @@ func BuildWorkSeedImage(ctx context.Context, runner CommandRunner, rootfsPath, o
defer cleanupRoot() defer cleanupRoot()
rootHome := filepath.Join(rootMount, "root") rootHome := filepath.Join(rootMount, "root")
sizeBytes, err := estimateWorkSeedSize(rootHome) sizeBytes, err := estimateWorkSeedSize(ctx, runner, rootHome)
if err != nil { if err != nil {
return err return err
} }
@ -105,7 +106,7 @@ func BuildWorkSeedImage(ctx context.Context, runner CommandRunner, rootfsPath, o
return CopyDirContents(ctx, runner, rootHome, workMount, true) return CopyDirContents(ctx, runner, rootHome, workMount, true)
} }
func estimateWorkSeedSize(rootHome string) (int64, error) { func estimateWorkSeedSize(ctx context.Context, runner CommandRunner, rootHome string) (int64, error) {
var usedBytes int64 var usedBytes int64
err := filepath.Walk(rootHome, func(path string, info os.FileInfo, err error) error { err := filepath.Walk(rootHome, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
@ -117,8 +118,19 @@ func estimateWorkSeedSize(rootHome string) (int64, error) {
return nil return nil
}) })
if err != nil { if err != nil {
if os.IsPermission(err) {
out, sudoErr := runner.RunSudo(ctx, "du", "-sb", rootHome)
if sudoErr != nil {
return 0, fmt.Errorf("%w; sudo du fallback failed: %v", err, sudoErr)
}
return roundWorkSeedSize(parseDuSize(out)), nil
}
return 0, err return 0, err
} }
return roundWorkSeedSize(usedBytes), nil
}
func roundWorkSeedSize(usedBytes int64) int64 {
sizeBytes := usedBytes*2 + workSeedSlackBytes sizeBytes := usedBytes*2 + workSeedSlackBytes
if sizeBytes < minWorkSeedBytes { if sizeBytes < minWorkSeedBytes {
sizeBytes = minWorkSeedBytes sizeBytes = minWorkSeedBytes
@ -126,7 +138,19 @@ func estimateWorkSeedSize(rootHome string) (int64, error) {
if rem := sizeBytes % workSeedRoundBytes; rem != 0 { if rem := sizeBytes % workSeedRoundBytes; rem != 0 {
sizeBytes += workSeedRoundBytes - rem sizeBytes += workSeedRoundBytes - rem
} }
return sizeBytes, nil return sizeBytes
}
func parseDuSize(out []byte) int64 {
fields := strings.Fields(string(out))
if len(fields) == 0 {
return 0
}
sizeBytes, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
return 0
}
return sizeBytes
} }
func ReadNormalizedLines(path string) ([]string, error) { func ReadNormalizedLines(path string) ([]string, error) {

View file

@ -409,3 +409,42 @@ func TestUseLoopMount(t *testing.T) {
t.Fatalf("useLoopMount(missing) = true, want false") t.Fatalf("useLoopMount(missing) = true, want false")
} }
} }
func TestEstimateWorkSeedSizeFallsBackToSudoDuWhenUnreadable(t *testing.T) {
t.Parallel()
rootHome := filepath.Join(t.TempDir(), "root")
if err := os.Mkdir(rootHome, 0o700); err != nil {
t.Fatalf("Mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(rootHome, "visible.txt"), []byte("seed"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.Chmod(rootHome, 0o000); err != nil {
t.Fatalf("Chmod: %v", err)
}
defer os.Chmod(rootHome, 0o700)
var sudoCalled bool
runner := funcRunner{
runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
sudoCalled = true
want := []string{"du", "-sb", rootHome}
if !reflect.DeepEqual(args, want) {
t.Fatalf("RunSudo args = %v, want %v", args, want)
}
return []byte("4096\t" + rootHome + "\n"), nil
},
}
sizeBytes, err := estimateWorkSeedSize(context.Background(), runner, rootHome)
if err != nil {
t.Fatalf("estimateWorkSeedSize: %v", err)
}
if !sudoCalled {
t.Fatal("estimateWorkSeedSize did not fall back to sudo du")
}
if sizeBytes != minWorkSeedBytes {
t.Fatalf("sizeBytes = %d, want %d", sizeBytes, minWorkSeedBytes)
}
}

View file

@ -0,0 +1,158 @@
package vsockagent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"time"
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
"github.com/sirupsen/logrus"
)
const (
Port uint32 = 42070
HealthPath = "/healthz"
HealthyStatus = "ok"
GuestBinaryName = "banger-vsock-agent"
GuestInstallPath = "/usr/local/bin/" + GuestBinaryName
ServiceName = "banger-vsock-agent.service"
serviceUnit = `[Unit]
Description=Banger vsock agent
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/banger-vsock-agent
Restart=on-failure
RestartSec=1
[Install]
WantedBy=multi-user.target
`
modulesLoadConfig = "vsock\nvmw_vsock_virtio_transport\n"
)
type HealthResponse struct {
Status string `json:"status"`
}
func NewHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(HealthPath, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(HealthResponse{Status: HealthyStatus})
})
return mux
}
func Health(ctx context.Context, logger *slog.Logger, socketPath string) error {
transport := &http.Transport{
DisableKeepAlives: true,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return sdkvsock.DialContext(
ctx,
socketPath,
Port,
sdkvsock.WithRetryTimeout(3*time.Second),
sdkvsock.WithRetryInterval(100*time.Millisecond),
sdkvsock.WithLogger(newLogger(logger)),
)
},
}
defer transport.CloseIdleConnections()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://vsock"+HealthPath, nil)
if err != nil {
return err
}
resp, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("unexpected health status %d: %s", resp.StatusCode, string(body))
}
var payload HealthResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return err
}
if payload.Status != HealthyStatus {
return fmt.Errorf("unexpected health response status %q", payload.Status)
}
if logger != nil {
logger.Debug("vsock health ok", "vsock_path", socketPath, "vsock_port", Port)
}
return nil
}
func ServiceUnit() string {
return serviceUnit
}
func ModulesLoadConfig() string {
return modulesLoadConfig
}
func ReminderMessage(name string) string {
return fmt.Sprintf("session ended; %s is still running (stop it with 'banger vm stop %s')", name, name)
}
func WarningMessage(name string, err error) string {
if err == nil {
return ""
}
return fmt.Sprintf("warning: failed to check whether %s is still running: %v", name, err)
}
func newLogger(base *slog.Logger) *logrus.Entry {
logger := logrus.New()
logger.SetOutput(io.Discard)
logger.SetLevel(logrus.DebugLevel)
logger.AddHook(slogHook{logger: base})
return logrus.NewEntry(logger)
}
type slogHook struct {
logger *slog.Logger
}
func (h slogHook) Levels() []logrus.Level {
return logrus.AllLevels
}
func (h slogHook) Fire(entry *logrus.Entry) error {
if h.logger == nil {
return nil
}
level := slog.LevelDebug
switch entry.Level {
case logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel:
level = slog.LevelError
case logrus.WarnLevel:
level = slog.LevelWarn
case logrus.InfoLevel:
level = slog.LevelInfo
}
attrs := make([]any, 0, len(entry.Data)*2)
for key, value := range entry.Data {
attrs = append(attrs, key, value)
}
h.logger.Log(context.Background(), level, entry.Message, attrs...)
return nil
}
func IsServerClosed(err error) bool {
return errors.Is(err, http.ErrServerClosed)
}

View file

@ -0,0 +1,133 @@
package vsockagent
import (
"bytes"
"context"
"encoding/json"
"net"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
)
func TestNewHandlerHealthz(t *testing.T) {
t.Parallel()
req, err := http.NewRequest(http.MethodGet, HealthPath, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
rr := newTestResponseRecorder()
NewHandler().ServeHTTP(rr, req)
if rr.status != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.status, http.StatusOK)
}
if got := rr.headers.Get("Content-Type"); got != "application/json" {
t.Fatalf("content-type = %q", got)
}
var payload HealthResponse
if err := json.Unmarshal(rr.body.Bytes(), &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if payload.Status != HealthyStatus {
t.Fatalf("status = %q, want %q", payload.Status, HealthyStatus)
}
}
func TestHealth(t *testing.T) {
t.Parallel()
dir := t.TempDir()
socketPath := filepath.Join(dir, "fc.vsock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer listener.Close()
done := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
done <- err
return
}
defer conn.Close()
buf := make([]byte, 0, 256)
tmp := make([]byte, 256)
for {
n, err := conn.Read(tmp)
if err != nil {
done <- err
return
}
buf = append(buf, tmp[:n]...)
if strings.Contains(string(buf), "\n") {
break
}
}
if got := string(buf); got != "CONNECT 42070\n" {
done <- unexpectedStringError(got)
return
}
if _, err := conn.Write([]byte("OK 55\n")); err != nil {
done <- err
return
}
buf = buf[:0]
for {
n, err := conn.Read(tmp)
if err != nil {
done <- err
return
}
buf = append(buf, tmp[:n]...)
if strings.Contains(string(buf), "\r\n\r\n") {
break
}
}
req := string(buf)
if !strings.Contains(req, "GET /healthz HTTP/1.1\r\n") {
done <- unexpectedStringError(req)
return
}
_, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
done <- err
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := Health(ctx, nil, socketPath); err != nil {
t.Fatalf("Health: %v", err)
}
if err := <-done; err != nil {
t.Fatalf("server: %v", err)
}
}
type testResponseRecorder struct {
headers http.Header
body bytes.Buffer
status int
}
func newTestResponseRecorder() *testResponseRecorder {
return &testResponseRecorder{headers: make(http.Header), status: http.StatusOK}
}
func (r *testResponseRecorder) Header() http.Header { return r.headers }
func (r *testResponseRecorder) Write(data []byte) (int, error) { return r.body.Write(data) }
func (r *testResponseRecorder) WriteHeader(status int) { r.status = status }
type unexpectedStringError string
func (e unexpectedStringError) Error() string {
return "unexpected string: " + string(e)
}

View file

@ -1,105 +0,0 @@
package vsockping
import (
"bufio"
"context"
"fmt"
"io"
"log/slog"
"net"
"strings"
"time"
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
)
const (
Port uint32 = 42070
RequestLine = "PING\n"
ResponseLine = "PONG\n"
GuestBinaryName = "banger-vsock-pingd"
GuestInstallPath = "/usr/local/bin/" + GuestBinaryName
ServiceName = "banger-vsock-pingd.service"
serviceUnit = `[Unit]
Description=Banger vsock ping responder
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/banger-vsock-pingd
Restart=on-failure
RestartSec=1
[Install]
WantedBy=multi-user.target
`
modulesLoadConfig = "vsock\nvmw_vsock_virtio_transport\n"
)
func Ping(ctx context.Context, logger *slog.Logger, socketPath string) error {
conn, err := sdkvsock.DialContext(
ctx,
socketPath,
Port,
sdkvsock.WithRetryTimeout(3*time.Second),
sdkvsock.WithRetryInterval(100*time.Millisecond),
)
if err != nil {
return err
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
} else {
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
}
if _, err := io.WriteString(conn, RequestLine); err != nil {
return err
}
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
return err
}
if strings.TrimSpace(line) != strings.TrimSpace(ResponseLine) {
return fmt.Errorf("unexpected vsock ping response %q", strings.TrimSpace(line))
}
if logger != nil {
logger.Debug("vsock ping ok", "vsock_path", socketPath, "vsock_port", Port)
}
return nil
}
func ServiceUnit() string {
return serviceUnit
}
func ModulesLoadConfig() string {
return modulesLoadConfig
}
func ReminderMessage(name string) string {
return fmt.Sprintf("session ended; %s is still running (stop it with 'banger vm stop %s')", name, name)
}
func WarningMessage(name string, err error) string {
if err == nil {
return ""
}
return fmt.Sprintf("warning: failed to check whether %s is still running: %v", name, err)
}
func ServeConn(conn net.Conn) error {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
return err
}
if strings.TrimSpace(line) != strings.TrimSpace(RequestLine) {
return fmt.Errorf("unexpected request %q", strings.TrimSpace(line))
}
_, err = io.WriteString(conn, ResponseLine)
return err
}

441
make-rootfs-void.sh Executable file
View file

@ -0,0 +1,441 @@
#!/usr/bin/env bash
set -euo pipefail
log() {
printf '[make-rootfs-void] %s\n' "$*"
}
usage() {
cat <<'EOF'
Usage: ./make-rootfs-void.sh [--out <path>] [--size <size>] [--mirror <url>] [--arch <arch>] [--packages <path>]
Build an experimental Void Linux rootfs image plus a matching /root work-seed.
Defaults:
--out ./runtime/rootfs-void.ext4
--size 2G
--mirror https://repo-default.voidlinux.org
--arch x86_64
--packages ./packages.void
This path is experimental and local-only. It reuses the current runtime bundle
kernel/initrd/modules and does not change the default Debian image flow.
EOF
}
parse_size() {
local raw="$1"
if [[ "$raw" =~ ^([0-9]+)([KMG])?$ ]]; then
local num="${BASH_REMATCH[1]}"
local unit="${BASH_REMATCH[2]}"
case "$unit" in
K) printf '%s\n' $((num * 1024)) ;;
M|"") printf '%s\n' $((num * 1024 * 1024)) ;;
G) printf '%s\n' $((num * 1024 * 1024 * 1024)) ;;
esac
return 0
fi
return 1
}
require_command() {
local name="$1"
command -v "$name" >/dev/null 2>&1 || {
log "required command not found: $name"
exit 1
}
}
resolve_banger_bin() {
if [[ -n "${BANGER_BIN:-}" ]]; then
printf '%s\n' "$BANGER_BIN"
return
fi
if [[ -x "$SCRIPT_DIR/banger" ]]; then
printf '%s\n' "$SCRIPT_DIR/banger"
return
fi
if command -v banger >/dev/null 2>&1; then
command -v banger
return
fi
log "banger binary not found; build it first with 'make build' or set BANGER_BIN"
exit 1
}
normalize_mirror() {
local mirror="${1%/}"
mirror="${mirror%/current}"
mirror="${mirror%/static}"
printf '%s\n' "$mirror"
}
bundle_path() {
local key="$1"
local fallback="$2"
local rel=""
if [[ -f "$BUNDLE_METADATA" ]] && command -v jq >/dev/null 2>&1; then
rel="$(jq -r --arg key "$key" '.[$key] // empty' "$BUNDLE_METADATA" 2>/dev/null || true)"
fi
if [[ -n "$rel" && "$rel" != "null" ]]; then
printf '%s\n' "$RUNTIME_DIR/$rel"
return
fi
printf '%s\n' "$fallback"
}
find_static_binary() {
local name="$1"
find "$STATIC_DIR" -type f \( -name "$name" -o -name "$name.static" \) -perm -u+x | sort | head -n 1
}
find_static_keys_dir() {
find "$STATIC_DIR" -type d -path '*/var/db/xbps/keys' | sort | head -n 1
}
ensure_sshd_include() {
local cfg="$ROOT_MOUNT/etc/ssh/sshd_config"
local tmp_cfg="$TMP_DIR/sshd_config"
local include_line="Include /etc/ssh/sshd_config.d/*.conf"
sudo mkdir -p "$ROOT_MOUNT/etc/ssh/sshd_config.d"
if sudo test -f "$cfg"; then
sudo cat "$cfg" > "$tmp_cfg"
else
: > "$tmp_cfg"
fi
if ! grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf([[:space:]]|$)' "$tmp_cfg"; then
{
printf '%s\n' "$include_line"
cat "$tmp_cfg"
} > "${tmp_cfg}.new"
mv "${tmp_cfg}.new" "$tmp_cfg"
sudo install -m 0644 "$tmp_cfg" "$cfg"
fi
}
install_vsock_service() {
local service_dir="$ROOT_MOUNT/etc/sv/banger-vsock-agent"
local run_path="$service_dir/run"
local finish_path="$service_dir/finish"
sudo mkdir -p "$service_dir"
cat <<'EOF' | sudo tee "$run_path" >/dev/null
#!/bin/sh
modprobe vsock 2>/dev/null || true
modprobe vmw_vsock_virtio_transport 2>/dev/null || true
exec /usr/local/bin/banger-vsock-agent
EOF
cat <<'EOF' | sudo tee "$finish_path" >/dev/null
#!/bin/sh
exit 0
EOF
sudo chmod 0755 "$run_path" "$finish_path"
sudo mkdir -p "$ROOT_MOUNT/etc/runit/runsvdir/default"
sudo ln -snf /etc/sv/banger-vsock-agent "$ROOT_MOUNT/etc/runit/runsvdir/default/banger-vsock-agent"
}
enable_sshd_service() {
if [[ ! -d "$ROOT_MOUNT/etc/sv/sshd" ]]; then
log "Void rootfs is missing /etc/sv/sshd after openssh install"
exit 1
fi
sudo mkdir -p "$ROOT_MOUNT/etc/runit/runsvdir/default"
sudo ln -snf /etc/sv/sshd "$ROOT_MOUNT/etc/runit/runsvdir/default/sshd"
}
normalize_root_shell() {
local passwd="$ROOT_MOUNT/etc/passwd"
local shells="$ROOT_MOUNT/etc/shells"
local wanted_shell="/bin/bash"
local tmp_passwd="$TMP_DIR/passwd"
local root_shell=""
if [[ ! -x "$ROOT_MOUNT$wanted_shell" ]]; then
log "required root shell is missing from the Void image: $wanted_shell"
exit 1
fi
if [[ ! -f "$shells" ]]; then
log "Void image is missing /etc/shells"
exit 1
fi
if ! sudo grep -Fxq "$wanted_shell" "$shells"; then
log "Void image does not allow $wanted_shell in /etc/shells"
exit 1
fi
sudo cat "$passwd" > "$tmp_passwd"
awk -F: -v OFS=: -v shell="$wanted_shell" '
$1 == "root" {
$7 = shell
found = 1
}
{ print }
END {
if (!found) {
exit 1
}
}
' "$tmp_passwd" > "${tmp_passwd}.new" || {
log "failed to rewrite root shell in /etc/passwd"
exit 1
}
mv "${tmp_passwd}.new" "$tmp_passwd"
sudo install -m 0644 "$tmp_passwd" "$passwd"
root_shell="$(sudo awk -F: '$1 == "root" { print $7 }' "$passwd")"
if [[ "$root_shell" != "$wanted_shell" ]]; then
log "root shell normalization failed: expected $wanted_shell, got ${root_shell:-<empty>}"
exit 1
fi
}
configure_root_bash_prompt() {
local bashrc="$ROOT_MOUNT/root/.bashrc"
local bash_profile="$ROOT_MOUNT/root/.bash_profile"
local profile_prompt="$ROOT_MOUNT/etc/profile.d/banger-bash-prompt.sh"
sudo mkdir -p "$ROOT_MOUNT/root" "$ROOT_MOUNT/etc/profile.d"
cat <<'EOF' | sudo tee "$bashrc" >/dev/null
# banger: default interactive prompt for experimental Void guests
case "$-" in
*i*) ;;
*) return ;;
esac
PS1='\u@\h:\w\$ '
EOF
cat <<'EOF' | sudo tee "$bash_profile" >/dev/null
if [ -f ~/.bashrc ]; then
. ~/.bashrc
fi
EOF
cat <<'EOF' | sudo tee "$profile_prompt" >/dev/null
case "$-" in
*i*) ;;
*) return 0 2>/dev/null || exit 0 ;;
esac
if [ -n "${BASH_VERSION:-}" ]; then
PS1='\u@\h:\w\$ '
fi
EOF
sudo chmod 0644 "$bashrc" "$bash_profile" "$profile_prompt"
}
cleanup() {
if [[ -n "${ROOT_MOUNT:-}" ]] && command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$ROOT_MOUNT"; then
sudo umount "$ROOT_MOUNT" || true
fi
if [[ "${BUILD_DONE:-0}" != "1" ]]; then
rm -f "${OUT_ROOTFS:-}" "${WORK_SEED:-}" "${OUT_ROOTFS:-}.packages.sha256"
fi
if [[ -n "${TMP_DIR:-}" && -d "${TMP_DIR:-}" ]]; then
rm -rf "$TMP_DIR"
fi
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGES_FILE="$SCRIPT_DIR/packages.void"
export BANGER_APT_PACKAGES_FILE="$PACKAGES_FILE"
source "$SCRIPT_DIR/packages.sh"
DEFAULT_RUNTIME_DIR="$SCRIPT_DIR"
if [[ -d "$SCRIPT_DIR/runtime" ]]; then
DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime"
fi
RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}"
if [[ ! -d "$RUNTIME_DIR" ]]; then
log "runtime bundle not found: $RUNTIME_DIR"
log "run 'make runtime-bundle' or set BANGER_RUNTIME_DIR"
exit 1
fi
BUNDLE_METADATA="$RUNTIME_DIR/bundle.json"
OUT_ROOTFS="$RUNTIME_DIR/rootfs-void.ext4"
SIZE_SPEC="2G"
MIRROR="https://repo-default.voidlinux.org"
ARCH="x86_64"
MODULES_DIR="$(bundle_path default_modules_dir "$RUNTIME_DIR/wtf/root/lib/modules/6.8.0-94-generic")"
VSOCK_AGENT="$(bundle_path vsock_agent_path "$RUNTIME_DIR/banger-vsock-agent")"
if [[ "$VSOCK_AGENT" == "$RUNTIME_DIR/banger-vsock-agent" && ! -x "$VSOCK_AGENT" ]]; then
VSOCK_AGENT="$(bundle_path vsock_ping_helper_path "$RUNTIME_DIR/banger-vsock-pingd")"
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--out)
OUT_ROOTFS="${2:-}"
shift 2
;;
--size)
SIZE_SPEC="${2:-}"
shift 2
;;
--mirror)
MIRROR="${2:-}"
shift 2
;;
--arch)
ARCH="${2:-}"
shift 2
;;
--packages)
PACKAGES_FILE="${2:-}"
export BANGER_APT_PACKAGES_FILE="$PACKAGES_FILE"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
log "unknown option: $1"
usage
exit 1
;;
esac
done
MIRROR="$(normalize_mirror "$MIRROR")"
REPO_URL="$MIRROR/current"
STATIC_ARCHIVE_URL="$MIRROR/static/xbps-static-latest.x86_64-musl.tar.xz"
if [[ "$ARCH" != "x86_64" ]]; then
log "unsupported arch: $ARCH"
log "this experimental builder currently supports only x86_64-glibc"
exit 1
fi
if [[ ! -f "$PACKAGES_FILE" ]]; then
log "package manifest not found: $PACKAGES_FILE"
exit 1
fi
if [[ ! -d "$MODULES_DIR" ]]; then
log "modules dir not found: $MODULES_DIR"
exit 1
fi
if [[ ! -x "$VSOCK_AGENT" ]]; then
log "vsock agent not found or not executable: $VSOCK_AGENT"
log "run 'make build' or refresh the runtime bundle"
exit 1
fi
if [[ -e "$OUT_ROOTFS" ]]; then
log "output rootfs already exists: $OUT_ROOTFS"
exit 1
fi
require_command curl
require_command tar
require_command sudo
require_command mkfs.ext4
require_command mount
require_command umount
require_command install
require_command find
require_command awk
require_command sed
require_command sha256sum
require_command truncate
require_command mountpoint
VOID_PACKAGES=()
if ! banger_packages_read_array VOID_PACKAGES "$PACKAGES_FILE"; then
log "package manifest is empty: $PACKAGES_FILE"
exit 1
fi
if ! PACKAGES_HASH="$(banger_packages_manifest_hash "$PACKAGES_FILE")"; then
log "failed to hash package manifest: $PACKAGES_FILE"
exit 1
fi
if ! SIZE_BYTES="$(parse_size "$SIZE_SPEC")"; then
log "invalid size: $SIZE_SPEC"
exit 1
fi
BANGER_BIN="$(resolve_banger_bin)"
if [[ "$OUT_ROOTFS" == *.ext4 ]]; then
WORK_SEED="${OUT_ROOTFS%.ext4}.work-seed.ext4"
else
WORK_SEED="${OUT_ROOTFS}.work-seed"
fi
TMP_DIR="$(mktemp -d -t banger-void-rootfs-XXXXXX)"
STATIC_DIR="$TMP_DIR/static"
ROOT_MOUNT="$TMP_DIR/rootfs"
STATIC_ARCHIVE="$TMP_DIR/xbps-static.tar.xz"
BUILD_DONE=0
trap cleanup EXIT
mkdir -p "$STATIC_DIR" "$ROOT_MOUNT"
log "downloading static XBPS from $STATIC_ARCHIVE_URL"
curl -fsSL "$STATIC_ARCHIVE_URL" -o "$STATIC_ARCHIVE"
tar -xf "$STATIC_ARCHIVE" -C "$STATIC_DIR"
XBPS_INSTALL="$(find_static_binary xbps-install)"
XBPS_QUERY="$(find_static_binary xbps-query)"
STATIC_KEYS_DIR="$(find_static_keys_dir)"
if [[ -z "$XBPS_INSTALL" || ! -x "$XBPS_INSTALL" ]]; then
log "failed to locate xbps-install in the static archive"
exit 1
fi
if [[ -z "$STATIC_KEYS_DIR" || ! -d "$STATIC_KEYS_DIR" ]]; then
log "failed to locate Void repository keys in the static archive"
exit 1
fi
log "creating $OUT_ROOTFS ($SIZE_SPEC)"
truncate -s "$SIZE_BYTES" "$OUT_ROOTFS"
mkfs.ext4 -F -m 0 -L banger-void-root "$OUT_ROOTFS" >/dev/null
sudo mount -o loop "$OUT_ROOTFS" "$ROOT_MOUNT"
sudo mkdir -p "$ROOT_MOUNT/var/db/xbps/keys"
sudo cp -a "$STATIC_KEYS_DIR/." "$ROOT_MOUNT/var/db/xbps/keys/"
log "installing Void packages into the rootfs"
sudo env XBPS_ARCH="$ARCH" "$XBPS_INSTALL" -S -y -r "$ROOT_MOUNT" -R "$REPO_URL" "${VOID_PACKAGES[@]}"
if [[ -n "$XBPS_QUERY" && -x "$XBPS_QUERY" ]]; then
log "installed package set:"
sudo env XBPS_ARCH="$ARCH" "$XBPS_QUERY" -r "$ROOT_MOUNT" -l | awk '/^ii/ {print " " $2}' || true
fi
log "copying bundled kernel modules into the guest"
sudo mkdir -p "$ROOT_MOUNT/lib/modules"
sudo cp -a "$MODULES_DIR" "$ROOT_MOUNT/lib/modules/"
log "installing the guest-side vsock agent"
sudo mkdir -p "$ROOT_MOUNT/usr/local/bin"
sudo install -m 0755 "$VSOCK_AGENT" "$ROOT_MOUNT/usr/local/bin/banger-vsock-agent"
log "preparing SSH and runit services"
ensure_sshd_include
enable_sshd_service
install_vsock_service
normalize_root_shell
configure_root_bash_prompt
sudo mkdir -p "$ROOT_MOUNT/root/.ssh"
sudo touch "$ROOT_MOUNT/etc/fstab" "$ROOT_MOUNT/etc/hostname"
sudo chroot "$ROOT_MOUNT" /usr/bin/ssh-keygen -A
log "removing bulky caches and docs from the experimental image"
sudo rm -rf \
"$ROOT_MOUNT/var/cache/xbps" \
"$ROOT_MOUNT/usr/share/doc" \
"$ROOT_MOUNT/usr/share/info" \
"$ROOT_MOUNT/usr/share/man"
sudo umount "$ROOT_MOUNT"
banger_write_rootfs_manifest_metadata "$OUT_ROOTFS" "$PACKAGES_HASH"
log "building work-seed $WORK_SEED"
"$BANGER_BIN" internal work-seed --rootfs "$OUT_ROOTFS" --out "$WORK_SEED"
BUILD_DONE=1
log "built experimental Void rootfs: $OUT_ROOTFS"
log "built experimental Void work-seed: $WORK_SEED"
log "use examples/void-exp.config.toml as the local config override template"

25
packages.void Normal file
View file

@ -0,0 +1,25 @@
base-minimal
base-devel
bash
openssh
ca-certificates
curl
fd
fzf
git
iputils
jq
kmod
iproute2
less
lsof
make
procps-ng
psmisc
ripgrep
strace
tmux
vim
unzip
zip
zstd

View file

@ -10,7 +10,7 @@ required_paths = [
"customize.sh", "customize.sh",
"packages.sh", "packages.sh",
"namegen", "namegen",
"banger-vsock-pingd", "banger-vsock-agent",
"packages.apt", "packages.apt",
"id_ed25519", "id_ed25519",
"rootfs-docker.ext4", "rootfs-docker.ext4",
@ -24,7 +24,7 @@ firecracker_bin = "firecracker"
ssh_key_path = "id_ed25519" ssh_key_path = "id_ed25519"
namegen_path = "namegen" namegen_path = "namegen"
customize_script = "customize.sh" customize_script = "customize.sh"
vsock_ping_helper_path = "banger-vsock-pingd" vsock_agent_path = "banger-vsock-agent"
default_packages_file = "packages.apt" default_packages_file = "packages.apt"
default_rootfs = "rootfs-docker.ext4" default_rootfs = "rootfs-docker.ext4"
default_work_seed = "rootfs-docker.work-seed.ext4" default_work_seed = "rootfs-docker.work-seed.ext4"

View file

@ -22,6 +22,17 @@ if [[ ! -f "$SSH_KEY" ]]; then
exit 1 exit 1
fi fi
DAEMON_LOG="${XDG_STATE_HOME:-$HOME/.local/state}/banger/bangerd.log" DAEMON_LOG="${XDG_STATE_HOME:-$HOME/.local/state}/banger/bangerd.log"
SSH_COMMON_ARGS=(
-F /dev/null
-i "$SSH_KEY"
-o IdentitiesOnly=yes
-o BatchMode=yes
-o PreferredAuthentications=publickey
-o PasswordAuthentication=no
-o KbdInteractiveAuthentication=no
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
)
firecracker_running() { firecracker_running() {
local pid="$1" local pid="$1"
@ -48,8 +59,7 @@ wait_for_ssh() {
local deadline="$2" local deadline="$2"
while ((SECONDS < deadline)); do while ((SECONDS < deadline)); do
if ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ if ssh "${SSH_COMMON_ARGS[@]}" -o ConnectTimeout=2 "root@${guest_ip}" "true" >/dev/null 2>&1; then
-o ConnectTimeout=2 "root@${guest_ip}" "true" >/dev/null 2>&1; then
return 0 return 0
fi fi
sleep 1 sleep 1
@ -127,23 +137,37 @@ dump_diagnostics() {
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: ./verify.sh [--nat] Usage: ./verify.sh [--nat] [--image <name>]
Run a basic smoke test for the Go VM workflow. Run a basic smoke test for the Go VM workflow.
Use --nat to additionally verify outbound NAT and host rule cleanup. Use --nat to additionally verify outbound NAT and host rule cleanup.
Use --image to verify a non-default image such as void-exp.
EOF EOF
} }
NAT_ENABLED=0 NAT_ENABLED=0
IMAGE_NAME=""
BOOT_TIMEOUT_SECS="${VERIFY_BOOT_TIMEOUT_SECS:-90}" BOOT_TIMEOUT_SECS="${VERIFY_BOOT_TIMEOUT_SECS:-90}"
if [[ "${1:-}" == "--nat" ]]; then while [[ $# -gt 0 ]]; do
NAT_ENABLED=1 case "$1" in
shift --nat)
fi NAT_ENABLED=1
if (($# != 0)); then shift
usage ;;
exit 1 --image)
fi IMAGE_NAME="${2:-}"
if [[ -z "$IMAGE_NAME" ]]; then
usage
exit 1
fi
shift 2
;;
*)
usage
exit 1
;;
esac
done
VM_NAME="verify-$(date +%s)" VM_NAME="verify-$(date +%s)"
VM_JSON="" VM_JSON=""
@ -172,6 +196,9 @@ trap cleanup EXIT
log "starting VM" log "starting VM"
CREATE_ARGS=(./banger vm create --name "$VM_NAME") CREATE_ARGS=(./banger vm create --name "$VM_NAME")
if [[ -n "$IMAGE_NAME" ]]; then
CREATE_ARGS+=(--image "$IMAGE_NAME")
fi
if (( NAT_ENABLED )); then if (( NAT_ENABLED )); then
CREATE_ARGS+=(--nat) CREATE_ARGS+=(--nat)
fi fi
@ -211,13 +238,11 @@ if ! wait_for_ssh "$GUEST_IP" "$BOOT_DEADLINE"; then
dump_diagnostics dump_diagnostics
exit 1 exit 1
fi fi
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "uname -a" >/dev/null
"root@${GUEST_IP}" "uname -a" >/dev/null
if (( NAT_ENABLED )); then if (( NAT_ENABLED )); then
log "asserting VM has outbound network access" log "asserting VM has outbound network access"
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "curl -fsS https://example.com >/dev/null" >/dev/null
"root@${GUEST_IP}" "curl -fsS https://example.com >/dev/null" >/dev/null
fi fi
log "cleaning up VM" log "cleaning up VM"