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.
## 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`.

View file

@ -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 ./...

View file

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

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")"
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
"

2
go.mod
View file

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

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/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=

View file

@ -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"`

View file

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

View file

@ -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"),

View file

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

View file

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

View file

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

View file

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

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

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("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"

View file

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

View file

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

View file

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

View file

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

View file

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

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) {
ctx := context.Background()
db := openDaemonStore(t)

View file

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

View file

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

View file

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

View file

@ -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"`

View file

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

View file

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

View file

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

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",
"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"