diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 10e4f91..5c97ede 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -165,12 +165,13 @@ func Command(h Actions) *cobra.Command { statusCmd := &cobra.Command{ Use: "status [VM...]", - Short: "Watch VM status in real time", + Short: "Show VM status; --watch for refresh loop, --event for streaming", RunE: h.Status, } - statusCmd.Flags().IntP("interval", "n", 5, "poll interval in seconds") //nolint:mnd - statusCmd.Flags().Bool("event", false, "event stream mode (append changes instead of refreshing)") - statusCmd.Flags().String("format", "", "output format: json (event mode only)") + statusCmd.Flags().IntP("interval", "n", 5, "poll interval in seconds (only with --watch or --event)") //nolint:mnd + statusCmd.Flags().BoolP("watch", "w", false, "refresh-loop mode (full-screen redraw each tick); omit for one-shot snapshot") + statusCmd.Flags().Bool("event", false, "event stream mode (append changes instead of refreshing); implies polling") + statusCmd.Flags().String("format", "", "output format: json (one-shot + event modes; --watch always renders a table)") vmCmd.AddCommand( createCmd, diff --git a/cmd/vm/status.go b/cmd/vm/status.go index 118b6dd..24e54ac 100644 --- a/cmd/vm/status.go +++ b/cmd/vm/status.go @@ -56,16 +56,26 @@ func (h Handler) List(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("list: %w", err) } + sortVMs(vms) + format, _ := cmd.Flags().GetString("format") + return renderVMList(vms, format) +} + +// renderVMList emits vms as JSON or table; "No VMs found." for empty in table mode. +func renderVMList(vms []*types.VM, format string) error { + if format == "json" { + if vms == nil { + vms = []*types.VM{} + } + return cmdcore.OutputJSON(vms) + } if len(vms) == 0 { fmt.Println("No VMs found.") return nil } - - sortVMs(vms) - - return cmdcore.OutputFormatted(cmd, vms, func(w *tabwriter.Writer) { - printVMTable(w, vms) - }) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + printVMTable(w, vms) + return w.Flush() } func (h Handler) Status(cmd *cobra.Command, args []string) error { @@ -79,19 +89,25 @@ func (h Handler) Status(cmd *cobra.Command, args []string) error { interval = 5 //nolint:mnd } eventMode, _ := cmd.Flags().GetBool("event") + watchMode, _ := cmd.Flags().GetBool("watch") + if eventMode && watchMode { + return fmt.Errorf("--event and --watch are mutually exclusive") + } + format, _ := cmd.Flags().GetString("format") hypers, hyperErr := cmdcore.InitAllHypervisors(conf) if hyperErr != nil { return hyperErr } - watchCh := mergeWatchChannels(ctx, hypers) + if !eventMode && !watchMode { + return statusOnce(ctx, hypers, args, format) + } + watchCh := mergeWatchChannels(ctx, hypers) ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() - format, _ := cmd.Flags().GetString("format") - if eventMode { if format == "json" { statusEventLoopJSON(ctx, hypers, args, watchCh, ticker.C) @@ -105,6 +121,17 @@ func (h Handler) Status(cmd *cobra.Command, args []string) error { return nil } +// statusOnce prints a single snapshot then returns; propagates ListAllVMs error (loop callers swallow). +func statusOnce(ctx context.Context, hypers []hypervisor.Hypervisor, filters []string, format string) error { + vms, err := cmdcore.ListAllVMs(ctx, hypers) + if err != nil { + return fmt.Errorf("status: %w", err) + } + vms = applyFilters(vms, filters) + sortVMs(vms) + return renderVMList(vms, format) +} + func mergeWatchChannels(ctx context.Context, hypers []hypervisor.Hypervisor) <-chan struct{} { var channels []<-chan struct{} for _, h := range hypers { @@ -266,6 +293,7 @@ func printEventRow(w *tabwriter.Writer, event string, snap vmSnapshot) { snap.ip, snap.image) } +// listAndFilter swallows backend errors with a warn so a transient hiccup can't break the polling tick; one-shot callers must use cmdcore.ListAllVMs directly. func listAndFilter(ctx context.Context, hypers []hypervisor.Hypervisor, filters []string) []*types.VM { vms, err := cmdcore.ListAllVMs(ctx, hypers) if err != nil { @@ -273,16 +301,20 @@ func listAndFilter(ctx context.Context, hypers []hypervisor.Hypervisor, filters return nil } sortVMs(vms) + return applyFilters(vms, filters) +} + +func applyFilters(vms []*types.VM, filters []string) []*types.VM { if len(filters) == 0 { return vms } - var result []*types.VM + out := make([]*types.VM, 0, len(vms)) for _, vm := range vms { if matchesFilter(vm, filters) { - result = append(result, vm) + out = append(out, vm) } } - return result + return out } func matchesFilter(vm *types.VM, filters []string) bool { diff --git a/cmd/vm/status_test.go b/cmd/vm/status_test.go index 6ca76ec..0f1aaf4 100644 --- a/cmd/vm/status_test.go +++ b/cmd/vm/status_test.go @@ -1,12 +1,41 @@ package vm import ( + "io" + "os" + "strings" "testing" "time" "github.com/cocoonstack/cocoon/types" ) +// captureStdout redirects os.Stdout to a pipe, runs fn, returns the bytes; panic-safe via deferred cleanup. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer r.Close() //nolint:errcheck + defer w.Close() //nolint:errcheck // idempotent fallback if fn panics before the inline Close + orig := os.Stdout + defer func() { os.Stdout = orig }() + os.Stdout = w + + var buf []byte + done := make(chan struct{}) + go func() { + buf, _ = io.ReadAll(r) + close(done) + }() + + fn() + _ = w.Close() + <-done + return string(buf) +} + func TestMatchesFilter(t *testing.T) { vm := &types.VM{ ID: "abcdef123456", @@ -85,3 +114,83 @@ func TestVMIPsAndSort(t *testing.T) { t.Fatalf("takeSnapshot() = %+v", snap) } } + +func TestRenderVMList(t *testing.T) { + vm := &types.VM{ + ID: "abc", + Config: types.VMConfig{Name: "demo", Config: types.Config{CPU: 1, Memory: 1 << 30, Image: "img"}}, + CreatedAt: time.Now(), + } + + tests := []struct { + name string + vms []*types.VM + format string + want string + notWant string + }{ + {name: "empty table → No VMs found", vms: nil, format: "", want: "No VMs found."}, + {name: "empty json → []", vms: nil, format: "json", want: "[]"}, + {name: "table with vm contains name", vms: []*types.VM{vm}, format: "", want: "demo"}, + {name: "json with vm contains id", vms: []*types.VM{vm}, format: "json", want: `"id": "abc"`}, + {name: "table mode skips json marker", vms: []*types.VM{vm}, format: "", notWant: `"id":`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := captureStdout(t, func() { + if err := renderVMList(tt.vms, tt.format); err != nil { + t.Fatalf("renderVMList: %v", err) + } + }) + if tt.want != "" && !strings.Contains(out, tt.want) { + t.Errorf("output %q does not contain %q", out, tt.want) + } + if tt.notWant != "" && strings.Contains(out, tt.notWant) { + t.Errorf("output %q unexpectedly contains %q", out, tt.notWant) + } + }) + } +} + +func TestApplyFilters(t *testing.T) { + vms := []*types.VM{ + {ID: "abcdef123456", Config: types.VMConfig{Name: "alpha"}}, + {ID: "beadbeef0000", Config: types.VMConfig{Name: "beta"}}, + } + tests := []struct { + name string + filters []string + wantIDs []string + }{ + {name: "no filter returns all", filters: nil, wantIDs: []string{"abcdef123456", "beadbeef0000"}}, + {name: "exact name", filters: []string{"alpha"}, wantIDs: []string{"abcdef123456"}}, + {name: "id prefix", filters: []string{"bead"}, wantIDs: []string{"beadbeef0000"}}, + {name: "multi filter union", filters: []string{"alpha", "bead"}, wantIDs: []string{"abcdef123456", "beadbeef0000"}}, + {name: "no match", filters: []string{"zzz"}, wantIDs: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyFilters(vms, tt.filters) + gotIDs := make([]string, 0, len(got)) + for _, vm := range got { + gotIDs = append(gotIDs, vm.ID) + } + if !equalStrings(gotIDs, tt.wantIDs) { + t.Errorf("applyFilters(%v) = %v, want %v", tt.filters, gotIDs, tt.wantIDs) + } + }) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/hypervisor/state.go b/hypervisor/state.go index c27dd78..b261e6f 100644 --- a/hypervisor/state.go +++ b/hypervisor/state.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io/fs" + "net" + "syscall" "time" "github.com/projecteru2/core/log" @@ -13,6 +15,8 @@ import ( "github.com/cocoonstack/cocoon/utils" ) +const socketProbeTimeout = 2 * time.Second + // WithRunningVM calls fn if rec still points to a live VM process. func (b *Backend) WithRunningVM(ctx context.Context, rec *VMRecord, fn func(pid int) error) error { pid, pidErr := utils.ReadPIDFile(b.PIDFilePath(rec.RunDir)) @@ -25,6 +29,22 @@ func (b *Backend) WithRunningVM(ctx context.Context, rec *VMRecord, fn func(pid return fn(pid) } +// IsAPISocketLive: (true,nil)=confirmed live; (false,nil)=ENOENT/ECONNREFUSED; (true,err)=fail-closed for unknown dial errors. +func (b *Backend) IsAPISocketLive(ctx context.Context, rec *VMRecord) (bool, error) { + sock := SocketPath(rec.RunDir) + dialCtx, cancel := context.WithTimeout(ctx, socketProbeTimeout) + defer cancel() + conn, err := (&net.Dialer{}).DialContext(dialCtx, "unix", sock) + if err == nil { + _ = conn.Close() + return true, nil + } + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) { + return false, nil + } + return true, err +} + // WithPausedVM pauses, runs fn, resumes; eager resume on success promotes its error, deferred resume on fn-error only logs. func (b *Backend) WithPausedVM(ctx context.Context, rec *VMRecord, pause, resume, fn func() error) error { return b.WithRunningVM(ctx, rec, func(_ int) error { diff --git a/hypervisor/stop.go b/hypervisor/stop.go index 707d57d..1028f8c 100644 --- a/hypervisor/stop.go +++ b/hypervisor/stop.go @@ -64,6 +64,16 @@ func (b *Backend) DeleteAll(ctx context.Context, refs []string, force bool, stop }); runningErr != nil && !errors.Is(runningErr, ErrNotRunning) { return fmt.Errorf("stop before delete: %w", runningErr) } + // Probe fires unconditionally: AF_UNIX has no TIME_WAIT, and catches false-negative pidfile/cmdline shortcuts. + if live, probeErr := b.IsAPISocketLive(ctx, &rec); live { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + if probeErr != nil { + return fmt.Errorf("refuse delete: api socket %s probe inconclusive: %w (resolve the host issue or kill the vmm process then retry)", SocketPath(rec.RunDir), probeErr) + } + return fmt.Errorf("refuse delete: api socket %s still responsive (suspected orphan vmm; kill the vmm process then retry)", SocketPath(rec.RunDir)) + } if rmErr := RemoveVMDirs(rec.RunDir, rec.LogDir); rmErr != nil { return fmt.Errorf("cleanup VM dirs: %w", rmErr) }