Add concurrent multi-VM CLI actions
Teach the lifecycle and set commands to accept multiple VM refs, resolve them from one vm list snapshot, dedupe repeated refs, and fan out the existing single-target RPCs concurrently. Valid targets still run when other refs are ambiguous or missing, and batch output stays in first-seen order. Refactor the daemon off the single global VM mutation lock by adding per-VM locks for start/stop/restart/delete/kill/set, touch, reconcile, stale-stop, and stats updates. That keeps same-VM operations serialized while allowing different VMs to progress in parallel, including newly created VMs once their ID exists. Verified with go test ./... and make build.
This commit is contained in:
parent
2d5bcb5516
commit
4812693c1e
5 changed files with 542 additions and 118 deletions
|
|
@ -9,6 +9,7 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
|
@ -187,9 +188,9 @@ func newVMCommand() *cobra.Command {
|
|||
func newVMKillCommand() *cobra.Command {
|
||||
var signal string
|
||||
cmd := &cobra.Command{
|
||||
Use: "kill <id-or-name>",
|
||||
Use: "kill <id-or-name>...",
|
||||
Short: "Send a signal to a VM process",
|
||||
Args: exactArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>"),
|
||||
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
|
|
@ -198,6 +199,20 @@ func newVMKillCommand() *cobra.Command {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) > 1 {
|
||||
return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) {
|
||||
result, err := rpc.Call[api.VMShowResult](
|
||||
ctx,
|
||||
layout.SocketPath,
|
||||
"vm.kill",
|
||||
api.VMKillParams{IDOrName: id, Signal: signal},
|
||||
)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return result.VM, nil
|
||||
})
|
||||
}
|
||||
result, err := rpc.Call[api.VMShowResult](
|
||||
cmd.Context(),
|
||||
layout.SocketPath,
|
||||
|
|
@ -316,9 +331,9 @@ func newVMShowCommand() *cobra.Command {
|
|||
|
||||
func newVMActionCommand(use, short, method string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: use + " <id-or-name>",
|
||||
Use: use + " <id-or-name>...",
|
||||
Short: short,
|
||||
Args: exactArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>", use)),
|
||||
Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", use)),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
|
|
@ -327,6 +342,15 @@ func newVMActionCommand(use, short, method string) *cobra.Command {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) > 1 {
|
||||
return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) {
|
||||
result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, method, api.VMRefParams{IDOrName: id})
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return result.VM, nil
|
||||
})
|
||||
}
|
||||
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, method, api.VMRefParams{IDOrName: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -345,9 +369,9 @@ func newVMSetCommand() *cobra.Command {
|
|||
noNat bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "set <id-or-name>",
|
||||
Use: "set <id-or-name>...",
|
||||
Short: "Update stopped VM settings",
|
||||
Args: exactArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>"),
|
||||
Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>..."),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat)
|
||||
if err != nil {
|
||||
|
|
@ -360,6 +384,17 @@ func newVMSetCommand() *cobra.Command {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) > 1 {
|
||||
return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) {
|
||||
batchParams := params
|
||||
batchParams.IDOrName = id
|
||||
result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, "vm.set", batchParams)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return result.VM, nil
|
||||
})
|
||||
}
|
||||
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.set", params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -597,6 +632,132 @@ func minArgsUsage(n int, usage string) cobra.PositionalArgs {
|
|||
}
|
||||
}
|
||||
|
||||
type resolvedVMTarget struct {
|
||||
Index int
|
||||
Ref string
|
||||
VM model.VMRecord
|
||||
}
|
||||
|
||||
type vmRefResolutionError struct {
|
||||
Index int
|
||||
Ref string
|
||||
Err error
|
||||
}
|
||||
|
||||
type vmBatchActionResult struct {
|
||||
Target resolvedVMTarget
|
||||
VM model.VMRecord
|
||||
Err error
|
||||
}
|
||||
|
||||
func runVMBatchAction(cmd *cobra.Command, socketPath string, refs []string, action func(context.Context, string) (model.VMRecord, error)) error {
|
||||
listResult, err := rpc.Call[api.VMListResult](cmd.Context(), socketPath, "vm.list", api.Empty{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targets, resolutionErrs := resolveVMTargets(listResult.VMs, refs)
|
||||
results := executeVMActionBatch(cmd.Context(), targets, action)
|
||||
|
||||
failed := false
|
||||
for _, resolutionErr := range resolutionErrs {
|
||||
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", resolutionErr.Ref, resolutionErr.Err); err != nil {
|
||||
return err
|
||||
}
|
||||
failed = true
|
||||
}
|
||||
for _, result := range results {
|
||||
if result.Err != nil {
|
||||
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", result.Target.Ref, result.Err); err != nil {
|
||||
return err
|
||||
}
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
if err := printVMSummary(cmd.OutOrStdout(), result.VM); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
return errors.New("one or more VM operations failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveVMTargets(vms []model.VMRecord, refs []string) ([]resolvedVMTarget, []vmRefResolutionError) {
|
||||
targets := make([]resolvedVMTarget, 0, len(refs))
|
||||
resolutionErrs := make([]vmRefResolutionError, 0)
|
||||
seen := make(map[string]struct{}, len(refs))
|
||||
for index, ref := range refs {
|
||||
vm, err := resolveVMRef(vms, ref)
|
||||
if err != nil {
|
||||
resolutionErrs = append(resolutionErrs, vmRefResolutionError{Index: index, Ref: ref, Err: err})
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[vm.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[vm.ID] = struct{}{}
|
||||
targets = append(targets, resolvedVMTarget{Index: index, Ref: ref, VM: vm})
|
||||
}
|
||||
return targets, resolutionErrs
|
||||
}
|
||||
|
||||
func resolveVMRef(vms []model.VMRecord, ref string) (model.VMRecord, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return model.VMRecord{}, errors.New("vm id or name is required")
|
||||
}
|
||||
exactMatches := make([]model.VMRecord, 0, 1)
|
||||
for _, vm := range vms {
|
||||
if vm.ID == ref || vm.Name == ref {
|
||||
exactMatches = append(exactMatches, vm)
|
||||
}
|
||||
}
|
||||
switch len(exactMatches) {
|
||||
case 1:
|
||||
return exactMatches[0], nil
|
||||
case 0:
|
||||
default:
|
||||
return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref)
|
||||
}
|
||||
|
||||
prefixMatches := make([]model.VMRecord, 0, 1)
|
||||
for _, vm := range vms {
|
||||
if strings.HasPrefix(vm.ID, ref) || strings.HasPrefix(vm.Name, ref) {
|
||||
prefixMatches = append(prefixMatches, vm)
|
||||
}
|
||||
}
|
||||
switch len(prefixMatches) {
|
||||
case 1:
|
||||
return prefixMatches[0], nil
|
||||
case 0:
|
||||
return model.VMRecord{}, fmt.Errorf("vm %q not found", ref)
|
||||
default:
|
||||
return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref)
|
||||
}
|
||||
}
|
||||
|
||||
func executeVMActionBatch(ctx context.Context, targets []resolvedVMTarget, action func(context.Context, string) (model.VMRecord, error)) []vmBatchActionResult {
|
||||
results := make([]vmBatchActionResult, len(targets))
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(targets))
|
||||
for index, target := range targets {
|
||||
index := index
|
||||
target := target
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
vm, err := action(ctx, target.VM.ID)
|
||||
results[index] = vmBatchActionResult{
|
||||
Target: target,
|
||||
VM: vm,
|
||||
Err: err,
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) {
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package cli
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/model"
|
||||
|
|
@ -155,6 +157,97 @@ func TestVMSetParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -312,3 +405,7 @@ func TestAbsolutizeImageBuildPaths(t *testing.T) {
|
|||
t.Fatalf("params = %+v, want %+v", params, want)
|
||||
}
|
||||
}
|
||||
|
||||
func testCLIResolvedVM(id, name string) model.VMRecord {
|
||||
return model.VMRecord{ID: id, Name: name}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue