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.
This commit is contained in:
Thales Maciel 2026-03-18 20:14:51 -03:00
parent 4930d82cb9
commit 08ef706e3f
No known key found for this signature in database
GPG key ID: 33112E6833C34679
31 changed files with 912 additions and 75 deletions

View file

@ -9,11 +9,11 @@
- The daemon keeps state under XDG directories rather than the old repo-local `state/` layout. - The daemon keeps state under XDG directories rather than the old repo-local `state/` layout.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `make build` builds `./banger` 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. - `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` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set.
- `./banger vm create --name testbox` creates and starts a VM. - `./banger vm create --name testbox` creates and starts a VM.
- `./banger vm ssh testbox` connects to a running guest. - `./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 testbox` stops a VM while preserving its disks.
- `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI. - `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI.
- `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon. - `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon.
@ -31,7 +31,7 @@
- Primary automated coverage is `go test ./...`. - Primary automated coverage is `go test ./...`.
- Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM. - Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM.
- For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke. - For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke.
- Rebuilt images now include `mise`, `opencode`, 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`. - 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`. - For NAT changes, verify both guest outbound access and host rule cleanup, for example with `./verify.sh --nat`.

View file

@ -12,8 +12,9 @@ RUNTIME_MANIFEST ?= runtime-bundle.toml
RUNTIME_SOURCE_DIR ?= runtime RUNTIME_SOURCE_DIR ?= runtime
RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz
BINARIES := banger bangerd BINARIES := banger bangerd
RUNTIME_HELPERS := $(RUNTIME_SOURCE_DIR)/banger-vsock-pingd
GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort) GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort)
RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen banger-vsock-pingd
RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4 RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4
RUNTIME_OPTIONAL_DATA_FILES := rootfs.ext4 bundle.json 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 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 clean Remove built Go binaries' \
' make rootfs Rebuild the source-checkout default rootfs image in ./runtime' ' make rootfs Rebuild the source-checkout default rootfs image in ./runtime'
build: $(BINARIES) build: $(BINARIES) $(RUNTIME_HELPERS)
banger: $(GO_SOURCES) go.mod go.sum banger: $(GO_SOURCES) go.mod go.sum
$(GO) build -o ./banger ./cmd/banger $(GO) build -o ./banger ./cmd/banger
@ -44,6 +45,10 @@ banger: $(GO_SOURCES) go.mod go.sum
bangerd: $(GO_SOURCES) go.mod go.sum bangerd: $(GO_SOURCES) go.mod go.sum
$(GO) build -o ./bangerd ./cmd/bangerd $(GO) build -o ./bangerd ./cmd/bangerd
$(RUNTIME_SOURCE_DIR)/banger-vsock-pingd: $(GO_SOURCES) go.mod go.sum
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: test:
$(GO) test ./... $(GO) test ./...

View file

@ -4,6 +4,7 @@ Persistent Firecracker development VMs managed through a Go daemon, CLI, and TUI
## Requirements ## Requirements
- Linux host with KVM (`/dev/kvm` access) - 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` - Core VM lifecycle: `sudo`, `ip`, `dmsetup`, `losetup`, `blockdev`, `truncate`, `pgrep`, `chown`, `chmod`, `kill`
- Guest rootfs patching: `e2cp`, `e2rm`, `debugfs` - Guest rootfs patching: `e2cp`, `e2rm`, `debugfs`
- Guest work disk creation/resizing: `mkfs.ext4`, `e2fsck`, `resize2fs`, `mount`, `umount`, `cp` - 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: The bundle contains:
- `firecracker` - `firecracker`
- `banger-vsock-pingd` for the guest-side SSH reminder responder
- `bundle.json` with the bundle's default kernel/initrd/modules/rootfs paths - `bundle.json` with the bundle's default kernel/initrd/modules/rootfs paths
- a kernel, initrd, and modules tree referenced by `bundle.json` - a kernel, initrd, and modules tree referenced by `bundle.json`
- `rootfs-docker.ext4` - `rootfs-docker.ext4`
@ -64,7 +66,8 @@ URL. `make install` will not fetch artifacts for you.
make build 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 Install into `~/.local/bin` by default, with the runtime bundle under
`~/.local/lib/banger`: `~/.local/lib/banger`:
@ -102,6 +105,9 @@ SSH into a running VM:
banger vm ssh calm-otter 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: Stop, restart, kill, or delete it:
```bash ```bash
banger vm stop calm-otter banger vm stop calm-otter
@ -160,6 +166,7 @@ Useful config keys:
- `ssh_key_path` - `ssh_key_path`
- `namegen_path` - `namegen_path`
- `customize_script` (manual helper compatibility; `banger image build` is Go-native) - `customize_script` (manual helper compatibility; `banger image build` is Go-native)
- `vsock_ping_helper_path`
- `default_rootfs` - `default_rootfs`
- `default_base_rootfs` - `default_base_rootfs`
- `default_kernel` - `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 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 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: Show or delete images:
```bash ```bash

View file

@ -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)
}
}

View file

