From 08ef706e3f51f70661e3dad36e9cb0d4fba11388 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Wed, 18 Mar 2026 20:14:51 -0300 Subject: [PATCH] Add vsock-backed SSH session reminders Remind users when a VM is still running after hanger vm ssh exits instead of silently dropping them back to the host shell.\n\nAttach a Firecracker vsock device to each VM, persist the host vsock path/CID,\nadd a new guest-side banger-vsock-pingd responder to the runtime bundle and both\nimage-build paths, and expose a vm.ping RPC that the CLI and TUI call after SSH\nreturns. Doctor and start/build preflight now validate the helper plus\n/dev/vhost-vsock so the feature fails early and clearly.\n\nValidated with go mod tidy, bash -n customize.sh, git diff --check, make build,\nand GOCACHE=/tmp/banger-gocache go test ./... outside the sandbox because the\ndaemon tests need real Unix/UDP sockets. Rebuild the image/rootfs used for new\nVMs so the guest ping service is present. --- AGENTS.md | 6 +- Makefile | 9 ++- README.md | 14 +++- cmd/banger-vsock-pingd/main.go | 49 ++++++++++++ customize.sh | 36 +++++++++ go.mod | 2 + go.sum | 2 + internal/api/types.go | 5 ++ internal/cli/banger.go | 50 ++++++++++-- internal/cli/cli_test.go | 52 +++++++++++++ internal/cli/tui.go | 57 ++++++++++++-- internal/cli/tui_test.go | 37 +++++++++ internal/config/config.go | 6 ++ internal/config/config_test.go | 27 ++++--- internal/daemon/daemon.go | 7 ++ internal/daemon/doctor.go | 9 +++ internal/daemon/imagebuild.go | 27 ++++++- internal/daemon/imagebuild_test.go | 5 ++ internal/daemon/logger_test.go | 28 +++++-- internal/daemon/preflight.go | 5 ++ internal/daemon/vm.go | 99 +++++++++++++++++++++-- internal/daemon/vm_test.go | 108 ++++++++++++++++++++++++++ internal/firecracker/client.go | 53 +++++++++++++ internal/firecracker/client_test.go | 90 +++++++++++++++++++++ internal/guest/ssh.go | 10 +++ internal/model/types.go | 3 + internal/paths/paths_test.go | 16 ++-- internal/runtimebundle/bundle.go | 25 +++--- internal/runtimebundle/bundle_test.go | 43 +++++----- internal/vsockping/vsockping.go | 105 +++++++++++++++++++++++++ runtime-bundle.toml | 2 + 31 files changed, 912 insertions(+), 75 deletions(-) create mode 100644 cmd/banger-vsock-pingd/main.go create mode 100644 internal/vsockping/vsockping.go diff --git a/AGENTS.md b/AGENTS.md index ca8ee05..3fa1249 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,11 +9,11 @@ - The daemon keeps state under XDG directories rather than the old repo-local `state/` layout. ## Build, Test, and Development Commands -- `make build` builds `./banger` and `./bangerd`. +- `make build` builds `./banger`, `./bangerd`, and the bundled `./runtime/banger-vsock-pingd` guest helper. - `make runtime-bundle` bootstraps `./runtime/` from the archive referenced by `RUNTIME_MANIFEST`; the checked-in `runtime-bundle.toml` is only a template. - `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. +- `./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 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. @@ -31,7 +31,7 @@ - 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`, and `tmux-resurrect`/`tmux-continuum` defaults for `root`; 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-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. - If you add a new operational workflow, document how to exercise it in `README.md`. - For NAT changes, verify both guest outbound access and host rule cleanup, for example with `./verify.sh --nat`. diff --git a/Makefile b/Makefile index 8a50f85..fee5bbd 100644 --- a/Makefile +++ b/Makefile @@ -12,8 +12,9 @@ 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 GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort) -RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen +RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen banger-vsock-pingd RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4 RUNTIME_OPTIONAL_DATA_FILES := rootfs.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 @@ -36,7 +37,7 @@ help: ' make clean Remove built Go binaries' \ ' make rootfs Rebuild the source-checkout default rootfs image in ./runtime' -build: $(BINARIES) +build: $(BINARIES) $(RUNTIME_HELPERS) banger: $(GO_SOURCES) go.mod go.sum $(GO) build -o ./banger ./cmd/banger @@ -44,6 +45,10 @@ 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 + 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 + test: $(GO) test ./... diff --git a/README.md b/README.md index 306e63a..79127a3 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Persistent Firecracker development VMs managed through a Go daemon, CLI, and TUI ## Requirements - Linux host with KVM (`/dev/kvm` access) +- Vsock support for post-SSH liveness reminders (`/dev/vhost-vsock`) - Core VM lifecycle: `sudo`, `ip`, `dmsetup`, `losetup`, `blockdev`, `truncate`, `pgrep`, `chown`, `chmod`, `kill` - Guest rootfs patching: `e2cp`, `e2rm`, `debugfs` - Guest work disk creation/resizing: `mkfs.ext4`, `e2fsck`, `resize2fs`, `mount`, `umount`, `cp` @@ -21,6 +22,7 @@ generated `./runtime/` bundle, while installed binaries use The bundle contains: - `firecracker` +- `banger-vsock-pingd` for the guest-side SSH reminder responder - `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` @@ -64,7 +66,8 @@ URL. `make install` will not fetch artifacts for you. make build ``` -Run `make build` after `./runtime/` has been bootstrapped. +Run `make build` after `./runtime/` has been bootstrapped. It also rebuilds the +bundled `banger-vsock-pingd` guest helper in `./runtime/`. Install into `~/.local/bin` by default, with the runtime bundle under `~/.local/lib/banger`: @@ -102,6 +105,9 @@ SSH into a running VM: banger vm ssh calm-otter ``` +When the SSH session exits normally, `banger` checks the guest over vsock and +reminds you if the VM is still running. + Stop, restart, kill, or delete it: ```bash banger vm stop calm-otter @@ -160,6 +166,7 @@ Useful config keys: - `ssh_key_path` - `namegen_path` - `customize_script` (manual helper compatibility; `banger image build` is Go-native) +- `vsock_ping_helper_path` - `default_rootfs` - `default_base_rootfs` - `default_kernel` @@ -197,9 +204,10 @@ banger image build --name docker-dev --docker ``` Rebuilt images install a pinned `mise` at `/usr/local/bin/mise`, activate it -for bash login and interactive shells, install `opencode` through `mise`, and +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. +autosaves and manual-only restore by default, and bake in the +`banger-vsock-pingd` systemd service used by the post-SSH reminder path. Show or delete images: ```bash diff --git a/cmd/banger-vsock-pingd/main.go b/cmd/banger-vsock-pingd/main.go new file mode 100644 index 0000000..57abc7d --- /dev/null +++ b/cmd/banger-vsock-pingd/main.go @@ -0,0 +1,49 @@ +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 eb2c35d..c11d117 100755 --- a/customize.sh +++ b/customize.sh @@ -68,6 +68,7 @@ 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")" BR_DEV="br-fc" BR_IP="172.16.0.1" @@ -207,6 +208,11 @@ 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" + log "run 'make build' or refresh the runtime bundle" + exit 1 +fi APT_PACKAGES=() if ! banger_packages_read_array APT_PACKAGES "$PACKAGES_FILE"; then @@ -382,6 +388,10 @@ if [[ "$SSH_READY" -ne 1 ]]; then fi log "configuring guest" +log "installing vsock ping helper" +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 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ "root@${GUEST_IP}" bash -lc "set -e printf 'nameserver %s\n' \"$DNS_SERVER\" > /etc/resolv.conf @@ -421,6 +431,32 @@ 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 +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' +[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 +EOF +chmod 0644 /etc/systemd/system/banger-vsock-pingd.service +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl enable --now banger-vsock-pingd.service || true +fi git config --system init.defaultBranch main " diff --git a/go.mod b/go.mod index 25fdb98..3b9a5ca 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,8 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mdlayher/socket v0.2.0 // indirect + github.com/mdlayher/vsock v1.1.1 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.1 // indirect diff --git a/go.sum b/go.sum index e9f1f16..3ba2450 100644 --- a/go.sum +++ b/go.sum @@ -514,7 +514,9 @@ github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vq github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mdlayher/socket v0.2.0 h1:EY4YQd6hTAg2tcXF84p5DTHazShE50u5HeBzBaNgjkA= github.com/mdlayher/socket v0.2.0/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E= +github.com/mdlayher/vsock v1.1.1 h1:8lFuiXQnmICBrCIIA9PMgVSke6Fg6V4+r0v7r55k88I= github.com/mdlayher/vsock v1.1.1/go.mod h1:Y43jzcy7KM3QB+/FK15pfqGxDMCMzUXWegEfIbSM18U= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= diff --git a/internal/api/types.go b/internal/api/types.go index eb9ba2c..885ea56 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -63,6 +63,11 @@ type VMSSHResult struct { GuestIP string `json:"guest_ip"` } +type VMPingResult struct { + Name string `json:"name"` + Alive bool `json:"alive"` +} + type ImageBuildParams struct { Name string `json:"name,omitempty"` BaseRootfs string `json:"base_rootfs,omitempty"` diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 388f14f..457adc2 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -23,6 +24,7 @@ import ( "banger/internal/rpc" "banger/internal/system" "banger/internal/vmdns" + "banger/internal/vsockping" "github.com/spf13/cobra" ) @@ -32,7 +34,17 @@ var ( daemonExePath = func(pid int) string { return filepath.Join("/proc", fmt.Sprintf("%d", pid), "exe") } - doctorFunc = daemon.Doctor + doctorFunc = daemon.Doctor + sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { + sshCmd := exec.CommandContext(ctx, "ssh", args...) + sshCmd.Stdout = stdout + sshCmd.Stderr = stderr + 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}) + } ) func NewBangerCommand() *cobra.Command { @@ -454,11 +466,7 @@ func newVMSSHCommand() *cobra.Command { if err != nil { return err } - sshCmd := exec.CommandContext(cmd.Context(), "ssh", sshArgs...) - sshCmd.Stdout = cmd.OutOrStdout() - sshCmd.Stderr = cmd.ErrOrStderr() - sshCmd.Stdin = cmd.InOrStdin() - return sshCmd.Run() + return runSSHSession(cmd.Context(), layout.SocketPath, result.Name, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs) }, } } @@ -953,6 +961,36 @@ func validatePositiveSetting(label string, value int) error { return nil } +func runSSHSession(ctx context.Context, socketPath, vmRef string, stdin io.Reader, stdout, stderr io.Writer, sshArgs []string) error { + sshErr := sshExecFunc(ctx, stdin, stdout, stderr, sshArgs) + if !shouldCheckSSHReminder(sshErr) || ctx.Err() != nil { + return sshErr + } + pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + ping, err := vmPingFunc(pingCtx, socketPath, vmRef) + if err != nil { + _, _ = fmt.Fprintln(stderr, vsockping.WarningMessage(vmRef, err)) + return sshErr + } + if ping.Alive { + name := ping.Name + if strings.TrimSpace(name) == "" { + name = vmRef + } + _, _ = fmt.Fprintln(stderr, vsockping.ReminderMessage(name)) + } + return sshErr +} + +func shouldCheckSSHReminder(err error) bool { + if err == nil { + return true + } + var exitErr *exec.ExitError + return errors.As(err, &exitErr) +} + func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) { if guestIP == "" { return nil, errors.New("vm has no guest IP") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0a88163..bf9a4b6 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "errors" + "io" "os" + "os/exec" "path/filepath" "reflect" "strings" @@ -209,6 +211,56 @@ func TestVMSetParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) { } } +func TestRunSSHSessionPrintsReminderWhenPingAlive(t *testing.T) { + origSSHExec := sshExecFunc + origPing := vmPingFunc + t.Cleanup(func() { + sshExecFunc = origSSHExec + vmPingFunc = origPing + }) + + 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 + } + + var stderr bytes.Buffer + if err := runSSHSession(context.Background(), "/tmp/bangerd.sock", "devbox", strings.NewReader(""), &bytes.Buffer{}, &stderr, []string{"root@127.0.0.1"}); err != nil { + t.Fatalf("runSSHSession: %v", err) + } + if !strings.Contains(stderr.String(), "devbox is still running") { + t.Fatalf("stderr = %q, want reminder", stderr.String()) + } +} + +func TestRunSSHSessionPreservesSSHExitStatusOnPingWarning(t *testing.T) { + origSSHExec := sshExecFunc + origPing := vmPingFunc + t.Cleanup(func() { + sshExecFunc = origSSHExec + vmPingFunc = origPing + }) + + sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { + return &exec.ExitError{} + } + vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { + return api.VMPingResult{}, errors.New("dial failed") + } + + 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) { + t.Fatalf("runSSHSession error = %v, want exit error", err) + } + if !strings.Contains(stderr.String(), "failed to check whether devbox is still running") { + t.Fatalf("stderr = %q, want warning", stderr.String()) + } +} + func TestResolveVMTargetsDeduplicatesAndReportsErrors(t *testing.T) { vms := []model.VMRecord{ testCLIResolvedVM("alpha-id", "alpha"), diff --git a/internal/cli/tui.go b/internal/cli/tui.go index f67356f..7fc2d54 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -16,6 +16,7 @@ import ( "banger/internal/paths" "banger/internal/rpc" "banger/internal/system" + "banger/internal/vsockping" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" @@ -104,6 +105,7 @@ type externalPreparedMsg struct { action actionRequest command *exec.Cmd doneStatus string + done func(error) tea.Msg refresh bool err error } @@ -716,10 +718,14 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } cmds = append(cmds, tea.ExecProcess(msg.command, func(err error) tea.Msg { + err = normalizeExecError(err) + if msg.done != nil { + return msg.done(err) + } return actionResultMsg{ action: msg.action, status: msg.doneStatus, - err: normalizeExecError(err), + err: err, refresh: msg.refresh, focusID: m.selectedID, } @@ -1439,14 +1445,55 @@ func prepareSSHCmd(layout paths.Layout, cfg model.DaemonConfig, action actionReq return externalPreparedMsg{action: action, err: err} } return externalPreparedMsg{ - action: action, - command: exec.Command("ssh", args...), - doneStatus: fmt.Sprintf("ssh session ended for %s", result.Name), - refresh: true, + action: action, + command: exec.Command("ssh", args...), + done: func(execErr error) tea.Msg { + return sshDoneMsg(layout, action, result.Name, execErr) + }, + refresh: true, } } } +func sshDoneMsg(layout paths.Layout, action actionRequest, name string, execErr error) tea.Msg { + if execErr != nil { + return actionResultMsg{ + action: action, + err: execErr, + refresh: true, + focusID: action.id, + } + } + pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + ping, err := vmPingFunc(pingCtx, layout.SocketPath, name) + if err != nil { + return actionResultMsg{ + action: action, + status: vsockping.WarningMessage(name, err), + refresh: true, + focusID: action.id, + } + } + if ping.Alive { + if strings.TrimSpace(ping.Name) != "" { + name = ping.Name + } + return actionResultMsg{ + action: action, + status: vsockping.ReminderMessage(name), + refresh: true, + focusID: action.id, + } + } + return actionResultMsg{ + action: action, + status: fmt.Sprintf("ssh session ended for %s", name), + refresh: true, + focusID: action.id, + } +} + func prepareLogsCmd(layout paths.Layout, action actionRequest) tea.Cmd { return func() tea.Msg { result, err := rpc.Call[api.VMLogsResult](context.Background(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: action.id}) diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 6ae077e..ea30a0d 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -2,12 +2,14 @@ package cli import ( "context" + "errors" "os" "path/filepath" "strings" "testing" "time" + "banger/internal/api" "banger/internal/model" "banger/internal/paths" @@ -236,6 +238,41 @@ func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) { } } +func TestSSHDoneMsgShowsReminderWhenPingAlive(t *testing.T) { + origPing := vmPingFunc + t.Cleanup(func() { + vmPingFunc = origPing + }) + vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { + return api.VMPingResult{Name: "devbox", Alive: true}, nil + } + + msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil) + result, ok := msg.(actionResultMsg) + if !ok { + t.Fatalf("msg = %T, want actionResultMsg", msg) + } + if !strings.Contains(result.status, "devbox is still running") { + t.Fatalf("status = %q, want reminder", result.status) + } +} + +func TestSSHDoneMsgShowsWarningWhenPingFails(t *testing.T) { + origPing := vmPingFunc + t.Cleanup(func() { + vmPingFunc = origPing + }) + vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) { + return api.VMPingResult{}, errors.New("dial failed") + } + + msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil) + result := msg.(actionResultMsg) + if !strings.Contains(result.status, "failed to check whether devbox is still running") { + t.Fatalf("status = %q, want warning", result.status) + } +} + func TestAggregateRunningVMResources(t *testing.T) { t.Parallel() diff --git a/internal/config/config.go b/internal/config/config.go index 6fbd4e3..475f520 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type fileConfig struct { SSHKeyPath string `toml:"ssh_key_path"` NamegenPath string `toml:"namegen_path"` CustomizeScript string `toml:"customize_script"` + VSockPingHelper string `toml:"vsock_ping_helper_path"` DefaultImageName string `toml:"default_image_name"` DefaultRootfs string `toml:"default_rootfs"` DefaultBaseRootfs string `toml:"default_base_rootfs"` @@ -87,6 +88,9 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { if file.CustomizeScript != "" { cfg.CustomizeScript = file.CustomizeScript } + if file.VSockPingHelper != "" { + cfg.VSockPingHelperPath = file.VSockPingHelper + } if file.DefaultImageName != "" { cfg.DefaultImageName = file.DefaultImageName } @@ -180,6 +184,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.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, runtimeDir, meta.DefaultKernel) cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, runtimeDir, meta.DefaultInitrd) cfg.DefaultModulesDir = defaultRuntimePath(cfg.DefaultModulesDir, runtimeDir, meta.DefaultModulesDir) @@ -193,6 +198,7 @@ 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.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, cfg.RuntimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, cfg.RuntimeDir, "wtf/root/boot/initrd.img-6.8.0-94-generic") cfg.DefaultModulesDir = defaultRuntimePath(cfg.DefaultModulesDir, cfg.RuntimeDir, "wtf/root/lib/modules/6.8.0-94-generic") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 39ddc7c..df932c2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -13,21 +13,23 @@ 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", - DefaultPackages: "config/packages.apt", - DefaultRootfs: "images/rootfs-docker.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", + VSockPingHelperPath: "bin/banger-vsock-pingd", + DefaultPackages: "config/packages.apt", + DefaultRootfs: "images/rootfs-docker.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.DefaultPackages, meta.DefaultRootfs, meta.DefaultKernel, @@ -71,6 +73,9 @@ 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.DefaultRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) { t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs) } @@ -98,6 +103,7 @@ func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) { "id_ed25519", "namegen", "customize.sh", + "banger-vsock-pingd", "packages.apt", "rootfs-docker.ext4", "wtf/root/boot/vmlinux-6.8.0-94-generic", @@ -122,6 +128,9 @@ 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.DefaultKernel != filepath.Join(runtimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") { t.Fatalf("DefaultKernel = %q", cfg.DefaultKernel) } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 4fc07f5..941fe48 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -323,6 +323,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.ping": + params, err := rpc.DecodeParams[api.VMRefParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + result, err := d.PingVM(ctx, params.IDOrName) + return marshalResultOrError(result, err) case "image.list": images, err := d.store.ListImages(ctx) return marshalResultOrError(api.ImageListResult{Images: images}, err) diff --git a/internal/daemon/doctor.go b/internal/daemon/doctor.go index b4a39bc..e3248d8 100644 --- a/internal/daemon/doctor.go +++ b/internal/daemon/doctor.go @@ -33,6 +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") d.addCapabilityDoctorChecks(ctx, &report) report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available") @@ -44,6 +45,7 @@ func (d *Daemon) runtimeBundleChecks() *system.Preflight { 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.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) != "" { @@ -75,6 +77,13 @@ func (d *Daemon) imageBuildChecks(ctx context.Context) *system.Preflight { return checks } +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.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host") + return checks +} + func runtimeBundleStatus(cfg model.DaemonConfig) string { if strings.TrimSpace(cfg.RuntimeDir) == "" { return "runtime dir not configured" diff --git a/internal/daemon/imagebuild.go b/internal/daemon/imagebuild.go index 2b01a0c..01fdce4 100644 --- a/internal/daemon/imagebuild.go +++ b/internal/daemon/imagebuild.go @@ -17,6 +17,7 @@ import ( "banger/internal/hostnat" "banger/internal/model" "banger/internal/system" + "banger/internal/vsockping" ) const ( @@ -103,6 +104,16 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) ( } defer client.Close() + helperBytes, err := os.ReadFile(d.config.VSockPingHelperPath) + if err != nil { + return err + } + if err := writeBuildLog(spec.BuildLog, "installing vsock ping helper"); err != nil { + return err + } + if err := client.UploadFile(ctx, vsockping.GuestInstallPath, 0o755, helperBytes, spec.BuildLog); err != nil { + return err + } if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil { return err } @@ -207,7 +218,7 @@ func (d *Daemon) startImageBuildVM(ctx context.Context, spec imageBuildSpec) (im return imageBuildVM{}, nil, err } vm.PID = d.resolveFirecrackerPID(firecrackerCtx, machine, vm.APISock) - if err := d.ensureSocketAccess(ctx, vm.APISock); err != nil { + if err := d.ensureSocketAccess(ctx, vm.APISock, "firecracker api socket"); err != nil { _ = d.killVMProcess(context.Background(), vm.PID) _ = hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, false) _, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice) @@ -255,6 +266,7 @@ func buildProvisionScript(vmName, dnsServer string, packages []string, installDo script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y install \"${PACKAGES[@]}\"\n") appendMiseSetup(&script) appendTmuxSetup(&script) + appendVSockPingSetup(&script) if installDocker { script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y remove containerd || true\n") script.WriteString("if ! DEBIAN_FRONTEND=noninteractive apt-get -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; then\n") @@ -318,6 +330,19 @@ func appendTmuxSetup(script *bytes.Buffer) { script.WriteString("chmod 0644 \"$TMUX_CONF\"\n") } +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("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("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") +} + func appendGitRepo(script *bytes.Buffer, dir, repo string) { fmt.Fprintf(script, "if [[ -d \"%s/.git\" ]]; then\n", dir) fmt.Fprintf(script, " git -C \"%s\" fetch --depth 1 origin\n", dir) diff --git a/internal/daemon/imagebuild_test.go b/internal/daemon/imagebuild_test.go index 0a241cc..2ad1d4c 100644 --- a/internal/daemon/imagebuild_test.go +++ b/internal/daemon/imagebuild_test.go @@ -26,6 +26,11 @@ func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) { "set -g @continuum-restore 'off'", "set -g @resurrect-dir '/root/.tmux/resurrect'", "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", "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/logger_test.go b/internal/daemon/logger_test.go index 9ed2b0d..b8667dd 100644 --- a/internal/daemon/logger_test.go +++ b/internal/daemon/logger_test.go @@ -43,6 +43,11 @@ func TestNewDaemonLoggerEmitsJSONAtConfiguredLevel(t *testing.T) { func TestStartVMLockedLogsBridgeFailure(t *testing.T) { ctx := context.Background() + origVsockHostDevicePath := vsockHostDevicePath + vsockHostDevicePath = filepath.Join(t.TempDir(), "vhost-vsock") + t.Cleanup(func() { + vsockHostDevicePath = origVsockHostDevicePath + }) binDir := t.TempDir() for _, name := range []string{ "sudo", "ip", "dmsetup", "losetup", "blockdev", "truncate", "pgrep", "ps", @@ -54,9 +59,16 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) { t.Setenv("PATH", binDir) firecrackerBin := filepath.Join(t.TempDir(), "firecracker") + vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-pingd") if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatalf("write firecracker: %v", err) } + if err := os.WriteFile(vsockHostDevicePath, []byte{}, 0o644); err != nil { + t.Fatalf("write vsock host device: %v", err) + } + if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write vsock helper: %v", err) + } rootfsPath := filepath.Join(t.TempDir(), "rootfs.ext4") kernelPath := filepath.Join(t.TempDir(), "vmlinux") for _, path := range []string{rootfsPath, kernelPath} { @@ -93,11 +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, - StatsPollInterval: model.DefaultStatsPollInterval, + BridgeName: "br-fc", + BridgeIP: model.DefaultBridgeIP, + DefaultDNS: model.DefaultDNS, + FirecrackerBin: firecrackerBin, + VSockPingHelperPath: vsockHelper, + StatsPollInterval: model.DefaultStatsPollInterval, }, runner: runner, logger: logger, @@ -138,11 +151,15 @@ 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") 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) } } + if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write %s: %v", vsockHelper, err) + } if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatalf("write %s: %v", firecrackerBin, err) } @@ -169,6 +186,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { DefaultPackagesFile: packagesPath, SSHKeyPath: sshKeyPath, FirecrackerBin: firecrackerBin, + VSockPingHelperPath: vsockHelper, }, store: store, runner: runner, diff --git a/internal/daemon/preflight.go b/internal/daemon/preflight.go index d72cf3e..4e7fa22 100644 --- a/internal/daemon/preflight.go +++ b/internal/daemon/preflight.go @@ -9,6 +9,8 @@ import ( "banger/internal/system" ) +var vsockHostDevicePath = "/dev/vhost-vsock" + func (d *Daemon) validateStartPrereqs(ctx context.Context, vm model.VMRecord, image model.Image) error { checks := system.NewPreflight() d.addBaseStartPrereqs(checks, image) @@ -52,6 +54,8 @@ 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.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`) if strings.TrimSpace(image.InitrdPath) != "" { @@ -73,6 +77,7 @@ func (d *Daemon) addImageBuildPrereqs(ctx context.Context, checks *system.Prefli } 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(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 5207f24..7ea81ff 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net" "os" "path/filepath" "strconv" @@ -75,6 +76,10 @@ func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm mo if err := os.MkdirAll(vmDir, 0o755); err != nil { return model.VMRecord{}, err } + vsockCID, err := defaultVSockCID(guestIP) + if err != nil { + return model.VMRecord{}, err + } systemOverlaySize := int64(model.DefaultSystemOverlaySize) if params.SystemOverlaySize != "" { systemOverlaySize, err = model.ParseSize(params.SystemOverlaySize) @@ -111,6 +116,8 @@ func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm mo GuestIP: guestIP, DNSName: vmdns.RecordName(name), VMDir: vmDir, + VSockPath: defaultVSockPath(d.layout.RuntimeDir, id), + VSockCID: vsockCID, SystemOverlay: filepath.Join(vmDir, "system.cow"), WorkDiskPath: filepath.Join(vmDir, "root.ext4"), LogPath: filepath.Join(vmDir, "firecracker.log"), @@ -183,9 +190,21 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod apiSock := filepath.Join(d.layout.RuntimeDir, "fc-"+shortID+".sock") tap := "tap-fc-" + shortID dmName := "fc-rootfs-" + shortID + if strings.TrimSpace(vm.Runtime.VSockPath) == "" { + vm.Runtime.VSockPath = defaultVSockPath(d.layout.RuntimeDir, vm.ID) + } + if vm.Runtime.VSockCID == 0 { + vm.Runtime.VSockCID, err = defaultVSockCID(vm.Runtime.GuestIP) + if err != nil { + return model.VMRecord{}, err + } + } if err := os.RemoveAll(apiSock); err != nil && !os.IsNotExist(err) { return model.VMRecord{}, err } + if err := os.RemoveAll(vm.Runtime.VSockPath); err != nil && !os.IsNotExist(err) { + return model.VMRecord{}, err + } op.stage("system_overlay", "overlay_path", vm.Runtime.SystemOverlay) if err := d.ensureSystemOverlay(ctx, &vm); err != nil { @@ -260,6 +279,8 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod IsRoot: true, }}, TapDevice: tap, + VSockPath: vm.Runtime.VSockPath, + VSockCID: vm.Runtime.VSockCID, VCPUCount: vm.Spec.VCPUCount, MemoryMiB: vm.Spec.MemoryMiB, Logger: d.logger, @@ -276,7 +297,11 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod vm.Runtime.PID = d.resolveFirecrackerPID(firecrackerCtx, machine, apiSock) op.debugStage("firecracker_started", "pid", vm.Runtime.PID) op.stage("socket_access", "api_socket", apiSock) - if err := d.ensureSocketAccess(ctx, apiSock); err != nil { + if err := d.ensureSocketAccess(ctx, apiSock, "firecracker api socket"); err != nil { + return cleanupOnErr(err) + } + op.stage("vsock_access", "vsock_path", vm.Runtime.VSockPath, "vsock_cid", vm.Runtime.VSockCID) + if err := d.ensureSocketAccess(ctx, vm.Runtime.VSockPath, "firecracker vsock socket"); err != nil { return cleanupOnErr(err) } op.stage("post_start_features") @@ -556,6 +581,33 @@ 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) { + _, 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 + return vm, nil + } + if strings.TrimSpace(vm.Runtime.VSockPath) == "" { + return model.VMRecord{}, errors.New("vm has no vsock path") + } + if vm.Runtime.VSockCID == 0 { + return model.VMRecord{}, errors.New("vm has no vsock cid") + } + if err := d.ensureSocketAccess(ctx, vm.Runtime.VSockPath, "firecracker vsock socket"); err != nil { + return model.VMRecord{}, err + } + pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + if err := firecracker.PingVSock(pingCtx, d.logger, vm.Runtime.VSockPath); err != nil { + return model.VMRecord{}, err + } + result.Alive = true + return vm, nil + }) + return result, err +} + func (d *Daemon) getVMStatsLocked(ctx context.Context, vm model.VMRecord) (model.VMRecord, error) { stats, err := d.collectStats(ctx, vm) if err == nil { @@ -812,11 +864,14 @@ func (d *Daemon) firecrackerBinary() (string, error) { return path, nil } -func (d *Daemon) ensureSocketAccess(ctx context.Context, apiSock string) error { - if _, err := d.runner.RunSudo(ctx, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), apiSock); err != nil { +func (d *Daemon) ensureSocketAccess(ctx context.Context, socketPath, label string) error { + if err := waitForPath(ctx, socketPath, 5*time.Second, label); err != nil { return err } - _, err := d.runner.RunSudo(ctx, "chmod", "600", apiSock) + if _, err := d.runner.RunSudo(ctx, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), socketPath); err != nil { + return err + } + _, err := d.runner.RunSudo(ctx, "chmod", "600", socketPath) return err } @@ -841,7 +896,7 @@ func (d *Daemon) resolveFirecrackerPID(ctx context.Context, machine *firecracker } func (d *Daemon) sendCtrlAltDel(ctx context.Context, vm model.VMRecord) error { - if err := d.ensureSocketAccess(ctx, vm.Runtime.APISockPath); err != nil { + if err := d.ensureSocketAccess(ctx, vm.Runtime.APISockPath, "firecracker api socket"); err != nil { return err } client := firecracker.New(vm.Runtime.APISockPath, d.logger) @@ -887,6 +942,9 @@ func (d *Daemon) cleanupRuntime(ctx context.Context, vm model.VMRecord, preserve if vm.Runtime.APISockPath != "" { _ = os.Remove(vm.Runtime.APISockPath) } + if vm.Runtime.VSockPath != "" { + _ = os.Remove(vm.Runtime.VSockPath) + } snapshotErr := d.cleanupDMSnapshot(ctx, dmSnapshotHandles{ BaseLoop: vm.Runtime.BaseLoop, COWLoop: vm.Runtime.COWLoop, @@ -910,6 +968,37 @@ func clearRuntimeHandles(vm *model.VMRecord) { vm.Runtime.DMDev = "" } +func defaultVSockPath(runtimeDir, vmID string) string { + return filepath.Join(runtimeDir, "fc-"+system.ShortID(vmID)+".vsock") +} + +func defaultVSockCID(guestIP string) (uint32, error) { + ip := net.ParseIP(strings.TrimSpace(guestIP)).To4() + if ip == nil { + return 0, fmt.Errorf("guest IP is not IPv4: %q", guestIP) + } + return 10000 + uint32(ip[3]), nil +} + +func waitForPath(ctx context.Context, path string, timeout time.Duration, label string) error { + deadline := time.Now().Add(timeout) + for { + if _, err := os.Stat(path); err == nil { + return nil + } else if err != nil && !os.IsNotExist(err) { + return err + } + if time.Now().After(deadline) { + return fmt.Errorf("%s not ready: %s: %w", label, path, context.DeadlineExceeded) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} + func (d *Daemon) setDNS(ctx context.Context, vmName, guestIP string) error { if d.vmDNS == nil { return nil diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index 79dbe22..5b2441c 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -261,6 +261,114 @@ func TestSetVMRejectsStoppedOnlyChangesForRunningVM(t *testing.T) { } } +func TestPingVMReturnsAliveForRunningGuest(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) + }) + serverDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverDone <- err + return + } + defer conn.Close() + buf := make([]byte, 128) + n, err := conn.Read(buf) + if err != nil { + serverDone <- err + return + } + if got := string(buf[:n]); got != "CONNECT 42070\n" { + serverDone <- fmt.Errorf("unexpected connect message %q", got) + return + } + if _, err := conn.Write([]byte("OK 1\n")); err != nil { + serverDone <- err + return + } + n, err = conn.Read(buf) + if err != nil { + serverDone <- err + return + } + if got := string(buf[:n]); got != "PING\n" { + serverDone <- fmt.Errorf("unexpected ping payload %q", got) + return + } + _, err = conn.Write([]byte("PONG\n")) + serverDone <- err + }() + + vm := testVM("alive", "image-alive", "172.16.0.41") + 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 = 10041 + if err := db.UpsertVM(ctx, vm); err != nil { + t.Fatalf("UpsertVM: %v", err) + } + + 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 || result.Name != vm.Name { + t.Fatalf("PingVM result = %+v, want alive %s", result, vm.Name) + } + runner.assertExhausted() + if err := <-serverDone; err != nil { + t.Fatalf("server: %v", err) + } +} + +func TestPingVMReturnsFalseForStoppedVM(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db := openDaemonStore(t) + vm := testVM("stopped-ping", "image-stopped", "172.16.0.42") + if err := db.UpsertVM(ctx, vm); err != nil { + t.Fatalf("UpsertVM: %v", err) + } + + d := &Daemon{store: db} + result, err := d.PingVM(ctx, vm.Name) + if err != nil { + t.Fatalf("PingVM: %v", err) + } + if result.Alive { + t.Fatalf("PingVM result = %+v, want not alive", result) + } +} + func TestSetVMDiskResizeFailsPreflightWhenToolsMissing(t *testing.T) { ctx := context.Background() db := openDaemonStore(t) diff --git a/internal/firecracker/client.go b/internal/firecracker/client.go index d95774c..963941e 100644 --- a/internal/firecracker/client.go +++ b/internal/firecracker/client.go @@ -1,17 +1,23 @@ 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" ) type MachineConfig struct { @@ -25,6 +31,8 @@ type MachineConfig struct { KernelArgs string Drives []DriveConfig TapDevice string + VSockPath string + VSockCID uint32 VCPUCount int MemoryMiB int Logger *slog.Logger @@ -132,6 +140,7 @@ func buildConfig(cfg MachineConfig) sdk.Config { HostDevName: cfg.TapDevice, }, }}, + VsockDevices: buildVsockDevices(cfg), MachineCfg: models.MachineConfiguration{ VcpuCount: sdk.Int64(int64(cfg.VCPUCount)), MemSizeMib: sdk.Int64(int64(cfg.MemoryMiB)), @@ -141,6 +150,17 @@ func buildConfig(cfg MachineConfig) sdk.Config { } } +func buildVsockDevices(cfg MachineConfig) []sdk.VsockDevice { + if strings.TrimSpace(cfg.VSockPath) == "" || cfg.VSockCID == 0 { + return nil + } + return []sdk.VsockDevice{{ + ID: "vsock", + Path: cfg.VSockPath, + CID: cfg.VSockCID, + }} +} + func splitDrives(drives []DriveConfig) (DriveConfig, []DriveConfig) { root := DriveConfig{ID: "rootfs"} var extras []DriveConfig @@ -192,6 +212,39 @@ func newLogger(base *slog.Logger) *logrus.Entry { return logrus.NewEntry(logger) } +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 +} + type slogHook struct { logger *slog.Logger } diff --git a/internal/firecracker/client_test.go b/internal/firecracker/client_test.go index aa3a720..4588f30 100644 --- a/internal/firecracker/client_test.go +++ b/internal/firecracker/client_test.go @@ -2,9 +2,13 @@ package firecracker import ( "bytes" + "context" "log/slog" + "net" + "path/filepath" "strings" "testing" + "time" ) func TestBuildConfig(t *testing.T) { @@ -21,6 +25,8 @@ func TestBuildConfig(t *testing.T) { {ID: "work", Path: "/var/lib/banger/root.ext4"}, }, TapDevice: "tap-fc-1", + VSockPath: "/tmp/fc.vsock", + VSockCID: 10042, VCPUCount: 4, MemoryMiB: 2048, }) @@ -46,6 +52,12 @@ func TestBuildConfig(t *testing.T) { if len(cfg.NetworkInterfaces) != 1 { t.Fatalf("interface count = %d, want 1", len(cfg.NetworkInterfaces)) } + if len(cfg.VsockDevices) != 1 { + t.Fatalf("vsock count = %d, want 1", len(cfg.VsockDevices)) + } + if cfg.VsockDevices[0].Path != "/tmp/fc.vsock" || cfg.VsockDevices[0].CID != 10042 { + t.Fatalf("unexpected vsock config: %+v", cfg.VsockDevices[0]) + } if got := cfg.NetworkInterfaces[0].StaticConfiguration.HostDevName; got != "tap-fc-1" { t.Fatalf("host dev name = %q", got) } @@ -115,3 +127,81 @@ func TestSDKLoggerBridgeSuppressesDebugAtInfoLevel(t *testing.T) { t.Fatalf("expected info-level logger to suppress sdk debug chatter, got %q", buf.String()) } } + +func TestPingVSock(t *testing.T) { + 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, 64) + tmp := make([]byte, 64) + 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 <- errUnexpectedString(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), "\n") { + break + } + } + if got := string(buf); got != "PING\n" { + done <- errUnexpectedString(got) + return + } + _, err = conn.Write([]byte("PONG\n")) + 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 := <-done; err != nil { + t.Fatalf("server: %v", err) + } +} + +type unexpectedStringError string + +func (e unexpectedStringError) Error() string { + return "unexpected string: " + string(e) +} + +func errUnexpectedString(value string) error { + return unexpectedStringError(value) +} diff --git a/internal/guest/ssh.go b/internal/guest/ssh.go index 3713f58..f03853e 100644 --- a/internal/guest/ssh.go +++ b/internal/guest/ssh.go @@ -2,6 +2,7 @@ package guest import ( "archive/tar" + "bytes" "context" "errors" "fmt" @@ -73,6 +74,11 @@ func (c *Client) RunScript(ctx context.Context, script string, logWriter io.Writ return c.runSession(ctx, "bash -se", strings.NewReader(script), logWriter) } +func (c *Client) UploadFile(ctx context.Context, remotePath string, mode os.FileMode, data []byte, logWriter io.Writer) error { + command := fmt.Sprintf("install -D -m %04o /dev/stdin %s", mode.Perm(), shellQuote(remotePath)) + return c.runSession(ctx, command, bytes.NewReader(data), logWriter) +} + func (c *Client) StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error { reader, writer := io.Pipe() writeErr := make(chan error, 1) @@ -123,6 +129,10 @@ func privateKeySigner(path string) (ssh.Signer, error) { return ssh.ParsePrivateKey(data) } +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + func writeTarArchive(dst io.Writer, sourceDir string) error { tw := tar.NewWriter(dst) defer tw.Close() diff --git a/internal/model/types.go b/internal/model/types.go index 1536746..cd14bff 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -41,6 +41,7 @@ type DaemonConfig struct { SSHKeyPath string NamegenPath string CustomizeScript string + VSockPingHelperPath string AutoStopStaleAfter time.Duration StatsPollInterval time.Duration MetricsPollInterval time.Duration @@ -87,6 +88,8 @@ type VMRuntime struct { GuestIP string `json:"guest_ip"` TapDevice string `json:"tap_device,omitempty"` APISockPath string `json:"api_sock_path,omitempty"` + VSockPath string `json:"vsock_path,omitempty"` + VSockCID uint32 `json:"vsock_cid,omitempty"` LogPath string `json:"log_path,omitempty"` MetricsPath string `json:"metrics_path,omitempty"` DNSName string `json:"dns_name,omitempty"` diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go index 9a0ba9e..aef90b0 100644 --- a/internal/paths/paths_test.go +++ b/internal/paths/paths_test.go @@ -56,19 +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", - 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", + VSockPingHelperPath: "bin/banger-vsock-pingd", + 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.DefaultPackages, metadata.DefaultRootfs, metadata.DefaultKernel, diff --git a/internal/runtimebundle/bundle.go b/internal/runtimebundle/bundle.go index 02546b0..9dbea9c 100644 --- a/internal/runtimebundle/bundle.go +++ b/internal/runtimebundle/bundle.go @@ -30,16 +30,17 @@ type Manifest struct { } type BundleMetadata struct { - FirecrackerBin string `json:"firecracker_bin" toml:"firecracker_bin"` - 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"` - 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"` - DefaultKernel string `json:"default_kernel" toml:"default_kernel"` - DefaultInitrd string `json:"default_initrd,omitempty" toml:"default_initrd"` - DefaultModulesDir string `json:"default_modules_dir,omitempty" toml:"default_modules_dir"` + FirecrackerBin string `json:"firecracker_bin" toml:"firecracker_bin"` + 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"` + 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"` + DefaultKernel string `json:"default_kernel" toml:"default_kernel"` + DefaultInitrd string `json:"default_initrd,omitempty" toml:"default_initrd"` + DefaultModulesDir string `json:"default_modules_dir,omitempty" toml:"default_modules_dir"` } const BundleMetadataFile = "bundle.json" @@ -209,6 +210,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.DefaultPackages, "default_packages_file"}, {meta.DefaultRootfs, "default_rootfs"}, {meta.DefaultKernel, "default_kernel"}, @@ -227,6 +229,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.DefaultPackages, "default_packages_file", true}, {meta.DefaultRootfs, "default_rootfs", true}, {meta.DefaultBaseRootfs, "default_base_rootfs", false}, @@ -264,6 +267,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.DefaultPackages) == "" && strings.TrimSpace(meta.DefaultRootfs) == "" && strings.TrimSpace(meta.DefaultBaseRootfs) == "" && @@ -283,6 +287,7 @@ func normalizeBundleMetadata(meta BundleMetadata) BundleMetadata { meta.SSHKeyPath = strings.TrimSpace(meta.SSHKeyPath) meta.NamegenPath = strings.TrimSpace(meta.NamegenPath) meta.CustomizeScript = strings.TrimSpace(meta.CustomizeScript) + meta.VSockPingHelperPath = strings.TrimSpace(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 b4683b4..2b91825 100644 --- a/internal/runtimebundle/bundle_test.go +++ b/internal/runtimebundle/bundle_test.go @@ -20,6 +20,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) { "runtime/firecracker": "fc", "runtime/id_ed25519": "key", "runtime/namegen": "namegen", + "runtime/banger-vsock-pingd": "pingd", "runtime/customize.sh": "#!/bin/bash\n", "runtime/packages.sh": "#!/bin/bash\n", "runtime/packages.apt": "vim\n", @@ -27,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", 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", 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"}), }) archivePath := filepath.Join(manifestDir, "bundle.tar.gz") if err := os.WriteFile(archivePath, bundleData, 0o644); err != nil { @@ -38,7 +39,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) { URL: "./bundle.tar.gz", SHA256: sha256Hex(bundleData), BundleRoot: "runtime", - RequiredPaths: []string{"firecracker", "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-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"}, } outDir := filepath.Join(t.TempDir(), "runtime") if err := Bootstrap(context.Background(), manifest, filepath.Join(manifestDir, "runtime-bundle.toml"), outDir); err != nil { @@ -99,6 +100,7 @@ func TestPackageWritesArchive(t *testing.T) { "firecracker", "id_ed25519", "namegen", + "banger-vsock-pingd", "customize.sh", "packages.apt", "rootfs-docker.ext4", @@ -126,20 +128,22 @@ func TestPackageWritesArchive(t *testing.T) { manifest := Manifest{ BundleRoot: "runtime", BundleMeta: BundleMetadata{ - FirecrackerBin: "firecracker", - SSHKeyPath: "id_ed25519", - NamegenPath: "namegen", - CustomizeScript: "customize.sh", - 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", + 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", }, RequiredPaths: []string{ "firecracker", "id_ed25519", "namegen", + "banger-vsock-pingd", "customize.sh", "packages.apt", "rootfs-docker.ext4", @@ -182,7 +186,7 @@ func TestPackageWritesArchive(t *testing.T) { func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) { runtimeDir := t.TempDir() - for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "customize.sh", "packages.apt", "rootfs-docker.ext4"} { + for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "banger-vsock-pingd", "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) @@ -192,13 +196,14 @@ func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) { } } data := mustJSON(t, BundleMetadata{ - FirecrackerBin: "firecracker", - SSHKeyPath: "id_ed25519", - NamegenPath: "namegen", - CustomizeScript: "customize.sh", - DefaultPackages: "packages.apt", - DefaultRootfs: "rootfs-docker.ext4", - DefaultKernel: "missing-kernel", + FirecrackerBin: "firecracker", + SSHKeyPath: "id_ed25519", + NamegenPath: "namegen", + CustomizeScript: "customize.sh", + VSockPingHelperPath: "banger-vsock-pingd", + 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) diff --git a/internal/vsockping/vsockping.go b/internal/vsockping/vsockping.go new file mode 100644 index 0000000..658a9de --- /dev/null +++ b/internal/vsockping/vsockping.go @@ -0,0 +1,105 @@ +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/runtime-bundle.toml b/runtime-bundle.toml index 2e8075d..bb35d6e 100644 --- a/runtime-bundle.toml +++ b/runtime-bundle.toml @@ -10,6 +10,7 @@ required_paths = [ "customize.sh", "packages.sh", "namegen", + "banger-vsock-pingd", "packages.apt", "id_ed25519", "rootfs-docker.ext4", @@ -23,6 +24,7 @@ firecracker_bin = "firecracker" ssh_key_path = "id_ed25519" namegen_path = "namegen" customize_script = "customize.sh" +vsock_ping_helper_path = "banger-vsock-pingd" default_packages_file = "packages.apt" default_rootfs = "rootfs-docker.ext4" default_kernel = "wtf/root/boot/vmlinux-6.8.0-94-generic"