Add experimental Void guest workflow and vsock agent
Make iterating on a Firecracker-friendly Void guest practical without replacing the Debian default image path. Add local Void rootfs build/register/verify plumbing, a language-agnostic dev package baseline, and guest SSH/work-disk hardening so new images use the runtime bundle key, keep a normal root bash environment, and repair stale nested /root layouts on restart. Replace the guest PING/PONG responder with an HTTP /healthz agent over vsock, rename the runtime bundle and config surface from ping helper to agent while still accepting the legacy keys, and route the post-SSH reminder through the new vm.health path. Validated with GOCACHE=/tmp/banger-gocache go test ./..., make build, bash -n customize.sh make-rootfs-void.sh, and git diff --check.
This commit is contained in:
parent
c8d9a122f9
commit
3ed78fdcfc
42 changed files with 2222 additions and 388 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -9,15 +9,18 @@
|
|||
- The daemon keeps state under XDG directories rather than the old repo-local `state/` layout.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `make build` builds `./banger`, `./bangerd`, and the bundled `./runtime/banger-vsock-pingd` guest helper.
|
||||
- `make build` builds `./banger`, `./bangerd`, and the bundled `./runtime/banger-vsock-agent` guest helper.
|
||||
- `make bench-create` benchmarks `vm create` and first-SSH readiness on the current host.
|
||||
- `make runtime-bundle` bootstraps `./runtime/` from the archive referenced by `RUNTIME_MANIFEST`; the checked-in `runtime-bundle.toml` is only a template.
|
||||
- `make rootfs-void` builds an experimental local-only `x86_64-glibc` Void rootfs plus work-seed under `./runtime/`; it does not replace the default Debian path or teach `banger image build` about Void.
|
||||
- `make verify-void` registers `void-exp` and runs the normal smoke test against that image.
|
||||
- `banger` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set.
|
||||
- `./banger vm create --name testbox` creates and starts a VM.
|
||||
- `./banger vm ssh testbox` connects to a running guest and reminds the user if the VM is still running when the session exits.
|
||||
- `./banger vm ssh testbox` connects to a running guest using the runtime bundle SSH key and reminds the user if the VM is still running when the session exits.
|
||||
- `./banger vm stop testbox` stops a VM while preserving its disks.
|
||||
- `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI.
|
||||
- `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon.
|
||||
- `./banger image register --name local --rootfs /abs/path/rootfs.ext4` creates or updates an unmanaged image record without changing the default image config; use it for experimental guest iteration paths such as Void.
|
||||
- `./banger tui` launches the terminal UI.
|
||||
- `make test` runs `go test ./...`.
|
||||
- `./verify.sh` runs the smoke test for the Go VM workflow.
|
||||
|
|
@ -32,7 +35,8 @@
|
|||
- Primary automated coverage is `go test ./...`.
|
||||
- Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM.
|
||||
- For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke.
|
||||
- Rebuilt images now include `mise`, `opencode`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-pingd` service used by the SSH reminder path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up.
|
||||
- Rebuilt images now include `mise`, `opencode`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up.
|
||||
- The experimental Void rootfs path is intentionally lean: keep it limited to boot, SSH, the vsock HTTP health agent, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed unless the user explicitly wants more baked in.
|
||||
- Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. If you touch `/root` provisioning, verify both the rootfs and the work-seed output.
|
||||
- The daemon may keep idle TAP devices in a pool for faster creates. Smoke tests should treat `tap-pool-*` devices as reusable capacity, not cleanup leaks.
|
||||
- If you add a new operational workflow, document how to exercise it in `README.md`.
|
||||
|
|
|
|||
30
Makefile
30
Makefile
|
|
@ -12,17 +12,19 @@ RUNTIME_MANIFEST ?= runtime-bundle.toml
|
|||
RUNTIME_SOURCE_DIR ?= runtime
|
||||
RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz
|
||||
BINARIES := banger bangerd
|
||||
RUNTIME_HELPERS := $(RUNTIME_SOURCE_DIR)/banger-vsock-pingd
|
||||
RUNTIME_HELPERS := $(RUNTIME_SOURCE_DIR)/banger-vsock-agent
|
||||
GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort)
|
||||
RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen banger-vsock-pingd
|
||||
RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen banger-vsock-agent
|
||||
RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4
|
||||
RUNTIME_OPTIONAL_DATA_FILES := rootfs.ext4 rootfs-docker.work-seed.ext4 bundle.json
|
||||
RUNTIME_BOOT_FILES := wtf/root/boot/vmlinux-6.8.0-94-generic wtf/root/boot/initrd.img-6.8.0-94-generic
|
||||
RUNTIME_MODULES_DIR := wtf/root/lib/modules/6.8.0-94-generic
|
||||
VOID_IMAGE_NAME ?= void-exp
|
||||
VOID_VM_NAME ?= void-dev
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help build banger bangerd test fmt tidy clean rootfs install runtime-bundle runtime-package check-runtime bench-create
|
||||
.PHONY: help build banger bangerd test fmt tidy clean rootfs rootfs-void void-register void-vm verify-void install runtime-bundle runtime-package check-runtime bench-create
|
||||
|
||||
help:
|
||||
@printf '%s\n' \
|
||||
|
|
@ -36,7 +38,11 @@ help:
|
|||
' make fmt Format Go sources under cmd/ and internal/' \
|
||||
' make tidy Run go mod tidy' \
|
||||
' make clean Remove built Go binaries' \
|
||||
' make rootfs Rebuild the source-checkout default rootfs image in ./runtime'
|
||||
' make rootfs Rebuild the source-checkout default Debian rootfs image in ./runtime' \
|
||||
' make rootfs-void Build an experimental Void Linux rootfs and work-seed in ./runtime' \
|
||||
' make void-register Register or update the experimental Void image as $(VOID_IMAGE_NAME)' \
|
||||
' make void-vm Register the experimental Void image and create a VM named $(VOID_VM_NAME)' \
|
||||
' make verify-void Register the experimental Void image and run verify.sh against it'
|
||||
|
||||
build: $(BINARIES) $(RUNTIME_HELPERS)
|
||||
|
||||
|
|
@ -46,9 +52,9 @@ banger: $(GO_SOURCES) go.mod go.sum
|
|||
bangerd: $(GO_SOURCES) go.mod go.sum
|
||||
$(GO) build -o ./bangerd ./cmd/bangerd
|
||||
|
||||
$(RUNTIME_SOURCE_DIR)/banger-vsock-pingd: $(GO_SOURCES) go.mod go.sum
|
||||
$(RUNTIME_SOURCE_DIR)/banger-vsock-agent: $(GO_SOURCES) go.mod go.sum
|
||||
mkdir -p "$(RUNTIME_SOURCE_DIR)"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -o "$(RUNTIME_SOURCE_DIR)/banger-vsock-pingd" ./cmd/banger-vsock-pingd
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -o "$(RUNTIME_SOURCE_DIR)/banger-vsock-agent" ./cmd/banger-vsock-agent
|
||||
|
||||
test:
|
||||
$(GO) test ./...
|
||||
|
|
@ -100,3 +106,15 @@ install: build check-runtime
|
|||
|
||||
rootfs:
|
||||
BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs.sh
|
||||
|
||||
rootfs-void:
|
||||
BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs-void.sh
|
||||
|
||||
void-register: build
|
||||
./banger image register --name "$(VOID_IMAGE_NAME)" --rootfs "$(abspath $(RUNTIME_SOURCE_DIR))/rootfs-void.ext4" --work-seed "$(abspath $(RUNTIME_SOURCE_DIR))/rootfs-void.work-seed.ext4" --packages "$(abspath packages.void)"
|
||||
|
||||
void-vm: void-register
|
||||
./banger vm create --image "$(VOID_IMAGE_NAME)" --name "$(VOID_VM_NAME)"
|
||||
|
||||
verify-void: void-register
|
||||
./verify.sh --image "$(VOID_IMAGE_NAME)"
|
||||
|
|
|
|||
101
README.md
101
README.md
|
|
@ -22,7 +22,7 @@ generated `./runtime/` bundle, while installed binaries use
|
|||
|
||||
The bundle contains:
|
||||
- `firecracker`
|
||||
- `banger-vsock-pingd` for the guest-side SSH reminder responder
|
||||
- `banger-vsock-agent` for the guest-side vsock HTTP health agent and SSH reminder checks
|
||||
- `bundle.json` with the bundle's default kernel/initrd/modules/rootfs paths
|
||||
- a kernel, initrd, and modules tree referenced by `bundle.json`
|
||||
- `rootfs-docker.ext4`
|
||||
|
|
@ -69,7 +69,7 @@ make build
|
|||
```
|
||||
|
||||
Run `make build` after `./runtime/` has been bootstrapped. It also rebuilds the
|
||||
bundled `banger-vsock-pingd` guest helper in `./runtime/`.
|
||||
bundled `banger-vsock-agent` guest helper in `./runtime/`.
|
||||
|
||||
Install into `~/.local/bin` by default, with the runtime bundle under
|
||||
`~/.local/lib/banger`:
|
||||
|
|
@ -166,10 +166,9 @@ Useful config keys:
|
|||
- `runtime_dir`
|
||||
- `tap_pool_size`
|
||||
- `firecracker_bin`
|
||||
- `ssh_key_path`
|
||||
- `namegen_path`
|
||||
- `customize_script` (manual helper compatibility; `banger image build` is Go-native)
|
||||
- `vsock_ping_helper_path`
|
||||
- `vsock_agent_path`
|
||||
- `default_rootfs`
|
||||
- `default_work_seed`
|
||||
- `default_base_rootfs`
|
||||
|
|
@ -178,6 +177,10 @@ Useful config keys:
|
|||
- `default_modules_dir`
|
||||
- `default_packages_file`
|
||||
|
||||
Guest SSH access always uses the private key shipped in the resolved runtime
|
||||
bundle. `ssh_key_path` is no longer a supported override for `banger vm ssh`,
|
||||
VM start key injection, or daemon guest provisioning.
|
||||
|
||||
## Doctor
|
||||
`banger doctor` runs the same readiness checks the Go control plane uses for VM
|
||||
start, host-integrated features, and image builds. It reports runtime bundle
|
||||
|
|
@ -211,7 +214,8 @@ Rebuilt images install a pinned `mise` at `/usr/local/bin/mise`, activate it
|
|||
for bash login and interactive shells, install `opencode` through `mise`,
|
||||
configure `tmux-resurrect` plus `tmux-continuum` for `root` with periodic
|
||||
autosaves and manual-only restore by default, and bake in the
|
||||
`banger-vsock-pingd` systemd service used by the post-SSH reminder path. They
|
||||
`banger-vsock-agent` systemd service used by the post-SSH reminder path and
|
||||
guest health checks. They
|
||||
also emit a `work-seed.ext4` sidecar that lets new VMs clone a prepared `/root`
|
||||
work disk instead of rebuilding it from scratch on every create.
|
||||
|
||||
|
|
@ -293,6 +297,93 @@ is not available, pass an explicit `--base-rootfs` to `./make-rootfs.sh`.
|
|||
Existing VMs keep using their current image and disks; rebuilds only affect VMs
|
||||
created from the rebuilt image afterward.
|
||||
|
||||
## Experimental Void Rootfs
|
||||
There is also a separate, opt-in builder for an experimental Void Linux guest
|
||||
path:
|
||||
```bash
|
||||
make rootfs-void
|
||||
```
|
||||
|
||||
That writes:
|
||||
- `./runtime/rootfs-void.ext4`
|
||||
- `./runtime/rootfs-void.work-seed.ext4`
|
||||
|
||||
This path is intentionally local-only and does not change the default Debian
|
||||
image flow. It reuses the current runtime bundle kernel, initrd, and modules,
|
||||
but builds a lean `x86_64-glibc` Void userspace with:
|
||||
- `bash` installed for interactive/admin use
|
||||
- `openssh` enabled under runit
|
||||
- the bundled `banger-vsock-agent` health agent enabled under runit
|
||||
- `root` normalized to `/bin/bash` while keeping `/bin/sh` as the distro's system shell
|
||||
- a generated `/root` work-seed for fast creates
|
||||
|
||||
It does not install the Debian-oriented extras from rebuilt default images:
|
||||
- no Docker
|
||||
- no `mise`
|
||||
- no `opencode`
|
||||
- no tmux plugin defaults
|
||||
|
||||
The builder fetches official static XBPS tools and packages from the Void
|
||||
mirror during the build. It currently supports only `x86_64-glibc`.
|
||||
|
||||
The package set comes from [`packages.void`](/home/thales/projects/personal/banger/packages.void).
|
||||
You can override the mirror, size, or output path directly:
|
||||
```bash
|
||||
./make-rootfs-void.sh --mirror https://repo-default.voidlinux.org --size 2G
|
||||
```
|
||||
|
||||
The fastest local iteration loop does not require changing your default image
|
||||
config at all:
|
||||
```bash
|
||||
make rootfs-void
|
||||
make void-register
|
||||
./banger vm create --image void-exp --name void-dev
|
||||
./banger vm ssh void-dev
|
||||
```
|
||||
|
||||
There is also a smoke path for the experimental image:
|
||||
```bash
|
||||
make verify-void
|
||||
```
|
||||
|
||||
`make void-register` uses the unmanaged image registration path to create or
|
||||
update a `void-exp` image record in place, so repeated rebuilds do not require
|
||||
editing `~/.config/banger/config.toml`.
|
||||
|
||||
There is also a one-step helper target:
|
||||
```bash
|
||||
make void-vm VOID_VM_NAME=void-a
|
||||
```
|
||||
|
||||
If you really want the Void image to become your default for `vm create`
|
||||
without `--image`, use the checked-in override template at
|
||||
[`examples/void-exp.config.toml`](/home/thales/projects/personal/banger/examples/void-exp.config.toml)
|
||||
and merge its four settings into `~/.config/banger/config.toml`.
|
||||
|
||||
`banger image build` remains Debian-only in this pass. Do not point
|
||||
`default_base_rootfs` at the Void artifact yet.
|
||||
|
||||
## Registering Unmanaged Images
|
||||
You can also register any local rootfs as an unmanaged image record without
|
||||
changing global defaults:
|
||||
```bash
|
||||
banger image register --name local-test --rootfs /abs/path/rootfs.ext4
|
||||
```
|
||||
|
||||
Optional paths let you point at an existing work seed, kernel, initrd, modules,
|
||||
and package manifest:
|
||||
```bash
|
||||
banger image register \
|
||||
--name void-exp \
|
||||
--rootfs ./runtime/rootfs-void.ext4 \
|
||||
--work-seed ./runtime/rootfs-void.work-seed.ext4 \
|
||||
--packages ./packages.void
|
||||
```
|
||||
|
||||
If an unmanaged image with the same name already exists, `image register`
|
||||
updates it in place so future `vm create --image <name>` calls pick up the new
|
||||
artifacts immediately.
|
||||
|
||||
## Maintaining The Runtime Bundle
|
||||
The checked-in [`runtime-bundle.toml`](/home/thales/projects/personal/banger/runtime-bundle.toml)
|
||||
is a template. Keep `bundle_metadata` accurate there, but use a separate local
|
||||
|
|
|
|||
58
cmd/banger-vsock-agent/main.go
Normal file
58
cmd/banger-vsock-agent/main.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"banger/internal/vsockagent"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
listener, err := sdkvsock.Listener(ctx, logrus.NewEntry(logger), vsockagent.Port)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "banger-vsock-agent: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
server := &http.Server{
|
||||
Handler: vsockagent.NewHandler(),
|
||||
ReadHeaderTimeout: 3 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
fmt.Fprintf(os.Stderr, "banger-vsock-agent: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
fmt.Fprintf(os.Stderr, "banger-vsock-agent: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"banger/internal/vsockping"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
listener, err := sdkvsock.Listener(ctx, logrus.NewEntry(logger), vsockping.Port)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "banger-vsock-pingd: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "banger-vsock-pingd: accept: %v\n", err)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
go func(conn net.Conn) {
|
||||
if err := vsockping.ServeConn(conn); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "banger-vsock-pingd: %v\n", err)
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
25
customize.sh
25
customize.sh
|
|
@ -68,7 +68,10 @@ FC_BIN="$RUNTIME_DIR/firecracker"
|
|||
KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")"
|
||||
INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")"
|
||||
SSH_KEY="$RUNTIME_DIR/id_ed25519"
|
||||
VSOCK_PING_HELPER="$(bundle_path vsock_ping_helper_path "$RUNTIME_DIR/banger-vsock-pingd")"
|
||||
VSOCK_AGENT="$(bundle_path vsock_agent_path "$RUNTIME_DIR/banger-vsock-agent")"
|
||||
if [[ "$VSOCK_AGENT" == "$RUNTIME_DIR/banger-vsock-agent" && ! -x "$VSOCK_AGENT" ]]; then
|
||||
VSOCK_AGENT="$(bundle_path vsock_ping_helper_path "$RUNTIME_DIR/banger-vsock-pingd")"
|
||||
fi
|
||||
|
||||
BR_DEV="br-fc"
|
||||
BR_IP="172.16.0.1"
|
||||
|
|
@ -213,8 +216,8 @@ if [[ ! -f "$PACKAGES_FILE" ]]; then
|
|||
log "package manifest not found: $PACKAGES_FILE"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$VSOCK_PING_HELPER" ]]; then
|
||||
log "vsock ping helper not found or not executable: $VSOCK_PING_HELPER"
|
||||
if [[ ! -x "$VSOCK_AGENT" ]]; then
|
||||
log "vsock agent not found or not executable: $VSOCK_AGENT"
|
||||
log "run 'make build' or refresh the runtime bundle"
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -393,9 +396,9 @@ if [[ "$SSH_READY" -ne 1 ]]; then
|
|||
fi
|
||||
|
||||
log "configuring guest"
|
||||
log "installing vsock ping helper"
|
||||
log "installing vsock agent"
|
||||
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
"$VSOCK_PING_HELPER" "root@${GUEST_IP}:/usr/local/bin/banger-vsock-pingd" >/dev/null
|
||||
"$VSOCK_AGENT" "root@${GUEST_IP}:/usr/local/bin/banger-vsock-agent" >/dev/null
|
||||
|
||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
"root@${GUEST_IP}" bash -lc "set -e
|
||||
|
|
@ -436,31 +439,31 @@ if [[ \"$INSTALL_DOCKER\" == \"1\" ]]; then
|
|||
fi
|
||||
fi
|
||||
rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh
|
||||
chmod 0755 /usr/local/bin/banger-vsock-pingd
|
||||
chmod 0755 /usr/local/bin/banger-vsock-agent
|
||||
mkdir -p /etc/modules-load.d /etc/systemd/system
|
||||
cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'
|
||||
vsock
|
||||
vmw_vsock_virtio_transport
|
||||
EOF
|
||||
chmod 0644 /etc/modules-load.d/banger-vsock.conf
|
||||
cat > /etc/systemd/system/banger-vsock-pingd.service <<'EOF'
|
||||
cat > /etc/systemd/system/banger-vsock-agent.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Banger vsock ping responder
|
||||
Description=Banger vsock agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/banger-vsock-pingd
|
||||
ExecStart=/usr/local/bin/banger-vsock-agent
|
||||
Restart=on-failure
|
||||
RestartSec=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
chmod 0644 /etc/systemd/system/banger-vsock-pingd.service
|
||||
chmod 0644 /etc/systemd/system/banger-vsock-agent.service
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload || true
|
||||
systemctl enable --now banger-vsock-pingd.service || true
|
||||
systemctl enable --now banger-vsock-agent.service || true
|
||||
fi
|
||||
git config --system init.defaultBranch main
|
||||
"
|
||||
|
|
|
|||
10
examples/void-exp.config.toml
Normal file
10
examples/void-exp.config.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Experimental Void Linux guest profile for local testing.
|
||||
#
|
||||
# Copy the values you want into ~/.config/banger/config.toml and replace
|
||||
# /abs/path/to/banger with your checkout path. Do not set default_base_rootfs
|
||||
# to the Void image yet; banger image build still assumes the Debian flow.
|
||||
|
||||
runtime_dir = "/abs/path/to/banger/runtime"
|
||||
default_image_name = "void-exp"
|
||||
default_rootfs = "/abs/path/to/banger/runtime/rootfs-void.ext4"
|
||||
default_work_seed = "/abs/path/to/banger/runtime/rootfs-void.work-seed.ext4"
|
||||
|
|
@ -63,6 +63,11 @@ type VMSSHResult struct {
|
|||
GuestIP string `json:"guest_ip"`
|
||||
}
|
||||
|
||||
type VMHealthResult struct {
|
||||
Name string `json:"name"`
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
type VMPingResult struct {
|
||||
Name string `json:"name"`
|
||||
Alive bool `json:"alive"`
|
||||
|
|
@ -78,6 +83,17 @@ type ImageBuildParams struct {
|
|||
Docker bool `json:"docker,omitempty"`
|
||||
}
|
||||
|
||||
type ImageRegisterParams struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
RootfsPath string `json:"rootfs_path,omitempty"`
|
||||
WorkSeedPath string `json:"work_seed_path,omitempty"`
|
||||
KernelPath string `json:"kernel_path,omitempty"`
|
||||
InitrdPath string `json:"initrd_path,omitempty"`
|
||||
ModulesDir string `json:"modules_dir,omitempty"`
|
||||
PackagesPath string `json:"packages_path,omitempty"`
|
||||
Docker bool `json:"docker,omitempty"`
|
||||
}
|
||||
|
||||
type ImageRefParams struct {
|
||||
IDOrName string `json:"id_or_name"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import (
|
|||
"banger/internal/rpc"
|
||||
"banger/internal/system"
|
||||
"banger/internal/vmdns"
|
||||
"banger/internal/vsockping"
|
||||
"banger/internal/vsockagent"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
@ -42,8 +42,8 @@ var (
|
|||
sshCmd.Stdin = stdin
|
||||
return sshCmd.Run()
|
||||
}
|
||||
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) {
|
||||
return rpc.Call[api.VMPingResult](ctx, socketPath, "vm.ping", api.VMRefParams{IDOrName: idOrName})
|
||||
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
||||
return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName})
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -550,6 +550,7 @@ func newImageCommand() *cobra.Command {
|
|||
}
|
||||
cmd.AddCommand(
|
||||
newImageBuildCommand(),
|
||||
newImageRegisterCommand(),
|
||||
newImageListCommand(),
|
||||
newImageShowCommand(),
|
||||
newImageDeleteCommand(),
|
||||
|
|
@ -591,6 +592,41 @@ func newImageBuildCommand() *cobra.Command {
|
|||
return cmd
|
||||
}
|
||||
|
||||
func newImageRegisterCommand() *cobra.Command {
|
||||
var params api.ImageRegisterParams
|
||||
cmd := &cobra.Command{
|
||||
Use: "register",
|
||||
Short: "Register or update an unmanaged image",
|
||||
Args: noArgsUsage("usage: banger image register --name <name> --rootfs <path> [--work-seed <path>] [--kernel <path>] [--initrd <path>] [--modules <dir>] [--packages <path>]"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := absolutizeImageRegisterPaths(¶ms); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.register", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printImageSummary(cmd.OutOrStdout(), result.Image)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(¶ms.Name, "name", "", "image name")
|
||||
cmd.Flags().StringVar(¶ms.RootfsPath, "rootfs", "", "rootfs path")
|
||||
cmd.Flags().StringVar(¶ms.WorkSeedPath, "work-seed", "", "work-seed path")
|
||||
cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path")
|
||||
cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path")
|
||||
cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir")
|
||||
cmd.Flags().StringVar(¶ms.PackagesPath, "packages", "", "packages manifest path")
|
||||
cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "mark image as docker-prepared")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newImageListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
|
|
@ -995,17 +1031,17 @@ func runSSHSession(ctx context.Context, socketPath, vmRef string, stdin io.Reade
|
|||
}
|
||||
pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
ping, err := vmPingFunc(pingCtx, socketPath, vmRef)
|
||||
health, err := vmHealthFunc(pingCtx, socketPath, vmRef)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, vsockping.WarningMessage(vmRef, err))
|
||||
_, _ = fmt.Fprintln(stderr, vsockagent.WarningMessage(vmRef, err))
|
||||
return sshErr
|
||||
}
|
||||
if ping.Alive {
|
||||
name := ping.Name
|
||||
if health.Healthy {
|
||||
name := health.Name
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = vmRef
|
||||
}
|
||||
_, _ = fmt.Fprintln(stderr, vsockping.ReminderMessage(name))
|
||||
_, _ = fmt.Fprintln(stderr, vsockagent.ReminderMessage(name))
|
||||
}
|
||||
return sshErr
|
||||
}
|
||||
|
|
@ -1015,7 +1051,10 @@ func shouldCheckSSHReminder(err error) bool {
|
|||
return true
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
return errors.As(err, &exitErr)
|
||||
if !errors.As(err, &exitErr) {
|
||||
return false
|
||||
}
|
||||
return exitErr.ExitCode() != 255
|
||||
}
|
||||
|
||||
func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) {
|
||||
|
|
@ -1023,10 +1062,21 @@ func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]s
|
|||
return nil, errors.New("vm has no guest IP")
|
||||
}
|
||||
args := []string{}
|
||||
args = append(args, "-F", "/dev/null")
|
||||
if cfg.SSHKeyPath != "" {
|
||||
args = append(args, "-i", cfg.SSHKeyPath)
|
||||
}
|
||||
args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "root@"+guestIP)
|
||||
args = append(
|
||||
args,
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
"-o", "PasswordAuthentication=no",
|
||||
"-o", "KbdInteractiveAuthentication=no",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"root@"+guestIP,
|
||||
)
|
||||
args = append(args, extra...)
|
||||
return args, nil
|
||||
}
|
||||
|
|
@ -1035,14 +1085,29 @@ func validateSSHPrereqs(cfg model.DaemonConfig) error {
|
|||
checks := system.NewPreflight()
|
||||
checks.RequireCommand("ssh", "install openssh-client")
|
||||
if strings.TrimSpace(cfg.SSHKeyPath) != "" {
|
||||
checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or refresh the runtime bundle`)
|
||||
checks.RequireFile(cfg.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`)
|
||||
}
|
||||
return checks.Err("ssh preflight failed")
|
||||
}
|
||||
|
||||
func absolutizeImageBuildPaths(params *api.ImageBuildParams) error {
|
||||
return absolutizePaths(¶ms.BaseRootfs, ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir)
|
||||
}
|
||||
|
||||
func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error {
|
||||
return absolutizePaths(
|
||||
¶ms.RootfsPath,
|
||||
¶ms.WorkSeedPath,
|
||||
¶ms.KernelPath,
|
||||
¶ms.InitrdPath,
|
||||
¶ms.ModulesDir,
|
||||
¶ms.PackagesPath,
|
||||
)
|
||||
}
|
||||
|
||||
func absolutizePaths(values ...*string) error {
|
||||
var err error
|
||||
for _, value := range []*string{¶ms.BaseRootfs, ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir} {
|
||||
for _, value := range values {
|
||||
if *value == "" || filepath.IsAbs(*value) {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
|
@ -118,6 +119,23 @@ func TestVMCreateFlagsExist(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImageRegisterFlagsExist(t *testing.T) {
|
||||
root := NewBangerCommand()
|
||||
image, _, err := root.Find([]string{"image"})
|
||||
if err != nil {
|
||||
t.Fatalf("find image: %v", err)
|
||||
}
|
||||
register, _, err := image.Find([]string{"register"})
|
||||
if err != nil {
|
||||
t.Fatalf("find register: %v", err)
|
||||
}
|
||||
for _, flagName := range []string{"name", "rootfs", "work-seed", "kernel", "initrd", "modules", "packages", "docker"} {
|
||||
if register.Flags().Lookup(flagName) == nil {
|
||||
t.Fatalf("missing flag %q", flagName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMKillFlagsExist(t *testing.T) {
|
||||
root := NewBangerCommand()
|
||||
vm, _, err := root.Find([]string{"vm"})
|
||||
|
|
@ -211,19 +229,58 @@ func TestVMSetParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunSSHSessionPrintsReminderWhenPingAlive(t *testing.T) {
|
||||
func TestAbsolutizeImageRegisterPaths(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
params := api.ImageRegisterParams{
|
||||
RootfsPath: filepath.Join(".", "runtime", "rootfs-void.ext4"),
|
||||
WorkSeedPath: filepath.Join(".", "runtime", "rootfs-void.work-seed.ext4"),
|
||||
KernelPath: filepath.Join(".", "runtime", "vmlinux"),
|
||||
InitrdPath: filepath.Join(".", "runtime", "initrd.img"),
|
||||
ModulesDir: filepath.Join(".", "runtime", "modules"),
|
||||
PackagesPath: filepath.Join(".", "packages.void"),
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("Chdir(%s): %v", tmp, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
})
|
||||
|
||||
if err := absolutizeImageRegisterPaths(¶ms); err != nil {
|
||||
t.Fatalf("absolutizeImageRegisterPaths: %v", err)
|
||||
}
|
||||
for _, value := range []string{
|
||||
params.RootfsPath,
|
||||
params.WorkSeedPath,
|
||||
params.KernelPath,
|
||||
params.InitrdPath,
|
||||
params.ModulesDir,
|
||||
params.PackagesPath,
|
||||
} {
|
||||
if !filepath.IsAbs(value) {
|
||||
t.Fatalf("path %q is not absolute", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSSHSessionPrintsReminderWhenHealthCheckPasses(t *testing.T) {
|
||||
origSSHExec := sshExecFunc
|
||||
origPing := vmPingFunc
|
||||
origHealth := vmHealthFunc
|
||||
t.Cleanup(func() {
|
||||
sshExecFunc = origSSHExec
|
||||
vmPingFunc = origPing
|
||||
vmHealthFunc = origHealth
|
||||
})
|
||||
|
||||
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
return nil
|
||||
}
|
||||
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) {
|
||||
return api.VMPingResult{Name: "devbox", Alive: true}, nil
|
||||
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
||||
return api.VMHealthResult{Name: "devbox", Healthy: true}, nil
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
|
@ -235,19 +292,19 @@ func TestRunSSHSessionPrintsReminderWhenPingAlive(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunSSHSessionPreservesSSHExitStatusOnPingWarning(t *testing.T) {
|
||||
func TestRunSSHSessionPreservesSSHExitStatusOnHealthWarning(t *testing.T) {
|
||||
origSSHExec := sshExecFunc
|
||||
origPing := vmPingFunc
|
||||
origHealth := vmHealthFunc
|
||||
t.Cleanup(func() {
|
||||
sshExecFunc = origSSHExec
|
||||
vmPingFunc = origPing
|
||||
vmHealthFunc = origHealth
|
||||
})
|
||||
|
||||
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
return &exec.ExitError{}
|
||||
return exitErrorWithCode(t, 1)
|
||||
}
|
||||
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) {
|
||||
return api.VMPingResult{}, errors.New("dial failed")
|
||||
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
||||
return api.VMHealthResult{}, errors.New("dial failed")
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
|
@ -261,6 +318,37 @@ func TestRunSSHSessionPreservesSSHExitStatusOnPingWarning(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunSSHSessionSkipsReminderOnSSHAuthFailure(t *testing.T) {
|
||||
origSSHExec := sshExecFunc
|
||||
origHealth := vmHealthFunc
|
||||
t.Cleanup(func() {
|
||||
sshExecFunc = origSSHExec
|
||||
vmHealthFunc = origHealth
|
||||
})
|
||||
|
||||
healthCalled := false
|
||||
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
return exitErrorWithCode(t, 255)
|
||||
}
|
||||
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
||||
healthCalled = true
|
||||
return api.VMHealthResult{Name: "devbox", Healthy: true}, nil
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
err := runSSHSession(context.Background(), "/tmp/bangerd.sock", "devbox", strings.NewReader(""), &bytes.Buffer{}, &stderr, []string{"root@127.0.0.1"})
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 255 {
|
||||
t.Fatalf("runSSHSession error = %v, want exit 255", err)
|
||||
}
|
||||
if healthCalled {
|
||||
t.Fatal("vm health should not run after ssh auth failure")
|
||||
}
|
||||
if strings.Contains(stderr.String(), "still running") {
|
||||
t.Fatalf("stderr = %q, should not contain reminder", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVMTargetsDeduplicatesAndReportsErrors(t *testing.T) {
|
||||
vms := []model.VMRecord{
|
||||
testCLIResolvedVM("alpha-id", "alpha"),
|
||||
|
|
@ -358,7 +446,13 @@ func TestSSHCommandArgs(t *testing.T) {
|
|||
t.Fatalf("sshCommandArgs: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"-F", "/dev/null",
|
||||
"-i", "/bundle/id_ed25519",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
"-o", "PasswordAuthentication=no",
|
||||
"-o", "KbdInteractiveAuthentication=no",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"root@172.16.0.2",
|
||||
|
|
@ -381,6 +475,17 @@ func TestValidateSSHPrereqs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func exitErrorWithCode(t *testing.T, code int) *exec.ExitError {
|
||||
t.Helper()
|
||||
cmd := exec.Command("bash", "-lc", fmt.Sprintf("exit %d", code))
|
||||
err := cmd.Run()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("exitErrorWithCode(%d) error = %v, want exit error", code, err)
|
||||
}
|
||||
return exitErr
|
||||
}
|
||||
|
||||
func TestValidateSSHPrereqsFailsForMissingKey(t *testing.T) {
|
||||
err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: "/does/not/exist"})
|
||||
if err == nil || !strings.Contains(err.Error(), "ssh private key") {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
"banger/internal/paths"
|
||||
"banger/internal/rpc"
|
||||
"banger/internal/system"
|
||||
"banger/internal/vsockping"
|
||||
"banger/internal/vsockagent"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
|
|
@ -1466,22 +1466,22 @@ func sshDoneMsg(layout paths.Layout, action actionRequest, name string, execErr
|
|||
}
|
||||
pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
ping, err := vmPingFunc(pingCtx, layout.SocketPath, name)
|
||||
health, err := vmHealthFunc(pingCtx, layout.SocketPath, name)
|
||||
if err != nil {
|
||||
return actionResultMsg{
|
||||
action: action,
|
||||
status: vsockping.WarningMessage(name, err),
|
||||
status: vsockagent.WarningMessage(name, err),
|
||||
refresh: true,
|
||||
focusID: action.id,
|
||||
}
|
||||
}
|
||||
if ping.Alive {
|
||||
if strings.TrimSpace(ping.Name) != "" {
|
||||
name = ping.Name
|
||||
if health.Healthy {
|
||||
if strings.TrimSpace(health.Name) != "" {
|
||||
name = health.Name
|
||||
}
|
||||
return actionResultMsg{
|
||||
action: action,
|
||||
status: vsockping.ReminderMessage(name),
|
||||
status: vsockagent.ReminderMessage(name),
|
||||
refresh: true,
|
||||
focusID: action.id,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,13 +238,13 @@ func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSSHDoneMsgShowsReminderWhenPingAlive(t *testing.T) {
|
||||
origPing := vmPingFunc
|
||||
func TestSSHDoneMsgShowsReminderWhenHealthCheckPasses(t *testing.T) {
|
||||
origHealth := vmHealthFunc
|
||||
t.Cleanup(func() {
|
||||
vmPingFunc = origPing
|
||||
vmHealthFunc = origHealth
|
||||
})
|
||||
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) {
|
||||
return api.VMPingResult{Name: "devbox", Alive: true}, nil
|
||||
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
||||
return api.VMHealthResult{Name: "devbox", Healthy: true}, nil
|
||||
}
|
||||
|
||||
msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil)
|
||||
|
|
@ -257,13 +257,13 @@ func TestSSHDoneMsgShowsReminderWhenPingAlive(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSSHDoneMsgShowsWarningWhenPingFails(t *testing.T) {
|
||||
origPing := vmPingFunc
|
||||
func TestSSHDoneMsgShowsWarningWhenHealthCheckFails(t *testing.T) {
|
||||
origHealth := vmHealthFunc
|
||||
t.Cleanup(func() {
|
||||
vmPingFunc = origPing
|
||||
vmHealthFunc = origHealth
|
||||
})
|
||||
vmPingFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPingResult, error) {
|
||||
return api.VMPingResult{}, errors.New("dial failed")
|
||||
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
||||
return api.VMHealthResult{}, errors.New("dial failed")
|
||||
}
|
||||
|
||||
msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type fileConfig struct {
|
|||
SSHKeyPath string `toml:"ssh_key_path"`
|
||||
NamegenPath string `toml:"namegen_path"`
|
||||
CustomizeScript string `toml:"customize_script"`
|
||||
VSockAgent string `toml:"vsock_agent_path"`
|
||||
VSockPingHelper string `toml:"vsock_ping_helper_path"`
|
||||
DefaultWorkSeed string `toml:"default_work_seed"`
|
||||
DefaultImageName string `toml:"default_image_name"`
|
||||
|
|
@ -83,17 +84,16 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) {
|
|||
if file.LogLevel != "" {
|
||||
cfg.LogLevel = file.LogLevel
|
||||
}
|
||||
if file.SSHKeyPath != "" {
|
||||
cfg.SSHKeyPath = file.SSHKeyPath
|
||||
}
|
||||
if file.NamegenPath != "" {
|
||||
cfg.NamegenPath = file.NamegenPath
|
||||
}
|
||||
if file.CustomizeScript != "" {
|
||||
cfg.CustomizeScript = file.CustomizeScript
|
||||
}
|
||||
if file.VSockPingHelper != "" {
|
||||
cfg.VSockPingHelperPath = file.VSockPingHelper
|
||||
if file.VSockAgent != "" {
|
||||
cfg.VSockAgentPath = file.VSockAgent
|
||||
} else if file.VSockPingHelper != "" {
|
||||
cfg.VSockAgentPath = file.VSockPingHelper
|
||||
}
|
||||
if file.DefaultWorkSeed != "" {
|
||||
cfg.DefaultWorkSeed = file.DefaultWorkSeed
|
||||
|
|
@ -197,7 +197,7 @@ func applyBundleMetadataDefaults(cfg *model.DaemonConfig, runtimeDir string, met
|
|||
cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, runtimeDir, meta.SSHKeyPath)
|
||||
cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, runtimeDir, meta.NamegenPath)
|
||||
cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, runtimeDir, meta.CustomizeScript)
|
||||
cfg.VSockPingHelperPath = defaultRuntimePath(cfg.VSockPingHelperPath, runtimeDir, meta.VSockPingHelperPath)
|
||||
cfg.VSockAgentPath = defaultRuntimePath(cfg.VSockAgentPath, runtimeDir, meta.VSockAgentPath)
|
||||
cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, runtimeDir, meta.DefaultWorkSeed)
|
||||
cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, runtimeDir, meta.DefaultKernel)
|
||||
cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, runtimeDir, meta.DefaultInitrd)
|
||||
|
|
@ -212,7 +212,10 @@ func applyLegacyRuntimeDefaults(cfg *model.DaemonConfig) {
|
|||
cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, cfg.RuntimeDir, "id_ed25519")
|
||||
cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, cfg.RuntimeDir, "namegen")
|
||||
cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, cfg.RuntimeDir, "customize.sh")
|
||||
cfg.VSockPingHelperPath = defaultRuntimePath(cfg.VSockPingHelperPath, cfg.RuntimeDir, "banger-vsock-pingd")
|
||||
cfg.VSockAgentPath = firstExistingRuntimePath(
|
||||
defaultRuntimePath(cfg.VSockAgentPath, cfg.RuntimeDir, "banger-vsock-agent"),
|
||||
filepath.Join(cfg.RuntimeDir, "banger-vsock-pingd"),
|
||||
)
|
||||
cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, cfg.RuntimeDir, "rootfs-docker.work-seed.ext4")
|
||||
cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, cfg.RuntimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic")
|
||||
cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, cfg.RuntimeDir, "wtf/root/boot/initrd.img-6.8.0-94-generic")
|
||||
|
|
|
|||
|
|
@ -13,24 +13,24 @@ import (
|
|||
func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) {
|
||||
runtimeDir := t.TempDir()
|
||||
meta := runtimebundle.BundleMetadata{
|
||||
FirecrackerBin: "bin/firecracker",
|
||||
SSHKeyPath: "keys/id_ed25519",
|
||||
NamegenPath: "bin/namegen",
|
||||
CustomizeScript: "scripts/customize.sh",
|
||||
VSockPingHelperPath: "bin/banger-vsock-pingd",
|
||||
DefaultPackages: "config/packages.apt",
|
||||
DefaultRootfs: "images/rootfs-docker.ext4",
|
||||
DefaultWorkSeed: "images/rootfs-docker.work-seed.ext4",
|
||||
DefaultKernel: "kernels/vmlinux",
|
||||
DefaultInitrd: "kernels/initrd.img",
|
||||
DefaultModulesDir: "modules/current",
|
||||
FirecrackerBin: "bin/firecracker",
|
||||
SSHKeyPath: "keys/id_ed25519",
|
||||
NamegenPath: "bin/namegen",
|
||||
CustomizeScript: "scripts/customize.sh",
|
||||
VSockAgentPath: "bin/banger-vsock-agent",
|
||||
DefaultPackages: "config/packages.apt",
|
||||
DefaultRootfs: "images/rootfs-docker.ext4",
|
||||
DefaultWorkSeed: "images/rootfs-docker.work-seed.ext4",
|
||||
DefaultKernel: "kernels/vmlinux",
|
||||
DefaultInitrd: "kernels/initrd.img",
|
||||
DefaultModulesDir: "modules/current",
|
||||
}
|
||||
for _, rel := range []string{
|
||||
meta.FirecrackerBin,
|
||||
meta.SSHKeyPath,
|
||||
meta.NamegenPath,
|
||||
meta.CustomizeScript,
|
||||
meta.VSockPingHelperPath,
|
||||
meta.VSockAgentPath,
|
||||
meta.DefaultPackages,
|
||||
meta.DefaultRootfs,
|
||||
meta.DefaultWorkSeed,
|
||||
|
|
@ -75,8 +75,8 @@ func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) {
|
|||
if cfg.CustomizeScript != filepath.Join(runtimeDir, meta.CustomizeScript) {
|
||||
t.Fatalf("CustomizeScript = %q", cfg.CustomizeScript)
|
||||
}
|
||||
if cfg.VSockPingHelperPath != filepath.Join(runtimeDir, meta.VSockPingHelperPath) {
|
||||
t.Fatalf("VSockPingHelperPath = %q", cfg.VSockPingHelperPath)
|
||||
if cfg.VSockAgentPath != filepath.Join(runtimeDir, meta.VSockAgentPath) {
|
||||
t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath)
|
||||
}
|
||||
if cfg.DefaultRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) {
|
||||
t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs)
|
||||
|
|
@ -108,7 +108,7 @@ func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) {
|
|||
"id_ed25519",
|
||||
"namegen",
|
||||
"customize.sh",
|
||||
"banger-vsock-pingd",
|
||||
"banger-vsock-agent",
|
||||
"packages.apt",
|
||||
"rootfs-docker.ext4",
|
||||
"rootfs-docker.work-seed.ext4",
|
||||
|
|
@ -134,8 +134,8 @@ func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) {
|
|||
if cfg.FirecrackerBin != filepath.Join(runtimeDir, "firecracker") {
|
||||
t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin)
|
||||
}
|
||||
if cfg.VSockPingHelperPath != filepath.Join(runtimeDir, "banger-vsock-pingd") {
|
||||
t.Fatalf("VSockPingHelperPath = %q", cfg.VSockPingHelperPath)
|
||||
if cfg.VSockAgentPath != filepath.Join(runtimeDir, "banger-vsock-agent") {
|
||||
t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath)
|
||||
}
|
||||
if cfg.DefaultWorkSeed != filepath.Join(runtimeDir, "rootfs-docker.work-seed.ext4") {
|
||||
t.Fatalf("DefaultWorkSeed = %q", cfg.DefaultWorkSeed)
|
||||
|
|
@ -167,3 +167,125 @@ func TestLoadDefaultsLogLevelToInfo(t *testing.T) {
|
|||
t.Fatalf("LogLevel = %q, want info", cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIgnoresConfigSSHKeyOverrideForGuestAccess(t *testing.T) {
|
||||
runtimeDir := t.TempDir()
|
||||
meta := runtimebundle.BundleMetadata{
|
||||
FirecrackerBin: "bin/firecracker",
|
||||
SSHKeyPath: "keys/id_ed25519",
|
||||
NamegenPath: "bin/namegen",
|
||||
CustomizeScript: "scripts/customize.sh",
|
||||
VSockAgentPath: "bin/banger-vsock-agent",
|
||||
DefaultPackages: "config/packages.apt",
|
||||
DefaultRootfs: "images/rootfs.ext4",
|
||||
DefaultWorkSeed: "images/rootfs.work-seed.ext4",
|
||||
DefaultKernel: "kernels/vmlinux",
|
||||
DefaultModulesDir: "modules/current",
|
||||
}
|
||||
for _, rel := range []string{
|
||||
meta.FirecrackerBin,
|
||||
meta.SSHKeyPath,
|
||||
meta.NamegenPath,
|
||||
meta.CustomizeScript,
|
||||
meta.VSockAgentPath,
|
||||
meta.DefaultPackages,
|
||||
meta.DefaultRootfs,
|
||||
meta.DefaultWorkSeed,
|
||||
meta.DefaultKernel,
|
||||
filepath.Join(meta.DefaultModulesDir, "modules.dep"),
|
||||
} {
|
||||
path := filepath.Join(runtimeDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil {
|
||||
t.Fatalf("write bundle metadata: %v", err)
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("ssh_key_path = \"/tmp/override-key\"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write config.toml: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("BANGER_RUNTIME_DIR", runtimeDir)
|
||||
cfg, err := Load(paths.Layout{ConfigDir: configDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
want := filepath.Join(runtimeDir, meta.SSHKeyPath)
|
||||
if cfg.SSHKeyPath != want {
|
||||
t.Fatalf("SSHKeyPath = %q, want runtime key %q", cfg.SSHKeyPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsLegacyBundleVsockPingHelperPath(t *testing.T) {
|
||||
runtimeDir := t.TempDir()
|
||||
meta := runtimebundle.BundleMetadata{
|
||||
FirecrackerBin: "bin/firecracker",
|
||||
SSHKeyPath: "keys/id_ed25519",
|
||||
NamegenPath: "bin/namegen",
|
||||
CustomizeScript: "scripts/customize.sh",
|
||||
VSockPingHelperPath: "bin/banger-vsock-pingd",
|
||||
DefaultPackages: "config/packages.apt",
|
||||
DefaultRootfs: "images/rootfs.ext4",
|
||||
DefaultKernel: "kernels/vmlinux",
|
||||
}
|
||||
for _, rel := range []string{
|
||||
meta.FirecrackerBin,
|
||||
meta.SSHKeyPath,
|
||||
meta.NamegenPath,
|
||||
meta.CustomizeScript,
|
||||
meta.VSockPingHelperPath,
|
||||
meta.DefaultPackages,
|
||||
meta.DefaultRootfs,
|
||||
meta.DefaultKernel,
|
||||
} {
|
||||
path := filepath.Join(runtimeDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil {
|
||||
t.Fatalf("write bundle metadata: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("BANGER_RUNTIME_DIR", runtimeDir)
|
||||
cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.VSockAgentPath != filepath.Join(runtimeDir, meta.VSockPingHelperPath) {
|
||||
t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsLegacyConfigVsockPingHelperPath(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("vsock_ping_helper_path = \"/tmp/legacy-agent\"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write config.toml: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(paths.Layout{ConfigDir: configDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.VSockAgentPath != "/tmp/legacy-agent" {
|
||||
t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,7 +191,10 @@ func (workDiskCapability) ContributeMachine(cfg *firecracker.MachineConfig, vm m
|
|||
}
|
||||
|
||||
func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.VMRecord, image model.Image) error {
|
||||
return d.ensureWorkDisk(ctx, vm, image)
|
||||
if err := d.ensureWorkDisk(ctx, vm, image); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.ensureAuthorizedKeyOnWorkDisk(ctx, vm)
|
||||
}
|
||||
|
||||
func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) {
|
||||
|
|
|
|||
|
|
@ -331,6 +331,13 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
return rpc.NewError("not_running", fmt.Sprintf("vm %s is not running", vm.Name))
|
||||
}
|
||||
return marshalResultOrError(api.VMSSHResult{Name: vm.Name, GuestIP: vm.Runtime.GuestIP}, nil)
|
||||
case "vm.health":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
result, err := d.HealthVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(result, err)
|
||||
case "vm.ping":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
|
|
@ -355,6 +362,13 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
}
|
||||
image, err := d.BuildImage(ctx, params)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
case "image.register":
|
||||
params, err := rpc.DecodeParams[api.ImageRegisterParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
image, err := d.RegisterImage(ctx, params)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
case "image.delete":
|
||||
params, err := rpc.DecodeParams[api.ImageRefParams](req)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -246,6 +246,128 @@ func TestEnsureDefaultImageSkipsRewriteWhenCurrentArtifactsMissing(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestRegisterImageCreatesUnmanagedImage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootfs, kernel, initrd, modulesDir, _ := writeDefaultImageArtifacts(t, dir)
|
||||
workSeed := filepath.Join(dir, "rootfs-void.work-seed.ext4")
|
||||
packages := filepath.Join(dir, "packages.void")
|
||||
if err := os.WriteFile(workSeed, []byte("seed"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(workSeed): %v", err)
|
||||
}
|
||||
if err := os.WriteFile(packages, []byte("base-minimal\nopenssh\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(packages): %v", err)
|
||||
}
|
||||
db := openDefaultImageStore(t, dir)
|
||||
d := &Daemon{
|
||||
config: model.DaemonConfig{
|
||||
DefaultKernel: kernel,
|
||||
DefaultInitrd: initrd,
|
||||
DefaultModulesDir: modulesDir,
|
||||
},
|
||||
store: db,
|
||||
}
|
||||
|
||||
image, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
|
||||
Name: "void-exp",
|
||||
RootfsPath: rootfs,
|
||||
WorkSeedPath: workSeed,
|
||||
PackagesPath: packages,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterImage: %v", err)
|
||||
}
|
||||
if image.Managed {
|
||||
t.Fatal("registered image should be unmanaged")
|
||||
}
|
||||
if image.Name != "void-exp" || image.RootfsPath != rootfs || image.WorkSeedPath != workSeed || image.KernelPath != kernel {
|
||||
t.Fatalf("registered image = %+v", image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterImageUpdatesExistingUnmanagedImageInPlace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, kernel, initrd, modulesDir, _ := writeDefaultImageArtifacts(t, dir)
|
||||
newRootfs := filepath.Join(dir, "rootfs-void-next.ext4")
|
||||
newWorkSeed := filepath.Join(dir, "rootfs-void-next.work-seed.ext4")
|
||||
packages := filepath.Join(dir, "packages.void")
|
||||
for _, path := range []string{newRootfs, newWorkSeed} {
|
||||
if err := os.WriteFile(path, []byte("next"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s): %v", path, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(packages, []byte("base-minimal\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(packages): %v", err)
|
||||
}
|
||||
db := openDefaultImageStore(t, dir)
|
||||
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
|
||||
existing := model.Image{
|
||||
ID: "void-image-id",
|
||||
Name: "void-exp",
|
||||
Managed: false,
|
||||
RootfsPath: filepath.Join(dir, "old-rootfs.ext4"),
|
||||
KernelPath: kernel,
|
||||
InitrdPath: initrd,
|
||||
ModulesDir: modulesDir,
|
||||
PackagesPath: packages,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.UpsertImage(context.Background(), existing); err != nil {
|
||||
t.Fatalf("UpsertImage: %v", err)
|
||||
}
|
||||
d := &Daemon{
|
||||
config: model.DaemonConfig{
|
||||
DefaultKernel: kernel,
|
||||
DefaultInitrd: initrd,
|
||||
DefaultModulesDir: modulesDir,
|
||||
},
|
||||
store: db,
|
||||
}
|
||||
|
||||
image, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
|
||||
Name: "void-exp",
|
||||
RootfsPath: newRootfs,
|
||||
WorkSeedPath: newWorkSeed,
|
||||
PackagesPath: packages,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterImage: %v", err)
|
||||
}
|
||||
if image.ID != existing.ID || !image.CreatedAt.Equal(existing.CreatedAt) {
|
||||
t.Fatalf("updated image identity changed: %+v", image)
|
||||
}
|
||||
if image.RootfsPath != newRootfs || image.WorkSeedPath != newWorkSeed {
|
||||
t.Fatalf("updated image paths not applied: %+v", image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterImageRejectsManagedOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir)
|
||||
db := openDefaultImageStore(t, dir)
|
||||
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
|
||||
if err := db.UpsertImage(context.Background(), model.Image{
|
||||
ID: "managed-id",
|
||||
Name: "void-exp",
|
||||
Managed: true,
|
||||
RootfsPath: rootfs,
|
||||
KernelPath: kernel,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertImage: %v", err)
|
||||
}
|
||||
d := &Daemon{config: model.DaemonConfig{DefaultKernel: kernel}, store: db}
|
||||
|
||||
_, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
|
||||
Name: "void-exp",
|
||||
RootfsPath: rootfs,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot be updated via register") {
|
||||
t.Fatalf("RegisterImage(managed) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openDefaultImageStore(t *testing.T, dir string) *store.Store {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(dir, "state.db"))
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ func (d *Daemon) doctorReport(ctx context.Context) system.Report {
|
|||
|
||||
report.AddPreflight("runtime bundle", d.runtimeBundleChecks(), runtimeBundleStatus(d.config))
|
||||
report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available")
|
||||
report.AddPreflight("vsock ssh reminder", d.vsockChecks(), "vsock reminder prerequisites available")
|
||||
report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock agent prerequisites available")
|
||||
d.addCapabilityDoctorChecks(ctx, &report)
|
||||
report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available")
|
||||
|
||||
|
|
@ -44,8 +44,8 @@ func (d *Daemon) runtimeBundleChecks() *system.Preflight {
|
|||
checks := system.NewPreflight()
|
||||
hint := paths.RuntimeBundleHint()
|
||||
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
|
||||
checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or refresh the runtime bundle`)
|
||||
checks.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`)
|
||||
checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`)
|
||||
checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
|
||||
checks.RequireFile(d.config.DefaultRootfs, "default rootfs image", `set "default_rootfs" or refresh the runtime bundle`)
|
||||
checks.RequireFile(d.config.DefaultKernel, "kernel image", `set "default_kernel" or refresh the runtime bundle`)
|
||||
if strings.TrimSpace(d.config.DefaultInitrd) != "" {
|
||||
|
|
@ -79,7 +79,7 @@ func (d *Daemon) imageBuildChecks(ctx context.Context) *system.Preflight {
|
|||
|
||||
func (d *Daemon) vsockChecks() *system.Preflight {
|
||||
checks := system.NewPreflight()
|
||||
checks.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`)
|
||||
checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
|
||||
checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host")
|
||||
return checks
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
"banger/internal/hostnat"
|
||||
"banger/internal/model"
|
||||
"banger/internal/system"
|
||||
"banger/internal/vsockping"
|
||||
"banger/internal/vsockagent"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -104,14 +104,14 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (
|
|||
}
|
||||
defer client.Close()
|
||||
|
||||
helperBytes, err := os.ReadFile(d.config.VSockPingHelperPath)
|
||||
helperBytes, err := os.ReadFile(d.config.VSockAgentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeBuildLog(spec.BuildLog, "installing vsock ping helper"); err != nil {
|
||||
if err := writeBuildLog(spec.BuildLog, "installing vsock agent"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.UploadFile(ctx, vsockping.GuestInstallPath, 0o755, helperBytes, spec.BuildLog); err != nil {
|
||||
if err := client.UploadFile(ctx, vsockagent.GuestInstallPath, 0o755, helperBytes, spec.BuildLog); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil {
|
||||
|
|
@ -333,14 +333,14 @@ func appendTmuxSetup(script *bytes.Buffer) {
|
|||
func appendVSockPingSetup(script *bytes.Buffer) {
|
||||
script.WriteString("mkdir -p /etc/modules-load.d /etc/systemd/system\n")
|
||||
script.WriteString("cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'\n")
|
||||
script.WriteString(vsockping.ModulesLoadConfig())
|
||||
script.WriteString(vsockagent.ModulesLoadConfig())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/modules-load.d/banger-vsock.conf\n")
|
||||
script.WriteString("cat > /etc/systemd/system/" + vsockping.ServiceName + " <<'EOF'\n")
|
||||
script.WriteString(vsockping.ServiceUnit())
|
||||
script.WriteString("cat > /etc/systemd/system/" + vsockagent.ServiceName + " <<'EOF'\n")
|
||||
script.WriteString(vsockagent.ServiceUnit())
|
||||
script.WriteString("EOF\n")
|
||||
script.WriteString("chmod 0644 /etc/systemd/system/" + vsockping.ServiceName + "\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + vsockping.ServiceName + " || true; fi\n")
|
||||
script.WriteString("chmod 0644 /etc/systemd/system/" + vsockagent.ServiceName + "\n")
|
||||
script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + vsockagent.ServiceName + " || true; fi\n")
|
||||
}
|
||||
|
||||
func appendGitRepo(script *bytes.Buffer, dir, repo string) {
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) {
|
|||
"run '~/.tmux/plugins/tpm/tpm'",
|
||||
"cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'",
|
||||
"vmw_vsock_virtio_transport",
|
||||
"cat > /etc/systemd/system/banger-vsock-pingd.service <<'EOF'",
|
||||
"ExecStart=/usr/local/bin/banger-vsock-pingd",
|
||||
"systemctl enable --now banger-vsock-pingd.service || true",
|
||||
"cat > /etc/systemd/system/banger-vsock-agent.service <<'EOF'",
|
||||
"ExecStart=/usr/local/bin/banger-vsock-agent",
|
||||
"systemctl enable --now banger-vsock-agent.service || true",
|
||||
"rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh",
|
||||
} {
|
||||
if !strings.Contains(script, snippet) {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ package daemon
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/model"
|
||||
|
|
@ -132,6 +135,110 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i
|
|||
return image, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterParams) (image model.Image, err error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
name := strings.TrimSpace(params.Name)
|
||||
if name == "" {
|
||||
return model.Image{}, fmt.Errorf("image name is required")
|
||||
}
|
||||
|
||||
rootfsPath := strings.TrimSpace(params.RootfsPath)
|
||||
if rootfsPath == "" {
|
||||
return model.Image{}, fmt.Errorf("rootfs path is required")
|
||||
}
|
||||
workSeedPath := strings.TrimSpace(params.WorkSeedPath)
|
||||
if workSeedPath == "" {
|
||||
candidate := system.WorkSeedPath(rootfsPath)
|
||||
if candidate != "" {
|
||||
if _, statErr := os.Stat(candidate); statErr == nil {
|
||||
workSeedPath = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
kernelPath := strings.TrimSpace(params.KernelPath)
|
||||
if kernelPath == "" {
|
||||
kernelPath = d.config.DefaultKernel
|
||||
}
|
||||
initrdPath := strings.TrimSpace(params.InitrdPath)
|
||||
if initrdPath == "" {
|
||||
initrdPath = d.config.DefaultInitrd
|
||||
}
|
||||
modulesDir := strings.TrimSpace(params.ModulesDir)
|
||||
if modulesDir == "" {
|
||||
modulesDir = d.config.DefaultModulesDir
|
||||
}
|
||||
packagesPath := strings.TrimSpace(params.PackagesPath)
|
||||
|
||||
if err := validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
|
||||
now := model.Now()
|
||||
existing, lookupErr := d.store.GetImageByName(ctx, name)
|
||||
switch {
|
||||
case lookupErr == nil:
|
||||
if existing.Managed {
|
||||
return model.Image{}, fmt.Errorf("managed image %s cannot be updated via register", name)
|
||||
}
|
||||
image = existing
|
||||
image.RootfsPath = rootfsPath
|
||||
image.WorkSeedPath = workSeedPath
|
||||
image.KernelPath = kernelPath
|
||||
image.InitrdPath = initrdPath
|
||||
image.ModulesDir = modulesDir
|
||||
image.PackagesPath = packagesPath
|
||||
image.Docker = params.Docker
|
||||
image.UpdatedAt = now
|
||||
case errors.Is(lookupErr, sql.ErrNoRows):
|
||||
id, idErr := model.NewID()
|
||||
if idErr != nil {
|
||||
return model.Image{}, idErr
|
||||
}
|
||||
image = model.Image{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Managed: false,
|
||||
RootfsPath: rootfsPath,
|
||||
WorkSeedPath: workSeedPath,
|
||||
KernelPath: kernelPath,
|
||||
InitrdPath: initrdPath,
|
||||
ModulesDir: modulesDir,
|
||||
PackagesPath: packagesPath,
|
||||
Docker: params.Docker,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
default:
|
||||
return model.Image{}, lookupErr
|
||||
}
|
||||
|
||||
if err := d.store.UpsertImage(ctx, image); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath string) error {
|
||||
checks := system.NewPreflight()
|
||||
checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs <path>`)
|
||||
checks.RequireFile(kernelPath, "kernel image", `pass --kernel <path> or set "default_kernel"`)
|
||||
if workSeedPath != "" {
|
||||
checks.RequireFile(workSeedPath, "work-seed image", `pass --work-seed <path> or rebuild the image with a work seed`)
|
||||
}
|
||||
if initrdPath != "" {
|
||||
checks.RequireFile(initrdPath, "initrd image", `pass --initrd <path> or set "default_initrd"`)
|
||||
}
|
||||
if modulesDir != "" {
|
||||
checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules <dir> or set "default_modules_dir"`)
|
||||
}
|
||||
if packagesPath != "" {
|
||||
checks.RequireFile(packagesPath, "packages manifest", `pass --packages <path>`)
|
||||
}
|
||||
return checks.Err("image register failed")
|
||||
}
|
||||
|
||||
func writePackagesMetadata(rootfsPath, packagesPath string) error {
|
||||
if rootfsPath == "" || packagesPath == "" {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
|
|||
t.Setenv("PATH", binDir)
|
||||
|
||||
firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
|
||||
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-pingd")
|
||||
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent")
|
||||
if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write firecracker: %v", err)
|
||||
}
|
||||
|
|
@ -105,12 +105,12 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
|
|||
d := &Daemon{
|
||||
layout: paths.Layout{RuntimeDir: filepath.Join(t.TempDir(), "runtime")},
|
||||
config: model.DaemonConfig{
|
||||
BridgeName: "br-fc",
|
||||
BridgeIP: model.DefaultBridgeIP,
|
||||
DefaultDNS: model.DefaultDNS,
|
||||
FirecrackerBin: firecrackerBin,
|
||||
VSockPingHelperPath: vsockHelper,
|
||||
StatsPollInterval: model.DefaultStatsPollInterval,
|
||||
BridgeName: "br-fc",
|
||||
BridgeIP: model.DefaultBridgeIP,
|
||||
DefaultDNS: model.DefaultDNS,
|
||||
FirecrackerBin: firecrackerBin,
|
||||
VSockAgentPath: vsockHelper,
|
||||
StatsPollInterval: model.DefaultStatsPollInterval,
|
||||
},
|
||||
runner: runner,
|
||||
logger: logger,
|
||||
|
|
@ -151,7 +151,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
|
|||
packagesPath := filepath.Join(t.TempDir(), "packages.apt")
|
||||
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
|
||||
firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
|
||||
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-pingd")
|
||||
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent")
|
||||
for _, path := range []string{baseRootfs, kernelPath, packagesPath, sshKeyPath} {
|
||||
if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
|
|
@ -186,7 +186,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
|
|||
DefaultPackagesFile: packagesPath,
|
||||
SSHKeyPath: sshKeyPath,
|
||||
FirecrackerBin: firecrackerBin,
|
||||
VSockPingHelperPath: vsockHelper,
|
||||
VSockAgentPath: vsockHelper,
|
||||
},
|
||||
store: store,
|
||||
runner: runner,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ func (d *Daemon) addBaseStartPrereqs(checks *system.Preflight, image model.Image
|
|||
|
||||
d.addBaseStartCommandPrereqs(checks)
|
||||
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
|
||||
checks.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`)
|
||||
checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
|
||||
checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host")
|
||||
checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid image or rebuild the runtime bundle")
|
||||
checks.RequireFile(image.KernelPath, "kernel image", `set "default_kernel" or refresh the runtime bundle`)
|
||||
|
|
@ -79,8 +79,8 @@ func (d *Daemon) addImageBuildPrereqs(ctx context.Context, checks *system.Prefli
|
|||
checks.RequireCommand(command, toolHint(command))
|
||||
}
|
||||
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
|
||||
checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or refresh the runtime bundle`)
|
||||
checks.RequireExecutable(d.config.VSockPingHelperPath, "vsock ping helper", `run 'make build' or refresh the runtime bundle`)
|
||||
checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`)
|
||||
checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
|
||||
checks.RequireFile(baseRootfs, "base rootfs image", `pass --base-rootfs or set "default_base_rootfs"`)
|
||||
checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`)
|
||||
checks.RequireFile(d.config.DefaultPackagesFile, "package manifest", `set "default_packages_file" or refresh the runtime bundle`)
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ import (
|
|||
|
||||
"banger/internal/api"
|
||||
"banger/internal/firecracker"
|
||||
"banger/internal/guest"
|
||||
"banger/internal/guestconfig"
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/system"
|
||||
"banger/internal/vmdns"
|
||||
"banger/internal/vsockagent"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -582,11 +584,11 @@ func (d *Daemon) GetVMStats(ctx context.Context, idOrName string) (model.VMRecor
|
|||
return vm, vm.Stats, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) PingVM(ctx context.Context, idOrName string) (result api.VMPingResult, err error) {
|
||||
func (d *Daemon) HealthVM(ctx context.Context, idOrName string) (result api.VMHealthResult, err error) {
|
||||
_, err = d.withVMLockByRef(ctx, idOrName, func(vm model.VMRecord) (model.VMRecord, error) {
|
||||
result.Name = vm.Name
|
||||
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
result.Alive = false
|
||||
result.Healthy = false
|
||||
return vm, nil
|
||||
}
|
||||
if strings.TrimSpace(vm.Runtime.VSockPath) == "" {
|
||||
|
|
@ -600,15 +602,23 @@ func (d *Daemon) PingVM(ctx context.Context, idOrName string) (result api.VMPing
|
|||
}
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
if err := firecracker.PingVSock(pingCtx, d.logger, vm.Runtime.VSockPath); err != nil {
|
||||
if err := vsockagent.Health(pingCtx, d.logger, vm.Runtime.VSockPath); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
result.Alive = true
|
||||
result.Healthy = true
|
||||
return vm, nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Daemon) PingVM(ctx context.Context, idOrName string) (result api.VMPingResult, err error) {
|
||||
health, err := d.HealthVM(ctx, idOrName)
|
||||
if err != nil {
|
||||
return api.VMPingResult{}, err
|
||||
}
|
||||
return api.VMPingResult{Name: health.Name, Alive: health.Healthy}, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) getVMStatsLocked(ctx context.Context, vm model.VMRecord) (model.VMRecord, error) {
|
||||
stats, err := d.collectStats(ctx, vm)
|
||||
if err == nil {
|
||||
|
|
@ -814,6 +824,84 @@ func (d *Daemon) ensureWorkDisk(ctx context.Context, vm *model.VMRecord, image m
|
|||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
publicKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive authorized ssh key: %w", err)
|
||||
}
|
||||
workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupWork()
|
||||
|
||||
if err := d.flattenNestedWorkHome(ctx, workMount); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshDir := filepath.Join(workMount, ".ssh")
|
||||
if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", sshDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := d.runner.RunSudo(ctx, "chmod", "700", sshDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authorizedKeysPath := filepath.Join(sshDir, "authorized_keys")
|
||||
existing, err := d.runner.RunSudo(ctx, "cat", authorizedKeysPath)
|
||||
if err != nil {
|
||||
existing = nil
|
||||
}
|
||||
merged := mergeAuthorizedKey(existing, publicKey)
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "banger-authorized-keys-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if _, err := tmpFile.Write(merged); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeAuthorizedKey(existing, managed []byte) []byte {
|
||||
managedLine := strings.TrimSpace(string(managed))
|
||||
if managedLine == "" {
|
||||
return append([]byte(nil), existing...)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.ReplaceAll(string(existing), "\r\n", "\n"), "\n")
|
||||
out := make([]string, 0, len(lines)+1)
|
||||
found := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed == managedLine {
|
||||
found = true
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if !found {
|
||||
out = append(out, managedLine)
|
||||
}
|
||||
return []byte(strings.Join(out, "\n") + "\n")
|
||||
}
|
||||
|
||||
func (d *Daemon) flattenNestedWorkHome(ctx context.Context, workMount string) error {
|
||||
nestedHome := filepath.Join(workMount, "root")
|
||||
if !exists(nestedHome) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ package daemon
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
|
@ -253,7 +257,7 @@ func TestSetVMRejectsStoppedOnlyChangesForRunningVM(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPingVMReturnsAliveForRunningGuest(t *testing.T) {
|
||||
func TestHealthVMReturnsHealthyForRunningGuest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
|
@ -296,16 +300,24 @@ func TestPingVMReturnsAliveForRunningGuest(t *testing.T) {
|
|||
serverDone <- err
|
||||
return
|
||||
}
|
||||
n, err = conn.Read(buf)
|
||||
if err != nil {
|
||||
serverDone <- err
|
||||
reqBuf := make([]byte, 0, 512)
|
||||
reqBuf = append(reqBuf, buf[:0]...)
|
||||
for {
|
||||
n, err = conn.Read(buf)
|
||||
if err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
reqBuf = append(reqBuf, buf[:n]...)
|
||||
if strings.Contains(string(reqBuf), "\r\n\r\n") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if got := string(reqBuf); !strings.Contains(got, "GET /healthz HTTP/1.1\r\n") {
|
||||
serverDone <- fmt.Errorf("unexpected health payload %q", got)
|
||||
return
|
||||
}
|
||||
if got := string(buf[:n]); got != "PING\n" {
|
||||
serverDone <- fmt.Errorf("unexpected ping payload %q", got)
|
||||
return
|
||||
}
|
||||
_, err = conn.Write([]byte("PONG\n"))
|
||||
_, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
|
||||
serverDone <- err
|
||||
}()
|
||||
|
||||
|
|
@ -326,12 +338,12 @@ func TestPingVMReturnsAliveForRunningGuest(t *testing.T) {
|
|||
},
|
||||
}
|
||||
d := &Daemon{store: db, runner: runner}
|
||||
result, err := d.PingVM(ctx, vm.Name)
|
||||
result, err := d.HealthVM(ctx, vm.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("PingVM: %v", err)
|
||||
t.Fatalf("HealthVM: %v", err)
|
||||
}
|
||||
if !result.Alive || result.Name != vm.Name {
|
||||
t.Fatalf("PingVM result = %+v, want alive %s", result, vm.Name)
|
||||
if !result.Healthy || result.Name != vm.Name {
|
||||
t.Fatalf("HealthVM result = %+v, want healthy %s", result, vm.Name)
|
||||
}
|
||||
runner.assertExhausted()
|
||||
if err := <-serverDone; err != nil {
|
||||
|
|
@ -339,7 +351,65 @@ func TestPingVMReturnsAliveForRunningGuest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPingVMReturnsFalseForStoppedVM(t *testing.T) {
|
||||
func TestPingVMAliasReturnsAliveForHealthyVM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
apiSock := filepath.Join(t.TempDir(), "fc.sock")
|
||||
fake := startFakeFirecrackerProcess(t, apiSock)
|
||||
t.Cleanup(func() {
|
||||
_ = fake.Process.Kill()
|
||||
_ = fake.Wait()
|
||||
})
|
||||
vsockSock := filepath.Join(t.TempDir(), "fc.vsock")
|
||||
listener, err := net.Listen("unix", vsockSock)
|
||||
if err != nil {
|
||||
t.Fatalf("listen vsock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
_ = os.Remove(vsockSock)
|
||||
})
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 512)
|
||||
_, _ = conn.Read(buf)
|
||||
_, _ = conn.Write([]byte("OK 1\n"))
|
||||
_, _ = conn.Read(buf)
|
||||
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
|
||||
}()
|
||||
vm := testVM("healthy-ping", "image-healthy", "172.16.0.42")
|
||||
vm.State = model.VMStateRunning
|
||||
vm.Runtime.State = model.VMStateRunning
|
||||
vm.Runtime.PID = fake.Process.Pid
|
||||
vm.Runtime.APISockPath = apiSock
|
||||
vm.Runtime.VSockPath = vsockSock
|
||||
vm.Runtime.VSockCID = 10042
|
||||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
runner := &scriptedRunner{
|
||||
t: t,
|
||||
steps: []runnerStep{
|
||||
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
|
||||
sudoStep("", nil, "chmod", "600", vsockSock),
|
||||
},
|
||||
}
|
||||
d := &Daemon{store: db, runner: runner}
|
||||
result, err := d.PingVM(ctx, vm.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("PingVM: %v", err)
|
||||
}
|
||||
if !result.Alive {
|
||||
t.Fatalf("PingVM result = %+v, want alive", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthVMReturnsFalseForStoppedVM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
|
@ -348,12 +418,12 @@ func TestPingVMReturnsFalseForStoppedVM(t *testing.T) {
|
|||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
d := &Daemon{store: db}
|
||||
result, err := d.PingVM(ctx, vm.Name)
|
||||
result, err := d.HealthVM(ctx, vm.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("PingVM: %v", err)
|
||||
t.Fatalf("HealthVM: %v", err)
|
||||
}
|
||||
if result.Alive {
|
||||
t.Fatalf("PingVM result = %+v, want not alive", result)
|
||||
if result.Healthy {
|
||||
t.Fatalf("HealthVM result = %+v, want not healthy", result)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -406,6 +476,64 @@ func TestFlattenNestedWorkHomeCopiesEntriesIndividually(t *testing.T) {
|
|||
runner.assertExhausted()
|
||||
}
|
||||
|
||||
func TestEnsureAuthorizedKeyOnWorkDiskRepairsNestedRootLayout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
nestedHome := filepath.Join(workDiskDir, "root")
|
||||
if err := os.MkdirAll(filepath.Join(nestedHome, ".ssh"), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll(.ssh): %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nestedHome, ".bashrc"), []byte("export TEST_PROMPT=1\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(.bashrc): %v", err)
|
||||
}
|
||||
legacyKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILEgacykey legacy@test\n"
|
||||
if err := os.WriteFile(filepath.Join(nestedHome, ".ssh", "authorized_keys"), []byte(legacyKey), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(authorized_keys): %v", err)
|
||||
}
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
})
|
||||
sshKeyPath := filepath.Join(t.TempDir(), "id_rsa")
|
||||
if err := os.WriteFile(sshKeyPath, privateKeyPEM, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(private key): %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
runner: &filesystemRunner{t: t},
|
||||
config: model.DaemonConfig{SSHKeyPath: sshKeyPath},
|
||||
}
|
||||
vm := testVM("seed-repair", "image-seed-repair", "172.16.0.61")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureAuthorizedKeyOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureAuthorizedKeyOnWorkDisk: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workDiskDir, "root")); !os.IsNotExist(err) {
|
||||
t.Fatalf("nested root still exists: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workDiskDir, ".bashrc")); err != nil {
|
||||
t.Fatalf(".bashrc missing at top level: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(workDiskDir, ".ssh", "authorized_keys"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(authorized_keys): %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, strings.TrimSpace(legacyKey)) {
|
||||
t.Fatalf("authorized_keys missing legacy key: %q", content)
|
||||
}
|
||||
if !strings.Contains(content, "ssh-rsa ") {
|
||||
t.Fatalf("authorized_keys missing managed key: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) {
|
||||
d := &Daemon{}
|
||||
if _, err := d.CreateVM(context.Background(), api.VMCreateParams{VCPUCount: ptr(0)}); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
|
||||
|
|
@ -824,6 +952,29 @@ func testImage(name string) model.Image {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMergeAuthorizedKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
managed := []byte("ssh-ed25519 AAAATESTKEY banger\n")
|
||||
existing := []byte("ssh-ed25519 AAAAOTHER other\n")
|
||||
merged := mergeAuthorizedKey(existing, managed)
|
||||
got := string(merged)
|
||||
if !strings.Contains(got, "ssh-ed25519 AAAAOTHER other") {
|
||||
t.Fatalf("merged keys dropped existing entry: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "ssh-ed25519 AAAATESTKEY banger") {
|
||||
t.Fatalf("merged keys missing managed entry: %q", got)
|
||||
}
|
||||
if strings.Count(got, "ssh-ed25519 AAAATESTKEY banger") != 1 {
|
||||
t.Fatalf("managed key duplicated in %q", got)
|
||||
}
|
||||
|
||||
merged = mergeAuthorizedKey(merged, managed)
|
||||
if strings.Count(string(merged), "ssh-ed25519 AAAATESTKEY banger") != 1 {
|
||||
t.Fatalf("managed key duplicated after second merge: %q", string(merged))
|
||||
}
|
||||
}
|
||||
|
||||
func startFakeFirecrackerProcess(t *testing.T, apiSock string) *exec.Cmd {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -878,6 +1029,117 @@ type processKillingRunner struct {
|
|||
proc *exec.Cmd
|
||||
}
|
||||
|
||||
type filesystemRunner struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (r *filesystemRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
r.t.Helper()
|
||||
return nil, fmt.Errorf("unexpected Run call: %s %v", name, args)
|
||||
}
|
||||
|
||||
func (r *filesystemRunner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
|
||||
r.t.Helper()
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("missing sudo command")
|
||||
}
|
||||
switch args[0] {
|
||||
case "mount":
|
||||
if len(args) != 3 {
|
||||
return nil, fmt.Errorf("unexpected mount args: %v", args)
|
||||
}
|
||||
source, mountDir := args[1], args[2]
|
||||
if err := os.Remove(mountDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Symlink(source, mountDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
case "umount":
|
||||
return nil, nil
|
||||
case "chmod":
|
||||
if len(args) != 3 {
|
||||
return nil, fmt.Errorf("unexpected chmod args: %v", args)
|
||||
}
|
||||
mode, err := strconv.ParseUint(args[1], 8, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, os.Chmod(args[2], os.FileMode(mode))
|
||||
case "cp":
|
||||
if len(args) != 4 || args[1] != "-a" {
|
||||
return nil, fmt.Errorf("unexpected cp args: %v", args)
|
||||
}
|
||||
return nil, copyIntoDir(args[2], args[3])
|
||||
case "rm":
|
||||
if len(args) != 3 || args[1] != "-rf" {
|
||||
return nil, fmt.Errorf("unexpected rm args: %v", args)
|
||||
}
|
||||
return nil, os.RemoveAll(args[2])
|
||||
case "mkdir":
|
||||
if len(args) != 3 || args[1] != "-p" {
|
||||
return nil, fmt.Errorf("unexpected mkdir args: %v", args)
|
||||
}
|
||||
return nil, os.MkdirAll(args[2], 0o755)
|
||||
case "cat":
|
||||
if len(args) != 2 {
|
||||
return nil, fmt.Errorf("unexpected cat args: %v", args)
|
||||
}
|
||||
return os.ReadFile(args[1])
|
||||
case "install":
|
||||
if len(args) != 5 || args[1] != "-m" {
|
||||
return nil, fmt.Errorf("unexpected install args: %v", args)
|
||||
}
|
||||
mode, err := strconv.ParseUint(args[2], 8, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(args[3])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(args[4]), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, os.WriteFile(args[4], data, os.FileMode(mode))
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected sudo command: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func copyIntoDir(sourcePath, targetDir string) error {
|
||||
targetDir = strings.TrimSuffix(targetDir, "/")
|
||||
info, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destPath := filepath.Join(targetDir, filepath.Base(sourcePath))
|
||||
if info.IsDir() {
|
||||
if err := os.MkdirAll(destPath, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyIntoDir(filepath.Join(sourcePath, entry.Name()), destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Chmod(destPath, info.Mode().Perm())
|
||||
}
|
||||
data, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(destPath, data, info.Mode().Perm())
|
||||
}
|
||||
|
||||
func (r *processKillingRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
return r.scriptedRunner.Run(ctx, name, args...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,19 @@
|
|||
package firecracker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdk "github.com/firecracker-microvm/firecracker-go-sdk"
|
||||
models "github.com/firecracker-microvm/firecracker-go-sdk/client/models"
|
||||
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"banger/internal/vsockping"
|
||||
"banger/internal/vsockagent"
|
||||
)
|
||||
|
||||
type MachineConfig struct {
|
||||
|
|
@ -212,37 +208,12 @@ func newLogger(base *slog.Logger) *logrus.Entry {
|
|||
return logrus.NewEntry(logger)
|
||||
}
|
||||
|
||||
func HealthVSock(ctx context.Context, logger *slog.Logger, socketPath string) error {
|
||||
return vsockagent.Health(ctx, logger, socketPath)
|
||||
}
|
||||
|
||||
func PingVSock(ctx context.Context, logger *slog.Logger, socketPath string) error {
|
||||
conn, err := sdkvsock.DialContext(
|
||||
ctx,
|
||||
socketPath,
|
||||
vsockping.Port,
|
||||
sdkvsock.WithRetryTimeout(3*time.Second),
|
||||
sdkvsock.WithRetryInterval(100*time.Millisecond),
|
||||
sdkvsock.WithLogger(newLogger(logger)),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(conn, vsockping.RequestLine); err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(line) != strings.TrimSpace(vsockping.ResponseLine) {
|
||||
return fmt.Errorf("unexpected vsock response %q", strings.TrimSpace(line))
|
||||
}
|
||||
return nil
|
||||
return HealthVSock(ctx, logger, socketPath)
|
||||
}
|
||||
|
||||
type slogHook struct {
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ func TestSDKLoggerBridgeSuppressesDebugAtInfoLevel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPingVSock(t *testing.T) {
|
||||
func TestHealthVSock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
socketPath := filepath.Join(dir, "fc.vsock")
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
|
|
@ -174,22 +174,22 @@ func TestPingVSock(t *testing.T) {
|
|||
return
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
if strings.Contains(string(buf), "\n") {
|
||||
if strings.Contains(string(buf), "\r\n\r\n") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if got := string(buf); got != "PING\n" {
|
||||
if got := string(buf); !strings.Contains(got, "GET /healthz HTTP/1.1\r\n") {
|
||||
done <- errUnexpectedString(got)
|
||||
return
|
||||
}
|
||||
_, err = conn.Write([]byte("PONG\n"))
|
||||
_, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
|
||||
done <- err
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := PingVSock(ctx, nil, socketPath); err != nil {
|
||||
t.Fatalf("PingVSock: %v", err)
|
||||
if err := HealthVSock(ctx, nil, socketPath); err != nil {
|
||||
t.Fatalf("HealthVSock: %v", err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
|
|
|
|||
|
|
@ -129,6 +129,14 @@ func privateKeySigner(path string) (ssh.Signer, error) {
|
|||
return ssh.ParsePrivateKey(data)
|
||||
}
|
||||
|
||||
func AuthorizedPublicKey(path string) ([]byte, error) {
|
||||
signer, err := privateKeySigner(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ssh.MarshalAuthorizedKey(signer.PublicKey()), nil
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@ package guest
|
|||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestWriteTarArchiveKeepsTopLevelDirectory(t *testing.T) {
|
||||
|
|
@ -56,3 +62,32 @@ func TestWriteTarArchiveKeepsTopLevelDirectory(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
})
|
||||
keyPath := filepath.Join(t.TempDir(), "id_rsa")
|
||||
if err := os.WriteFile(keyPath, privateKeyPEM, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
publicKey, err := AuthorizedPublicKey(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("AuthorizedPublicKey: %v", err)
|
||||
}
|
||||
parsed, _, _, _, err := ssh.ParseAuthorizedKey(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuthorizedKey: %v", err)
|
||||
}
|
||||
if parsed.Type() != ssh.KeyAlgoRSA {
|
||||
t.Fatalf("key type = %q, want %q", parsed.Type(), ssh.KeyAlgoRSA)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ type DaemonConfig struct {
|
|||
SSHKeyPath string
|
||||
NamegenPath string
|
||||
CustomizeScript string
|
||||
VSockPingHelperPath string
|
||||
VSockAgentPath string
|
||||
DefaultWorkSeed string
|
||||
AutoStopStaleAfter time.Duration
|
||||
StatsPollInterval time.Duration
|
||||
|
|
|
|||
|
|
@ -56,21 +56,21 @@ func TestResolveRuntimeDirUsesSourceCheckoutRuntimeSubdir(t *testing.T) {
|
|||
func createRuntimeBundle(t *testing.T, runtimeDir string) {
|
||||
t.Helper()
|
||||
metadata := runtimebundle.BundleMetadata{
|
||||
FirecrackerBin: "bin/firecracker",
|
||||
SSHKeyPath: "keys/id_ed25519",
|
||||
NamegenPath: "bin/namegen",
|
||||
CustomizeScript: "scripts/customize.sh",
|
||||
VSockPingHelperPath: "bin/banger-vsock-pingd",
|
||||
DefaultPackages: "config/packages.apt",
|
||||
DefaultRootfs: "images/rootfs-docker.ext4",
|
||||
DefaultKernel: "kernels/vmlinux",
|
||||
FirecrackerBin: "bin/firecracker",
|
||||
SSHKeyPath: "keys/id_ed25519",
|
||||
NamegenPath: "bin/namegen",
|
||||
CustomizeScript: "scripts/customize.sh",
|
||||
VSockAgentPath: "bin/banger-vsock-agent",
|
||||
DefaultPackages: "config/packages.apt",
|
||||
DefaultRootfs: "images/rootfs-docker.ext4",
|
||||
DefaultKernel: "kernels/vmlinux",
|
||||
}
|
||||
for _, rel := range []string{
|
||||
metadata.FirecrackerBin,
|
||||
metadata.SSHKeyPath,
|
||||
metadata.NamegenPath,
|
||||
metadata.CustomizeScript,
|
||||
metadata.VSockPingHelperPath,
|
||||
metadata.VSockAgentPath,
|
||||
metadata.DefaultPackages,
|
||||
metadata.DefaultRootfs,
|
||||
metadata.DefaultKernel,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ type BundleMetadata struct {
|
|||
SSHKeyPath string `json:"ssh_key_path" toml:"ssh_key_path"`
|
||||
NamegenPath string `json:"namegen_path" toml:"namegen_path"`
|
||||
CustomizeScript string `json:"customize_script" toml:"customize_script"`
|
||||
VSockPingHelperPath string `json:"vsock_ping_helper_path" toml:"vsock_ping_helper_path"`
|
||||
VSockAgentPath string `json:"vsock_agent_path,omitempty" toml:"vsock_agent_path"`
|
||||
VSockPingHelperPath string `json:"vsock_ping_helper_path,omitempty" toml:"vsock_ping_helper_path"`
|
||||
DefaultPackages string `json:"default_packages_file" toml:"default_packages_file"`
|
||||
DefaultRootfs string `json:"default_rootfs" toml:"default_rootfs"`
|
||||
DefaultBaseRootfs string `json:"default_base_rootfs,omitempty" toml:"default_base_rootfs"`
|
||||
|
|
@ -211,7 +212,7 @@ func validateBundleMetadata(runtimeDir string, meta BundleMetadata) error {
|
|||
{meta.SSHKeyPath, "ssh_key_path"},
|
||||
{meta.NamegenPath, "namegen_path"},
|
||||
{meta.CustomizeScript, "customize_script"},
|
||||
{meta.VSockPingHelperPath, "vsock_ping_helper_path"},
|
||||
{meta.VSockAgentPath, "vsock_agent_path"},
|
||||
{meta.DefaultPackages, "default_packages_file"},
|
||||
{meta.DefaultRootfs, "default_rootfs"},
|
||||
{meta.DefaultKernel, "default_kernel"},
|
||||
|
|
@ -230,7 +231,7 @@ func validateBundleMetadata(runtimeDir string, meta BundleMetadata) error {
|
|||
{meta.SSHKeyPath, "ssh_key_path", true},
|
||||
{meta.NamegenPath, "namegen_path", true},
|
||||
{meta.CustomizeScript, "customize_script", true},
|
||||
{meta.VSockPingHelperPath, "vsock_ping_helper_path", true},
|
||||
{meta.VSockAgentPath, "vsock_agent_path", true},
|
||||
{meta.DefaultPackages, "default_packages_file", true},
|
||||
{meta.DefaultRootfs, "default_rootfs", true},
|
||||
{meta.DefaultBaseRootfs, "default_base_rootfs", false},
|
||||
|
|
@ -269,7 +270,7 @@ func metadataArchiveBytes(runtimeDir string, meta BundleMetadata) ([]byte, error
|
|||
strings.TrimSpace(meta.SSHKeyPath) == "" &&
|
||||
strings.TrimSpace(meta.NamegenPath) == "" &&
|
||||
strings.TrimSpace(meta.CustomizeScript) == "" &&
|
||||
strings.TrimSpace(meta.VSockPingHelperPath) == "" &&
|
||||
strings.TrimSpace(meta.VSockAgentPath) == "" &&
|
||||
strings.TrimSpace(meta.DefaultPackages) == "" &&
|
||||
strings.TrimSpace(meta.DefaultRootfs) == "" &&
|
||||
strings.TrimSpace(meta.DefaultBaseRootfs) == "" &&
|
||||
|
|
@ -290,7 +291,11 @@ func normalizeBundleMetadata(meta BundleMetadata) BundleMetadata {
|
|||
meta.SSHKeyPath = strings.TrimSpace(meta.SSHKeyPath)
|
||||
meta.NamegenPath = strings.TrimSpace(meta.NamegenPath)
|
||||
meta.CustomizeScript = strings.TrimSpace(meta.CustomizeScript)
|
||||
meta.VSockAgentPath = strings.TrimSpace(meta.VSockAgentPath)
|
||||
meta.VSockPingHelperPath = strings.TrimSpace(meta.VSockPingHelperPath)
|
||||
if meta.VSockAgentPath == "" {
|
||||
meta.VSockAgentPath = meta.VSockPingHelperPath
|
||||
}
|
||||
meta.DefaultPackages = strings.TrimSpace(meta.DefaultPackages)
|
||||
meta.DefaultRootfs = strings.TrimSpace(meta.DefaultRootfs)
|
||||
meta.DefaultBaseRootfs = strings.TrimSpace(meta.DefaultBaseRootfs)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) {
|
|||
"runtime/firecracker": "fc",
|
||||
"runtime/id_ed25519": "key",
|
||||
"runtime/namegen": "namegen",
|
||||
"runtime/banger-vsock-pingd": "pingd",
|
||||
"runtime/banger-vsock-agent": "agent",
|
||||
"runtime/customize.sh": "#!/bin/bash\n",
|
||||
"runtime/packages.sh": "#!/bin/bash\n",
|
||||
"runtime/packages.apt": "vim\n",
|
||||
|
|
@ -28,7 +28,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) {
|
|||
"runtime/wtf/root/boot/vmlinux-6.8.0-94-generic": "kernel",
|
||||
"runtime/wtf/root/boot/initrd.img-6.8.0-94-generic": "initrd",
|
||||
"runtime/wtf/root/lib/modules/6.8.0-94-generic/modules.dep": "dep",
|
||||
"runtime/bundle.json": mustJSON(t, BundleMetadata{FirecrackerBin: "firecracker", SSHKeyPath: "id_ed25519", NamegenPath: "namegen", CustomizeScript: "customize.sh", VSockPingHelperPath: "banger-vsock-pingd", DefaultPackages: "packages.apt", DefaultRootfs: "rootfs-docker.ext4", DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic", DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic"}),
|
||||
"runtime/bundle.json": mustJSON(t, BundleMetadata{FirecrackerBin: "firecracker", SSHKeyPath: "id_ed25519", NamegenPath: "namegen", CustomizeScript: "customize.sh", VSockAgentPath: "banger-vsock-agent", DefaultPackages: "packages.apt", DefaultRootfs: "rootfs-docker.ext4", DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic", DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic"}),
|
||||
})
|
||||
archivePath := filepath.Join(manifestDir, "bundle.tar.gz")
|
||||
if err := os.WriteFile(archivePath, bundleData, 0o644); err != nil {
|
||||
|
|
@ -39,7 +39,7 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) {
|
|||
URL: "./bundle.tar.gz",
|
||||
SHA256: sha256Hex(bundleData),
|
||||
BundleRoot: "runtime",
|
||||
RequiredPaths: []string{"firecracker", "banger-vsock-pingd", "customize.sh", "packages.apt", "rootfs-docker.ext4", "wtf/root/boot/vmlinux-6.8.0-94-generic", "wtf/root/lib/modules/6.8.0-94-generic"},
|
||||
RequiredPaths: []string{"firecracker", "banger-vsock-agent", "customize.sh", "packages.apt", "rootfs-docker.ext4", "wtf/root/boot/vmlinux-6.8.0-94-generic", "wtf/root/lib/modules/6.8.0-94-generic"},
|
||||
}
|
||||
outDir := filepath.Join(t.TempDir(), "runtime")
|
||||
if err := Bootstrap(context.Background(), manifest, filepath.Join(manifestDir, "runtime-bundle.toml"), outDir); err != nil {
|
||||
|
|
@ -100,7 +100,7 @@ func TestPackageWritesArchive(t *testing.T) {
|
|||
"firecracker",
|
||||
"id_ed25519",
|
||||
"namegen",
|
||||
"banger-vsock-pingd",
|
||||
"banger-vsock-agent",
|
||||
"customize.sh",
|
||||
"packages.apt",
|
||||
"rootfs-docker.ext4",
|
||||
|
|
@ -128,22 +128,22 @@ func TestPackageWritesArchive(t *testing.T) {
|
|||
manifest := Manifest{
|
||||
BundleRoot: "runtime",
|
||||
BundleMeta: BundleMetadata{
|
||||
FirecrackerBin: "firecracker",
|
||||
SSHKeyPath: "id_ed25519",
|
||||
NamegenPath: "namegen",
|
||||
CustomizeScript: "customize.sh",
|
||||
VSockPingHelperPath: "banger-vsock-pingd",
|
||||
DefaultPackages: "packages.apt",
|
||||
DefaultRootfs: "rootfs-docker.ext4",
|
||||
DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic",
|
||||
DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic",
|
||||
DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic",
|
||||
FirecrackerBin: "firecracker",
|
||||
SSHKeyPath: "id_ed25519",
|
||||
NamegenPath: "namegen",
|
||||
CustomizeScript: "customize.sh",
|
||||
VSockAgentPath: "banger-vsock-agent",
|
||||
DefaultPackages: "packages.apt",
|
||||
DefaultRootfs: "rootfs-docker.ext4",
|
||||
DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic",
|
||||
DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic",
|
||||
DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic",
|
||||
},
|
||||
RequiredPaths: []string{
|
||||
"firecracker",
|
||||
"id_ed25519",
|
||||
"namegen",
|
||||
"banger-vsock-pingd",
|
||||
"banger-vsock-agent",
|
||||
"customize.sh",
|
||||
"packages.apt",
|
||||
"rootfs-docker.ext4",
|
||||
|
|
@ -186,7 +186,36 @@ func TestPackageWritesArchive(t *testing.T) {
|
|||
|
||||
func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) {
|
||||
runtimeDir := t.TempDir()
|
||||
for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "banger-vsock-pingd", "customize.sh", "packages.apt", "rootfs-docker.ext4"} {
|
||||
for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "banger-vsock-agent", "customize.sh", "packages.apt", "rootfs-docker.ext4"} {
|
||||
path := filepath.Join(runtimeDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(rel), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
data := mustJSON(t, BundleMetadata{
|
||||
FirecrackerBin: "firecracker",
|
||||
SSHKeyPath: "id_ed25519",
|
||||
NamegenPath: "namegen",
|
||||
CustomizeScript: "customize.sh",
|
||||
VSockAgentPath: "banger-vsock-agent",
|
||||
DefaultPackages: "packages.apt",
|
||||
DefaultRootfs: "rootfs-docker.ext4",
|
||||
DefaultKernel: "missing-kernel",
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(runtimeDir, BundleMetadataFile), []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if _, err := LoadBundleMetadata(runtimeDir); err == nil || !strings.Contains(err.Error(), "default_kernel") {
|
||||
t.Fatalf("LoadBundleMetadata() error = %v, want default_kernel failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBundleMetadataAcceptsLegacyVsockPingHelperPath(t *testing.T) {
|
||||
runtimeDir := t.TempDir()
|
||||
for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "banger-vsock-pingd", "customize.sh", "packages.apt", "rootfs-docker.ext4", "wtf/root/boot/vmlinux-6.8.0-94-generic"} {
|
||||
path := filepath.Join(runtimeDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
|
|
@ -203,13 +232,17 @@ func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) {
|
|||
VSockPingHelperPath: "banger-vsock-pingd",
|
||||
DefaultPackages: "packages.apt",
|
||||
DefaultRootfs: "rootfs-docker.ext4",
|
||||
DefaultKernel: "missing-kernel",
|
||||
DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic",
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(runtimeDir, BundleMetadataFile), []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if _, err := LoadBundleMetadata(runtimeDir); err == nil || !strings.Contains(err.Error(), "default_kernel") {
|
||||
t.Fatalf("LoadBundleMetadata() error = %v, want default_kernel failure", err)
|
||||
meta, err := LoadBundleMetadata(runtimeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBundleMetadata: %v", err)
|
||||
}
|
||||
if meta.VSockAgentPath != "banger-vsock-pingd" {
|
||||
t.Fatalf("VSockAgentPath = %q", meta.VSockAgentPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
|
@ -75,7 +76,7 @@ func BuildWorkSeedImage(ctx context.Context, runner CommandRunner, rootfsPath, o
|
|||
defer cleanupRoot()
|
||||
|
||||
rootHome := filepath.Join(rootMount, "root")
|
||||
sizeBytes, err := estimateWorkSeedSize(rootHome)
|
||||
sizeBytes, err := estimateWorkSeedSize(ctx, runner, rootHome)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -105,7 +106,7 @@ func BuildWorkSeedImage(ctx context.Context, runner CommandRunner, rootfsPath, o
|
|||
return CopyDirContents(ctx, runner, rootHome, workMount, true)
|
||||
}
|
||||
|
||||
func estimateWorkSeedSize(rootHome string) (int64, error) {
|
||||
func estimateWorkSeedSize(ctx context.Context, runner CommandRunner, rootHome string) (int64, error) {
|
||||
var usedBytes int64
|
||||
err := filepath.Walk(rootHome, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
|
|
@ -117,8 +118,19 @@ func estimateWorkSeedSize(rootHome string) (int64, error) {
|
|||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
out, sudoErr := runner.RunSudo(ctx, "du", "-sb", rootHome)
|
||||
if sudoErr != nil {
|
||||
return 0, fmt.Errorf("%w; sudo du fallback failed: %v", err, sudoErr)
|
||||
}
|
||||
return roundWorkSeedSize(parseDuSize(out)), nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return roundWorkSeedSize(usedBytes), nil
|
||||
}
|
||||
|
||||
func roundWorkSeedSize(usedBytes int64) int64 {
|
||||
sizeBytes := usedBytes*2 + workSeedSlackBytes
|
||||
if sizeBytes < minWorkSeedBytes {
|
||||
sizeBytes = minWorkSeedBytes
|
||||
|
|
@ -126,7 +138,19 @@ func estimateWorkSeedSize(rootHome string) (int64, error) {
|
|||
if rem := sizeBytes % workSeedRoundBytes; rem != 0 {
|
||||
sizeBytes += workSeedRoundBytes - rem
|
||||
}
|
||||
return sizeBytes, nil
|
||||
return sizeBytes
|
||||
}
|
||||
|
||||
func parseDuSize(out []byte) int64 {
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
sizeBytes, err := strconv.ParseInt(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return sizeBytes
|
||||
}
|
||||
|
||||
func ReadNormalizedLines(path string) ([]string, error) {
|
||||
|
|
|
|||
|
|
@ -409,3 +409,42 @@ func TestUseLoopMount(t *testing.T) {
|
|||
t.Fatalf("useLoopMount(missing) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateWorkSeedSizeFallsBackToSudoDuWhenUnreadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootHome := filepath.Join(t.TempDir(), "root")
|
||||
if err := os.Mkdir(rootHome, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(rootHome, "visible.txt"), []byte("seed"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := os.Chmod(rootHome, 0o000); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
defer os.Chmod(rootHome, 0o700)
|
||||
|
||||
var sudoCalled bool
|
||||
runner := funcRunner{
|
||||
runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
sudoCalled = true
|
||||
want := []string{"du", "-sb", rootHome}
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("RunSudo args = %v, want %v", args, want)
|
||||
}
|
||||
return []byte("4096\t" + rootHome + "\n"), nil
|
||||
},
|
||||
}
|
||||
|
||||
sizeBytes, err := estimateWorkSeedSize(context.Background(), runner, rootHome)
|
||||
if err != nil {
|
||||
t.Fatalf("estimateWorkSeedSize: %v", err)
|
||||
}
|
||||
if !sudoCalled {
|
||||
t.Fatal("estimateWorkSeedSize did not fall back to sudo du")
|
||||
}
|
||||
if sizeBytes != minWorkSeedBytes {
|
||||
t.Fatalf("sizeBytes = %d, want %d", sizeBytes, minWorkSeedBytes)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
158
internal/vsockagent/vsockagent.go
Normal file
158
internal/vsockagent/vsockagent.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package vsockagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
Port uint32 = 42070
|
||||
HealthPath = "/healthz"
|
||||
HealthyStatus = "ok"
|
||||
GuestBinaryName = "banger-vsock-agent"
|
||||
GuestInstallPath = "/usr/local/bin/" + GuestBinaryName
|
||||
ServiceName = "banger-vsock-agent.service"
|
||||
serviceUnit = `[Unit]
|
||||
Description=Banger vsock agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/banger-vsock-agent
|
||||
Restart=on-failure
|
||||
RestartSec=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
modulesLoadConfig = "vsock\nvmw_vsock_virtio_transport\n"
|
||||
)
|
||||
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(HealthPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(HealthResponse{Status: HealthyStatus})
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func Health(ctx context.Context, logger *slog.Logger, socketPath string) error {
|
||||
transport := &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return sdkvsock.DialContext(
|
||||
ctx,
|
||||
socketPath,
|
||||
Port,
|
||||
sdkvsock.WithRetryTimeout(3*time.Second),
|
||||
sdkvsock.WithRetryInterval(100*time.Millisecond),
|
||||
sdkvsock.WithLogger(newLogger(logger)),
|
||||
)
|
||||
},
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://vsock"+HealthPath, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("unexpected health status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var payload HealthResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if payload.Status != HealthyStatus {
|
||||
return fmt.Errorf("unexpected health response status %q", payload.Status)
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Debug("vsock health ok", "vsock_path", socketPath, "vsock_port", Port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ServiceUnit() string {
|
||||
return serviceUnit
|
||||
}
|
||||
|
||||
func ModulesLoadConfig() string {
|
||||
return modulesLoadConfig
|
||||
}
|
||||
|
||||
func ReminderMessage(name string) string {
|
||||
return fmt.Sprintf("session ended; %s is still running (stop it with 'banger vm stop %s')", name, name)
|
||||
}
|
||||
|
||||
func WarningMessage(name string, err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("warning: failed to check whether %s is still running: %v", name, err)
|
||||
}
|
||||
|
||||
func newLogger(base *slog.Logger) *logrus.Entry {
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
logger.SetLevel(logrus.DebugLevel)
|
||||
logger.AddHook(slogHook{logger: base})
|
||||
return logrus.NewEntry(logger)
|
||||
}
|
||||
|
||||
type slogHook struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h slogHook) Levels() []logrus.Level {
|
||||
return logrus.AllLevels
|
||||
}
|
||||
|
||||
func (h slogHook) Fire(entry *logrus.Entry) error {
|
||||
if h.logger == nil {
|
||||
return nil
|
||||
}
|
||||
level := slog.LevelDebug
|
||||
switch entry.Level {
|
||||
case logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel:
|
||||
level = slog.LevelError
|
||||
case logrus.WarnLevel:
|
||||
level = slog.LevelWarn
|
||||
case logrus.InfoLevel:
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
attrs := make([]any, 0, len(entry.Data)*2)
|
||||
for key, value := range entry.Data {
|
||||
attrs = append(attrs, key, value)
|
||||
}
|
||||
h.logger.Log(context.Background(), level, entry.Message, attrs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsServerClosed(err error) bool {
|
||||
return errors.Is(err, http.ErrServerClosed)
|
||||
}
|
||||
133
internal/vsockagent/vsockagent_test.go
Normal file
133
internal/vsockagent/vsockagent_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package vsockagent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewHandlerHealthz(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, HealthPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
rr := newTestResponseRecorder()
|
||||
NewHandler().ServeHTTP(rr, req)
|
||||
|
||||
if rr.status != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.status, http.StatusOK)
|
||||
}
|
||||
if got := rr.headers.Get("Content-Type"); got != "application/json" {
|
||||
t.Fatalf("content-type = %q", got)
|
||||
}
|
||||
var payload HealthResponse
|
||||
if err := json.Unmarshal(rr.body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if payload.Status != HealthyStatus {
|
||||
t.Fatalf("status = %q, want %q", payload.Status, HealthyStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
socketPath := filepath.Join(dir, "fc.vsock")
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
buf := make([]byte, 0, 256)
|
||||
tmp := make([]byte, 256)
|
||||
for {
|
||||
n, err := conn.Read(tmp)
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
if strings.Contains(string(buf), "\n") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if got := string(buf); got != "CONNECT 42070\n" {
|
||||
done <- unexpectedStringError(got)
|
||||
return
|
||||
}
|
||||
if _, err := conn.Write([]byte("OK 55\n")); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
|
||||
buf = buf[:0]
|
||||
for {
|
||||
n, err := conn.Read(tmp)
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
if strings.Contains(string(buf), "\r\n\r\n") {
|
||||
break
|
||||
}
|
||||
}
|
||||
req := string(buf)
|
||||
if !strings.Contains(req, "GET /healthz HTTP/1.1\r\n") {
|
||||
done <- unexpectedStringError(req)
|
||||
return
|
||||
}
|
||||
_, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
|
||||
done <- err
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := Health(ctx, nil, socketPath); err != nil {
|
||||
t.Fatalf("Health: %v", err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type testResponseRecorder struct {
|
||||
headers http.Header
|
||||
body bytes.Buffer
|
||||
status int
|
||||
}
|
||||
|
||||
func newTestResponseRecorder() *testResponseRecorder {
|
||||
return &testResponseRecorder{headers: make(http.Header), status: http.StatusOK}
|
||||
}
|
||||
|
||||
func (r *testResponseRecorder) Header() http.Header { return r.headers }
|
||||
|
||||
func (r *testResponseRecorder) Write(data []byte) (int, error) { return r.body.Write(data) }
|
||||
|
||||
func (r *testResponseRecorder) WriteHeader(status int) { r.status = status }
|
||||
|
||||
type unexpectedStringError string
|
||||
|
||||
func (e unexpectedStringError) Error() string {
|
||||
return "unexpected string: " + string(e)
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
package vsockping
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock"
|
||||
)
|
||||
|
||||
const (
|
||||
Port uint32 = 42070
|
||||
RequestLine = "PING\n"
|
||||
ResponseLine = "PONG\n"
|
||||
GuestBinaryName = "banger-vsock-pingd"
|
||||
GuestInstallPath = "/usr/local/bin/" + GuestBinaryName
|
||||
ServiceName = "banger-vsock-pingd.service"
|
||||
serviceUnit = `[Unit]
|
||||
Description=Banger vsock ping responder
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/banger-vsock-pingd
|
||||
Restart=on-failure
|
||||
RestartSec=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
modulesLoadConfig = "vsock\nvmw_vsock_virtio_transport\n"
|
||||
)
|
||||
|
||||
func Ping(ctx context.Context, logger *slog.Logger, socketPath string) error {
|
||||
conn, err := sdkvsock.DialContext(
|
||||
ctx,
|
||||
socketPath,
|
||||
Port,
|
||||
sdkvsock.WithRetryTimeout(3*time.Second),
|
||||
sdkvsock.WithRetryInterval(100*time.Millisecond),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(conn, RequestLine); err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(line) != strings.TrimSpace(ResponseLine) {
|
||||
return fmt.Errorf("unexpected vsock ping response %q", strings.TrimSpace(line))
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Debug("vsock ping ok", "vsock_path", socketPath, "vsock_port", Port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ServiceUnit() string {
|
||||
return serviceUnit
|
||||
}
|
||||
|
||||
func ModulesLoadConfig() string {
|
||||
return modulesLoadConfig
|
||||
}
|
||||
|
||||
func ReminderMessage(name string) string {
|
||||
return fmt.Sprintf("session ended; %s is still running (stop it with 'banger vm stop %s')", name, name)
|
||||
}
|
||||
|
||||
func WarningMessage(name string, err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("warning: failed to check whether %s is still running: %v", name, err)
|
||||
}
|
||||
|
||||
func ServeConn(conn net.Conn) error {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
line, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(line) != strings.TrimSpace(RequestLine) {
|
||||
return fmt.Errorf("unexpected request %q", strings.TrimSpace(line))
|
||||
}
|
||||
_, err = io.WriteString(conn, ResponseLine)
|
||||
return err
|
||||
}
|
||||
441
make-rootfs-void.sh
Executable file
441
make-rootfs-void.sh
Executable file
|
|
@ -0,0 +1,441 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() {
|
||||
printf '[make-rootfs-void] %s\n' "$*"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./make-rootfs-void.sh [--out <path>] [--size <size>] [--mirror <url>] [--arch <arch>] [--packages <path>]
|
||||
|
||||
Build an experimental Void Linux rootfs image plus a matching /root work-seed.
|
||||
|
||||
Defaults:
|
||||
--out ./runtime/rootfs-void.ext4
|
||||
--size 2G
|
||||
--mirror https://repo-default.voidlinux.org
|
||||
--arch x86_64
|
||||
--packages ./packages.void
|
||||
|
||||
This path is experimental and local-only. It reuses the current runtime bundle
|
||||
kernel/initrd/modules and does not change the default Debian image flow.
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_size() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^([0-9]+)([KMG])?$ ]]; then
|
||||
local num="${BASH_REMATCH[1]}"
|
||||
local unit="${BASH_REMATCH[2]}"
|
||||
case "$unit" in
|
||||
K) printf '%s\n' $((num * 1024)) ;;
|
||||
M|"") printf '%s\n' $((num * 1024 * 1024)) ;;
|
||||
G) printf '%s\n' $((num * 1024 * 1024 * 1024)) ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local name="$1"
|
||||
command -v "$name" >/dev/null 2>&1 || {
|
||||
log "required command not found: $name"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
resolve_banger_bin() {
|
||||
if [[ -n "${BANGER_BIN:-}" ]]; then
|
||||
printf '%s\n' "$BANGER_BIN"
|
||||
return
|
||||
fi
|
||||
if [[ -x "$SCRIPT_DIR/banger" ]]; then
|
||||
printf '%s\n' "$SCRIPT_DIR/banger"
|
||||
return
|
||||
fi
|
||||
if command -v banger >/dev/null 2>&1; then
|
||||
command -v banger
|
||||
return
|
||||
fi
|
||||
log "banger binary not found; build it first with 'make build' or set BANGER_BIN"
|
||||
exit 1
|
||||
}
|
||||
|
||||
normalize_mirror() {
|
||||
local mirror="${1%/}"
|
||||
mirror="${mirror%/current}"
|
||||
mirror="${mirror%/static}"
|
||||
printf '%s\n' "$mirror"
|
||||
}
|
||||
|
||||
bundle_path() {
|
||||
local key="$1"
|
||||
local fallback="$2"
|
||||
local rel=""
|
||||
|
||||
if [[ -f "$BUNDLE_METADATA" ]] && command -v jq >/dev/null 2>&1; then
|
||||
rel="$(jq -r --arg key "$key" '.[$key] // empty' "$BUNDLE_METADATA" 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -n "$rel" && "$rel" != "null" ]]; then
|
||||
printf '%s\n' "$RUNTIME_DIR/$rel"
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$fallback"
|
||||
}
|
||||
|
||||
find_static_binary() {
|
||||
local name="$1"
|
||||
find "$STATIC_DIR" -type f \( -name "$name" -o -name "$name.static" \) -perm -u+x | sort | head -n 1
|
||||
}
|
||||
|
||||
find_static_keys_dir() {
|
||||
find "$STATIC_DIR" -type d -path '*/var/db/xbps/keys' | sort | head -n 1
|
||||
}
|
||||
|
||||
ensure_sshd_include() {
|
||||
local cfg="$ROOT_MOUNT/etc/ssh/sshd_config"
|
||||
local tmp_cfg="$TMP_DIR/sshd_config"
|
||||
local include_line="Include /etc/ssh/sshd_config.d/*.conf"
|
||||
|
||||
sudo mkdir -p "$ROOT_MOUNT/etc/ssh/sshd_config.d"
|
||||
if sudo test -f "$cfg"; then
|
||||
sudo cat "$cfg" > "$tmp_cfg"
|
||||
else
|
||||
: > "$tmp_cfg"
|
||||
fi
|
||||
|
||||
if ! grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf([[:space:]]|$)' "$tmp_cfg"; then
|
||||
{
|
||||
printf '%s\n' "$include_line"
|
||||
cat "$tmp_cfg"
|
||||
} > "${tmp_cfg}.new"
|
||||
mv "${tmp_cfg}.new" "$tmp_cfg"
|
||||
sudo install -m 0644 "$tmp_cfg" "$cfg"
|
||||
fi
|
||||
}
|
||||
|
||||
install_vsock_service() {
|
||||
local service_dir="$ROOT_MOUNT/etc/sv/banger-vsock-agent"
|
||||
local run_path="$service_dir/run"
|
||||
local finish_path="$service_dir/finish"
|
||||
|
||||
sudo mkdir -p "$service_dir"
|
||||
cat <<'EOF' | sudo tee "$run_path" >/dev/null
|
||||
#!/bin/sh
|
||||
modprobe vsock 2>/dev/null || true
|
||||
modprobe vmw_vsock_virtio_transport 2>/dev/null || true
|
||||
exec /usr/local/bin/banger-vsock-agent
|
||||
EOF
|
||||
cat <<'EOF' | sudo tee "$finish_path" >/dev/null
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOF
|
||||
sudo chmod 0755 "$run_path" "$finish_path"
|
||||
sudo mkdir -p "$ROOT_MOUNT/etc/runit/runsvdir/default"
|
||||
sudo ln -snf /etc/sv/banger-vsock-agent "$ROOT_MOUNT/etc/runit/runsvdir/default/banger-vsock-agent"
|
||||
}
|
||||
|
||||
enable_sshd_service() {
|
||||
if [[ ! -d "$ROOT_MOUNT/etc/sv/sshd" ]]; then
|
||||
log "Void rootfs is missing /etc/sv/sshd after openssh install"
|
||||
exit 1
|
||||
fi
|
||||
sudo mkdir -p "$ROOT_MOUNT/etc/runit/runsvdir/default"
|
||||
sudo ln -snf /etc/sv/sshd "$ROOT_MOUNT/etc/runit/runsvdir/default/sshd"
|
||||
}
|
||||
|
||||
normalize_root_shell() {
|
||||
local passwd="$ROOT_MOUNT/etc/passwd"
|
||||
local shells="$ROOT_MOUNT/etc/shells"
|
||||
local wanted_shell="/bin/bash"
|
||||
local tmp_passwd="$TMP_DIR/passwd"
|
||||
local root_shell=""
|
||||
|
||||
if [[ ! -x "$ROOT_MOUNT$wanted_shell" ]]; then
|
||||
log "required root shell is missing from the Void image: $wanted_shell"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$shells" ]]; then
|
||||
log "Void image is missing /etc/shells"
|
||||
exit 1
|
||||
fi
|
||||
if ! sudo grep -Fxq "$wanted_shell" "$shells"; then
|
||||
log "Void image does not allow $wanted_shell in /etc/shells"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo cat "$passwd" > "$tmp_passwd"
|
||||
awk -F: -v OFS=: -v shell="$wanted_shell" '
|
||||
$1 == "root" {
|
||||
$7 = shell
|
||||
found = 1
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!found) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$tmp_passwd" > "${tmp_passwd}.new" || {
|
||||
log "failed to rewrite root shell in /etc/passwd"
|
||||
exit 1
|
||||
}
|
||||
mv "${tmp_passwd}.new" "$tmp_passwd"
|
||||
sudo install -m 0644 "$tmp_passwd" "$passwd"
|
||||
|
||||
root_shell="$(sudo awk -F: '$1 == "root" { print $7 }' "$passwd")"
|
||||
if [[ "$root_shell" != "$wanted_shell" ]]; then
|
||||
log "root shell normalization failed: expected $wanted_shell, got ${root_shell:-<empty>}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
configure_root_bash_prompt() {
|
||||
local bashrc="$ROOT_MOUNT/root/.bashrc"
|
||||
local bash_profile="$ROOT_MOUNT/root/.bash_profile"
|
||||
local profile_prompt="$ROOT_MOUNT/etc/profile.d/banger-bash-prompt.sh"
|
||||
|
||||
sudo mkdir -p "$ROOT_MOUNT/root" "$ROOT_MOUNT/etc/profile.d"
|
||||
cat <<'EOF' | sudo tee "$bashrc" >/dev/null
|
||||
# banger: default interactive prompt for experimental Void guests
|
||||
case "$-" in
|
||||
*i*) ;;
|
||||
*) return ;;
|
||||
esac
|
||||
|
||||
PS1='\u@\h:\w\$ '
|
||||
EOF
|
||||
cat <<'EOF' | sudo tee "$bash_profile" >/dev/null
|
||||
if [ -f ~/.bashrc ]; then
|
||||
. ~/.bashrc
|
||||
fi
|
||||
EOF
|
||||
cat <<'EOF' | sudo tee "$profile_prompt" >/dev/null
|
||||
case "$-" in
|
||||
*i*) ;;
|
||||
*) return 0 2>/dev/null || exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ -n "${BASH_VERSION:-}" ]; then
|
||||
PS1='\u@\h:\w\$ '
|
||||
fi
|
||||
EOF
|
||||
sudo chmod 0644 "$bashrc" "$bash_profile" "$profile_prompt"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${ROOT_MOUNT:-}" ]] && command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$ROOT_MOUNT"; then
|
||||
sudo umount "$ROOT_MOUNT" || true
|
||||
fi
|
||||
if [[ "${BUILD_DONE:-0}" != "1" ]]; then
|
||||
rm -f "${OUT_ROOTFS:-}" "${WORK_SEED:-}" "${OUT_ROOTFS:-}.packages.sha256"
|
||||
fi
|
||||
if [[ -n "${TMP_DIR:-}" && -d "${TMP_DIR:-}" ]]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PACKAGES_FILE="$SCRIPT_DIR/packages.void"
|
||||
export BANGER_APT_PACKAGES_FILE="$PACKAGES_FILE"
|
||||
source "$SCRIPT_DIR/packages.sh"
|
||||
|
||||
DEFAULT_RUNTIME_DIR="$SCRIPT_DIR"
|
||||
if [[ -d "$SCRIPT_DIR/runtime" ]]; then
|
||||
DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime"
|
||||
fi
|
||||
RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}"
|
||||
if [[ ! -d "$RUNTIME_DIR" ]]; then
|
||||
log "runtime bundle not found: $RUNTIME_DIR"
|
||||
log "run 'make runtime-bundle' or set BANGER_RUNTIME_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUNDLE_METADATA="$RUNTIME_DIR/bundle.json"
|
||||
OUT_ROOTFS="$RUNTIME_DIR/rootfs-void.ext4"
|
||||
SIZE_SPEC="2G"
|
||||
MIRROR="https://repo-default.voidlinux.org"
|
||||
ARCH="x86_64"
|
||||
MODULES_DIR="$(bundle_path default_modules_dir "$RUNTIME_DIR/wtf/root/lib/modules/6.8.0-94-generic")"
|
||||
VSOCK_AGENT="$(bundle_path vsock_agent_path "$RUNTIME_DIR/banger-vsock-agent")"
|
||||
if [[ "$VSOCK_AGENT" == "$RUNTIME_DIR/banger-vsock-agent" && ! -x "$VSOCK_AGENT" ]]; then
|
||||
VSOCK_AGENT="$(bundle_path vsock_ping_helper_path "$RUNTIME_DIR/banger-vsock-pingd")"
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--out)
|
||||
OUT_ROOTFS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--size)
|
||||
SIZE_SPEC="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--mirror)
|
||||
MIRROR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--arch)
|
||||
ARCH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--packages)
|
||||
PACKAGES_FILE="${2:-}"
|
||||
export BANGER_APT_PACKAGES_FILE="$PACKAGES_FILE"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log "unknown option: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
MIRROR="$(normalize_mirror "$MIRROR")"
|
||||
REPO_URL="$MIRROR/current"
|
||||
STATIC_ARCHIVE_URL="$MIRROR/static/xbps-static-latest.x86_64-musl.tar.xz"
|
||||
|
||||
if [[ "$ARCH" != "x86_64" ]]; then
|
||||
log "unsupported arch: $ARCH"
|
||||
log "this experimental builder currently supports only x86_64-glibc"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$PACKAGES_FILE" ]]; then
|
||||
log "package manifest not found: $PACKAGES_FILE"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "$MODULES_DIR" ]]; then
|
||||
log "modules dir not found: $MODULES_DIR"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$VSOCK_AGENT" ]]; then
|
||||
log "vsock agent not found or not executable: $VSOCK_AGENT"
|
||||
log "run 'make build' or refresh the runtime bundle"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$OUT_ROOTFS" ]]; then
|
||||
log "output rootfs already exists: $OUT_ROOTFS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_command curl
|
||||
require_command tar
|
||||
require_command sudo
|
||||
require_command mkfs.ext4
|
||||
require_command mount
|
||||
require_command umount
|
||||
require_command install
|
||||
require_command find
|
||||
require_command awk
|
||||
require_command sed
|
||||
require_command sha256sum
|
||||
require_command truncate
|
||||
require_command mountpoint
|
||||
|
||||
VOID_PACKAGES=()
|
||||
if ! banger_packages_read_array VOID_PACKAGES "$PACKAGES_FILE"; then
|
||||
log "package manifest is empty: $PACKAGES_FILE"
|
||||
exit 1
|
||||
fi
|
||||
if ! PACKAGES_HASH="$(banger_packages_manifest_hash "$PACKAGES_FILE")"; then
|
||||
log "failed to hash package manifest: $PACKAGES_FILE"
|
||||
exit 1
|
||||
fi
|
||||
if ! SIZE_BYTES="$(parse_size "$SIZE_SPEC")"; then
|
||||
log "invalid size: $SIZE_SPEC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BANGER_BIN="$(resolve_banger_bin)"
|
||||
if [[ "$OUT_ROOTFS" == *.ext4 ]]; then
|
||||
WORK_SEED="${OUT_ROOTFS%.ext4}.work-seed.ext4"
|
||||
else
|
||||
WORK_SEED="${OUT_ROOTFS}.work-seed"
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d -t banger-void-rootfs-XXXXXX)"
|
||||
STATIC_DIR="$TMP_DIR/static"
|
||||
ROOT_MOUNT="$TMP_DIR/rootfs"
|
||||
STATIC_ARCHIVE="$TMP_DIR/xbps-static.tar.xz"
|
||||
BUILD_DONE=0
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$STATIC_DIR" "$ROOT_MOUNT"
|
||||
|
||||
log "downloading static XBPS from $STATIC_ARCHIVE_URL"
|
||||
curl -fsSL "$STATIC_ARCHIVE_URL" -o "$STATIC_ARCHIVE"
|
||||
tar -xf "$STATIC_ARCHIVE" -C "$STATIC_DIR"
|
||||
|
||||
XBPS_INSTALL="$(find_static_binary xbps-install)"
|
||||
XBPS_QUERY="$(find_static_binary xbps-query)"
|
||||
STATIC_KEYS_DIR="$(find_static_keys_dir)"
|
||||
|
||||
if [[ -z "$XBPS_INSTALL" || ! -x "$XBPS_INSTALL" ]]; then
|
||||
log "failed to locate xbps-install in the static archive"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$STATIC_KEYS_DIR" || ! -d "$STATIC_KEYS_DIR" ]]; then
|
||||
log "failed to locate Void repository keys in the static archive"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "creating $OUT_ROOTFS ($SIZE_SPEC)"
|
||||
truncate -s "$SIZE_BYTES" "$OUT_ROOTFS"
|
||||
mkfs.ext4 -F -m 0 -L banger-void-root "$OUT_ROOTFS" >/dev/null
|
||||
sudo mount -o loop "$OUT_ROOTFS" "$ROOT_MOUNT"
|
||||
sudo mkdir -p "$ROOT_MOUNT/var/db/xbps/keys"
|
||||
sudo cp -a "$STATIC_KEYS_DIR/." "$ROOT_MOUNT/var/db/xbps/keys/"
|
||||
|
||||
log "installing Void packages into the rootfs"
|
||||
sudo env XBPS_ARCH="$ARCH" "$XBPS_INSTALL" -S -y -r "$ROOT_MOUNT" -R "$REPO_URL" "${VOID_PACKAGES[@]}"
|
||||
|
||||
if [[ -n "$XBPS_QUERY" && -x "$XBPS_QUERY" ]]; then
|
||||
log "installed package set:"
|
||||
sudo env XBPS_ARCH="$ARCH" "$XBPS_QUERY" -r "$ROOT_MOUNT" -l | awk '/^ii/ {print " " $2}' || true
|
||||
fi
|
||||
|
||||
log "copying bundled kernel modules into the guest"
|
||||
sudo mkdir -p "$ROOT_MOUNT/lib/modules"
|
||||
sudo cp -a "$MODULES_DIR" "$ROOT_MOUNT/lib/modules/"
|
||||
|
||||
log "installing the guest-side vsock agent"
|
||||
sudo mkdir -p "$ROOT_MOUNT/usr/local/bin"
|
||||
sudo install -m 0755 "$VSOCK_AGENT" "$ROOT_MOUNT/usr/local/bin/banger-vsock-agent"
|
||||
|
||||
log "preparing SSH and runit services"
|
||||
ensure_sshd_include
|
||||
enable_sshd_service
|
||||
install_vsock_service
|
||||
normalize_root_shell
|
||||
configure_root_bash_prompt
|
||||
sudo mkdir -p "$ROOT_MOUNT/root/.ssh"
|
||||
sudo touch "$ROOT_MOUNT/etc/fstab" "$ROOT_MOUNT/etc/hostname"
|
||||
sudo chroot "$ROOT_MOUNT" /usr/bin/ssh-keygen -A
|
||||
|
||||
log "removing bulky caches and docs from the experimental image"
|
||||
sudo rm -rf \
|
||||
"$ROOT_MOUNT/var/cache/xbps" \
|
||||
"$ROOT_MOUNT/usr/share/doc" \
|
||||
"$ROOT_MOUNT/usr/share/info" \
|
||||
"$ROOT_MOUNT/usr/share/man"
|
||||
|
||||
sudo umount "$ROOT_MOUNT"
|
||||
|
||||
banger_write_rootfs_manifest_metadata "$OUT_ROOTFS" "$PACKAGES_HASH"
|
||||
|
||||
log "building work-seed $WORK_SEED"
|
||||
"$BANGER_BIN" internal work-seed --rootfs "$OUT_ROOTFS" --out "$WORK_SEED"
|
||||
|
||||
BUILD_DONE=1
|
||||
log "built experimental Void rootfs: $OUT_ROOTFS"
|
||||
log "built experimental Void work-seed: $WORK_SEED"
|
||||
log "use examples/void-exp.config.toml as the local config override template"
|
||||
25
packages.void
Normal file
25
packages.void
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
base-minimal
|
||||
base-devel
|
||||
bash
|
||||
openssh
|
||||
ca-certificates
|
||||
curl
|
||||
fd
|
||||
fzf
|
||||
git
|
||||
iputils
|
||||
jq
|
||||
kmod
|
||||
iproute2
|
||||
less
|
||||
lsof
|
||||
make
|
||||
procps-ng
|
||||
psmisc
|
||||
ripgrep
|
||||
strace
|
||||
tmux
|
||||
vim
|
||||
unzip
|
||||
zip
|
||||
zstd
|
||||
|
|
@ -10,7 +10,7 @@ required_paths = [
|
|||
"customize.sh",
|
||||
"packages.sh",
|
||||
"namegen",
|
||||
"banger-vsock-pingd",
|
||||
"banger-vsock-agent",
|
||||
"packages.apt",
|
||||
"id_ed25519",
|
||||
"rootfs-docker.ext4",
|
||||
|
|
@ -24,7 +24,7 @@ firecracker_bin = "firecracker"
|
|||
ssh_key_path = "id_ed25519"
|
||||
namegen_path = "namegen"
|
||||
customize_script = "customize.sh"
|
||||
vsock_ping_helper_path = "banger-vsock-pingd"
|
||||
vsock_agent_path = "banger-vsock-agent"
|
||||
default_packages_file = "packages.apt"
|
||||
default_rootfs = "rootfs-docker.ext4"
|
||||
default_work_seed = "rootfs-docker.work-seed.ext4"
|
||||
|
|
|
|||
55
verify.sh
55
verify.sh
|
|
@ -22,6 +22,17 @@ if [[ ! -f "$SSH_KEY" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
DAEMON_LOG="${XDG_STATE_HOME:-$HOME/.local/state}/banger/bangerd.log"
|
||||
SSH_COMMON_ARGS=(
|
||||
-F /dev/null
|
||||
-i "$SSH_KEY"
|
||||
-o IdentitiesOnly=yes
|
||||
-o BatchMode=yes
|
||||
-o PreferredAuthentications=publickey
|
||||
-o PasswordAuthentication=no
|
||||
-o KbdInteractiveAuthentication=no
|
||||
-o StrictHostKeyChecking=no
|
||||
-o UserKnownHostsFile=/dev/null
|
||||
)
|
||||
|
||||
firecracker_running() {
|
||||
local pid="$1"
|
||||
|
|
@ -48,8 +59,7 @@ wait_for_ssh() {
|
|||
local deadline="$2"
|
||||
|
||||
while ((SECONDS < deadline)); do
|
||||
if ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=2 "root@${guest_ip}" "true" >/dev/null 2>&1; then
|
||||
if ssh "${SSH_COMMON_ARGS[@]}" -o ConnectTimeout=2 "root@${guest_ip}" "true" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
|
|
@ -127,23 +137,37 @@ dump_diagnostics() {
|
|||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./verify.sh [--nat]
|
||||
Usage: ./verify.sh [--nat] [--image <name>]
|
||||
|
||||
Run a basic smoke test for the Go VM workflow.
|
||||
Use --nat to additionally verify outbound NAT and host rule cleanup.
|
||||
Use --image to verify a non-default image such as void-exp.
|
||||
EOF
|
||||
}
|
||||
|
||||
NAT_ENABLED=0
|
||||
IMAGE_NAME=""
|
||||
BOOT_TIMEOUT_SECS="${VERIFY_BOOT_TIMEOUT_SECS:-90}"
|
||||
if [[ "${1:-}" == "--nat" ]]; then
|
||||
NAT_ENABLED=1
|
||||
shift
|
||||
fi
|
||||
if (($# != 0)); then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--nat)
|
||||
NAT_ENABLED=1
|
||||
shift
|
||||
;;
|
||||
--image)
|
||||
IMAGE_NAME="${2:-}"
|
||||
if [[ -z "$IMAGE_NAME" ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
VM_NAME="verify-$(date +%s)"
|
||||
VM_JSON=""
|
||||
|
|
@ -172,6 +196,9 @@ trap cleanup EXIT
|
|||
|
||||
log "starting VM"
|
||||
CREATE_ARGS=(./banger vm create --name "$VM_NAME")
|
||||
if [[ -n "$IMAGE_NAME" ]]; then
|
||||
CREATE_ARGS+=(--image "$IMAGE_NAME")
|
||||
fi
|
||||
if (( NAT_ENABLED )); then
|
||||
CREATE_ARGS+=(--nat)
|
||||
fi
|
||||
|
|
@ -211,13 +238,11 @@ if ! wait_for_ssh "$GUEST_IP" "$BOOT_DEADLINE"; then
|
|||
dump_diagnostics
|
||||
exit 1
|
||||
fi
|
||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
"root@${GUEST_IP}" "uname -a" >/dev/null
|
||||
ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "uname -a" >/dev/null
|
||||
|
||||
if (( NAT_ENABLED )); then
|
||||
log "asserting VM has outbound network access"
|
||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
"root@${GUEST_IP}" "curl -fsS https://example.com >/dev/null" >/dev/null
|
||||
ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "curl -fsS https://example.com >/dev/null" >/dev/null
|
||||
fi
|
||||
|
||||
log "cleaning up VM"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue