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:
parent
8bcc767824
commit
2ebc6f99c6
5 changed files with 929 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue