diff --git a/AGENTS.md b/AGENTS.md index d95c724..c41e815 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,15 +9,18 @@ - The daemon keeps state under XDG directories rather than the old repo-local `state/` layout. ## 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 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 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 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 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. - `make test` runs `go test ./...`. - `./verify.sh` runs the smoke test for the Go VM workflow. @@ -32,7 +35,8 @@ - Primary automated coverage is `go test ./...`. - 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. -- 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. - 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`. diff --git a/Makefile b/Makefile index 7006650..2eea15d 100644 --- a/Makefile +++ b/Makefile @@ -12,17 +12,19 @@ RUNTIME_MANIFEST ?= runtime-bundle.toml RUNTIME_SOURCE_DIR ?= runtime RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz 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) -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_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_MODULES_DIR := wtf/root/lib/modules/6.8.0-94-generic +VOID_IMAGE_NAME ?= void-exp +VOID_VM_NAME ?= void-dev .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: @printf '%s\n' \ @@ -36,7 +38,11 @@ help: ' make fmt Format Go sources under cmd/ and internal/' \ ' make tidy Run go mod tidy' \ ' 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) @@ -46,9 +52,9 @@ banger: $(GO_SOURCES) go.mod go.sum bangerd: $(GO_SOURCES) go.mod go.sum $(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)" - 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: $(GO) test ./... @@ -100,3 +106,15 @@ install: build check-runtime rootfs: 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)" diff --git a/README.md b/README.md index 82bcd2f..fe8c316 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ generated `./runtime/` bundle, while installed binaries use The bundle contains: - `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 - a kernel, initrd, and modules tree referenced by `bundle.json` - `rootfs-docker.ext4` @@ -69,7 +69,7 @@ make build ``` 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 `~/.local/lib/banger`: @@ -166,10 +166,9 @@ Useful config keys: - `runtime_dir` - `tap_pool_size` - `firecracker_bin` -- `ssh_key_path` - `namegen_path` - `customize_script` (manual helper compatibility; `banger image build` is Go-native) -- `vsock_ping_helper_path` +- `vsock_agent_path` - `default_rootfs` - `default_work_seed` - `default_base_rootfs` @@ -178,6 +177,10 @@ Useful config keys: - `default_modules_dir` - `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 `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 @@ -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`, configure `tmux-resurrect` plus `tmux-continuum` for `root` with periodic 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` 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 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 ` calls pick up the new +artifacts immediately. + ## Maintaining The Runtime Bundle 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 diff --git a/cmd/banger-vsock-agent/main.go b/cmd/banger-vsock-agent/main.go new file mode 100644 index 0000000..54cf31a --- /dev/null +++ b/cmd/banger-vsock-agent/main.go @@ -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) + } + } +} diff --git a/cmd/banger-vsock-pingd/main.go b/cmd/banger-vsock-pingd/main.go deleted file mode 100644 index 57abc7d..0000000 --- a/cmd/banger-vsock-pingd/main.go +++ /dev/null @@ -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) - } -} diff --git a/customize.sh b/customize.sh index 6509990..a290e96 100755 --- a/customize.sh +++ b/customize.sh @@ -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")" INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")" 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_IP="172.16.0.1" @@ -213,8 +216,8 @@ if [[ ! -f "$PACKAGES_FILE" ]]; then log "package manifest not found: $PACKAGES_FILE" exit 1 fi -if [[ ! -x "$VSOCK_PING_HELPER" ]]; then - log "vsock ping helper not found or not executable: $VSOCK_PING_HELPER" +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 @@ -393,9 +396,9 @@ if [[ "$SSH_READY" -ne 1 ]]; then fi log "configuring guest" -log "installing vsock ping helper" +log "installing vsock agent" 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 \ "root@${GUEST_IP}" bash -lc "set -e @@ -436,31 +439,31 @@ if [[ \"$INSTALL_DOCKER\" == \"1\" ]]; then fi fi 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 cat > /etc/modules-load.d/banger-vsock.conf <<'EOF' vsock vmw_vsock_virtio_transport EOF 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] -Description=Banger vsock ping responder +Description=Banger vsock agent After=network.target [Service] Type=simple -ExecStart=/usr/local/bin/banger-vsock-pingd +ExecStart=/usr/local/bin/banger-vsock-agent Restart=on-failure RestartSec=1 [Install] WantedBy=multi-user.target 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 systemctl daemon-reload || true - systemctl enable --now banger-vsock-pingd.service || true + systemctl enable --now banger-vsock-agent.service || true fi git config --system init.defaultBranch main " diff --git a/examples/void-exp.config.toml b/examples/void-exp.config.toml new file mode 100644 index 0000000..75ee7e2 --- /dev/null +++ b/examples/void-exp.config.toml @@ -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" diff --git a/internal/api/types.go b/internal/api/types.go index 885ea56..b3f9df0 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -63,6 +63,11 @@ type VMSSHResult struct { GuestIP string `json:"guest_ip"` } +type VMHealthResult struct { + Name string `json:"name"` + Healthy bool `json:"healthy"` +} + type VMPingResult struct { Name string `json:"name"` Alive bool `json:"alive"` @@ -78,6 +83,17 @@ type ImageBuildParams struct { 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 { IDOrName string `json:"id_or_name"` } diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 8714545..6167c2a 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -24,7 +24,7 @@ import ( "banger/internal/rpc" "banger/internal/system" "banger/internal/vmdns" - "banger/internal/vsockping" + "banger/internal/vsockagent" "github.com/spf13/cobra" ) @@ -42,8 +42,8 @@ var ( sshCmd.Stdin = stdin return sshCmd.Run() } - vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { - return rpc.Call[api.VMPingResult](ctx, socketPath, "vm.ping", api.VMRefParams{IDOrName: idOrName}) + vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { + return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName}) } ) @@ -550,6 +550,7 @@ func newImageCommand() *cobra.Command { } cmd.AddCommand( newImageBuildCommand(), + newImageRegisterCommand(), newImageListCommand(), newImageShowCommand(), newImageDeleteCommand(), @@ -591,6 +592,41 @@ func newImageBuildCommand() *cobra.Command { 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 --rootfs [--work-seed ] [--kernel ] [--initrd ] [--modules ] [--packages ]"), + RunE: func(cmd *cobra.Command, args []string) error { + if err := absolutizeImageRegisterPaths(¶ms); 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(¶ms.Name, "name", "", "image name") + cmd.Flags().StringVar(¶ms.RootfsPath, "rootfs", "", "rootfs path") + cmd.Flags().StringVar(¶ms.WorkSeedPath, "work-seed", "", "work-seed path") + cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path") + cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path") + cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") + cmd.Flags().StringVar(¶ms.PackagesPath, "packages", "", "packages manifest path") + cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "mark image as docker-prepared") + return cmd +} + func newImageListCommand() *cobra.Command { return &cobra.Command{ 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) defer cancel() - ping, err := vmPingFunc(pingCtx, socketPath, vmRef) + health, err := vmHealthFunc(pingCtx, socketPath, vmRef) if err != nil { - _, _ = fmt.Fprintln(stderr, vsockping.WarningMessage(vmRef, err)) + _, _ = fmt.Fprintln(stderr, vsockagent.WarningMessage(vmRef, err)) return sshErr } - if ping.Alive { - name := ping.Name + if health.Healthy { + name := health.Name if strings.TrimSpace(name) == "" { name = vmRef } - _, _ = fmt.Fprintln(stderr, vsockping.ReminderMessage(name)) + _, _ = fmt.Fprintln(stderr, vsockagent.ReminderMessage(name)) } return sshErr } @@ -1015,7 +1051,10 @@ func shouldCheckSSHReminder(err error) bool { return true } 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) { @@ -1023,10 +1062,21 @@ func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]s return nil, errors.New("vm has no guest IP") } args := []string{} + args = append(args, "-F", "/dev/null") if 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...) return args, nil } @@ -1035,14 +1085,29 @@ func validateSSHPrereqs(cfg model.DaemonConfig) error { checks := system.NewPreflight() checks.RequireCommand("ssh", "install openssh-client") 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") } func absolutizeImageBuildPaths(params *api.ImageBuildParams) error { + return absolutizePaths(¶ms.BaseRootfs, ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir) +} + +func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error { + return absolutizePaths( + ¶ms.RootfsPath, + ¶ms.WorkSeedPath, + ¶ms.KernelPath, + ¶ms.InitrdPath, + ¶ms.ModulesDir, + ¶ms.PackagesPath, + ) +} + +func absolutizePaths(values ...*string) error { var err error - for _, value := range []*string{¶ms.BaseRootfs, ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir} { + for _, value := range values { if *value == "" || filepath.IsAbs(*value) { continue } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index bf9a4b6..d12a9a4 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "os" "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) { root := NewBangerCommand() 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(¶ms); 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 - origPing := vmPingFunc + origHealth := vmHealthFunc t.Cleanup(func() { sshExecFunc = origSSHExec - vmPingFunc = origPing + vmHealthFunc = origHealth }) sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { return nil } - vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { - return api.VMPingResult{Name: "devbox", Alive: true}, nil + vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { + return api.VMHealthResult{Name: "devbox", Healthy: true}, nil } 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 - origPing := vmPingFunc + origHealth := vmHealthFunc t.Cleanup(func() { sshExecFunc = origSSHExec - vmPingFunc = origPing + vmHealthFunc = origHealth }) 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) { - return api.VMPingResult{}, errors.New("dial failed") + vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { + return api.VMHealthResult{}, errors.New("dial failed") } 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) { vms := []model.VMRecord{ testCLIResolvedVM("alpha-id", "alpha"), @@ -358,7 +446,13 @@ func TestSSHCommandArgs(t *testing.T) { t.Fatalf("sshCommandArgs: %v", err) } want := []string{ + "-F", "/dev/null", "-i", "/bundle/id_ed25519", + "-o", "IdentitiesOnly=yes", + "-o", "BatchMode=yes", + "-o", "PreferredAuthentications=publickey", + "-o", "PasswordAuthentication=no", + "-o", "KbdInteractiveAuthentication=no", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "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) { err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: "/does/not/exist"}) if err == nil || !strings.Contains(err.Error(), "ssh private key") { diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 7fc2d54..8448c04 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -16,7 +16,7 @@ import ( "banger/internal/paths" "banger/internal/rpc" "banger/internal/system" - "banger/internal/vsockping" + "banger/internal/vsockagent" "github.com/charmbracelet/bubbles/help" "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) defer cancel() - ping, err := vmPingFunc(pingCtx, layout.SocketPath, name) + health, err := vmHealthFunc(pingCtx, layout.SocketPath, name) if err != nil { return actionResultMsg{ action: action, - status: vsockping.WarningMessage(name, err), + status: vsockagent.WarningMessage(name, err), refresh: true, focusID: action.id, } } - if ping.Alive { - if strings.TrimSpace(ping.Name) != "" { - name = ping.Name + if health.Healthy { + if strings.TrimSpace(health.Name) != "" { + name = health.Name } return actionResultMsg{ action: action, - status: vsockping.ReminderMessage(name), + status: vsockagent.ReminderMessage(name), refresh: true, focusID: action.id, } diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index ea30a0d..acdb078 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -238,13 +238,13 @@ func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) { } } -func TestSSHDoneMsgShowsReminderWhenPingAlive(t *testing.T) { - origPing := vmPingFunc +func TestSSHDoneMsgShowsReminderWhenHealthCheckPasses(t *testing.T) { + origHealth := vmHealthFunc t.Cleanup(func() { - vmPingFunc = origPing + vmHealthFunc = origHealth }) - vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { - return api.VMPingResult{Name: "devbox", Alive: true}, nil + vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { + return api.VMHealthResult{Name: "devbox", Healthy: true}, 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) { - origPing := vmPingFunc +func TestSSHDoneMsgShowsWarningWhenHealthCheckFails(t *testing.T) { + origHealth := vmHealthFunc t.Cleanup(func() { - vmPingFunc = origPing + vmHealthFunc = origHealth }) - vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { - return api.VMPingResult{}, errors.New("dial failed") + vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { + return api.VMHealthResult{}, errors.New("dial failed") } msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil) diff --git a/internal/config/config.go b/internal/config/config.go index 7d3372d..fc6807d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type fileConfig struct { SSHKeyPath string `toml:"ssh_key_path"` NamegenPath string `toml:"namegen_path"` CustomizeScript string `toml:"customize_script"` + VSockAgent string `toml:"vsock_agent_path"` VSockPingHelper string `toml:"vsock_ping_helper_path"` DefaultWorkSeed string `toml:"default_work_seed"` DefaultImageName string `toml:"default_image_name"` @@ -83,17 +84,16 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { if file.LogLevel != "" { cfg.LogLevel = file.LogLevel } - if file.SSHKeyPath != "" { - cfg.SSHKeyPath = file.SSHKeyPath - } if file.NamegenPath != "" { cfg.NamegenPath = file.NamegenPath } if file.CustomizeScript != "" { cfg.CustomizeScript = file.CustomizeScript } - if file.VSockPingHelper != "" { - cfg.VSockPingHelperPath = file.VSockPingHelper + if file.VSockAgent != "" { + cfg.VSockAgentPath = file.VSockAgent + } else if file.VSockPingHelper != "" { + cfg.VSockAgentPath = file.VSockPingHelper } if 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.NamegenPath = defaultRuntimePath(cfg.NamegenPath, runtimeDir, meta.NamegenPath) 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.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, runtimeDir, meta.DefaultKernel) 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.NamegenPath = defaultRuntimePath(cfg.NamegenPath, cfg.RuntimeDir, "namegen") 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.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") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5575544..4791084 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -13,24 +13,24 @@ import ( func TestLoadDerivesArtifactPathsFromRuntimeDir(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-docker.ext4", - DefaultWorkSeed: "images/rootfs-docker.work-seed.ext4", - DefaultKernel: "kernels/vmlinux", - DefaultInitrd: "kernels/initrd.img", - DefaultModulesDir: "modules/current", + 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-docker.ext4", + DefaultWorkSeed: "images/rootfs-docker.work-seed.ext4", + DefaultKernel: "kernels/vmlinux", + DefaultInitrd: "kernels/initrd.img", + DefaultModulesDir: "modules/current", } for _, rel := range []string{ meta.FirecrackerBin, meta.SSHKeyPath, meta.NamegenPath, meta.CustomizeScript, - meta.VSockPingHelperPath, + meta.VSockAgentPath, meta.DefaultPackages, meta.DefaultRootfs, meta.DefaultWorkSeed, @@ -75,8 +75,8 @@ func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) { if cfg.CustomizeScript != filepath.Join(runtimeDir, meta.CustomizeScript) { t.Fatalf("CustomizeScript = %q", cfg.CustomizeScript) } - if cfg.VSockPingHelperPath != filepath.Join(runtimeDir, meta.VSockPingHelperPath) { - t.Fatalf("VSockPingHelperPath = %q", cfg.VSockPingHelperPath) + if cfg.VSockAgentPath != filepath.Join(runtimeDir, meta.VSockAgentPath) { + t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath) } if cfg.DefaultRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) { t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs) @@ -108,7 +108,7 @@ func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) { "id_ed25519", "namegen", "customize.sh", - "banger-vsock-pingd", + "banger-vsock-agent", "packages.apt", "rootfs-docker.ext4", "rootfs-docker.work-seed.ext4", @@ -134,8 +134,8 @@ func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) { if cfg.FirecrackerBin != filepath.Join(runtimeDir, "firecracker") { t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) } - if cfg.VSockPingHelperPath != filepath.Join(runtimeDir, "banger-vsock-pingd") { - t.Fatalf("VSockPingHelperPath = %q", cfg.VSockPingHelperPath) + if cfg.VSockAgentPath != filepath.Join(runtimeDir, "banger-vsock-agent") { + t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath) } if cfg.DefaultWorkSeed != filepath.Join(runtimeDir, "rootfs-docker.work-seed.ext4") { t.Fatalf("DefaultWorkSeed = %q", cfg.DefaultWorkSeed) @@ -167,3 +167,125 @@ func TestLoadDefaultsLogLevelToInfo(t *testing.T) { 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) + } +} diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index 70fcdee..c5dabb8 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -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 { - 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) { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 9502622..d19b8d1 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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 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": params, err := rpc.DecodeParams[api.VMRefParams](req) 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) 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": params, err := rpc.DecodeParams[api.ImageRefParams](req) if err != nil { diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 1197f6e..7ecd4e2 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -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 { t.Helper() db, err := store.Open(filepath.Join(dir, "state.db")) diff --git a/internal/daemon/doctor.go b/internal/daemon/doctor.go index e3248d8..e4c9802 100644 --- a/internal/daemon/doctor.go +++ b/internal/daemon/doctor.go @@ -33,7 +33,7 @@ func (d *Daemon) doctorReport(ctx context.Context) system.Report { report.AddPreflight("runtime bundle", d.runtimeBundleChecks(), runtimeBundleStatus(d.config)) 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) report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available") @@ -44,8 +44,8 @@ func (d *Daemon) runtimeBundleChecks() *system.Preflight { checks := system.NewPreflight() hint := paths.RuntimeBundleHint() 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.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`) + checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `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.DefaultKernel, "kernel image", `set "default_kernel" or refresh the runtime bundle`) 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 { 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") return checks } diff --git a/internal/daemon/imagebuild.go b/internal/daemon/imagebuild.go index 01fdce4..7848335 100644 --- a/internal/daemon/imagebuild.go +++ b/internal/daemon/imagebuild.go @@ -17,7 +17,7 @@ import ( "banger/internal/hostnat" "banger/internal/model" "banger/internal/system" - "banger/internal/vsockping" + "banger/internal/vsockagent" ) const ( @@ -104,14 +104,14 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) ( } defer client.Close() - helperBytes, err := os.ReadFile(d.config.VSockPingHelperPath) + helperBytes, err := os.ReadFile(d.config.VSockAgentPath) if err != nil { 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 } - 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 } if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil { @@ -333,14 +333,14 @@ func appendTmuxSetup(script *bytes.Buffer) { func appendVSockPingSetup(script *bytes.Buffer) { 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(vsockping.ModulesLoadConfig()) + script.WriteString(vsockagent.ModulesLoadConfig()) script.WriteString("EOF\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(vsockping.ServiceUnit()) + script.WriteString("cat > /etc/systemd/system/" + vsockagent.ServiceName + " <<'EOF'\n") + script.WriteString(vsockagent.ServiceUnit()) script.WriteString("EOF\n") - script.WriteString("chmod 0644 /etc/systemd/system/" + vsockping.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("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 " + vsockagent.ServiceName + " || true; fi\n") } func appendGitRepo(script *bytes.Buffer, dir, repo string) { diff --git a/internal/daemon/imagebuild_test.go b/internal/daemon/imagebuild_test.go index 2ad1d4c..f8cc09d 100644 --- a/internal/daemon/imagebuild_test.go +++ b/internal/daemon/imagebuild_test.go @@ -28,9 +28,9 @@ func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) { "run '~/.tmux/plugins/tpm/tpm'", "cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'", "vmw_vsock_virtio_transport", - "cat > /etc/systemd/system/banger-vsock-pingd.service <<'EOF'", - "ExecStart=/usr/local/bin/banger-vsock-pingd", - "systemctl enable --now banger-vsock-pingd.service || true", + "cat > /etc/systemd/system/banger-vsock-agent.service <<'EOF'", + "ExecStart=/usr/local/bin/banger-vsock-agent", + "systemctl enable --now banger-vsock-agent.service || true", "rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh", } { if !strings.Contains(script, snippet) { diff --git a/internal/daemon/images.go b/internal/daemon/images.go index 482dd86..6efdc9e 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -2,9 +2,12 @@ package daemon import ( "context" + "database/sql" + "errors" "fmt" "os" "path/filepath" + "strings" "banger/internal/api" "banger/internal/model" @@ -132,6 +135,110 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i 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 `) + checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`) + if workSeedPath != "" { + checks.RequireFile(workSeedPath, "work-seed image", `pass --work-seed or rebuild the image with a work seed`) + } + if initrdPath != "" { + checks.RequireFile(initrdPath, "initrd image", `pass --initrd or set "default_initrd"`) + } + if modulesDir != "" { + checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules or set "default_modules_dir"`) + } + if packagesPath != "" { + checks.RequireFile(packagesPath, "packages manifest", `pass --packages `) + } + return checks.Err("image register failed") +} + func writePackagesMetadata(rootfsPath, packagesPath string) error { if rootfsPath == "" || packagesPath == "" { return nil diff --git a/internal/daemon/logger_test.go b/internal/daemon/logger_test.go index df1b298..d848064 100644 --- a/internal/daemon/logger_test.go +++ b/internal/daemon/logger_test.go @@ -59,7 +59,7 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) { t.Setenv("PATH", binDir) 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 { t.Fatalf("write firecracker: %v", err) } @@ -105,12 +105,12 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) { d := &Daemon{ layout: paths.Layout{RuntimeDir: filepath.Join(t.TempDir(), "runtime")}, config: model.DaemonConfig{ - BridgeName: "br-fc", - BridgeIP: model.DefaultBridgeIP, - DefaultDNS: model.DefaultDNS, - FirecrackerBin: firecrackerBin, - VSockPingHelperPath: vsockHelper, - StatsPollInterval: model.DefaultStatsPollInterval, + BridgeName: "br-fc", + BridgeIP: model.DefaultBridgeIP, + DefaultDNS: model.DefaultDNS, + FirecrackerBin: firecrackerBin, + VSockAgentPath: vsockHelper, + StatsPollInterval: model.DefaultStatsPollInterval, }, runner: runner, logger: logger, @@ -151,7 +151,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { packagesPath := filepath.Join(t.TempDir(), "packages.apt") sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519") 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} { if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) @@ -186,7 +186,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { DefaultPackagesFile: packagesPath, SSHKeyPath: sshKeyPath, FirecrackerBin: firecrackerBin, - VSockPingHelperPath: vsockHelper, + VSockAgentPath: vsockHelper, }, store: store, runner: runner, diff --git a/internal/daemon/preflight.go b/internal/daemon/preflight.go index 85f880a..c4dd41c 100644 --- a/internal/daemon/preflight.go +++ b/internal/daemon/preflight.go @@ -54,7 +54,7 @@ func (d *Daemon) addBaseStartPrereqs(checks *system.Preflight, image model.Image d.addBaseStartCommandPrereqs(checks) 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(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`) @@ -79,8 +79,8 @@ func (d *Daemon) addImageBuildPrereqs(ctx context.Context, checks *system.Prefli checks.RequireCommand(command, toolHint(command)) } 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.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`) + checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `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(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`) diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index 04ecd68..a0507e5 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -13,11 +13,13 @@ import ( "banger/internal/api" "banger/internal/firecracker" + "banger/internal/guest" "banger/internal/guestconfig" "banger/internal/model" "banger/internal/paths" "banger/internal/system" "banger/internal/vmdns" + "banger/internal/vsockagent" ) var ( @@ -582,11 +584,11 @@ func (d *Daemon) GetVMStats(ctx context.Context, idOrName string) (model.VMRecor 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) { result.Name = vm.Name if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) { - result.Alive = false + result.Healthy = false return vm, nil } 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) 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 } - result.Alive = true + result.Healthy = true return vm, nil }) 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) { stats, err := d.collectStats(ctx, vm) if err == nil { @@ -814,6 +824,84 @@ func (d *Daemon) ensureWorkDisk(ctx context.Context, vm *model.VMRecord, image m 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 { nestedHome := filepath.Join(workMount, "root") if !exists(nestedHome) { diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index b4fec45..c14bd94 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -2,6 +2,10 @@ package daemon import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "errors" "fmt" "net" @@ -253,7 +257,7 @@ func TestSetVMRejectsStoppedOnlyChangesForRunningVM(t *testing.T) { } } -func TestPingVMReturnsAliveForRunningGuest(t *testing.T) { +func TestHealthVMReturnsHealthyForRunningGuest(t *testing.T) { t.Parallel() ctx := context.Background() @@ -296,16 +300,24 @@ func TestPingVMReturnsAliveForRunningGuest(t *testing.T) { serverDone <- err return } - n, err = conn.Read(buf) - if err != nil { - serverDone <- err + reqBuf := make([]byte, 0, 512) + reqBuf = append(reqBuf, buf[:0]...) + 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 } - if got := string(buf[:n]); got != "PING\n" { - serverDone <- fmt.Errorf("unexpected ping payload %q", got) - 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\"}")) serverDone <- err }() @@ -326,12 +338,12 @@ func TestPingVMReturnsAliveForRunningGuest(t *testing.T) { }, } d := &Daemon{store: db, runner: runner} - result, err := d.PingVM(ctx, vm.Name) + result, err := d.HealthVM(ctx, vm.Name) if err != nil { - t.Fatalf("PingVM: %v", err) + t.Fatalf("HealthVM: %v", err) } - if !result.Alive || result.Name != vm.Name { - t.Fatalf("PingVM result = %+v, want alive %s", result, vm.Name) + if !result.Healthy || result.Name != vm.Name { + t.Fatalf("HealthVM result = %+v, want healthy %s", result, vm.Name) } runner.assertExhausted() 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() ctx := context.Background() @@ -348,12 +418,12 @@ func TestPingVMReturnsFalseForStoppedVM(t *testing.T) { upsertDaemonVM(t, ctx, db, vm) d := &Daemon{store: db} - result, err := d.PingVM(ctx, vm.Name) + result, err := d.HealthVM(ctx, vm.Name) if err != nil { - t.Fatalf("PingVM: %v", err) + t.Fatalf("HealthVM: %v", err) } - if result.Alive { - t.Fatalf("PingVM result = %+v, want not alive", result) + if result.Healthy { + t.Fatalf("HealthVM result = %+v, want not healthy", result) } } @@ -406,6 +476,64 @@ func TestFlattenNestedWorkHomeCopiesEntriesIndividually(t *testing.T) { 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) { 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") { @@ -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 { t.Helper() @@ -878,6 +1029,117 @@ type processKillingRunner struct { 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) { return r.scriptedRunner.Run(ctx, name, args...) } diff --git a/internal/firecracker/client.go b/internal/firecracker/client.go index 963941e..d0d8aec 100644 --- a/internal/firecracker/client.go +++ b/internal/firecracker/client.go @@ -1,23 +1,19 @@ package firecracker import ( - "bufio" "context" - "fmt" "io" "log/slog" "os" "os/exec" "strings" "sync" - "time" sdk "github.com/firecracker-microvm/firecracker-go-sdk" models "github.com/firecracker-microvm/firecracker-go-sdk/client/models" - sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock" "github.com/sirupsen/logrus" - "banger/internal/vsockping" + "banger/internal/vsockagent" ) type MachineConfig struct { @@ -212,37 +208,12 @@ func newLogger(base *slog.Logger) *logrus.Entry { 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 { - conn, err := sdkvsock.DialContext( - 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 + return HealthVSock(ctx, logger, socketPath) } type slogHook struct { diff --git a/internal/firecracker/client_test.go b/internal/firecracker/client_test.go index 4588f30..e02f6a9 100644 --- a/internal/firecracker/client_test.go +++ b/internal/firecracker/client_test.go @@ -128,7 +128,7 @@ func TestSDKLoggerBridgeSuppressesDebugAtInfoLevel(t *testing.T) { } } -func TestPingVSock(t *testing.T) { +func TestHealthVSock(t *testing.T) { dir := t.TempDir() socketPath := filepath.Join(dir, "fc.vsock") listener, err := net.Listen("unix", socketPath) @@ -174,22 +174,22 @@ func TestPingVSock(t *testing.T) { return } buf = append(buf, tmp[:n]...) - if strings.Contains(string(buf), "\n") { + if strings.Contains(string(buf), "\r\n\r\n") { 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) 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 }() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - if err := PingVSock(ctx, nil, socketPath); err != nil { - t.Fatalf("PingVSock: %v", err) + if err := HealthVSock(ctx, nil, socketPath); err != nil { + t.Fatalf("HealthVSock: %v", err) } if err := <-done; err != nil { t.Fatalf("server: %v", err) diff --git a/internal/guest/ssh.go b/internal/guest/ssh.go index f03853e..01829d0 100644 --- a/internal/guest/ssh.go +++ b/internal/guest/ssh.go @@ -129,6 +129,14 @@ func privateKeySigner(path string) (ssh.Signer, error) { 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 { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } diff --git a/internal/guest/ssh_test.go b/internal/guest/ssh_test.go index d12d27a..3c8411d 100644 --- a/internal/guest/ssh_test.go +++ b/internal/guest/ssh_test.go @@ -3,10 +3,16 @@ package guest import ( "archive/tar" "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "io" "os" "path/filepath" "testing" + + "golang.org/x/crypto/ssh" ) 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) + } +} diff --git a/internal/model/types.go b/internal/model/types.go index 183be93..aabbc4f 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -41,7 +41,7 @@ type DaemonConfig struct { SSHKeyPath string NamegenPath string CustomizeScript string - VSockPingHelperPath string + VSockAgentPath string DefaultWorkSeed string AutoStopStaleAfter time.Duration StatsPollInterval time.Duration diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go index aef90b0..50cdcde 100644 --- a/internal/paths/paths_test.go +++ b/internal/paths/paths_test.go @@ -56,21 +56,21 @@ func TestResolveRuntimeDirUsesSourceCheckoutRuntimeSubdir(t *testing.T) { func createRuntimeBundle(t *testing.T, runtimeDir string) { t.Helper() metadata := 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-docker.ext4", - DefaultKernel: "kernels/vmlinux", + 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-docker.ext4", + DefaultKernel: "kernels/vmlinux", } for _, rel := range []string{ metadata.FirecrackerBin, metadata.SSHKeyPath, metadata.NamegenPath, metadata.CustomizeScript, - metadata.VSockPingHelperPath, + metadata.VSockAgentPath, metadata.DefaultPackages, metadata.DefaultRootfs, metadata.DefaultKernel, diff --git a/internal/runtimebundle/bundle.go b/internal/runtimebundle/bundle.go index 5e0cac8..111cb60 100644 --- a/internal/runtimebundle/bundle.go +++ b/internal/runtimebundle/bundle.go @@ -34,7 +34,8 @@ type BundleMetadata struct { SSHKeyPath string `json:"ssh_key_path" toml:"ssh_key_path"` NamegenPath string `json:"namegen_path" toml:"namegen_path"` 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"` DefaultRootfs string `json:"default_rootfs" toml:"default_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.NamegenPath, "namegen_path"}, {meta.CustomizeScript, "customize_script"}, - {meta.VSockPingHelperPath, "vsock_ping_helper_path"}, + {meta.VSockAgentPath, "vsock_agent_path"}, {meta.DefaultPackages, "default_packages_file"}, {meta.DefaultRootfs, "default_rootfs"}, {meta.DefaultKernel, "default_kernel"}, @@ -230,7 +231,7 @@ func validateBundleMetadata(runtimeDir string, meta BundleMetadata) error { {meta.SSHKeyPath, "ssh_key_path", true}, {meta.NamegenPath, "namegen_path", 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.DefaultRootfs, "default_rootfs", true}, {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.NamegenPath) == "" && strings.TrimSpace(meta.CustomizeScript) == "" && - strings.TrimSpace(meta.VSockPingHelperPath) == "" && + strings.TrimSpace(meta.VSockAgentPath) == "" && strings.TrimSpace(meta.DefaultPackages) == "" && strings.TrimSpace(meta.DefaultRootfs) == "" && strings.TrimSpace(meta.DefaultBaseRootfs) == "" && @@ -290,7 +291,11 @@ func normalizeBundleMetadata(meta BundleMetadata) BundleMetadata { meta.SSHKeyPath = strings.TrimSpace(meta.SSHKeyPath) meta.NamegenPath = strings.TrimSpace(meta.NamegenPath) meta.CustomizeScript = strings.TrimSpace(meta.CustomizeScript) + meta.VSockAgentPath = strings.TrimSpace(meta.VSockAgentPath) meta.VSockPingHelperPath = strings.TrimSpace(meta.VSockPingHelperPath) + if meta.VSockAgentPath == "" { + meta.VSockAgentPath = meta.VSockPingHelperPath + } meta.DefaultPackages = strings.TrimSpace(meta.DefaultPackages) meta.DefaultRootfs = strings.TrimSpace(meta.DefaultRootfs) meta.DefaultBaseRootfs = strings.TrimSpace(meta.DefaultBaseRootfs) diff --git a/internal/runtimebundle/bundle_test.go b/internal/runtimebundle/bundle_test.go index 2b91825..ea8c56c 100644 --- a/internal/runtimebundle/bundle_test.go +++ b/internal/runtimebundle/bundle_test.go @@ -20,7 +20,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) { "runtime/firecracker": "fc", "runtime/id_ed25519": "key", "runtime/namegen": "namegen", - "runtime/banger-vsock-pingd": "pingd", + "runtime/banger-vsock-agent": "agent", "runtime/customize.sh": "#!/bin/bash\n", "runtime/packages.sh": "#!/bin/bash\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/initrd.img-6.8.0-94-generic": "initrd", "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") if err := os.WriteFile(archivePath, bundleData, 0o644); err != nil { @@ -39,7 +39,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) { URL: "./bundle.tar.gz", SHA256: sha256Hex(bundleData), 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") 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", "id_ed25519", "namegen", - "banger-vsock-pingd", + "banger-vsock-agent", "customize.sh", "packages.apt", "rootfs-docker.ext4", @@ -128,22 +128,22 @@ func TestPackageWritesArchive(t *testing.T) { manifest := Manifest{ BundleRoot: "runtime", BundleMeta: 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", + 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", }, RequiredPaths: []string{ "firecracker", "id_ed25519", "namegen", - "banger-vsock-pingd", + "banger-vsock-agent", "customize.sh", "packages.apt", "rootfs-docker.ext4", @@ -186,7 +186,36 @@ func TestPackageWritesArchive(t *testing.T) { func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) { 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) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) @@ -203,13 +232,17 @@ func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) { VSockPingHelperPath: "banger-vsock-pingd", DefaultPackages: "packages.apt", 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 { 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) + meta, err := LoadBundleMetadata(runtimeDir) + if err != nil { + t.Fatalf("LoadBundleMetadata: %v", err) + } + if meta.VSockAgentPath != "banger-vsock-pingd" { + t.Fatalf("VSockAgentPath = %q", meta.VSockAgentPath) } } diff --git a/internal/system/files.go b/internal/system/files.go index c7dd4f4..3ca732e 100644 --- a/internal/system/files.go +++ b/internal/system/files.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "golang.org/x/sys/unix" @@ -75,7 +76,7 @@ func BuildWorkSeedImage(ctx context.Context, runner CommandRunner, rootfsPath, o defer cleanupRoot() rootHome := filepath.Join(rootMount, "root") - sizeBytes, err := estimateWorkSeedSize(rootHome) + sizeBytes, err := estimateWorkSeedSize(ctx, runner, rootHome) if err != nil { return err } @@ -105,7 +106,7 @@ func BuildWorkSeedImage(ctx context.Context, runner CommandRunner, rootfsPath, o 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 err := filepath.Walk(rootHome, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -117,8 +118,19 @@ func estimateWorkSeedSize(rootHome string) (int64, error) { return 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 roundWorkSeedSize(usedBytes), nil +} + +func roundWorkSeedSize(usedBytes int64) int64 { sizeBytes := usedBytes*2 + workSeedSlackBytes if sizeBytes < minWorkSeedBytes { sizeBytes = minWorkSeedBytes @@ -126,7 +138,19 @@ func estimateWorkSeedSize(rootHome string) (int64, error) { if rem := sizeBytes % workSeedRoundBytes; rem != 0 { 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) { diff --git a/internal/system/system_test.go b/internal/system/system_test.go index c2b43a8..deaa7f1 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -409,3 +409,42 @@ func TestUseLoopMount(t *testing.T) { 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) + } +} diff --git a/internal/vsockagent/vsockagent.go b/internal/vsockagent/vsockagent.go new file mode 100644 index 0000000..547034f --- /dev/null +++ b/internal/vsockagent/vsockagent.go @@ -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) +} diff --git a/internal/vsockagent/vsockagent_test.go b/internal/vsockagent/vsockagent_test.go new file mode 100644 index 0000000..2a78241 --- /dev/null +++ b/internal/vsockagent/vsockagent_test.go @@ -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) +} diff --git a/internal/vsockping/vsockping.go b/internal/vsockping/vsockping.go deleted file mode 100644 index 658a9de..0000000 --- a/internal/vsockping/vsockping.go +++ /dev/null @@ -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 -} diff --git a/make-rootfs-void.sh b/make-rootfs-void.sh new file mode 100755 index 0000000..0be5276 --- /dev/null +++ b/make-rootfs-void.sh @@ -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 ] [--size ] [--mirror ] [--arch ] [--packages ] + +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:-}" + 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" diff --git a/packages.void b/packages.void new file mode 100644 index 0000000..a64c5b8 --- /dev/null +++ b/packages.void @@ -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 diff --git a/runtime-bundle.toml b/runtime-bundle.toml index cfeb781..7867a9d 100644 --- a/runtime-bundle.toml +++ b/runtime-bundle.toml @@ -10,7 +10,7 @@ required_paths = [ "customize.sh", "packages.sh", "namegen", - "banger-vsock-pingd", + "banger-vsock-agent", "packages.apt", "id_ed25519", "rootfs-docker.ext4", @@ -24,7 +24,7 @@ firecracker_bin = "firecracker" ssh_key_path = "id_ed25519" namegen_path = "namegen" customize_script = "customize.sh" -vsock_ping_helper_path = "banger-vsock-pingd" +vsock_agent_path = "banger-vsock-agent" default_packages_file = "packages.apt" default_rootfs = "rootfs-docker.ext4" default_work_seed = "rootfs-docker.work-seed.ext4" diff --git a/verify.sh b/verify.sh index ce6ec1a..4e5e652 100755 --- a/verify.sh +++ b/verify.sh @@ -22,6 +22,17 @@ if [[ ! -f "$SSH_KEY" ]]; then exit 1 fi 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() { local pid="$1" @@ -48,8 +59,7 @@ wait_for_ssh() { local deadline="$2" while ((SECONDS < deadline)); do - if ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ - -o ConnectTimeout=2 "root@${guest_ip}" "true" >/dev/null 2>&1; then + if ssh "${SSH_COMMON_ARGS[@]}" -o ConnectTimeout=2 "root@${guest_ip}" "true" >/dev/null 2>&1; then return 0 fi sleep 1 @@ -127,23 +137,37 @@ dump_diagnostics() { usage() { cat <<'EOF' -Usage: ./verify.sh [--nat] +Usage: ./verify.sh [--nat] [--image ] Run a basic smoke test for the Go VM workflow. Use --nat to additionally verify outbound NAT and host rule cleanup. +Use --image to verify a non-default image such as void-exp. EOF } NAT_ENABLED=0 +IMAGE_NAME="" BOOT_TIMEOUT_SECS="${VERIFY_BOOT_TIMEOUT_SECS:-90}" -if [[ "${1:-}" == "--nat" ]]; then - NAT_ENABLED=1 - shift -fi -if (($# != 0)); then - usage - exit 1 -fi +while [[ $# -gt 0 ]]; do + case "$1" in + --nat) + NAT_ENABLED=1 + shift + ;; + --image) + 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_JSON="" @@ -172,6 +196,9 @@ trap cleanup EXIT log "starting VM" CREATE_ARGS=(./banger vm create --name "$VM_NAME") +if [[ -n "$IMAGE_NAME" ]]; then + CREATE_ARGS+=(--image "$IMAGE_NAME") +fi if (( NAT_ENABLED )); then CREATE_ARGS+=(--nat) fi @@ -211,13 +238,11 @@ if ! wait_for_ssh "$GUEST_IP" "$BOOT_DEADLINE"; then dump_diagnostics exit 1 fi -ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ - "root@${GUEST_IP}" "uname -a" >/dev/null +ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "uname -a" >/dev/null if (( NAT_ENABLED )); then log "asserting VM has outbound network access" - ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ - "root@${GUEST_IP}" "curl -fsS https://example.com >/dev/null" >/dev/null + ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "curl -fsS https://example.com >/dev/null" >/dev/null fi log "cleaning up VM"