From 3c0af3a2de50fb3fa27cd862335527c73b4adfdc Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Tue, 28 Apr 2026 18:14:57 -0300 Subject: [PATCH] opstate,daemon: list in-flight operations via daemon.operations.list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prerequisite for `banger update`'s preflight, which refuses to swap binaries while anything is in flight. Today's opstate.Registry exposes Insert/Get/Prune but no iteration; without a snapshot accessor the update flow can't tell whether a vm.create is mid-prepare-work-disk. * opstate.Registry.List(): returns a freshly-allocated snapshot of every entry. Mutating the slice doesn't poison the registry. Pinned by tests covering the snapshot semantics and the empty case. * api.OperationSummary / OperationsListResult: a public-shape record per op. Today the Kind is always "vm.create" — the field exists so future async kinds (image.pull, kernel.pull) plug in without an API change. * Daemon.ListOperations + daemon.operations.list RPC: walks vmService.createOps and emits OperationSummary entries. Done ops are included in the snapshot; the update preflight filters by Done itself. * dispatch_test's documented-methods list updated. No behaviour change for existing flows; this is a read-only addition. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/types.go | 14 +++++++++ internal/daemon/dispatch.go | 9 ++++-- internal/daemon/dispatch_test.go | 2 ++ internal/daemon/operations.go | 37 ++++++++++++++++++++++ internal/daemon/opstate/registry.go | 17 ++++++++++ internal/daemon/opstate/registry_test.go | 40 ++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 internal/daemon/operations.go diff --git a/internal/api/types.go b/internal/api/types.go index 63665a8..7cfd6b1 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -174,6 +174,20 @@ type ImageRefParams struct { IDOrName string `json:"id_or_name"` } +type OperationSummary struct { + ID string `json:"id"` + Kind string `json:"kind"` + Stage string `json:"stage,omitempty"` + Detail string `json:"detail,omitempty"` + Done bool `json:"done"` + StartedAt time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +type OperationsListResult struct { + Operations []OperationSummary `json:"operations"` +} + type ImageCachePruneParams struct { DryRun bool `json:"dry_run,omitempty"` } diff --git a/internal/daemon/dispatch.go b/internal/daemon/dispatch.go index a9ce04e..e4a79f8 100644 --- a/internal/daemon/dispatch.go +++ b/internal/daemon/dispatch.go @@ -50,8 +50,9 @@ func noParamHandler[R any](call func(ctx context.Context, d *Daemon) (R, error)) // live below the map; they need pre-service validation or raw result // encoding that the generic wrapper can't express. var rpcHandlers = map[string]handler{ - "ping": pingHandler, - "shutdown": shutdownHandler, + "ping": pingHandler, + "shutdown": shutdownHandler, + "daemon.operations.list": noParamHandler(daemonOperationsListDispatch), "vm.create": paramHandler(vmCreateDispatch), "vm.create.begin": paramHandler(vmCreateBeginDispatch), @@ -214,6 +215,10 @@ func imageCachePruneDispatch(ctx context.Context, d *Daemon, p api.ImageCachePru return d.img.PruneOCICache(ctx, p) } +func daemonOperationsListDispatch(ctx context.Context, d *Daemon) (api.OperationsListResult, error) { + return d.ListOperations(ctx) +} + func kernelListDispatch(ctx context.Context, d *Daemon) (api.KernelListResult, error) { return d.img.KernelList(ctx) } diff --git a/internal/daemon/dispatch_test.go b/internal/daemon/dispatch_test.go index 8d063ce..602ffbc 100644 --- a/internal/daemon/dispatch_test.go +++ b/internal/daemon/dispatch_test.go @@ -35,6 +35,8 @@ func TestRPCHandlersMatchDocumentedMethods(t *testing.T) { "kernel.pull", "kernel.show", + "daemon.operations.list", + "ping", "shutdown", diff --git a/internal/daemon/operations.go b/internal/daemon/operations.go new file mode 100644 index 0000000..00046d1 --- /dev/null +++ b/internal/daemon/operations.go @@ -0,0 +1,37 @@ +package daemon + +import ( + "context" + + "banger/internal/api" +) + +// ListOperations returns a snapshot of every async operation tracked +// across the daemon's per-kind registries. Today the only kind is +// vm.create; future async kinds (image build, kernel pull) will plug +// in here. +// +// The primary consumer is `banger update`'s preflight, which refuses +// to swap binaries while anything is in flight. Done operations are +// included in the snapshot so an operator running an interactive +// `banger ... | jq` can see recently-completed work; the update +// preflight filters by Done itself. +func (d *Daemon) ListOperations(_ context.Context) (api.OperationsListResult, error) { + out := api.OperationsListResult{Operations: []api.OperationSummary{}} + if d.vm == nil { + return out, nil + } + for _, op := range d.vm.createOps.List() { + snap := op.snapshot() + out.Operations = append(out.Operations, api.OperationSummary{ + ID: snap.ID, + Kind: "vm.create", + Stage: snap.Stage, + Detail: snap.Detail, + Done: snap.Done, + StartedAt: snap.StartedAt, + UpdatedAt: snap.UpdatedAt, + }) + } + return out, nil +} diff --git a/internal/daemon/opstate/registry.go b/internal/daemon/opstate/registry.go index d82c2be..f82ac40 100644 --- a/internal/daemon/opstate/registry.go +++ b/internal/daemon/opstate/registry.go @@ -43,6 +43,23 @@ func (r *Registry[T]) Get(id string) (T, bool) { return op, ok } +// List returns a snapshot of every operation currently in the +// registry — both pending and (un-pruned) completed. Callers filter +// by IsDone() if they care about state. The slice is freshly +// allocated; mutating it doesn't affect the registry. +// +// Used by `banger update`'s preflight to detect in-flight operations +// before swapping binaries. +func (r *Registry[T]) List() []T { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]T, 0, len(r.byID)) + for _, op := range r.byID { + out = append(out, op) + } + return out +} + // Prune drops completed operations last updated before the cutoff. func (r *Registry[T]) Prune(before time.Time) { r.mu.Lock() diff --git a/internal/daemon/opstate/registry_test.go b/internal/daemon/opstate/registry_test.go index 2ea56b7..d0965c3 100644 --- a/internal/daemon/opstate/registry_test.go +++ b/internal/daemon/opstate/registry_test.go @@ -67,6 +67,46 @@ func TestRegistryPruneDropsCompletedOldOps(t *testing.T) { } } +func TestRegistryListReturnsSnapshot(t *testing.T) { + var r Registry[*fakeOp] + now := time.Now() + + a := &fakeOp{id: "a", updatedAt: now} + b := &fakeOp{id: "b", updatedAt: now} + c := &fakeOp{id: "c", updatedAt: now} + c.done.Store(true) + r.Insert(a) + r.Insert(b) + r.Insert(c) + + got := r.List() + if len(got) != 3 { + t.Fatalf("List() returned %d entries, want 3", len(got)) + } + ids := map[string]bool{} + for _, op := range got { + ids[op.ID()] = true + } + for _, want := range []string{"a", "b", "c"} { + if !ids[want] { + t.Errorf("List() missing %q; got %v", want, ids) + } + } + + // Mutating the returned slice must not poison the registry. + got[0] = &fakeOp{id: "tampered"} + if _, ok := r.Get("tampered"); ok { + t.Error("List() returned the registry's internal map, not a copy") + } +} + +func TestRegistryListEmpty(t *testing.T) { + var r Registry[*fakeOp] + if got := r.List(); len(got) != 0 { + t.Fatalf("List() on empty registry returned %d entries, want 0", len(got)) + } +} + func TestRegistryPruneNoOpOnEmpty(t *testing.T) { var r Registry[*fakeOp] // Just shouldn't panic.