vm run redesign: one command, three modes
`vm run` now covers bare sandbox (no args), workspace sandbox (path), and workspace+command (path -- cmd) in a single entry point. Replaces the old print-next-steps-and-exit behaviour: bare and workspace modes drop into interactive ssh, command mode execs via ssh and propagates the remote exit code through banger's own exit status. - path argument is optional; --branch / --from still require a path. - workspace prep and mise tooling bootstrap only run when a path is given; command mode skips the bootstrap. - remote command exit status is wrapped as exitCodeError so main() can propagate it instead of collapsing every failure to 1. - README: promote vm run with three-mode examples; demote vm create to a scripting primitive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8f4be112c2
commit
feb679a301
4 changed files with 376 additions and 225 deletions
|
|
@ -1285,27 +1285,28 @@ func TestInspectVMRunRepoRejectsSubmodules(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) {
|
||||
func TestRunVMRunWorkspacePreparesAndAttaches(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
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
origSSHExec := sshExecFunc
|
||||
origHealth := vmHealthFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
vmCreateCancelFunc = origCancel
|
||||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
sshExecFunc = origSSHExec
|
||||
vmHealthFunc = origHealth
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
|
|
@ -1320,12 +1321,8 @@ func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) {
|
|||
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,
|
||||
ID: "op-1", Stage: "ready", Detail: "vm is ready",
|
||||
Done: true, Success: true, VM: &vm,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -1339,28 +1336,12 @@ func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
var workspaceParams api.VMWorkspacePrepareParams
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
workspaceParams = params
|
||||
|
|
@ -1370,110 +1351,49 @@ func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) {
|
|||
return toolingplan.Plan{
|
||||
RepoManagedTools: []string{"go"},
|
||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||
}
|
||||
}
|
||||
var sshArgsSeen []string
|
||||
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
sshArgsSeen = args
|
||||
return nil
|
||||
}
|
||||
vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) {
|
||||
return api.VMHealthResult{Name: "devbox", Healthy: false}, nil
|
||||
}
|
||||
|
||||
spec := vmRunRepoSpec{
|
||||
SourcePath: repoRoot,
|
||||
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"},
|
||||
SourcePath: repoRoot, RepoRoot: repoRoot, RepoName: "repo",
|
||||
HeadCommit: "deadbeef", CurrentBranch: "main",
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&stdout,
|
||||
&stderr,
|
||||
&stdout, &stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
spec,
|
||||
&spec,
|
||||
nil,
|
||||
)
|
||||
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 workspaceParams.IDOrName != "devbox" {
|
||||
t.Fatalf("workspaceParams.IDOrName = %q, want devbox", workspaceParams.IDOrName)
|
||||
}
|
||||
if workspaceParams.SourcePath != repoRoot {
|
||||
t.Fatalf("workspaceParams.SourcePath = %q, want %q", workspaceParams.SourcePath, repoRoot)
|
||||
}
|
||||
if workspaceParams.GuestPath != "/root/repo" {
|
||||
t.Fatalf("workspaceParams.GuestPath = %q, want /root/repo", workspaceParams.GuestPath)
|
||||
}
|
||||
if workspaceParams.Mode != string(model.WorkspacePrepareModeShallowOverlay) {
|
||||
t.Fatalf("workspaceParams.Mode = %q", workspaceParams.Mode)
|
||||
if workspaceParams.IDOrName != "devbox" || workspaceParams.SourcePath != repoRoot {
|
||||
t.Fatalf("workspaceParams = %+v", workspaceParams)
|
||||
}
|
||||
if len(fakeClient.uploads) != 1 {
|
||||
t.Fatalf("uploads = %d, want 1", len(fakeClient.uploads))
|
||||
}
|
||||
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), `repo-managed mise tools: go`) {
|
||||
t.Fatalf("uploadData = %q, want repo-managed tool log", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" install`) {
|
||||
t.Fatalf("uploadData = %q, want repo mise install step", 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), `opencode run`) {
|
||||
t.Fatalf("uploadData = %q, want no opencode harness run", 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)
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"VM ready.",
|
||||
"Name: devbox",
|
||||
"Host: devbox.vm",
|
||||
"Repo: /root/repo",
|
||||
"banger vm ssh devbox",
|
||||
"opencode attach http://devbox.vm:4096 --dir /root/repo",
|
||||
"banger vm acp devbox",
|
||||
`banger vm ssh devbox -- "cd /root/repo && claude"`,
|
||||
`banger vm ssh devbox -- "cd /root/repo && pi"`,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stdout = %q, want %q", output, want)
|
||||
}
|
||||
t.Fatalf("uploads = %d, want tooling harness upload", len(fakeClient.uploads))
|
||||
}
|
||||
if !fakeClient.closed {
|
||||
t.Fatal("guest client should be closed")
|
||||
t.Fatal("guest client should be closed after tooling bootstrap")
|
||||
}
|
||||
if len(sshArgsSeen) == 0 || sshArgsSeen[len(sshArgsSeen)-1] != "root@172.16.0.2" {
|
||||
t.Fatalf("sshArgsSeen = %v, want interactive ssh to 172.16.0.2 (no trailing command)", sshArgsSeen)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "VM ready.") {
|
||||
t.Fatalf("stdout = %q, want no next-steps block", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1483,16 +1403,18 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
origCancel := vmCreateCancelFunc
|
||||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
origSSHExec := sshExecFunc
|
||||
origHealth := vmHealthFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
vmCreateCancelFunc = origCancel
|
||||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
sshExecFunc = origSSHExec
|
||||
vmHealthFunc = origHealth
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
|
|
@ -1506,12 +1428,8 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
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,
|
||||
ID: "op-1", Stage: "ready", Detail: "vm is ready",
|
||||
Done: true, Success: true, VM: &vm,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -1529,24 +1447,27 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
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
|
||||
}
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||
}
|
||||
sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error {
|
||||
return nil
|
||||
}
|
||||
vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) {
|
||||
return api.VMHealthResult{Name: "devbox", Healthy: false}, nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
spec := vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&stdout,
|
||||
&stderr,
|
||||
&stdout, &stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
||||
&spec,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
|
|
@ -1554,18 +1475,18 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
|
||||
output := stderr.String()
|
||||
for _, want := range []string{
|
||||
"[vm run] preparing guest workspace",
|
||||
"[vm run] waiting for guest ssh",
|
||||
"[vm run] preparing guest workspace",
|
||||
"[vm run] starting guest tooling bootstrap",
|
||||
"[vm run] guest tooling log: /root/.cache/banger/vm-run-tooling-repo.log",
|
||||
"[vm run] printing next steps",
|
||||
"[vm run] attaching to guest",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stderr = %q, want %q", output, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "[vm run] attaching opencode") {
|
||||
t.Fatalf("stderr = %q, want no auto-attach progress", output)
|
||||
if strings.Contains(output, "[vm run] printing next steps") {
|
||||
t.Fatalf("stderr = %q, should not print next-steps progress", output)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1575,16 +1496,18 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
origCancel := vmCreateCancelFunc
|
||||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
origSSHExec := sshExecFunc
|
||||
origHealth := vmHealthFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
vmCreateCancelFunc = origCancel
|
||||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
sshExecFunc = origSSHExec
|
||||
vmHealthFunc = origHealth
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
|
|
@ -1613,24 +1536,29 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
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
|
||||
}
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||
}
|
||||
sshExecCalls := 0
|
||||
sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error {
|
||||
sshExecCalls++
|
||||
return nil
|
||||
}
|
||||
vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) {
|
||||
return api.VMHealthResult{Healthy: false}, nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
spec := vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&stdout,
|
||||
&stderr,
|
||||
&stdout, &stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
||||
&spec,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
|
|
@ -1638,8 +1566,187 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
if !strings.Contains(stderr.String(), "[vm run] warning: guest tooling bootstrap start failed: launch guest tooling bootstrap") {
|
||||
t.Fatalf("stderr = %q, want tooling bootstrap warning", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "VM ready.") {
|
||||
t.Fatalf("stdout = %q, want next steps summary", stdout.String())
|
||||
if sshExecCalls != 1 {
|
||||
t.Fatalf("sshExec calls = %d, want 1 (interactive attach still runs)", sshExecCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) {
|
||||
origBegin := vmCreateBeginFunc
|
||||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origGuestDial := guestDialFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
origSSHExec := sshExecFunc
|
||||
origHealth := vmHealthFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
sshExecFunc = origSSHExec
|
||||
vmHealthFunc = origHealth
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
ID: "vm-id", Name: "bare",
|
||||
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", Done: true, Success: true, VM: &vm}}, nil
|
||||
}
|
||||
guestWaitForSSHFunc = func(context.Context, string, string, time.Duration) error { return nil }
|
||||
guestDialFunc = func(context.Context, string, string) (vmRunGuestClient, error) {
|
||||
t.Fatal("guestDialFunc should not be called in bare mode")
|
||||
return nil, nil
|
||||
}
|
||||
vmWorkspacePrepareFunc = func(context.Context, string, api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
t.Fatal("vmWorkspacePrepareFunc should not be called in bare mode")
|
||||
return api.VMWorkspacePrepareResult{}, nil
|
||||
}
|
||||
sshExecCalls := 0
|
||||
sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error {
|
||||
sshExecCalls++
|
||||
return nil
|
||||
}
|
||||
vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) {
|
||||
return api.VMHealthResult{Healthy: false}, nil
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&stdout, &stderr,
|
||||
api.VMCreateParams{Name: "bare"},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
}
|
||||
if sshExecCalls != 1 {
|
||||
t.Fatalf("sshExec calls = %d, want 1", sshExecCalls)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[vm run] attaching to guest") {
|
||||
t.Fatalf("stderr = %q, want attach progress", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVMRunCommandModePropagatesExitCode(t *testing.T) {
|
||||
origBegin := vmCreateBeginFunc
|
||||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
origSSHExec := sshExecFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
guestWaitForSSHFunc = origWaitForSSH
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
sshExecFunc = origSSHExec
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
ID: "vm-id", Name: "cmdbox",
|
||||
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", Done: true, Success: true, VM: &vm}}, nil
|
||||
}
|
||||
guestWaitForSSHFunc = func(context.Context, string, string, time.Duration) error { return nil }
|
||||
vmWorkspacePrepareFunc = func(context.Context, string, api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
t.Fatal("workspace prepare should not run without spec")
|
||||
return api.VMWorkspacePrepareResult{}, nil
|
||||
}
|
||||
var sshArgsSeen []string
|
||||
sshExecFunc = func(_ context.Context, _ io.Reader, _, _ io.Writer, args []string) error {
|
||||
sshArgsSeen = args
|
||||
return exitErrorWithCode(t, 7)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&stdout, &stderr,
|
||||
api.VMCreateParams{Name: "cmdbox"},
|
||||
nil,
|
||||
[]string{"false"},
|
||||
)
|
||||
var exitErr exitCodeError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != 7 {
|
||||
t.Fatalf("runVMRun error = %v, want exitCodeError{7}", err)
|
||||
}
|
||||
if len(sshArgsSeen) == 0 || sshArgsSeen[len(sshArgsSeen)-1] != "false" {
|
||||
t.Fatalf("sshArgsSeen = %v, want trailing command 'false'", sshArgsSeen)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[vm run] running command in guest") {
|
||||
t.Fatalf("stderr = %q, want command progress", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMRunCommandRejectsBranchWithoutPath(t *testing.T) {
|
||||
cmd := NewBangerCommand()
|
||||
cmd.SetArgs([]string{"vm", "run", "--branch", "feat"})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--branch requires a path") {
|
||||
t.Fatalf("Execute() error = %v, want --branch requires a path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitVMRunArgsPartitionsOnDash(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
argv []string
|
||||
wantPath []string
|
||||
wantCmd []string
|
||||
}{
|
||||
{"empty", []string{}, []string{}, nil},
|
||||
{"path only", []string{"./repo"}, []string{"./repo"}, nil},
|
||||
{"cmd only", []string{"--", "make", "test"}, []string{}, []string{"make", "test"}},
|
||||
{"path and cmd", []string{"./repo", "--", "ls"}, []string{"./repo"}, []string{"ls"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Parse through cobra so ArgsLenAtDash is populated.
|
||||
var seenPath, seenCmd []string
|
||||
root := &cobra.Command{Use: "root"}
|
||||
run := &cobra.Command{
|
||||
Use: "run",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
seenPath, seenCmd = splitVMRunArgs(cmd, args)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
root.AddCommand(run)
|
||||
root.SetArgs(append([]string{"run"}, tc.argv...))
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(seenPath) != len(tc.wantPath) {
|
||||
t.Fatalf("path = %v, want %v", seenPath, tc.wantPath)
|
||||
}
|
||||
for i := range seenPath {
|
||||
if seenPath[i] != tc.wantPath[i] {
|
||||
t.Fatalf("path = %v, want %v", seenPath, tc.wantPath)
|
||||
}
|
||||
}
|
||||
if len(seenCmd) != len(tc.wantCmd) {
|
||||
t.Fatalf("cmd = %v, want %v", seenCmd, tc.wantCmd)
|
||||
}
|
||||
for i := range seenCmd {
|
||||
if seenCmd[i] != tc.wantCmd[i] {
|
||||
t.Fatalf("cmd = %v, want %v", seenCmd, tc.wantCmd)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue