Speed up first use of repo backed VMs by bootstrapping obvious tools before the best effort LLM harness runs. Add a host side tooling plan for pinned Go, Node, Python, and Rust versions, summarize that plan in the uploaded prompt, and run repo mise install plus guest global mise use -g --pin steps before the bounded opencode inspection. Keep the harness non fatal, prefer host opencode attach when the client supports it, fall back to guest opencode over SSH for older clients, and cover the new flow with CLI plus planner tests. Validation: - go test ./internal/cli ./internal/toolingplan - GOCACHE=/tmp/banger-gocache go test ./... - make build
1883 lines
61 KiB
Go
1883 lines
61 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/buildinfo"
|
|
"banger/internal/model"
|
|
"banger/internal/system"
|
|
"banger/internal/toolingplan"
|
|
)
|
|
|
|
func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
|
|
cmd := NewBangerCommand()
|
|
names := []string{}
|
|
for _, sub := range cmd.Commands() {
|
|
names = append(names, sub.Name())
|
|
}
|
|
want := []string{"daemon", "doctor", "image", "internal", "version", "vm"}
|
|
if !reflect.DeepEqual(names, want) {
|
|
t.Fatalf("subcommands = %v, want %v", names, want)
|
|
}
|
|
}
|
|
|
|
func TestVersionCommandPrintsBuildInfo(t *testing.T) {
|
|
cmd := NewBangerCommand()
|
|
var stdout bytes.Buffer
|
|
cmd.SetOut(&stdout)
|
|
cmd.SetErr(&stdout)
|
|
cmd.SetArgs([]string{"version"})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
|
|
info := buildinfo.Current()
|
|
output := stdout.String()
|
|
for _, want := range []string{
|
|
"version: " + info.Version,
|
|
"commit: " + info.Commit,
|
|
"built_at: " + info.BuiltAt,
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("output = %q, want %q", output, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLegacyRemovedCommandIsRejected(t *testing.T) {
|
|
cmd := NewBangerCommand()
|
|
cmd.SetArgs([]string{"tui"})
|
|
err := cmd.Execute()
|
|
if err == nil || !strings.Contains(err.Error(), "unknown command \"tui\"") {
|
|
t.Fatalf("Execute() error = %v, want unknown legacy command", err)
|
|
}
|
|
}
|
|
|
|
func TestDoctorCommandPrintsReportAndFailsOnHardFailures(t *testing.T) {
|
|
original := doctorFunc
|
|
t.Cleanup(func() {
|
|
doctorFunc = original
|
|
})
|
|
doctorFunc = func(context.Context) (system.Report, error) {
|
|
return system.Report{
|
|
Checks: []system.CheckResult{
|
|
{Name: "runtime bundle", Status: system.CheckStatusPass, Details: []string{"runtime dir /tmp/runtime"}},
|
|
{Name: "feature nat", Status: system.CheckStatusFail, Details: []string{"missing iptables"}},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
cmd := NewBangerCommand()
|
|
var stdout bytes.Buffer
|
|
cmd.SetOut(&stdout)
|
|
cmd.SetErr(&stdout)
|
|
cmd.SetArgs([]string{"doctor"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil || !strings.Contains(err.Error(), "doctor found failing checks") {
|
|
t.Fatalf("Execute() error = %v, want doctor failure", err)
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "PASS\truntime bundle") {
|
|
t.Fatalf("output = %q, want runtime bundle pass", output)
|
|
}
|
|
if !strings.Contains(output, "FAIL\tfeature nat") {
|
|
t.Fatalf("output = %q, want feature nat fail", output)
|
|
}
|
|
}
|
|
|
|
func TestDoctorCommandReturnsUnderlyingError(t *testing.T) {
|
|
original := doctorFunc
|
|
t.Cleanup(func() {
|
|
doctorFunc = original
|
|
})
|
|
doctorFunc = func(context.Context) (system.Report, error) {
|
|
return system.Report{}, errors.New("load failed")
|
|
}
|
|
|
|
cmd := NewBangerCommand()
|
|
cmd.SetArgs([]string{"doctor"})
|
|
err := cmd.Execute()
|
|
if err == nil || !strings.Contains(err.Error(), "load failed") {
|
|
t.Fatalf("Execute() error = %v, want load failed", err)
|
|
}
|
|
}
|
|
|
|
func TestInternalNATFlagsExist(t *testing.T) {
|
|
root := NewBangerCommand()
|
|
internal, _, err := root.Find([]string{"internal"})
|
|
if err != nil {
|
|
t.Fatalf("find internal: %v", err)
|
|
}
|
|
nat, _, err := internal.Find([]string{"nat"})
|
|
if err != nil {
|
|
t.Fatalf("find nat: %v", err)
|
|
}
|
|
up, _, err := nat.Find([]string{"up"})
|
|
if err != nil {
|
|
t.Fatalf("find nat up: %v", err)
|
|
}
|
|
for _, flagName := range []string{"guest-ip", "tap"} {
|
|
if up.Flags().Lookup(flagName) == nil {
|
|
t.Fatalf("missing flag %q", flagName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInternalPackagesCommandSupportsAlpine(t *testing.T) {
|
|
cmd := NewBangerCommand()
|
|
var stdout bytes.Buffer
|
|
cmd.SetOut(&stdout)
|
|
cmd.SetArgs([]string{"internal", "packages", "alpine"})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute(): %v", err)
|
|
}
|
|
|
|
output := stdout.String()
|
|
for _, want := range []string{"alpine-base", "docker", "libgcc", "libstdc++", "mkinitfs", "openssh"} {
|
|
if !strings.Contains(output, want+"\n") {
|
|
t.Fatalf("output = %q, want package %q", output, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVMCreateFlagsExist(t *testing.T) {
|
|
root := NewBangerCommand()
|
|
vm, _, err := root.Find([]string{"vm"})
|
|
if err != nil {
|
|
t.Fatalf("find vm: %v", err)
|
|
}
|
|
create, _, err := vm.Find([]string{"create"})
|
|
if err != nil {
|
|
t.Fatalf("find create: %v", err)
|
|
}
|
|
for _, flagName := range []string{"name", "image", "vcpu", "memory", "system-overlay-size", "disk-size", "nat", "no-start"} {
|
|
if create.Flags().Lookup(flagName) == nil {
|
|
t.Fatalf("missing flag %q", flagName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVMRunFlagsExist(t *testing.T) {
|
|
root := NewBangerCommand()
|
|
vm, _, err := root.Find([]string{"vm"})
|
|
if err != nil {
|
|
t.Fatalf("find vm: %v", err)
|
|
}
|
|
run, _, err := vm.Find([]string{"run"})
|
|
if err != nil {
|
|
t.Fatalf("find run: %v", err)
|
|
}
|
|
for _, flagName := range []string{"name", "image", "vcpu", "memory", "system-overlay-size", "disk-size", "nat", "branch", "from"} {
|
|
if run.Flags().Lookup(flagName) == nil {
|
|
t.Fatalf("missing flag %q", flagName)
|
|
}
|
|
}
|
|
if run.Flags().Lookup("no-start") != nil {
|
|
t.Fatal("vm run should not expose --no-start")
|
|
}
|
|
}
|
|
|
|
func TestVMCreateFlagsShowStaticDefaults(t *testing.T) {
|
|
root := NewBangerCommand()
|
|
vm, _, err := root.Find([]string{"vm"})
|
|
if err != nil {
|
|
t.Fatalf("find vm: %v", err)
|
|
}
|
|
create, _, err := vm.Find([]string{"create"})
|
|
if err != nil {
|
|
t.Fatalf("find create: %v", err)
|
|
}
|
|
|
|
if got := create.Flags().Lookup("vcpu").DefValue; got != fmt.Sprintf("%d", model.DefaultVCPUCount) {
|
|
t.Fatalf("vcpu default = %q, want %d", got, model.DefaultVCPUCount)
|
|
}
|
|
if got := create.Flags().Lookup("memory").DefValue; got != fmt.Sprintf("%d", model.DefaultMemoryMiB) {
|
|
t.Fatalf("memory default = %q, want %d", got, model.DefaultMemoryMiB)
|
|
}
|
|
if got := create.Flags().Lookup("system-overlay-size").DefValue; got != model.FormatSizeBytes(model.DefaultSystemOverlaySize) {
|
|
t.Fatalf("system-overlay-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultSystemOverlaySize))
|
|
}
|
|
if got := create.Flags().Lookup("disk-size").DefValue; got != model.FormatSizeBytes(model.DefaultWorkDiskSize) {
|
|
t.Fatalf("disk-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultWorkDiskSize))
|
|
}
|
|
}
|
|
|
|
func TestVMRunRejectsFromWithoutBranch(t *testing.T) {
|
|
cmd := NewBangerCommand()
|
|
cmd.SetArgs([]string{"vm", "run", "--from", "HEAD"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil || !strings.Contains(err.Error(), "--from requires --branch") {
|
|
t.Fatalf("Execute() error = %v, want --from requires --branch", err)
|
|
}
|
|
}
|
|
|
|
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", "docker"} {
|
|
if register.Flags().Lookup(flagName) == nil {
|
|
t.Fatalf("missing flag %q", flagName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImagePromoteCommandExists(t *testing.T) {
|
|
root := NewBangerCommand()
|
|
image, _, err := root.Find([]string{"image"})
|
|
if err != nil {
|
|
t.Fatalf("find image: %v", err)
|
|
}
|
|
if _, _, err := image.Find([]string{"promote"}); err != nil {
|
|
t.Fatalf("find promote: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVMKillFlagsExist(t *testing.T) {
|
|
root := NewBangerCommand()
|
|
vm, _, err := root.Find([]string{"vm"})
|
|
if err != nil {
|
|
t.Fatalf("find vm: %v", err)
|
|
}
|
|
kill, _, err := vm.Find([]string{"kill"})
|
|
if err != nil {
|
|
t.Fatalf("find kill: %v", err)
|
|
}
|
|
if kill.Flags().Lookup("signal") == nil {
|
|
t.Fatal("missing signal flag")
|
|
}
|
|
}
|
|
|
|
func TestVMPortsCommandExists(t *testing.T) {
|
|
root := NewBangerCommand()
|
|
vm, _, err := root.Find([]string{"vm"})
|
|
if err != nil {
|
|
t.Fatalf("find vm: %v", err)
|
|
}
|
|
if _, _, err := vm.Find([]string{"ports"}); err != nil {
|
|
t.Fatalf("find ports: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVMPortsCommandRejectsMultipleRefs(t *testing.T) {
|
|
cmd := NewBangerCommand()
|
|
cmd.SetArgs([]string{"vm", "ports", "alpha", "beta"})
|
|
err := cmd.Execute()
|
|
if err == nil || !strings.Contains(err.Error(), "usage: banger vm ports <id-or-name>") {
|
|
t.Fatalf("Execute() error = %v, want single-vm usage error", err)
|
|
}
|
|
}
|
|
|
|
func TestVMSetParamsFromFlags(t *testing.T) {
|
|
params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false)
|
|
if err != nil {
|
|
t.Fatalf("vmSetParamsFromFlags: %v", err)
|
|
}
|
|
if params.IDOrName != "devbox" || params.VCPUCount == nil || *params.VCPUCount != 4 {
|
|
t.Fatalf("unexpected params: %+v", params)
|
|
}
|
|
if params.MemoryMiB == nil || *params.MemoryMiB != 2048 {
|
|
t.Fatalf("unexpected memory: %+v", params)
|
|
}
|
|
if params.WorkDiskSize != "16G" {
|
|
t.Fatalf("unexpected disk size: %+v", params)
|
|
}
|
|
if params.NATEnabled == nil || !*params.NATEnabled {
|
|
t.Fatalf("unexpected nat value: %+v", params)
|
|
}
|
|
}
|
|
|
|
func TestVMCreateParamsFromFlagsOmitsStaticDefaultsWhenFlagsAreUnchanged(t *testing.T) {
|
|
cmd := NewBangerCommand()
|
|
vm, _, err := cmd.Find([]string{"vm"})
|
|
if err != nil {
|
|
t.Fatalf("find vm: %v", err)
|
|
}
|
|
create, _, err := vm.Find([]string{"create"})
|
|
if err != nil {
|
|
t.Fatalf("find create: %v", err)
|
|
}
|
|
|
|
params, err := vmCreateParamsFromFlags(
|
|
create,
|
|
"devbox",
|
|
"default",
|
|
model.DefaultVCPUCount,
|
|
model.DefaultMemoryMiB,
|
|
model.FormatSizeBytes(model.DefaultSystemOverlaySize),
|
|
model.FormatSizeBytes(model.DefaultWorkDiskSize),
|
|
false,
|
|
false,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("vmCreateParamsFromFlags: %v", err)
|
|
}
|
|
if params.VCPUCount != nil || params.MemoryMiB != nil || params.SystemOverlaySize != "" || params.WorkDiskSize != "" {
|
|
t.Fatalf("expected unchanged defaults to stay omitted: %+v", params)
|
|
}
|
|
}
|
|
|
|
func TestVMCreateParamsFromFlagsIncludesChangedDiskFlags(t *testing.T) {
|
|
cmd := NewBangerCommand()
|
|
vm, _, err := cmd.Find([]string{"vm"})
|
|
if err != nil {
|
|
t.Fatalf("find vm: %v", err)
|
|
}
|
|
create, _, err := vm.Find([]string{"create"})
|
|
if err != nil {
|
|
t.Fatalf("find create: %v", err)
|
|
}
|
|
if err := create.Flags().Set("system-overlay-size", "16G"); err != nil {
|
|
t.Fatalf("set system-overlay-size flag: %v", err)
|
|
}
|
|
if err := create.Flags().Set("disk-size", "32G"); err != nil {
|
|
t.Fatalf("set disk-size flag: %v", err)
|
|
}
|
|
|
|
params, err := vmCreateParamsFromFlags(create, "devbox", "default", model.DefaultVCPUCount, model.DefaultMemoryMiB, "16G", "32G", false, false)
|
|
if err != nil {
|
|
t.Fatalf("vmCreateParamsFromFlags: %v", err)
|
|
}
|
|
if params.SystemOverlaySize != "16G" || params.WorkDiskSize != "32G" {
|
|
t.Fatalf("expected changed disk flags to be included: %+v", params)
|
|
}
|
|
}
|
|
|
|
func TestVMCreateParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
|
|
cmd := NewBangerCommand()
|
|
vm, _, err := cmd.Find([]string{"vm"})
|
|
if err != nil {
|
|
t.Fatalf("find vm: %v", err)
|
|
}
|
|
create, _, err := vm.Find([]string{"create"})
|
|
if err != nil {
|
|
t.Fatalf("find create: %v", err)
|
|
}
|
|
if err := create.Flags().Set("vcpu", "0"); err != nil {
|
|
t.Fatalf("set vcpu flag: %v", err)
|
|
}
|
|
if _, err := vmCreateParamsFromFlags(create, "devbox", "default", 0, 0, "", "", false, false); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
|
|
t.Fatalf("vmCreateParamsFromFlags(vcpu=0) error = %v", err)
|
|
}
|
|
if err := create.Flags().Set("memory", "-1"); err != nil {
|
|
t.Fatalf("set memory flag: %v", err)
|
|
}
|
|
if _, err := vmCreateParamsFromFlags(create, "devbox", "default", 1, -1, "", "", false, false); err == nil || !strings.Contains(err.Error(), "memory must be a positive integer") {
|
|
t.Fatalf("vmCreateParamsFromFlags(memory=-1) error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunVMCreatePollsUntilDone(t *testing.T) {
|
|
origBegin := vmCreateBeginFunc
|
|
origStatus := vmCreateStatusFunc
|
|
origCancel := vmCreateCancelFunc
|
|
t.Cleanup(func() {
|
|
vmCreateBeginFunc = origBegin
|
|
vmCreateStatusFunc = origStatus
|
|
vmCreateCancelFunc = origCancel
|
|
})
|
|
|
|
vm := model.VMRecord{
|
|
ID: "vm-id",
|
|
Name: "devbox",
|
|
Spec: model.VMSpec{WorkDiskSizeBytes: model.DefaultWorkDiskSize},
|
|
Runtime: model.VMRuntime{
|
|
State: model.VMStateRunning,
|
|
GuestIP: "172.16.0.2",
|
|
DNSName: "devbox.vm",
|
|
},
|
|
}
|
|
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
|
return api.VMCreateBeginResult{
|
|
Operation: api.VMCreateOperation{
|
|
ID: "op-1",
|
|
Stage: "prepare_work_disk",
|
|
Detail: "cloning work seed",
|
|
},
|
|
}, nil
|
|
}
|
|
statusCalls := 0
|
|
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
|
statusCalls++
|
|
if statusCalls == 1 {
|
|
return api.VMCreateStatusResult{
|
|
Operation: api.VMCreateOperation{
|
|
ID: "op-1",
|
|
Stage: "wait_opencode",
|
|
Detail: "waiting for opencode on guest port 4096",
|
|
},
|
|
}, nil
|
|
}
|
|
return api.VMCreateStatusResult{
|
|
Operation: api.VMCreateOperation{
|
|
ID: "op-1",
|
|
Stage: "ready",
|
|
Detail: "vm is ready",
|
|
Done: true,
|
|
Success: true,
|
|
VM: &vm,
|
|
},
|
|
}, nil
|
|
}
|
|
vmCreateCancelFunc = func(context.Context, string, string) error {
|
|
t.Fatal("cancel should not be called")
|
|
return nil
|
|
}
|
|
|
|
got, err := runVMCreate(context.Background(), "/tmp/bangerd.sock", &bytes.Buffer{}, api.VMCreateParams{Name: "devbox"})
|
|
if err != nil {
|
|
t.Fatalf("runVMCreate: %v", err)
|
|
}
|
|
if got.Name != vm.Name || got.Runtime.GuestIP != vm.Runtime.GuestIP {
|
|
t.Fatalf("vm = %+v, want %+v", got, vm)
|
|
}
|
|
if statusCalls != 2 {
|
|
t.Fatalf("statusCalls = %d, want 2", statusCalls)
|
|
}
|
|
}
|
|
|
|
func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) {
|
|
var stderr bytes.Buffer
|
|
renderer := &vmCreateProgressRenderer{out: &stderr, enabled: true}
|
|
|
|
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
|
|
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
|
|
renderer.render(api.VMCreateOperation{Stage: "wait_opencode", Detail: "waiting for opencode on guest port 4096"})
|
|
|
|
lines := strings.Split(strings.TrimSpace(stderr.String()), "\n")
|
|
if len(lines) != 2 {
|
|
t.Fatalf("rendered lines = %q, want 2 lines", stderr.String())
|
|
}
|
|
if lines[0] != "[vm create] preparing work disk: cloning work seed" {
|
|
t.Fatalf("first line = %q", lines[0])
|
|
}
|
|
if lines[1] != "[vm create] waiting for opencode: waiting for opencode on guest port 4096" {
|
|
t.Fatalf("second line = %q", lines[1])
|
|
}
|
|
}
|
|
|
|
func TestVMRunProgressRendererSuppressesDuplicateLines(t *testing.T) {
|
|
var stderr bytes.Buffer
|
|
renderer := newVMRunProgressRenderer(&stderr)
|
|
|
|
renderer.render("waiting for guest ssh")
|
|
renderer.render("waiting for guest ssh")
|
|
renderer.render("overlaying host working tree")
|
|
|
|
lines := strings.Split(strings.TrimSpace(stderr.String()), "\n")
|
|
if len(lines) != 2 {
|
|
t.Fatalf("rendered lines = %q, want 2 lines", stderr.String())
|
|
}
|
|
if lines[0] != "[vm run] waiting for guest ssh" {
|
|
t.Fatalf("first line = %q", lines[0])
|
|
}
|
|
if lines[1] != "[vm run] overlaying host working tree" {
|
|
t.Fatalf("second line = %q", lines[1])
|
|
}
|
|
}
|
|
|
|
func TestVMSetParamsFromFlagsConflict(t *testing.T) {
|
|
if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil {
|
|
t.Fatal("expected nat conflict error")
|
|
}
|
|
}
|
|
|
|
func TestVMSetParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
|
|
if _, err := vmSetParamsFromFlags("devbox", 0, -1, "", false, false); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
|
|
t.Fatalf("vmSetParamsFromFlags(vcpu=0) error = %v", err)
|
|
}
|
|
if _, err := vmSetParamsFromFlags("devbox", -1, 0, "", false, false); err == nil || !strings.Contains(err.Error(), "memory must be a positive integer") {
|
|
t.Fatalf("vmSetParamsFromFlags(memory=0) error = %v", err)
|
|
}
|
|
}
|
|
|
|
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"),
|
|
}
|
|
|
|
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,
|
|
} {
|
|
if !filepath.IsAbs(value) {
|
|
t.Fatalf("path %q is not absolute", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrintImageListTableShowsRootfsSizes(t *testing.T) {
|
|
rootfs := filepath.Join(t.TempDir(), "rootfs.ext4")
|
|
if err := os.WriteFile(rootfs, nil, 0o644); err != nil {
|
|
t.Fatalf("WriteFile(%s): %v", rootfs, err)
|
|
}
|
|
if err := os.Truncate(rootfs, 8*1024); err != nil {
|
|
t.Fatalf("Truncate(%s): %v", rootfs, err)
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
err := printImageListTable(&out, []model.Image{
|
|
{
|
|
ID: "0123456789abcdef",
|
|
Name: "alpine",
|
|
Managed: true,
|
|
RootfsPath: rootfs,
|
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
|
},
|
|
{
|
|
ID: "fedcba9876543210",
|
|
Name: "missing",
|
|
Managed: false,
|
|
RootfsPath: filepath.Join(t.TempDir(), "missing.ext4"),
|
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("printImageListTable() error = %v", err)
|
|
}
|
|
|
|
output := out.String()
|
|
if !strings.Contains(output, "ROOTFS SIZE") {
|
|
t.Fatalf("output = %q, want rootfs size header", output)
|
|
}
|
|
if !strings.Contains(output, "alpine") || !strings.Contains(output, "8K") {
|
|
t.Fatalf("output = %q, want alpine row with 8K size", output)
|
|
}
|
|
if strings.Contains(output, rootfs) {
|
|
t.Fatalf("output = %q, should not include rootfs path", output)
|
|
}
|
|
if !strings.Contains(output, "missing") || !strings.Contains(output, "-") {
|
|
t.Fatalf("output = %q, want fallback size for missing image", output)
|
|
}
|
|
}
|
|
|
|
func TestPrintVMListTableShowsImageNames(t *testing.T) {
|
|
var out bytes.Buffer
|
|
err := printVMListTable(&out, []model.VMRecord{
|
|
{
|
|
ID: "0123456789abcdef",
|
|
Name: "alp-fast",
|
|
ImageID: "image-alpine-123456",
|
|
State: model.VMStateRunning,
|
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
|
Spec: model.VMSpec{
|
|
VCPUCount: 2,
|
|
MemoryMiB: model.DefaultMemoryMiB,
|
|
WorkDiskSizeBytes: model.DefaultWorkDiskSize,
|
|
},
|
|
Runtime: model.VMRuntime{GuestIP: "172.16.0.4"},
|
|
},
|
|
{
|
|
ID: "fedcba9876543210",
|
|
Name: "mystery",
|
|
ImageID: "abcdef1234567890",
|
|
State: model.VMStateStopped,
|
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
|
Spec: model.VMSpec{
|
|
VCPUCount: 1,
|
|
MemoryMiB: 512,
|
|
WorkDiskSizeBytes: 4 * 1024 * 1024 * 1024,
|
|
},
|
|
},
|
|
}, map[string]string{
|
|
"image-alpine-123456": "alpine",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("printVMListTable() error = %v", err)
|
|
}
|
|
|
|
output := out.String()
|
|
if !strings.Contains(output, "IMAGE") || !strings.Contains(output, "MEM") {
|
|
t.Fatalf("output = %q, want vm list headers", output)
|
|
}
|
|
if !strings.Contains(output, "alp-fast") || !strings.Contains(output, "alpine") {
|
|
t.Fatalf("output = %q, want resolved image name", output)
|
|
}
|
|
if strings.Contains(output, "image-alpine-123456") {
|
|
t.Fatalf("output = %q, should not include full image id when name is known", output)
|
|
}
|
|
if !strings.Contains(output, shortID("abcdef1234567890")) {
|
|
t.Fatalf("output = %q, want short image id fallback", output)
|
|
}
|
|
if !strings.Contains(output, fmt.Sprintf("%d MiB", model.DefaultMemoryMiB)) {
|
|
t.Fatalf("output = %q, want updated default memory display", output)
|
|
}
|
|
}
|
|
|
|
func TestPrintVMPortsTableSortsAndRendersURLEndpoints(t *testing.T) {
|
|
result := api.VMPortsResult{
|
|
Name: "alpha",
|
|
Ports: []api.VMPort{
|
|
{
|
|
Proto: "https",
|
|
Port: 443,
|
|
Endpoint: "https://alpha.vm:443/",
|
|
Process: "caddy",
|
|
Command: "caddy run",
|
|
},
|
|
{
|
|
Proto: "udp",
|
|
Port: 53,
|
|
Endpoint: "alpha.vm:53",
|
|
Process: "dnsd",
|
|
Command: "dnsd --foreground",
|
|
},
|
|
},
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
if err := printVMPortsTable(&out, result); err != nil {
|
|
t.Fatalf("printVMPortsTable: %v", err)
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
|
if len(lines) != 3 {
|
|
t.Fatalf("lines = %q, want header + 2 rows", lines)
|
|
}
|
|
if !strings.Contains(lines[0], "PROTO") || !strings.Contains(lines[0], "ENDPOINT") || strings.Contains(lines[0], "VM") || strings.Contains(lines[0], "WEB") {
|
|
t.Fatalf("header = %q, want PROTO/ENDPOINT without VM/WEB", lines[0])
|
|
}
|
|
if !strings.Contains(lines[1], "https") || !strings.Contains(lines[1], "https://alpha.vm:443/") {
|
|
t.Fatalf("first row = %q, want https endpoint row", lines[1])
|
|
}
|
|
if !strings.Contains(lines[2], "udp") || !strings.Contains(lines[2], "alpha.vm:53") {
|
|
t.Fatalf("second row = %q, want udp endpoint row", lines[2])
|
|
}
|
|
}
|
|
|
|
func TestRunSSHSessionPrintsReminderWhenHealthCheckPasses(t *testing.T) {
|
|
origSSHExec := sshExecFunc
|
|
origHealth := vmHealthFunc
|
|
t.Cleanup(func() {
|
|
sshExecFunc = origSSHExec
|
|
vmHealthFunc = origHealth
|
|
})
|
|
|
|
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
return nil
|
|
}
|
|
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
|
return api.VMHealthResult{Name: "devbox", Healthy: true}, nil
|
|
}
|
|
|
|
var stderr bytes.Buffer
|
|
if err := runSSHSession(context.Background(), "/tmp/bangerd.sock", "devbox", strings.NewReader(""), &bytes.Buffer{}, &stderr, []string{"root@127.0.0.1"}); err != nil {
|
|
t.Fatalf("runSSHSession: %v", err)
|
|
}
|
|
if !strings.Contains(stderr.String(), "devbox is still running") {
|
|
t.Fatalf("stderr = %q, want reminder", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunSSHSessionPreservesSSHExitStatusOnHealthWarning(t *testing.T) {
|
|
origSSHExec := sshExecFunc
|
|
origHealth := vmHealthFunc
|
|
t.Cleanup(func() {
|
|
sshExecFunc = origSSHExec
|
|
vmHealthFunc = origHealth
|
|
})
|
|
|
|
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
return exitErrorWithCode(t, 1)
|
|
}
|
|
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
|
return api.VMHealthResult{}, errors.New("dial failed")
|
|
}
|
|
|
|
var stderr bytes.Buffer
|
|
err := runSSHSession(context.Background(), "/tmp/bangerd.sock", "devbox", strings.NewReader(""), &bytes.Buffer{}, &stderr, []string{"root@127.0.0.1"})
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("runSSHSession error = %v, want exit error", err)
|
|
}
|
|
if !strings.Contains(stderr.String(), "failed to check whether devbox is still running") {
|
|
t.Fatalf("stderr = %q, want warning", stderr.String())
|
|
}
|
|
}
|
|
|
|
func 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"),
|
|
testCLIResolvedVM("alpine-id", "alpine"),
|
|
testCLIResolvedVM("bravo-id", "bravo"),
|
|
}
|
|
|
|
targets, errs := resolveVMTargets(vms, []string{"alpha", "alpha-id", "al", "missing", "br"})
|
|
|
|
if len(targets) != 2 {
|
|
t.Fatalf("len(targets) = %d, want 2", len(targets))
|
|
}
|
|
if targets[0].VM.ID != "alpha-id" || targets[0].Ref != "alpha" {
|
|
t.Fatalf("targets[0] = %+v, want alpha target", targets[0])
|
|
}
|
|
if targets[1].VM.ID != "bravo-id" || targets[1].Ref != "br" {
|
|
t.Fatalf("targets[1] = %+v, want bravo target", targets[1])
|
|
}
|
|
if len(errs) != 2 {
|
|
t.Fatalf("len(errs) = %d, want 2", len(errs))
|
|
}
|
|
if errs[0].Ref != "al" || !strings.Contains(errs[0].Err.Error(), "multiple VMs match") {
|
|
t.Fatalf("errs[0] = %+v, want ambiguous prefix", errs[0])
|
|
}
|
|
if errs[1].Ref != "missing" || !strings.Contains(errs[1].Err.Error(), `vm "missing" not found`) {
|
|
t.Fatalf("errs[1] = %+v, want missing vm", errs[1])
|
|
}
|
|
}
|
|
|
|
func TestResolveVMRefPrefersExactMatchBeforePrefix(t *testing.T) {
|
|
vms := []model.VMRecord{
|
|
testCLIResolvedVM("1111111111111111111111111111111111111111111111111111111111111111", "alpha"),
|
|
testCLIResolvedVM("alpha222222222222222222222222222222222222222222222222222222222222", "bravo"),
|
|
}
|
|
|
|
vm, err := resolveVMRef(vms, "alpha")
|
|
if err != nil {
|
|
t.Fatalf("resolveVMRef(alpha): %v", err)
|
|
}
|
|
if vm.Name != "alpha" {
|
|
t.Fatalf("resolveVMRef(alpha) = %+v, want exact-name vm", vm)
|
|
}
|
|
}
|
|
|
|
func TestExecuteVMActionBatchRunsConcurrentlyAndPreservesOrder(t *testing.T) {
|
|
targets := []resolvedVMTarget{
|
|
{Ref: "alpha", VM: testCLIResolvedVM("alpha-id", "alpha")},
|
|
{Ref: "bravo", VM: testCLIResolvedVM("bravo-id", "bravo")},
|
|
}
|
|
|
|
started := make(chan string, len(targets))
|
|
release := make(chan struct{})
|
|
done := make(chan []vmBatchActionResult, 1)
|
|
go func() {
|
|
done <- executeVMActionBatch(context.Background(), targets, func(ctx context.Context, id string) (model.VMRecord, error) {
|
|
started <- id
|
|
<-release
|
|
return model.VMRecord{ID: id, Name: id}, nil
|
|
})
|
|
}()
|
|
|
|
for range targets {
|
|
select {
|
|
case <-started:
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("batch actions did not overlap")
|
|
}
|
|
}
|
|
|
|
close(release)
|
|
|
|
var results []vmBatchActionResult
|
|
select {
|
|
case results = <-done:
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("executeVMActionBatch did not finish")
|
|
}
|
|
|
|
if len(results) != len(targets) {
|
|
t.Fatalf("len(results) = %d, want %d", len(results), len(targets))
|
|
}
|
|
for index, result := range results {
|
|
if result.Target.Ref != targets[index].Ref {
|
|
t.Fatalf("results[%d].Target.Ref = %q, want %q", index, result.Target.Ref, targets[index].Ref)
|
|
}
|
|
if result.VM.ID != targets[index].VM.ID {
|
|
t.Fatalf("results[%d].VM.ID = %q, want %q", index, result.VM.ID, targets[index].VM.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHCommandArgs(t *testing.T) {
|
|
args, err := sshCommandArgs(model.DaemonConfig{SSHKeyPath: "/bundle/id_ed25519"}, "172.16.0.2", []string{"--", "uname", "-a"})
|
|
if err != nil {
|
|
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",
|
|
"--", "uname", "-a",
|
|
}
|
|
if !reflect.DeepEqual(args, want) {
|
|
t.Fatalf("args = %v, want %v", args, want)
|
|
}
|
|
}
|
|
|
|
func TestValidateSSHPrereqs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
keyPath := filepath.Join(dir, "id_ed25519")
|
|
if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil {
|
|
t.Fatalf("write key: %v", err)
|
|
}
|
|
|
|
if err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: keyPath}); err != nil {
|
|
t.Fatalf("validateSSHPrereqs: %v", err)
|
|
}
|
|
}
|
|
|
|
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") {
|
|
t.Fatalf("validateSSHPrereqs() error = %v, want missing key", err)
|
|
}
|
|
}
|
|
|
|
func TestResolveVMRunSourcePathDefaultsToCWD(t *testing.T) {
|
|
origCWD := cwdFunc
|
|
t.Cleanup(func() {
|
|
cwdFunc = origCWD
|
|
})
|
|
|
|
want := t.TempDir()
|
|
cwdFunc = func() (string, error) {
|
|
return want, nil
|
|
}
|
|
|
|
got, err := resolveVMRunSourcePath("")
|
|
if err != nil {
|
|
t.Fatalf("resolveVMRunSourcePath: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("resolveVMRunSourcePath() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestInspectVMRunRepoUsesRepoRootAndOverlayPaths(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
|
|
repoRoot := t.TempDir()
|
|
globalConfigPath := filepath.Join(t.TempDir(), "global.gitconfig")
|
|
t.Setenv("GIT_CONFIG_GLOBAL", globalConfigPath)
|
|
testRunGit(t, repoRoot, "config", "--global", "user.email", "global@example.com")
|
|
testRunGit(t, repoRoot, "config", "--global", "user.name", "Global User")
|
|
testRunGit(t, repoRoot, "init")
|
|
testRunGit(t, repoRoot, "remote", "add", "origin", "https://example.com/repo.git")
|
|
testRunGit(t, repoRoot, "config", "user.email", "test@example.com")
|
|
testRunGit(t, repoRoot, "config", "user.name", "Banger Test")
|
|
|
|
if err := os.MkdirAll(filepath.Join(repoRoot, "dir"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(dir): %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repoRoot, ".gitignore"), []byte("ignored.txt\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(.gitignore): %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repoRoot, "tracked.txt"), []byte("tracked\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(tracked.txt): %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repoRoot, "dir", "keep.txt"), []byte("keep\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(keep.txt): %v", err)
|
|
}
|
|
testRunGit(t, repoRoot, "add", ".")
|
|
testRunGit(t, repoRoot, "commit", "-m", "init")
|
|
testRunGit(t, repoRoot, "checkout", "-b", "trunk")
|
|
|
|
if err := os.WriteFile(filepath.Join(repoRoot, "tracked.txt"), []byte("tracked local\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(tracked.txt local): %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repoRoot, "untracked.txt"), []byte("untracked\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(untracked.txt): %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repoRoot, "ignored.txt"), []byte("ignored\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(ignored.txt): %v", err)
|
|
}
|
|
|
|
spec, err := inspectVMRunRepo(context.Background(), filepath.Join(repoRoot, "dir"), "", "HEAD")
|
|
if err != nil {
|
|
t.Fatalf("inspectVMRunRepo: %v", err)
|
|
}
|
|
|
|
if spec.RepoRoot != repoRoot {
|
|
t.Fatalf("RepoRoot = %q, want %q", spec.RepoRoot, repoRoot)
|
|
}
|
|
if spec.RepoName != filepath.Base(repoRoot) {
|
|
t.Fatalf("RepoName = %q, want %q", spec.RepoName, filepath.Base(repoRoot))
|
|
}
|
|
if spec.CurrentBranch != "trunk" {
|
|
t.Fatalf("CurrentBranch = %q, want trunk", spec.CurrentBranch)
|
|
}
|
|
if spec.HeadCommit == "" {
|
|
t.Fatal("HeadCommit should not be empty")
|
|
}
|
|
if spec.BaseCommit != spec.HeadCommit {
|
|
t.Fatalf("BaseCommit = %q, want head %q", spec.BaseCommit, spec.HeadCommit)
|
|
}
|
|
if spec.OriginURL != "https://example.com/repo.git" {
|
|
t.Fatalf("OriginURL = %q, want https://example.com/repo.git", spec.OriginURL)
|
|
}
|
|
if spec.GitUserName != "Banger Test" {
|
|
t.Fatalf("GitUserName = %q, want Banger Test", spec.GitUserName)
|
|
}
|
|
if spec.GitUserEmail != "test@example.com" {
|
|
t.Fatalf("GitUserEmail = %q, want test@example.com", spec.GitUserEmail)
|
|
}
|
|
wantOverlay := []string{".gitignore", "dir/keep.txt", "tracked.txt", "untracked.txt"}
|
|
if !reflect.DeepEqual(spec.OverlayPaths, wantOverlay) {
|
|
t.Fatalf("OverlayPaths = %v, want %v", spec.OverlayPaths, wantOverlay)
|
|
}
|
|
}
|
|
|
|
func TestInspectVMRunRepoRejectsSubmodules(t *testing.T) {
|
|
repoRoot := t.TempDir()
|
|
|
|
origHostCommandOutput := hostCommandOutputFunc
|
|
t.Cleanup(func() {
|
|
hostCommandOutputFunc = origHostCommandOutput
|
|
})
|
|
|
|
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
t.Helper()
|
|
if name != "git" {
|
|
t.Fatalf("command = %q, want git", name)
|
|
}
|
|
switch {
|
|
case reflect.DeepEqual(args, []string{"-C", repoRoot, "rev-parse", "--show-toplevel"}):
|
|
return []byte(repoRoot + "\n"), nil
|
|
case reflect.DeepEqual(args, []string{"-C", repoRoot, "rev-parse", "--is-bare-repository"}):
|
|
return []byte("false\n"), nil
|
|
case reflect.DeepEqual(args, []string{"-C", repoRoot, "ls-files", "--stage", "-z"}):
|
|
return []byte("160000 deadbeef 0\tvendor/submodule\x00"), nil
|
|
default:
|
|
t.Fatalf("unexpected git args: %v", args)
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
_, err := inspectVMRunRepo(context.Background(), repoRoot, "", "HEAD")
|
|
if err == nil || !strings.Contains(err.Error(), "submodules") {
|
|
t.Fatalf("inspectVMRunRepo() error = %v, want submodule rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|
repoRoot := t.TempDir()
|
|
repoCopyDir := filepath.Join(t.TempDir(), "repo-copy")
|
|
|
|
origBegin := vmCreateBeginFunc
|
|
origStatus := vmCreateStatusFunc
|
|
origCancel := vmCreateCancelFunc
|
|
origWaitForSSH := guestWaitForSSHFunc
|
|
origGuestDial := guestDialFunc
|
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
|
origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc
|
|
origOpencodeExec := opencodeExecFunc
|
|
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
|
t.Cleanup(func() {
|
|
vmCreateBeginFunc = origBegin
|
|
vmCreateStatusFunc = origStatus
|
|
vmCreateCancelFunc = origCancel
|
|
guestWaitForSSHFunc = origWaitForSSH
|
|
guestDialFunc = origGuestDial
|
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
|
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
|
|
opencodeExecFunc = origOpencodeExec
|
|
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
|
})
|
|
|
|
vm := model.VMRecord{
|
|
ID: "vm-id",
|
|
Name: "devbox",
|
|
Runtime: model.VMRuntime{
|
|
State: model.VMStateRunning,
|
|
GuestIP: "172.16.0.2",
|
|
},
|
|
}
|
|
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
|
return api.VMCreateBeginResult{
|
|
Operation: api.VMCreateOperation{
|
|
ID: "op-1",
|
|
Stage: "ready",
|
|
Detail: "vm is ready",
|
|
Done: true,
|
|
Success: true,
|
|
VM: &vm,
|
|
},
|
|
}, nil
|
|
}
|
|
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
|
t.Fatal("vmCreateStatusFunc should not be called")
|
|
return api.VMCreateStatusResult{}, nil
|
|
}
|
|
vmCreateCancelFunc = func(context.Context, string, string) error {
|
|
t.Fatal("vmCreateCancelFunc should not be called")
|
|
return nil
|
|
}
|
|
|
|
fakeClient := &testVMRunGuestClient{}
|
|
waitAddress := ""
|
|
waitKeyPath := ""
|
|
waitInterval := time.Duration(0)
|
|
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
|
waitAddress = address
|
|
waitKeyPath = privateKeyPath
|
|
waitInterval = interval
|
|
return nil
|
|
}
|
|
dialAddress := ""
|
|
dialKeyPath := ""
|
|
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
|
dialAddress = address
|
|
dialKeyPath = privateKeyPath
|
|
return fakeClient, nil
|
|
}
|
|
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
|
if spec.RepoRoot != repoRoot {
|
|
t.Fatalf("spec.RepoRoot = %q, want %q", spec.RepoRoot, repoRoot)
|
|
}
|
|
return repoCopyDir, func() {}, nil
|
|
}
|
|
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
|
return true, nil
|
|
}
|
|
buildVMRunToolingPlanFunc = func(context.Context, string) toolingplan.Plan {
|
|
return toolingplan.Plan{
|
|
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
|
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
|
}
|
|
}
|
|
var attachArgs []string
|
|
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
attachArgs = append([]string(nil), args...)
|
|
return nil
|
|
}
|
|
|
|
spec := vmRunRepoSpec{
|
|
RepoRoot: repoRoot,
|
|
RepoName: "repo",
|
|
HeadCommit: "deadbeef",
|
|
CurrentBranch: "main",
|
|
BranchName: "feature",
|
|
BaseCommit: "cafebabe",
|
|
GitUserName: "Repo User",
|
|
GitUserEmail: "repo@example.com",
|
|
OverlayPaths: []string{"tracked.txt", "nested/keep.txt"},
|
|
}
|
|
err := runVMRun(
|
|
context.Background(),
|
|
"/tmp/bangerd.sock",
|
|
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
|
strings.NewReader(""),
|
|
&bytes.Buffer{},
|
|
&bytes.Buffer{},
|
|
api.VMCreateParams{Name: "devbox"},
|
|
spec,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("runVMRun: %v", err)
|
|
}
|
|
|
|
if waitAddress != "172.16.0.2:22" {
|
|
t.Fatalf("waitAddress = %q, want 172.16.0.2:22", waitAddress)
|
|
}
|
|
if waitKeyPath != "/tmp/id_ed25519" {
|
|
t.Fatalf("waitKeyPath = %q, want /tmp/id_ed25519", waitKeyPath)
|
|
}
|
|
if waitInterval <= 0 {
|
|
t.Fatalf("waitInterval = %s, want positive interval", waitInterval)
|
|
}
|
|
if dialAddress != waitAddress {
|
|
t.Fatalf("dialAddress = %q, want %q", dialAddress, waitAddress)
|
|
}
|
|
if dialKeyPath != waitKeyPath {
|
|
t.Fatalf("dialKeyPath = %q, want %q", dialKeyPath, waitKeyPath)
|
|
}
|
|
if fakeClient.tarSourceDir != repoCopyDir {
|
|
t.Fatalf("tarSourceDir = %q, want %q", fakeClient.tarSourceDir, repoCopyDir)
|
|
}
|
|
if fakeClient.tarCommand != "rm -rf '/root/repo' && mkdir -p '/root/repo' && tar -o -C '/root/repo' --strip-components=1 -xf -" {
|
|
t.Fatalf("tarCommand = %q", fakeClient.tarCommand)
|
|
}
|
|
if len(fakeClient.uploads) != 2 {
|
|
t.Fatalf("uploads = %d, want 2", len(fakeClient.uploads))
|
|
}
|
|
if fakeClient.uploads[0].path != vmRunToolingHarnessPromptPath("repo") {
|
|
t.Fatalf("prompt upload path = %q, want %q", fakeClient.uploads[0].path, vmRunToolingHarnessPromptPath("repo"))
|
|
}
|
|
if fakeClient.uploads[0].mode != 0o644 {
|
|
t.Fatalf("prompt upload mode = %v, want 0644", fakeClient.uploads[0].mode)
|
|
}
|
|
if !strings.Contains(string(fakeClient.uploads[0].data), `Do not edit repository files.`) {
|
|
t.Fatalf("prompt upload data = %q, want prompt body", string(fakeClient.uploads[0].data))
|
|
}
|
|
if !strings.Contains(string(fakeClient.uploads[0].data), `Planned deterministic install: go@1.25.0 from go.mod`) {
|
|
t.Fatalf("prompt upload data = %q, want deterministic install summary", string(fakeClient.uploads[0].data))
|
|
}
|
|
if !strings.Contains(string(fakeClient.uploads[0].data), `Deterministic skip: python (no .python-version)`) {
|
|
t.Fatalf("prompt upload data = %q, want deterministic skip summary", string(fakeClient.uploads[0].data))
|
|
}
|
|
if fakeClient.uploadPath != vmRunToolingHarnessPath("repo") {
|
|
t.Fatalf("uploadPath = %q, want %q", fakeClient.uploadPath, vmRunToolingHarnessPath("repo"))
|
|
}
|
|
if fakeClient.uploadMode != 0o755 {
|
|
t.Fatalf("uploadMode = %v, want 0755", fakeClient.uploadMode)
|
|
}
|
|
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" install`) {
|
|
t.Fatalf("uploadData = %q, want mise install best-effort step", string(fakeClient.uploadData))
|
|
}
|
|
if !strings.Contains(string(fakeClient.uploadData), fmt.Sprintf(`INSTALL_TIMEOUT_SECS=%d`, vmRunToolingInstallTimeoutSeconds)) {
|
|
t.Fatalf("uploadData = %q, want deterministic install timeout", string(fakeClient.uploadData))
|
|
}
|
|
if !strings.Contains(string(fakeClient.uploadData), `deterministic install: go@1.25.0 (go.mod)`) {
|
|
t.Fatalf("uploadData = %q, want deterministic install log", string(fakeClient.uploadData))
|
|
}
|
|
if !strings.Contains(string(fakeClient.uploadData), `run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`) {
|
|
t.Fatalf("uploadData = %q, want deterministic go install step", string(fakeClient.uploadData))
|
|
}
|
|
if !strings.Contains(string(fakeClient.uploadData), `deterministic skip: python (no .python-version)`) {
|
|
t.Fatalf("uploadData = %q, want deterministic skip log", string(fakeClient.uploadData))
|
|
}
|
|
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" reshim`) {
|
|
t.Fatalf("uploadData = %q, want deterministic reshim step", string(fakeClient.uploadData))
|
|
}
|
|
if !strings.Contains(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1 </dev/null &`) {
|
|
t.Fatalf("launchScript = %q, want nohup launcher", fakeClient.launchScript)
|
|
}
|
|
if !strings.Contains(fakeClient.launchScript, vmRunToolingHarnessLogPath("repo")) {
|
|
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
|
|
}
|
|
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
|
|
t.Fatalf("script = %q, want guest branch checkout", fakeClient.script)
|
|
}
|
|
if !strings.Contains(fakeClient.script, `find "$DIR" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +`) {
|
|
t.Fatalf("script = %q, want guest worktree reset", fakeClient.script)
|
|
}
|
|
if !strings.Contains(fakeClient.script, `git config --global --add safe.directory "$DIR"`) {
|
|
t.Fatalf("script = %q, want guest safe.directory config", fakeClient.script)
|
|
}
|
|
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.name 'Repo User'`) {
|
|
t.Fatalf("script = %q, want guest repo user.name config", fakeClient.script)
|
|
}
|
|
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.email 'repo@example.com'`) {
|
|
t.Fatalf("script = %q, want guest repo user.email config", fakeClient.script)
|
|
}
|
|
if fakeClient.streamSourceDir != repoRoot {
|
|
t.Fatalf("streamSourceDir = %q, want %q", fakeClient.streamSourceDir, repoRoot)
|
|
}
|
|
if !reflect.DeepEqual(fakeClient.streamEntries, spec.OverlayPaths) {
|
|
t.Fatalf("streamEntries = %v, want %v", fakeClient.streamEntries, spec.OverlayPaths)
|
|
}
|
|
if fakeClient.streamCommand != "tar -o -C '/root/repo' --strip-components=1 -xf -" {
|
|
t.Fatalf("streamCommand = %q", fakeClient.streamCommand)
|
|
}
|
|
wantAttach := []string{"attach", "--dir", "/root/repo", "http://172.16.0.2:4096"}
|
|
if !reflect.DeepEqual(attachArgs, wantAttach) {
|
|
t.Fatalf("attachArgs = %v, want %v", attachArgs, wantAttach)
|
|
}
|
|
if !fakeClient.closed {
|
|
t.Fatal("guest client should be closed")
|
|
}
|
|
}
|
|
|
|
func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|
origBegin := vmCreateBeginFunc
|
|
origStatus := vmCreateStatusFunc
|
|
origCancel := vmCreateCancelFunc
|
|
origWaitForSSH := guestWaitForSSHFunc
|
|
origGuestDial := guestDialFunc
|
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
|
origOpencodeExec := opencodeExecFunc
|
|
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
|
t.Cleanup(func() {
|
|
vmCreateBeginFunc = origBegin
|
|
vmCreateStatusFunc = origStatus
|
|
vmCreateCancelFunc = origCancel
|
|
guestWaitForSSHFunc = origWaitForSSH
|
|
guestDialFunc = origGuestDial
|
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
|
opencodeExecFunc = origOpencodeExec
|
|
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
|
})
|
|
|
|
vm := model.VMRecord{
|
|
ID: "vm-id",
|
|
Name: "devbox",
|
|
Runtime: model.VMRuntime{
|
|
State: model.VMStateRunning,
|
|
GuestIP: "172.16.0.2",
|
|
},
|
|
}
|
|
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
|
return api.VMCreateBeginResult{
|
|
Operation: api.VMCreateOperation{
|
|
ID: "op-1",
|
|
Stage: "ready",
|
|
Detail: "vm is ready",
|
|
Done: true,
|
|
Success: true,
|
|
VM: &vm,
|
|
},
|
|
}, nil
|
|
}
|
|
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
|
t.Fatal("vmCreateStatusFunc should not be called")
|
|
return api.VMCreateStatusResult{}, nil
|
|
}
|
|
vmCreateCancelFunc = func(context.Context, string, string) error {
|
|
t.Fatal("vmCreateCancelFunc should not be called")
|
|
return nil
|
|
}
|
|
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
|
return nil
|
|
}
|
|
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
|
return &testVMRunGuestClient{}, nil
|
|
}
|
|
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
|
return t.TempDir(), func() {}, nil
|
|
}
|
|
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
|
return true, nil
|
|
}
|
|
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
return nil
|
|
}
|
|
|
|
var stderr bytes.Buffer
|
|
err := runVMRun(
|
|
context.Background(),
|
|
"/tmp/bangerd.sock",
|
|
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
|
strings.NewReader(""),
|
|
&bytes.Buffer{},
|
|
&stderr,
|
|
api.VMCreateParams{Name: "devbox"},
|
|
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("runVMRun: %v", err)
|
|
}
|
|
|
|
output := stderr.String()
|
|
for _, want := range []string{
|
|
"[vm run] waiting for guest ssh",
|
|
"[vm run] preparing shallow repo",
|
|
"[vm run] copying repo metadata to guest",
|
|
"[vm run] preparing guest checkout",
|
|
"[vm run] overlaying host working tree",
|
|
"[vm run] starting tooling harness",
|
|
"[vm run] tooling harness log: /root/.cache/banger/vm-run-tooling-repo.log",
|
|
"[vm run] attaching opencode",
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("stderr = %q, want %q", output, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|
origBegin := vmCreateBeginFunc
|
|
origStatus := vmCreateStatusFunc
|
|
origCancel := vmCreateCancelFunc
|
|
origWaitForSSH := guestWaitForSSHFunc
|
|
origGuestDial := guestDialFunc
|
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
|
origOpencodeExec := opencodeExecFunc
|
|
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
|
t.Cleanup(func() {
|
|
vmCreateBeginFunc = origBegin
|
|
vmCreateStatusFunc = origStatus
|
|
vmCreateCancelFunc = origCancel
|
|
guestWaitForSSHFunc = origWaitForSSH
|
|
guestDialFunc = origGuestDial
|
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
|
opencodeExecFunc = origOpencodeExec
|
|
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
|
})
|
|
|
|
vm := model.VMRecord{
|
|
ID: "vm-id",
|
|
Name: "devbox",
|
|
Runtime: model.VMRuntime{
|
|
State: model.VMStateRunning,
|
|
GuestIP: "172.16.0.2",
|
|
},
|
|
}
|
|
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
|
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
|
|
}
|
|
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
|
t.Fatal("vmCreateStatusFunc should not be called")
|
|
return api.VMCreateStatusResult{}, nil
|
|
}
|
|
vmCreateCancelFunc = func(context.Context, string, string) error {
|
|
t.Fatal("vmCreateCancelFunc should not be called")
|
|
return nil
|
|
}
|
|
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
|
return nil
|
|
}
|
|
fakeClient := &testVMRunGuestClient{launchErr: errors.New("launch failed")}
|
|
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
|
return fakeClient, nil
|
|
}
|
|
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
|
return t.TempDir(), func() {}, nil
|
|
}
|
|
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
|
return true, nil
|
|
}
|
|
attachCalled := false
|
|
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
attachCalled = true
|
|
return nil
|
|
}
|
|
|
|
var stderr bytes.Buffer
|
|
err := runVMRun(
|
|
context.Background(),
|
|
"/tmp/bangerd.sock",
|
|
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
|
strings.NewReader(""),
|
|
&bytes.Buffer{},
|
|
&stderr,
|
|
api.VMCreateParams{Name: "devbox"},
|
|
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("runVMRun: %v", err)
|
|
}
|
|
if !attachCalled {
|
|
t.Fatal("opencode attach should still run when tooling harness launch fails")
|
|
}
|
|
if !strings.Contains(stderr.String(), "[vm run] warning: tooling harness start failed: launch tooling harness: launch failed") {
|
|
t.Fatalf("stderr = %q, want tooling harness warning", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunVMRunFallsBackToGuestOpencodeWhenHostAttachUnsupported(t *testing.T) {
|
|
repoRoot := t.TempDir()
|
|
|
|
origBegin := vmCreateBeginFunc
|
|
origStatus := vmCreateStatusFunc
|
|
origCancel := vmCreateCancelFunc
|
|
origWaitForSSH := guestWaitForSSHFunc
|
|
origGuestDial := guestDialFunc
|
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
|
origOpencodeExec := opencodeExecFunc
|
|
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
|
origSSHExec := sshExecFunc
|
|
t.Cleanup(func() {
|
|
vmCreateBeginFunc = origBegin
|
|
vmCreateStatusFunc = origStatus
|
|
vmCreateCancelFunc = origCancel
|
|
guestWaitForSSHFunc = origWaitForSSH
|
|
guestDialFunc = origGuestDial
|
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
|
opencodeExecFunc = origOpencodeExec
|
|
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
|
sshExecFunc = origSSHExec
|
|
})
|
|
|
|
vm := model.VMRecord{
|
|
ID: "vm-id",
|
|
Name: "devbox",
|
|
Runtime: model.VMRuntime{
|
|
State: model.VMStateRunning,
|
|
GuestIP: "172.16.0.2",
|
|
},
|
|
}
|
|
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
|
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
|
|
}
|
|
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
|
t.Fatal("vmCreateStatusFunc should not be called")
|
|
return api.VMCreateStatusResult{}, nil
|
|
}
|
|
vmCreateCancelFunc = func(context.Context, string, string) error {
|
|
t.Fatal("vmCreateCancelFunc should not be called")
|
|
return nil
|
|
}
|
|
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
|
return nil
|
|
}
|
|
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
|
return &testVMRunGuestClient{}, nil
|
|
}
|
|
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
|
return t.TempDir(), func() {}, nil
|
|
}
|
|
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
|
return false, nil
|
|
}
|
|
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
t.Fatalf("opencodeExecFunc should not be called when host attach is unsupported: %v", args)
|
|
return nil
|
|
}
|
|
var sshArgs []string
|
|
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
sshArgs = append([]string(nil), args...)
|
|
return nil
|
|
}
|
|
|
|
var stderr bytes.Buffer
|
|
err := runVMRun(
|
|
context.Background(),
|
|
"/tmp/bangerd.sock",
|
|
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
|
strings.NewReader(""),
|
|
&bytes.Buffer{},
|
|
&stderr,
|
|
api.VMCreateParams{Name: "devbox"},
|
|
vmRunRepoSpec{RepoRoot: repoRoot, RepoName: "repo", HeadCommit: "deadbeef"},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("runVMRun: %v", err)
|
|
}
|
|
if len(sshArgs) < 3 {
|
|
t.Fatalf("sshArgs = %v, want fallback SSH invocation", sshArgs)
|
|
}
|
|
if sshArgs[len(sshArgs)-3] != "bash" || sshArgs[len(sshArgs)-2] != "-lc" {
|
|
t.Fatalf("sshArgs = %v, want bash -lc fallback command", sshArgs)
|
|
}
|
|
if sshArgs[len(sshArgs)-1] != "cd '/root/repo' && exec opencode ." {
|
|
t.Fatalf("ssh fallback command = %q, want guest opencode launch", sshArgs[len(sshArgs)-1])
|
|
}
|
|
if !strings.Contains(stderr.String(), "[vm run] host opencode has no attach support; starting guest opencode over ssh") {
|
|
t.Fatalf("stderr = %q, want SSH fallback progress", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestOpencodeAttachHelpOutputSupported(t *testing.T) {
|
|
if !opencodeAttachHelpOutputSupported([]byte("opencode attach [url]\n\nAttach a terminal")) {
|
|
t.Fatal("expected attach help output to be recognized")
|
|
}
|
|
if opencodeAttachHelpOutputSupported([]byte("opencode [project]\n\nCommands:\n opencode run [message..]")) {
|
|
t.Fatal("unexpected attach support for top-level help output")
|
|
}
|
|
}
|
|
|
|
func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) {
|
|
script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{
|
|
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
|
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
|
})
|
|
for _, want := range []string{
|
|
`if [ -f .mise.toml ] || [ -f .tool-versions ]; then`,
|
|
"PROMPT_FILE=" + shellQuote(vmRunToolingHarnessPromptPath("repo")),
|
|
fmt.Sprintf("INSTALL_TIMEOUT_SECS=%d", vmRunToolingInstallTimeoutSeconds),
|
|
"MODEL=" + shellQuote(vmRunToolingHarnessModel),
|
|
fmt.Sprintf("TIMEOUT_SECS=%d", vmRunToolingHarnessTimeoutSeconds),
|
|
`run_best_effort "$MISE_BIN" install`,
|
|
`deterministic install: go@1.25.0 (go.mod)`,
|
|
`run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`,
|
|
`deterministic skip: python (no .python-version)`,
|
|
`run_best_effort "$MISE_BIN" reshim`,
|
|
`run_bounded_best_effort "$TIMEOUT_SECS" bash -lc 'exec "$1" run --format json -m "$2" "$(cat "$3")"' _ "$OPENCODE_BIN" "$MODEL" "$PROMPT_FILE"`,
|
|
`command timed out after ${timeout_secs}s: $*`,
|
|
`tooling prompt file missing: $PROMPT_FILE`,
|
|
} {
|
|
if !strings.Contains(script, want) {
|
|
t.Fatalf("script = %q, want %q", script, want)
|
|
}
|
|
}
|
|
for _, unwanted := range []string{"git add", "cat > .mise.toml", "cat > .tool-versions"} {
|
|
if strings.Contains(script, unwanted) {
|
|
t.Fatalf("script = %q, want no %q", script, unwanted)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
|
|
repoRoot := t.TempDir()
|
|
testRunGit(t, repoRoot, "init")
|
|
testRunGit(t, repoRoot, "remote", "add", "origin", "https://example.com/repo.git")
|
|
for i := 0; i < 12; i++ {
|
|
name := fmt.Sprintf("file-%02d.txt", i)
|
|
if err := os.WriteFile(filepath.Join(repoRoot, name), []byte(fmt.Sprintf("commit-%02d\n", i)), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(%s): %v", name, err)
|
|
}
|
|
testRunGit(t, repoRoot, "add", name)
|
|
testRunGit(t, repoRoot, "commit", "-m", fmt.Sprintf("commit-%02d", i))
|
|
}
|
|
baseCommit := strings.TrimSpace(testRunGit(t, repoRoot, "rev-parse", "HEAD~5"))
|
|
|
|
repoCopyDir, cleanup, err := prepareVMRunRepoCopy(context.Background(), vmRunRepoSpec{
|
|
RepoRoot: repoRoot,
|
|
RepoName: "repo",
|
|
BranchName: "feature",
|
|
BaseCommit: baseCommit,
|
|
HeadCommit: strings.TrimSpace(testRunGit(t, repoRoot, "rev-parse", "HEAD")),
|
|
OriginURL: "https://example.com/repo.git",
|
|
OverlayPaths: []string{"file-11.txt"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("prepareVMRunRepoCopy: %v", err)
|
|
}
|
|
defer cleanup()
|
|
|
|
entries, err := os.ReadDir(repoCopyDir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir(repoCopyDir): %v", err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Name() != ".git" {
|
|
t.Fatalf("repo copy entries = %v, want only .git", entries)
|
|
}
|
|
if got := strings.TrimSpace(testRunGit(t, repoCopyDir, "rev-parse", "--is-shallow-repository")); got != "true" {
|
|
t.Fatalf("is-shallow-repository = %q, want true", got)
|
|
}
|
|
if got := strings.TrimSpace(testRunGit(t, repoCopyDir, "config", "--get", "remote.origin.url")); got != "https://example.com/repo.git" {
|
|
t.Fatalf("remote.origin.url = %q, want https://example.com/repo.git", got)
|
|
}
|
|
if _, err := exec.Command("git", "-C", repoCopyDir, "cat-file", "-e", baseCommit+"^{commit}").CombinedOutput(); err != nil {
|
|
t.Fatalf("cat-file -e %s^{commit}: %v", baseCommit, err)
|
|
}
|
|
}
|
|
|
|
func TestVMRunCheckoutScriptSkipsRepoGitIdentityWhenIncomplete(t *testing.T) {
|
|
script := vmRunCheckoutScript(vmRunRepoSpec{
|
|
RepoName: "repo",
|
|
HeadCommit: "deadbeef",
|
|
CurrentBranch: "main",
|
|
GitUserName: "Repo User",
|
|
})
|
|
|
|
if strings.Contains(script, `git -C "$DIR" config user.name`) || strings.Contains(script, `git -C "$DIR" config user.email`) {
|
|
t.Fatalf("script = %q, want no repo-local git identity commands", script)
|
|
}
|
|
}
|
|
|
|
func TestNewBangerdCommandRejectsArgs(t *testing.T) {
|
|
cmd := NewBangerdCommand()
|
|
cmd.SetArgs([]string{"extra"})
|
|
if err := cmd.Execute(); err == nil {
|
|
t.Fatal("expected extra args to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestDaemonOutdated(t *testing.T) {
|
|
dir := t.TempDir()
|
|
current := filepath.Join(dir, "bangerd-current")
|
|
same := filepath.Join(dir, "bangerd-same")
|
|
stale := filepath.Join(dir, "bangerd-stale")
|
|
if err := os.WriteFile(current, []byte("current"), 0o755); err != nil {
|
|
t.Fatalf("write current: %v", err)
|
|
}
|
|
if err := os.Link(current, same); err != nil {
|
|
t.Fatalf("hard link: %v", err)
|
|
}
|
|
if err := os.WriteFile(stale, []byte("stale"), 0o755); err != nil {
|
|
t.Fatalf("write stale: %v", err)
|
|
}
|
|
|
|
origBangerdPath := bangerdPathFunc
|
|
origDaemonExePath := daemonExePath
|
|
t.Cleanup(func() {
|
|
bangerdPathFunc = origBangerdPath
|
|
daemonExePath = origDaemonExePath
|
|
})
|
|
|
|
bangerdPathFunc = func() (string, error) {
|
|
return current, nil
|
|
}
|
|
daemonExePath = func(pid int) string {
|
|
if pid == 1 {
|
|
return same
|
|
}
|
|
return stale
|
|
}
|
|
|
|
if daemonOutdated(1) {
|
|
t.Fatal("expected matching daemon executable to be current")
|
|
}
|
|
if !daemonOutdated(2) {
|
|
t.Fatal("expected replaced daemon executable to be outdated")
|
|
}
|
|
}
|
|
|
|
func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) {
|
|
configHome := filepath.Join(t.TempDir(), "config")
|
|
stateHome := filepath.Join(t.TempDir(), "state")
|
|
runtimeHome := filepath.Join(t.TempDir(), "runtime")
|
|
t.Setenv("XDG_CONFIG_HOME", configHome)
|
|
t.Setenv("XDG_STATE_HOME", stateHome)
|
|
t.Setenv("XDG_RUNTIME_DIR", runtimeHome)
|
|
|
|
cmd := NewBangerCommand()
|
|
var stdout bytes.Buffer
|
|
cmd.SetOut(&stdout)
|
|
cmd.SetErr(&stdout)
|
|
cmd.SetArgs([]string{"daemon", "status"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "stopped\n") {
|
|
t.Fatalf("output = %q, want stopped status", output)
|
|
}
|
|
if !strings.Contains(output, "log: "+filepath.Join(stateHome, "banger", "bangerd.log")) {
|
|
t.Fatalf("output = %q, want daemon log path", output)
|
|
}
|
|
if !strings.Contains(output, "dns: 127.0.0.1:42069") {
|
|
t.Fatalf("output = %q, want dns listener", output)
|
|
}
|
|
if !strings.Contains(output, "web: http://127.0.0.1:7777") {
|
|
t.Fatalf("output = %q, want default web listener", output)
|
|
}
|
|
}
|
|
|
|
func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
|
|
origDaemonPing := daemonPingFunc
|
|
t.Cleanup(func() {
|
|
daemonPingFunc = origDaemonPing
|
|
})
|
|
|
|
configHome := filepath.Join(t.TempDir(), "config")
|
|
stateHome := filepath.Join(t.TempDir(), "state")
|
|
runtimeHome := filepath.Join(t.TempDir(), "runtime")
|
|
t.Setenv("XDG_CONFIG_HOME", configHome)
|
|
t.Setenv("XDG_STATE_HOME", stateHome)
|
|
t.Setenv("XDG_RUNTIME_DIR", runtimeHome)
|
|
|
|
daemonPingFunc = func(context.Context, string) (api.PingResult, error) {
|
|
return api.PingResult{
|
|
Status: "ok",
|
|
PID: 42,
|
|
WebURL: "http://127.0.0.1:7777",
|
|
Version: "v1.2.3",
|
|
Commit: "abc123",
|
|
BuiltAt: "2026-03-22T12:00:00Z",
|
|
}, nil
|
|
}
|
|
|
|
cmd := NewBangerCommand()
|
|
var stdout bytes.Buffer
|
|
cmd.SetOut(&stdout)
|
|
cmd.SetErr(&stdout)
|
|
cmd.SetArgs([]string{"daemon", "status"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
|
|
output := stdout.String()
|
|
for _, want := range []string{
|
|
"running\n",
|
|
"pid: 42",
|
|
"version: v1.2.3",
|
|
"commit: abc123",
|
|
"built_at: 2026-03-22T12:00:00Z",
|
|
"log: " + filepath.Join(stateHome, "banger", "bangerd.log"),
|
|
"web: http://127.0.0.1:7777",
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("output = %q, want %q", output, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) {
|
|
cmd := buildDaemonCommand("/tmp/bangerd")
|
|
|
|
if cmd.Path != "/tmp/bangerd" {
|
|
t.Fatalf("command path = %q", cmd.Path)
|
|
}
|
|
if cmd.Cancel != nil {
|
|
t.Fatal("daemon process should not be tied to a CLI request context")
|
|
}
|
|
}
|
|
|
|
func TestAbsolutizeImageBuildPaths(t *testing.T) {
|
|
dir := t.TempDir()
|
|
prev, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatalf("chdir: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = os.Chdir(prev)
|
|
})
|
|
|
|
params := api.ImageBuildParams{
|
|
FromImage: "base-image",
|
|
KernelPath: "/kernel",
|
|
InitrdPath: "boot/initrd.img",
|
|
ModulesDir: "modules",
|
|
}
|
|
if err := absolutizeImageBuildPaths(¶ms); err != nil {
|
|
t.Fatalf("absolutizeImageBuildPaths: %v", err)
|
|
}
|
|
|
|
want := api.ImageBuildParams{
|
|
FromImage: "base-image",
|
|
KernelPath: "/kernel",
|
|
InitrdPath: filepath.Join(dir, "boot/initrd.img"),
|
|
ModulesDir: filepath.Join(dir, "modules"),
|
|
}
|
|
if !reflect.DeepEqual(params, want) {
|
|
t.Fatalf("params = %+v, want %+v", params, want)
|
|
}
|
|
}
|
|
|
|
func testCLIResolvedVM(id, name string) model.VMRecord {
|
|
return model.VMRecord{ID: id, Name: name}
|
|
}
|
|
|
|
func testRunGit(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false", "-C", dir}, args...)...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, string(output))
|
|
}
|
|
return string(output)
|
|
}
|
|
|
|
type testVMRunUpload struct {
|
|
path string
|
|
mode os.FileMode
|
|
data []byte
|
|
}
|
|
|
|
type testVMRunGuestClient struct {
|
|
closed bool
|
|
uploads []testVMRunUpload
|
|
uploadPath string
|
|
uploadMode os.FileMode
|
|
uploadData []byte
|
|
uploadErr error
|
|
checkoutErr error
|
|
launchErr error
|
|
script string
|
|
launchScript string
|
|
runScriptCalls int
|
|
tarSourceDir string
|
|
tarCommand string
|
|
streamSourceDir string
|
|
streamEntries []string
|
|
streamCommand string
|
|
}
|
|
|
|
func (c *testVMRunGuestClient) Close() error {
|
|
c.closed = true
|
|
return nil
|
|
}
|
|
|
|
func (c *testVMRunGuestClient) UploadFile(ctx context.Context, remotePath string, mode os.FileMode, data []byte, logWriter io.Writer) error {
|
|
copyData := append([]byte(nil), data...)
|
|
c.uploads = append(c.uploads, testVMRunUpload{path: remotePath, mode: mode, data: copyData})
|
|
c.uploadPath = remotePath
|
|
c.uploadMode = mode
|
|
c.uploadData = copyData
|
|
return c.uploadErr
|
|
}
|
|
|
|
func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error {
|
|
c.tarSourceDir = sourceDir
|
|
c.tarCommand = remoteCommand
|
|
return nil
|
|
}
|
|
|
|
func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error {
|
|
c.runScriptCalls++
|
|
if c.runScriptCalls == 1 {
|
|
c.script = script
|
|
return c.checkoutErr
|
|
}
|
|
c.launchScript = script
|
|
return c.launchErr
|
|
}
|
|
|
|
func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error {
|
|
c.streamSourceDir = sourceDir
|
|
c.streamEntries = append([]string(nil), entries...)
|
|
c.streamCommand = remoteCommand
|
|
return nil
|
|
}
|