@ -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")" KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")"
INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")" INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")"
SSH_KEY="$RUNTIME_DIR/id_ed25519" SSH_KEY="$RUNTIME_DIR/id_ed25519"
VSOCK_PING_HELPER="$(bundle_path vsock_ping_helper_path "$RUNTIME_DIR/banger-vsock-pingd")"
BR_DEV="br-fc" BR_DEV="br-fc"
BR_IP="172.16.0.1" BR_IP="172.16.0.1"
@ -207,6 +208,11 @@ if [[ ! -f "$PACKAGES_FILE" ]]; then
log "package manifest not found: $PACKAGES_FILE" log "package manifest not found: $PACKAGES_FILE"
exit 1 exit 1
fi fi
if [[ ! -x "$VSOCK_PING_HELPER" ]]; then
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=() APT_PACKAGES=()
if ! banger_packages_read_array APT_PACKAGES "$PACKAGES_FILE"; then if ! banger_packages_read_array APT_PACKAGES "$PACKAGES_FILE"; then
@ -382,6 +388,10 @@ if [[ "$SSH_READY" -ne 1 ]]; then
fi fi
log "configuring guest" 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 \ ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"root@${GUEST_IP}" bash -lc "set -e "root@${GUEST_IP}" bash -lc "set -e
printf 'nameserver %s\n' \"$DNS_SERVER\" > /etc/resolv.conf printf 'nameserver %s\n' \"$DNS_SERVER\" > /etc/resolv.conf
@ -421,6 +431,32 @@ if [[ \"$INSTALL_DOCKER\" == \"1\" ]]; then
fi fi
fi fi
rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh
chmod 0755 /usr/local/bin/banger-vsock-pingd
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 git config --system init.defaultBranch main
" "

2
go.mod
View file

@ -46,6 +46,8 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-runewidth v0.0.13 // 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/mitchellh/mapstructure v1.4.3 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.1 // indirect github.com/muesli/cancelreader v0.2.1 // indirect

2
go.sum
View file

@ -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/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.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/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/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/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 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=

View file

@ -63,6 +63,11 @@ type VMSSHResult struct {
GuestIP string `json:"guest_ip"` GuestIP string `json:"guest_ip"`
} }
type VMPingResult struct {
Name string `json:"name"`
Alive bool `json:"alive"`
}
type ImageBuildParams struct { type ImageBuildParams struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
BaseRootfs string `json:"base_rootfs,omitempty"` BaseRootfs string `json:"base_rootfs,omitempty"`

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -23,6 +24,7 @@ import (
"banger/internal/rpc" "banger/internal/rpc"
"banger/internal/system" "banger/internal/system"
"banger/internal/vmdns" "banger/internal/vmdns"
"banger/internal/vsockping"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -32,7 +34,17 @@ var (
daemonExePath = func(pid int) string { daemonExePath = func(pid int) string {
return filepath.Join("/proc", fmt.Sprintf("%d", pid), "exe") 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 { func NewBangerCommand() *cobra.Command {
@ -454,11 +466,7 @@ func newVMSSHCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
sshCmd := exec.CommandContext(cmd.Context(), "ssh", sshArgs...) return runSSHSession(cmd.Context(), layout.SocketPath, result.Name, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs)
sshCmd.Stdout = cmd.OutOrStdout()
sshCmd.Stderr = cmd.ErrOrStderr()
sshCmd.Stdin = cmd.InOrStdin()
return sshCmd.Run()
}, },
} }
} }
@ -953,6 +961,36 @@ func validatePositiveSetting(label string, value int) error {
return nil 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) { func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) {
if guestIP == "" { if guestIP == "" {
return nil, errors.New("vm has no guest IP") return nil, errors.New("vm has no guest IP")

View file

@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings" "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) { func TestResolveVMTargetsDeduplicatesAndReportsErrors(t *testing.T) {
vms := []model.VMRecord{ vms := []model.VMRecord{
testCLIResolvedVM("alpha-id", "alpha"), testCLIResolvedVM("alpha-id", "alpha"),

View file

@ -16,6 +16,7 @@ import (
"banger/internal/paths" "banger/internal/paths"
"banger/internal/rpc" "banger/internal/rpc"
"banger/internal/system" "banger/internal/system"
"banger/internal/vsockping"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@ -104,6 +105,7 @@ type externalPreparedMsg struct {
action actionRequest action actionRequest
command *exec.Cmd command *exec.Cmd
doneStatus string doneStatus string
done func(error) tea.Msg
refresh bool refresh bool
err error err error
} }
@ -716,10 +718,14 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
break break
} }
cmds = append(cmds, tea.ExecProcess(msg.command, func(err error) tea.Msg { 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{ return actionResultMsg{
action: msg.action, action: msg.action,
status: msg.doneStatus, status: msg.doneStatus,
err: normalizeExecError(err), err: err,
refresh: msg.refresh, refresh: msg.refresh,
focusID: m.selectedID, 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, err: err}
} }
return externalPreparedMsg{ return externalPreparedMsg{
action: action, action: action,
command: exec.Command("ssh", args...), command: exec.Command("ssh", args...),
doneStatus: fmt.Sprintf("ssh session ended for %s", result.Name), done: func(execErr error) tea.Msg {
refresh: true, 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 { func prepareLogsCmd(layout paths.Layout, action actionRequest) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
result, err := rpc.Call[api.VMLogsResult](context.Background(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: action.id}) result, err := rpc.Call[api.VMLogsResult](context.Background(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: action.id})

View file

@ -2,12 +2,14 @@ package cli
import ( import (
"context" "context"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"banger/internal/api"
"banger/internal/model" "banger/internal/model"
"banger/internal/paths" "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) { func TestAggregateRunningVMResources(t *testing.T) {
t.Parallel() t.Parallel()

View file

@ -21,6 +21,7 @@ type fileConfig struct {
SSHKeyPath string `toml:"ssh_key_path"` SSHKeyPath string `toml:"ssh_key_path"`
NamegenPath string `toml:"namegen_path"` NamegenPath string `toml:"namegen_path"`
CustomizeScript string `toml:"customize_script"` CustomizeScript string `toml:"customize_script"`
VSockPingHelper string `toml:"vsock_ping_helper_path"`
DefaultImageName string `toml:"default_image_name"` DefaultImageName string `toml:"default_image_name"`
DefaultRootfs string `toml:"default_rootfs"` DefaultRootfs string `toml:"default_rootfs"`
DefaultBaseRootfs string `toml:"default_base_rootfs"` DefaultBaseRootfs string `toml:"default_base_rootfs"`
@ -87,6 +88,9 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) {
if file.CustomizeScript != "" { if file.CustomizeScript != "" {
cfg.CustomizeScript = file.CustomizeScript cfg.CustomizeScript = file.CustomizeScript
} }
if file.VSockPingHelper != "" {
cfg.VSockPingHelperPath = file.VSockPingHelper
}
if file.DefaultImageName != "" { if file.DefaultImageName != "" {
cfg.DefaultImageName = 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.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, runtimeDir, meta.SSHKeyPath)
cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, runtimeDir, meta.NamegenPath) cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, runtimeDir, meta.NamegenPath)
cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, runtimeDir, meta.CustomizeScript) cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, runtimeDir, meta.CustomizeScript)
cfg.VSockPingHelperPath = defaultRuntimePath(cfg.VSockPingHelperPath, runtimeDir, meta.VSockPingHelperPath)
cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, runtimeDir, meta.DefaultKernel) cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, runtimeDir, meta.DefaultKernel)
cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, runtimeDir, meta.DefaultInitrd) cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, runtimeDir, meta.DefaultInitrd)
cfg.DefaultModulesDir = defaultRuntimePath(cfg.DefaultModulesDir, runtimeDir, meta.DefaultModulesDir) 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.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, cfg.RuntimeDir, "id_ed25519")
cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, cfg.RuntimeDir, "namegen") cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, cfg.RuntimeDir, "namegen")
cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, cfg.RuntimeDir, "customize.sh") cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, cfg.RuntimeDir, "customize.sh")
cfg.VSockPingHelperPath = defaultRuntimePath(cfg.VSockPingHelperPath, cfg.RuntimeDir, "banger-vsock-pingd")
cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, cfg.RuntimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, cfg.RuntimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic")
cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, cfg.RuntimeDir, "wtf/root/boot/initrd.img-6.8.0-94-generic") cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, cfg.RuntimeDir, "wtf/root/boot/initrd.img-6.8.0-94-generic")
cfg.DefaultModulesDir = defaultRuntimePath(cfg.DefaultModulesDir, cfg.RuntimeDir, "wtf/root/lib/modules/6.8.0-94-generic") cfg.DefaultModulesDir = defaultRuntimePath(cfg.DefaultModulesDir, cfg.RuntimeDir, "wtf/root/lib/modules/6.8.0-94-generic")

View file

@ -13,21 +13,23 @@ import (
func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) { func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) {
runtimeDir := t.TempDir() runtimeDir := t.TempDir()
meta := runtimebundle.BundleMetadata{ meta := runtimebundle.BundleMetadata{
FirecrackerBin: "bin/firecracker", FirecrackerBin: "bin/firecracker",
SSHKeyPath: "keys/id_ed25519", SSHKeyPath: "keys/id_ed25519",
NamegenPath: "bin/namegen", NamegenPath: "bin/namegen",
CustomizeScript: "scripts/customize.sh", CustomizeScript: "scripts/customize.sh",
DefaultPackages: "config/packages.apt", VSockPingHelperPath: "bin/banger-vsock-pingd",
DefaultRootfs: "images/rootfs-docker.ext4", DefaultPackages: "config/packages.apt",
DefaultKernel: "kernels/vmlinux", DefaultRootfs: "images/rootfs-docker.ext4",
DefaultInitrd: "kernels/initrd.img", DefaultKernel: "kernels/vmlinux",
DefaultModulesDir: "modules/current", DefaultInitrd: "kernels/initrd.img",
DefaultModulesDir: "modules/current",
} }
for _, rel := range []string{ for _, rel := range []string{
meta.FirecrackerBin, meta.FirecrackerBin,
meta.SSHKeyPath, meta.SSHKeyPath,
meta.NamegenPath, meta.NamegenPath,
meta.CustomizeScript, meta.CustomizeScript,
meta.VSockPingHelperPath,
meta.DefaultPackages, meta.DefaultPackages,
meta.DefaultRootfs, meta.DefaultRootfs,
meta.DefaultKernel, meta.DefaultKernel,
@ -71,6 +73,9 @@ func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) {
if cfg.CustomizeScript != filepath.Join(runtimeDir, meta.CustomizeScript) { if cfg.CustomizeScript != filepath.Join(runtimeDir, meta.CustomizeScript) {
t.Fatalf("CustomizeScript = %q", cfg.CustomizeScript) t.Fatalf("CustomizeScript = %q", cfg.CustomizeScript)
} }
if cfg.VSockPingHelperPath != filepath.Join(runtimeDir, meta.VSockPingHelperPath) {
t.Fatalf("VSockPingHelperPath = %q", cfg.VSockPingHelperPath)
}
if cfg.DefaultRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) { if cfg.DefaultRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) {
t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs) t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs)
} }
@ -98,6 +103,7 @@ func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) {
"id_ed25519", "id_ed25519",
"namegen", "namegen",
"customize.sh", "customize.sh",
"banger-vsock-pingd",
"packages.apt", "packages.apt",
"rootfs-docker.ext4", "rootfs-docker.ext4",
"wtf/root/boot/vmlinux-6.8.0-94-generic", "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") { if cfg.FirecrackerBin != filepath.Join(runtimeDir, "firecracker") {
t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin)
} }
if cfg.VSockPingHelperPath != filepath.Join(runtimeDir, "banger-vsock-pingd") {
t.Fatalf("VSockPingHelperPath = %q", cfg.VSockPingHelperPath)
}
if cfg.DefaultKernel != filepath.Join(runtimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") { if cfg.DefaultKernel != filepath.Join(runtimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") {
t.Fatalf("DefaultKernel = %q", cfg.DefaultKernel) t.Fatalf("DefaultKernel = %q", cfg.DefaultKernel)
} }

View file

@ -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 rpc.NewError("not_running", fmt.Sprintf("vm %s is not running", vm.Name))
} }
return marshalResultOrError(api.VMSSHResult{Name: vm.Name, GuestIP: vm.Runtime.GuestIP}, nil) return marshalResultOrError(api.VMSSHResult{Name: vm.Name, GuestIP: vm.Runtime.GuestIP}, nil)
case "vm.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": case "image.list":
images, err := d.store.ListImages(ctx) images, err := d.store.ListImages(ctx)
return marshalResultOrError(api.ImageListResult{Images: images}, err) return marshalResultOrError(api.ImageListResult{Images: images}, err)

View file

@ -33,6 +33,7 @@ func (d *Daemon) doctorReport(ctx context.Context) system.Report {
report.AddPreflight("runtime bundle", d.runtimeBundleChecks(), runtimeBundleStatus(d.config)) report.AddPreflight("runtime bundle", d.runtimeBundleChecks(), runtimeBundleStatus(d.config))
report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available") report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available")
report.AddPreflight("vsock ssh reminder", d.vsockChecks(), "vsock reminder prerequisites available")
d.addCapabilityDoctorChecks(ctx, &report) d.addCapabilityDoctorChecks(ctx, &report)
report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available") report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available")
@ -44,6 +45,7 @@ func (d *Daemon) runtimeBundleChecks() *system.Preflight {
hint := paths.RuntimeBundleHint() hint := paths.RuntimeBundleHint()
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or refresh the runtime bundle`) checks.RequireFile(d.config.SSHKeyPath, "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.DefaultRootfs, "default rootfs image", `set "default_rootfs" or refresh the runtime bundle`)
checks.RequireFile(d.config.DefaultKernel, "kernel image", `set "default_kernel" or refresh the runtime bundle`) checks.RequireFile(d.config.DefaultKernel, "kernel image", `set "default_kernel" or refresh the runtime bundle`)
if strings.TrimSpace(d.config.DefaultInitrd) != "" { if strings.TrimSpace(d.config.DefaultInitrd) != "" {
@ -75,6 +77,13 @@ func (d *Daemon) imageBuildChecks(ctx context.Context) *system.Preflight {
return checks 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 { func runtimeBundleStatus(cfg model.DaemonConfig) string {
if strings.TrimSpace(cfg.RuntimeDir) == "" { if strings.TrimSpace(cfg.RuntimeDir) == "" {
return "runtime dir not configured" return "runtime dir not configured"

View file

@ -17,6 +17,7 @@ import (
"banger/internal/hostnat" "banger/internal/hostnat"
"banger/internal/model" "banger/internal/model"
"banger/internal/system" "banger/internal/system"
"banger/internal/vsockping"
) )
const ( const (
@ -103,6 +104,16 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (
} }
defer client.Close() 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 { if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil {
return err return err
} }
@ -207,7 +218,7 @@ func (d *Daemon) startImageBuildVM(ctx context.Context, spec imageBuildSpec) (im
return imageBuildVM{}, nil, err return imageBuildVM{}, nil, err
} }
vm.PID = d.resolveFirecrackerPID(firecrackerCtx, machine, vm.APISock) 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) _ = d.killVMProcess(context.Background(), vm.PID)
_ = hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, false) _ = hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, false)
_, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice) _, _ = 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") script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y install \"${PACKAGES[@]}\"\n")
appendMiseSetup(&script) appendMiseSetup(&script)
appendTmuxSetup(&script) appendTmuxSetup(&script)
appendVSockPingSetup(&script)
if installDocker { if installDocker {
script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y remove containerd || true\n") 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") 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") 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) { func appendGitRepo(script *bytes.Buffer, dir, repo string) {
fmt.Fprintf(script, "if [[ -d \"%s/.git\" ]]; then\n", dir) fmt.Fprintf(script, "if [[ -d \"%s/.git\" ]]; then\n", dir)
fmt.Fprintf(script, " git -C \"%s\" fetch --depth 1 origin\n", dir) fmt.Fprintf(script, " git -C \"%s\" fetch --depth 1 origin\n", dir)

View file

@ -26,6 +26,11 @@ func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) {
"set -g @continuum-restore 'off'", "set -g @continuum-restore 'off'",
"set -g @resurrect-dir '/root/.tmux/resurrect'", "set -g @resurrect-dir '/root/.tmux/resurrect'",
"run '~/.tmux/plugins/tpm/tpm'", "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", "rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh",
} { } {
if !strings.Contains(script, snippet) { if !strings.Contains(script, snippet) {

View file

@ -43,6 +43,11 @@ func TestNewDaemonLoggerEmitsJSONAtConfiguredLevel(t *testing.T) {
func TestStartVMLockedLogsBridgeFailure(t *testing.T) { func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
ctx := context.Background() ctx := context.Background()
origVsockHostDevicePath := vsockHostDevicePath
vsockHostDevicePath = filepath.Join(t.TempDir(), "vhost-vsock")
t.Cleanup(func() {
vsockHostDevicePath = origVsockHostDevicePath
})
binDir := t.TempDir() binDir := t.TempDir()
for _, name := range []string{ for _, name := range []string{
"sudo", "ip", "dmsetup", "losetup", "blockdev", "truncate", "pgrep", "ps", "sudo", "ip", "dmsetup", "losetup", "blockdev", "truncate", "pgrep", "ps",
@ -54,9 +59,16 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
t.Setenv("PATH", binDir) t.Setenv("PATH", binDir)
firecrackerBin := filepath.Join(t.TempDir(), "firecracker") firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-pingd")
if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write firecracker: %v", err) t.Fatalf("write firecracker: %v", err)
} }
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") rootfsPath := filepath.Join(t.TempDir(), "rootfs.ext4")
kernelPath := filepath.Join(t.TempDir(), "vmlinux") kernelPath := filepath.Join(t.TempDir(), "vmlinux")
for _, path := range []string{rootfsPath, kernelPath} { for _, path := range []string{rootfsPath, kernelPath} {
@ -93,11 +105,12 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
d := &Daemon{ d := &Daemon{
layout: paths.Layout{RuntimeDir: filepath.Join(t.TempDir(), "runtime")}, layout: paths.Layout{RuntimeDir: filepath.Join(t.TempDir(), "runtime")},
config: model.DaemonConfig{ config: model.DaemonConfig{
BridgeName: "br-fc", BridgeName: "br-fc",
BridgeIP: model.DefaultBridgeIP, BridgeIP: model.DefaultBridgeIP,
DefaultDNS: model.DefaultDNS, DefaultDNS: model.DefaultDNS,
FirecrackerBin: firecrackerBin, FirecrackerBin: firecrackerBin,
StatsPollInterval: model.DefaultStatsPollInterval, VSockPingHelperPath: vsockHelper,
StatsPollInterval: model.DefaultStatsPollInterval,
}, },
runner: runner, runner: runner,
logger: logger, logger: logger,
@ -138,11 +151,15 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
packagesPath := filepath.Join(t.TempDir(), "packages.apt") packagesPath := filepath.Join(t.TempDir(), "packages.apt")
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519") sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
firecrackerBin := filepath.Join(t.TempDir(), "firecracker") firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-pingd")
for _, path := range []string{baseRootfs, kernelPath, packagesPath, sshKeyPath} { for _, path := range []string{baseRootfs, kernelPath, packagesPath, sshKeyPath} {
if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil { if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err) t.Fatalf("write %s: %v", path, err)
} }
} }
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 { if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write %s: %v", firecrackerBin, err) t.Fatalf("write %s: %v", firecrackerBin, err)
} }
@ -169,6 +186,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
DefaultPackagesFile: packagesPath, DefaultPackagesFile: packagesPath,
SSHKeyPath: sshKeyPath, SSHKeyPath: sshKeyPath,
FirecrackerBin: firecrackerBin, FirecrackerBin: firecrackerBin,
VSockPingHelperPath: vsockHelper,
}, },
store: store, store: store,
runner: runner, runner: runner,

View file

@ -9,6 +9,8 @@ import (
"banger/internal/system" "banger/internal/system"
) )
var vsockHostDevicePath = "/dev/vhost-vsock"
func (d *Daemon) validateStartPrereqs(ctx context.Context, vm model.VMRecord, image model.Image) error { func (d *Daemon) validateStartPrereqs(ctx context.Context, vm model.VMRecord, image model.Image) error {
checks := system.NewPreflight() checks := system.NewPreflight()
d.addBaseStartPrereqs(checks, image) d.addBaseStartPrereqs(checks, image)
@ -52,6 +54,8 @@ func (d *Daemon) addBaseStartPrereqs(checks *system.Preflight, image model.Image
d.addBaseStartCommandPrereqs(checks) d.addBaseStartCommandPrereqs(checks)
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
checks.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`)
checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host")
checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid image or rebuild the runtime bundle") checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid image or rebuild the runtime bundle")
checks.RequireFile(image.KernelPath, "kernel image", `set "default_kernel" or refresh the runtime bundle`) checks.RequireFile(image.KernelPath, "kernel image", `set "default_kernel" or refresh the runtime bundle`)
if strings.TrimSpace(image.InitrdPath) != "" { 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.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or refresh the runtime bundle`) checks.RequireFile(d.config.SSHKeyPath, "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(baseRootfs, "base rootfs image", `pass --base-rootfs or set "default_base_rootfs"`)
checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`) checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`)
checks.RequireFile(d.config.DefaultPackagesFile, "package manifest", `set "default_packages_file" or refresh the runtime bundle`) checks.RequireFile(d.config.DefaultPackagesFile, "package manifest", `set "default_packages_file" or refresh the runtime bundle`)

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "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 { if err := os.MkdirAll(vmDir, 0o755); err != nil {
return model.VMRecord{}, err return model.VMRecord{}, err
} }
vsockCID, err := defaultVSockCID(guestIP)
if err != nil {
return model.VMRecord{}, err
}
systemOverlaySize := int64(model.DefaultSystemOverlaySize) systemOverlaySize := int64(model.DefaultSystemOverlaySize)
if params.SystemOverlaySize != "" { if params.SystemOverlaySize != "" {
systemOverlaySize, err = model.ParseSize(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, GuestIP: guestIP,
DNSName: vmdns.RecordName(name), DNSName: vmdns.RecordName(name),
VMDir: vmDir, VMDir: vmDir,
VSockPath: defaultVSockPath(d.layout.RuntimeDir, id),
VSockCID: vsockCID,
SystemOverlay: filepath.Join(vmDir, "system.cow"), SystemOverlay: filepath.Join(vmDir, "system.cow"),
WorkDiskPath: filepath.Join(vmDir, "root.ext4"), WorkDiskPath: filepath.Join(vmDir, "root.ext4"),
LogPath: filepath.Join(vmDir, "firecracker.log"), 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") apiSock := filepath.Join(d.layout.RuntimeDir, "fc-"+shortID+".sock")
tap := "tap-fc-" + shortID tap := "tap-fc-" + shortID
dmName := "fc-rootfs-" + 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) { if err := os.RemoveAll(apiSock); err != nil && !os.IsNotExist(err) {
return model.VMRecord{}, 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) op.stage("system_overlay", "overlay_path", vm.Runtime.SystemOverlay)
if err := d.ensureSystemOverlay(ctx, &vm); err != nil { 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, IsRoot: true,
}}, }},
TapDevice: tap, TapDevice: tap,
VSockPath: vm.Runtime.VSockPath,
VSockCID: vm.Runtime.VSockCID,
VCPUCount: vm.Spec.VCPUCount, VCPUCount: vm.Spec.VCPUCount,
MemoryMiB: vm.Spec.MemoryMiB, MemoryMiB: vm.Spec.MemoryMiB,
Logger: d.logger, 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) vm.Runtime.PID = d.resolveFirecrackerPID(firecrackerCtx, machine, apiSock)
op.debugStage("firecracker_started", "pid", vm.Runtime.PID) op.debugStage("firecracker_started", "pid", vm.Runtime.PID)
op.stage("socket_access", "api_socket", apiSock) 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) return cleanupOnErr(err)
} }
op.stage("post_start_features") 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 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) { func (d *Daemon) getVMStatsLocked(ctx context.Context, vm model.VMRecord) (model.VMRecord, error) {
stats, err := d.collectStats(ctx, vm) stats, err := d.collectStats(ctx, vm)
if err == nil { if err == nil {
@ -812,11 +864,14 @@ func (d *Daemon) firecrackerBinary() (string, error) {
return path, nil return path, nil
} }
func (d *Daemon) ensureSocketAccess(ctx context.Context, apiSock string) error { func (d *Daemon) ensureSocketAccess(ctx context.Context, socketPath, label string) error {
if _, err := d.runner.RunSudo(ctx, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), apiSock); err != nil { if err := waitForPath(ctx, socketPath, 5*time.Second, label); err != nil {
return err 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 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 { 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 return err
} }
client := firecracker.New(vm.Runtime.APISockPath, d.logger) 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 != "" { if vm.Runtime.APISockPath != "" {
_ = os.Remove(vm.Runtime.APISockPath) _ = os.Remove(vm.Runtime.APISockPath)
} }
if vm.Runtime.VSockPath != "" {
_ = os.Remove(vm.Runtime.VSockPath)
}
snapshotErr := d.cleanupDMSnapshot(ctx, dmSnapshotHandles{ snapshotErr := d.cleanupDMSnapshot(ctx, dmSnapshotHandles{
BaseLoop: vm.Runtime.BaseLoop, BaseLoop: vm.Runtime.BaseLoop,
COWLoop: vm.Runtime.COWLoop, COWLoop: vm.Runtime.COWLoop,
@ -910,6 +968,37 @@ func clearRuntimeHandles(vm *model.VMRecord) {
vm.Runtime.DMDev = "" 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 { func (d *Daemon) setDNS(ctx context.Context, vmName, guestIP string) error {
if d.vmDNS == nil { if d.vmDNS == nil {
return nil return nil

View file

@ -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) { func TestSetVMDiskResizeFailsPreflightWhenToolsMissing(t *testing.T) {
ctx := context.Background() ctx := context.Background()
db := openDaemonStore(t) db := openDaemonStore(t)

View file

@ -1,17 +1,23 @@
package firecracker package firecracker
import ( import (
"bufio"
"context" "context"
"fmt"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"sync" "sync"
"time"
sdk "github.com/firecracker-microvm/firecracker-go-sdk" sdk "github.com/firecracker-microvm/firecracker-go-sdk"
models "github.com/firecracker-microvm/firecracker-go-sdk/client/models" models "github.com/firecracker-microvm/firecracker-go-sdk/client/models"
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"banger/internal/vsockping"
) )
type MachineConfig struct { type MachineConfig struct {
@ -25,6 +31,8 @@ type MachineConfig struct {
KernelArgs string KernelArgs string
Drives []DriveConfig Drives []DriveConfig
TapDevice string TapDevice string
VSockPath string
VSockCID uint32
VCPUCount int VCPUCount int
MemoryMiB int MemoryMiB int
Logger *slog.Logger Logger *slog.Logger
@ -132,6 +140,7 @@ func buildConfig(cfg MachineConfig) sdk.Config {
HostDevName: cfg.TapDevice, HostDevName: cfg.TapDevice,
}, },
}}, }},
VsockDevices: buildVsockDevices(cfg),
MachineCfg: models.MachineConfiguration{ MachineCfg: models.MachineConfiguration{
VcpuCount: sdk.Int64(int64(cfg.VCPUCount)), VcpuCount: sdk.Int64(int64(cfg.VCPUCount)),
MemSizeMib: sdk.Int64(int64(cfg.MemoryMiB)), 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) { func splitDrives(drives []DriveConfig) (DriveConfig, []DriveConfig) {
root := DriveConfig{ID: "rootfs"} root := DriveConfig{ID: "rootfs"}
var extras []DriveConfig var extras []DriveConfig
@ -192,6 +212,39 @@ func newLogger(base *slog.Logger) *logrus.Entry {
return logrus.NewEntry(logger) 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 { type slogHook struct {
logger *slog.Logger logger *slog.Logger
} }

View file

@ -2,9 +2,13 @@ package firecracker
import ( import (
"bytes" "bytes"
"context"
"log/slog" "log/slog"
"net"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestBuildConfig(t *testing.T) { func TestBuildConfig(t *testing.T) {
@ -21,6 +25,8 @@ func TestBuildConfig(t *testing.T) {
{ID: "work", Path: "/var/lib/banger/root.ext4"}, {ID: "work", Path: "/var/lib/banger/root.ext4"},
}, },
TapDevice: "tap-fc-1", TapDevice: "tap-fc-1",
VSockPath: "/tmp/fc.vsock",
VSockCID: 10042,
VCPUCount: 4, VCPUCount: 4,
MemoryMiB: 2048, MemoryMiB: 2048,
}) })
@ -46,6 +52,12 @@ func TestBuildConfig(t *testing.T) {
if len(cfg.NetworkInterfaces) != 1 { if len(cfg.NetworkInterfaces) != 1 {
t.Fatalf("interface count = %d, want 1", len(cfg.NetworkInterfaces)) 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" { if got := cfg.NetworkInterfaces[0].StaticConfiguration.HostDevName; got != "tap-fc-1" {
t.Fatalf("host dev name = %q", got) 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()) 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)
}

View file

@ -2,6 +2,7 @@ package guest
import ( import (
"archive/tar" "archive/tar"
"bytes"
"context" "context"
"errors" "errors"
"fmt" "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) 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 { func (c *Client) StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error {
reader, writer := io.Pipe() reader, writer := io.Pipe()
writeErr := make(chan error, 1) writeErr := make(chan error, 1)
@ -123,6 +129,10 @@ func privateKeySigner(path string) (ssh.Signer, error) {
return ssh.ParsePrivateKey(data) return ssh.ParsePrivateKey(data)
} }
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func writeTarArchive(dst io.Writer, sourceDir string) error { func writeTarArchive(dst io.Writer, sourceDir string) error {
tw := tar.NewWriter(dst) tw := tar.NewWriter(dst)
defer tw.Close() defer tw.Close()

View file

@ -41,6 +41,7 @@ type DaemonConfig struct {
SSHKeyPath string SSHKeyPath string
NamegenPath string NamegenPath string
CustomizeScript string CustomizeScript string
VSockPingHelperPath string
AutoStopStaleAfter time.Duration AutoStopStaleAfter time.Duration
StatsPollInterval time.Duration StatsPollInterval time.Duration
MetricsPollInterval time.Duration MetricsPollInterval time.Duration
@ -87,6 +88,8 @@ type VMRuntime struct {
GuestIP string `json:"guest_ip"` GuestIP string `json:"guest_ip"`
TapDevice string `json:"tap_device,omitempty"` TapDevice string `json:"tap_device,omitempty"`
APISockPath string `json:"api_sock_path,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"` LogPath string `json:"log_path,omitempty"`
MetricsPath string `json:"metrics_path,omitempty"` MetricsPath string `json:"metrics_path,omitempty"`
DNSName string `json:"dns_name,omitempty"` DNSName string `json:"dns_name,omitempty"`

View file

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

View file

@ -30,16 +30,17 @@ type Manifest struct {
} }
type BundleMetadata struct { type BundleMetadata struct {
FirecrackerBin string `json:"firecracker_bin" toml:"firecracker_bin"` FirecrackerBin string `json:"firecracker_bin" toml:"firecracker_bin"`
SSHKeyPath string `json:"ssh_key_path" toml:"ssh_key_path"` SSHKeyPath string `json:"ssh_key_path" toml:"ssh_key_path"`
NamegenPath string `json:"namegen_path" toml:"namegen_path"` NamegenPath string `json:"namegen_path" toml:"namegen_path"`
CustomizeScript string `json:"customize_script" toml:"customize_script"` CustomizeScript string `json:"customize_script" toml:"customize_script"`
DefaultPackages string `json:"default_packages_file" toml:"default_packages_file"` VSockPingHelperPath string `json:"vsock_ping_helper_path" toml:"vsock_ping_helper_path"`
DefaultRootfs string `json:"default_rootfs" toml:"default_rootfs"` DefaultPackages string `json:"default_packages_file" toml:"default_packages_file"`
DefaultBaseRootfs string `json:"default_base_rootfs,omitempty" toml:"default_base_rootfs"` DefaultRootfs string `json:"default_rootfs" toml:"default_rootfs"`
DefaultKernel string `json:"default_kernel" toml:"default_kernel"` DefaultBaseRootfs string `json:"default_base_rootfs,omitempty" toml:"default_base_rootfs"`
DefaultInitrd string `json:"default_initrd,omitempty" toml:"default_initrd"` DefaultKernel string `json:"default_kernel" toml:"default_kernel"`
DefaultModulesDir string `json:"default_modules_dir,omitempty" toml:"default_modules_dir"` DefaultInitrd string `json:"default_initrd,omitempty" toml:"default_initrd"`
DefaultModulesDir string `json:"default_modules_dir,omitempty" toml:"default_modules_dir"`
} }
const BundleMetadataFile = "bundle.json" const BundleMetadataFile = "bundle.json"
@ -209,6 +210,7 @@ func validateBundleMetadata(runtimeDir string, meta BundleMetadata) error {
{meta.SSHKeyPath, "ssh_key_path"}, {meta.SSHKeyPath, "ssh_key_path"},
{meta.NamegenPath, "namegen_path"}, {meta.NamegenPath, "namegen_path"},
{meta.CustomizeScript, "customize_script"}, {meta.CustomizeScript, "customize_script"},
{meta.VSockPingHelperPath, "vsock_ping_helper_path"},
{meta.DefaultPackages, "default_packages_file"}, {meta.DefaultPackages, "default_packages_file"},
{meta.DefaultRootfs, "default_rootfs"}, {meta.DefaultRootfs, "default_rootfs"},
{meta.DefaultKernel, "default_kernel"}, {meta.DefaultKernel, "default_kernel"},
@ -227,6 +229,7 @@ func validateBundleMetadata(runtimeDir string, meta BundleMetadata) error {
{meta.SSHKeyPath, "ssh_key_path", true}, {meta.SSHKeyPath, "ssh_key_path", true},
{meta.NamegenPath, "namegen_path", true}, {meta.NamegenPath, "namegen_path", true},
{meta.CustomizeScript, "customize_script", true}, {meta.CustomizeScript, "customize_script", true},
{meta.VSockPingHelperPath, "vsock_ping_helper_path", true},
{meta.DefaultPackages, "default_packages_file", true}, {meta.DefaultPackages, "default_packages_file", true},
{meta.DefaultRootfs, "default_rootfs", true}, {meta.DefaultRootfs, "default_rootfs", true},
{meta.DefaultBaseRootfs, "default_base_rootfs", false}, {meta.DefaultBaseRootfs, "default_base_rootfs", false},
@ -264,6 +267,7 @@ func metadataArchiveBytes(runtimeDir string, meta BundleMetadata) ([]byte, error
strings.TrimSpace(meta.SSHKeyPath) == "" && strings.TrimSpace(meta.SSHKeyPath) == "" &&
strings.TrimSpace(meta.NamegenPath) == "" && strings.TrimSpace(meta.NamegenPath) == "" &&
strings.TrimSpace(meta.CustomizeScript) == "" && strings.TrimSpace(meta.CustomizeScript) == "" &&
strings.TrimSpace(meta.VSockPingHelperPath) == "" &&
strings.TrimSpace(meta.DefaultPackages) == "" && strings.TrimSpace(meta.DefaultPackages) == "" &&
strings.TrimSpace(meta.DefaultRootfs) == "" && strings.TrimSpace(meta.DefaultRootfs) == "" &&
strings.TrimSpace(meta.DefaultBaseRootfs) == "" && strings.TrimSpace(meta.DefaultBaseRootfs) == "" &&
@ -283,6 +287,7 @@ func normalizeBundleMetadata(meta BundleMetadata) BundleMetadata {
meta.SSHKeyPath = strings.TrimSpace(meta.SSHKeyPath) meta.SSHKeyPath = strings.TrimSpace(meta.SSHKeyPath)
meta.NamegenPath = strings.TrimSpace(meta.NamegenPath) meta.NamegenPath = strings.TrimSpace(meta.NamegenPath)
meta.CustomizeScript = strings.TrimSpace(meta.CustomizeScript) meta.CustomizeScript = strings.TrimSpace(meta.CustomizeScript)
meta.VSockPingHelperPath = strings.TrimSpace(meta.VSockPingHelperPath)
meta.DefaultPackages = strings.TrimSpace(meta.DefaultPackages) meta.DefaultPackages = strings.TrimSpace(meta.DefaultPackages)
meta.DefaultRootfs = strings.TrimSpace(meta.DefaultRootfs) meta.DefaultRootfs = strings.TrimSpace(meta.DefaultRootfs)
meta.DefaultBaseRootfs = strings.TrimSpace(meta.DefaultBaseRootfs) meta.DefaultBaseRootfs = strings.TrimSpace(meta.DefaultBaseRootfs)

View file

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

View file

@ -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
}

View file

@ -10,6 +10,7 @@ required_paths = [
"customize.sh", "customize.sh",
"packages.sh", "packages.sh",
"namegen", "namegen",
"banger-vsock-pingd",
"packages.apt", "packages.apt",
"id_ed25519", "id_ed25519",
"rootfs-docker.ext4", "rootfs-docker.ext4",
@ -23,6 +24,7 @@ firecracker_bin = "firecracker"
ssh_key_path = "id_ed25519" ssh_key_path = "id_ed25519"
namegen_path = "namegen" namegen_path = "namegen"
customize_script = "customize.sh" customize_script = "customize.sh"
vsock_ping_helper_path = "banger-vsock-pingd"
default_packages_file = "packages.apt" default_packages_file = "packages.apt"
default_rootfs = "rootfs-docker.ext4" default_rootfs = "rootfs-docker.ext4"
default_kernel = "wtf/root/boot/vmlinux-6.8.0-94-generic" default_kernel = "wtf/root/boot/vmlinux-6.8.0-94-generic"