Add repo-backed vm run command

Create a CLI-only banger vm run [path] flow that resolves the enclosing git repository, creates a VM, imports a guest checkout, and launches opencode attach automatically from the host.

Build the guest checkout by bundling git history plus the resolved base and head commits, cloning that bundle in the guest, and overlaying tracked plus untracked non-ignored files over SSH so local working-tree changes carry over. Support guest-only branch creation with --branch and --from, reject bare repos and submodules, and add selective tar helpers plus CLI seams to keep the workflow testable.

Validate with go test ./..., make build, banger vm run --help, and the expected --from requires --branch error path.
This commit is contained in:
Thales Maciel 2026-03-21 23:34:20 -03:00
parent 8bcc767824
commit 2ebc6f99c6
No known key found for this signature in database
GPG key ID: 33112E6833C34679
5 changed files with 929 additions and 0 deletions

View file

@ -146,6 +146,26 @@ func TestVMCreateFlagsExist(t *testing.T) {
}
}
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"})
@ -171,6 +191,16 @@ func TestVMCreateFlagsShowStaticDefaults(t *testing.T) {
}
}
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"})
@ -837,6 +867,278 @@ func TestValidateSSHPrereqsFailsForMissingKey(t *testing.T) {
}
}
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()
testRunGit(t, repoRoot, "init")
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)
}
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()
origBegin := vmCreateBeginFunc
origStatus := vmCreateStatusFunc
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origHostCommandOutput := hostCommandOutputFunc
origOpencodeExec := opencodeExecFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
hostCommandOutputFunc = origHostCommandOutput
opencodeExecFunc = origOpencodeExec
})
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
}
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
if name != "git" {
t.Fatalf("command = %q, want git", name)
}
if len(args) < 7 || args[0] != "-C" || args[1] != repoRoot || args[2] != "bundle" || args[3] != "create" || args[5] != "--all" {
t.Fatalf("unexpected bundle args: %v", args)
}
if !reflect.DeepEqual(args[6:], []string{"deadbeef", "cafebabe"}) {
t.Fatalf("bundle revs = %v, want deadbeef/cafebabe", args[6:])
}
if err := os.WriteFile(args[4], []byte("bundle-data"), 0o600); err != nil {
t.Fatalf("WriteFile(bundle): %v", err)
}
return nil, nil
}
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",
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.uploadPath != vmRunGuestBundlePath {
t.Fatalf("uploadPath = %q, want %q", fakeClient.uploadPath, vmRunGuestBundlePath)
}
if fakeClient.uploadMode != 0o600 {
t.Fatalf("uploadMode = %v, want 0600", fakeClient.uploadMode)
}
if string(fakeClient.uploadData) != "bundle-data" {
t.Fatalf("uploadData = %q, want bundle-data", string(fakeClient.uploadData))
}
if !strings.Contains(fakeClient.script, `git clone "$BUNDLE" "$DIR"`) {
t.Fatalf("script = %q, want clone command", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
t.Fatalf("script = %q, want guest branch checkout", 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 -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 TestNewBangerdCommandRejectsArgs(t *testing.T) {
cmd := NewBangerdCommand()
cmd.SetArgs([]string{"extra"})
@ -965,3 +1267,48 @@ func TestAbsolutizeImageBuildPaths(t *testing.T) {
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 testVMRunGuestClient struct {
closed bool
uploadPath string
uploadMode os.FileMode
uploadData []byte
script 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 {
c.uploadPath = remotePath
c.uploadMode = mode
c.uploadData = append([]byte(nil), data...)
return nil
}
func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error {
c.script = script
return nil
}
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
}