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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue