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:
parent
4930d82cb9
commit
08ef706e3f
31 changed files with 912 additions and 75 deletions
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
9
Makefile
9
Makefile
|
|
@ -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 ./...
|
||||||
|
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
49
cmd/banger-vsock-pingd/main.go
Normal file
49
cmd/banger-vsock-pingd/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
customize.sh
36
customize.sh
|
|
@ -68,6 +68,7 @@ FC_BIN="$RUNTIME_DIR/firecracker"
|
||||||
KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")"
|
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
2
go.mod
|
|
@ -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
2
go.sum
|
|
@ -514,7 +514,9 @@ github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vq
|
||||||
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
github.com/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=
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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`)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
105
internal/vsockping/vsockping.go
Normal file
105
internal/vsockping/vsockping.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue