From 45818ecd77255c03ff3a6e302994b44fdd723653 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 12 Jul 2026 00:17:42 +0200 Subject: [PATCH] feat(status): warn when the workspace lost its bundle components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `grounds cluster status` rendered an emptied workspace as a perfectly healthy `active` / `0 ready` — the exact output a working, freshly created workspace produces. When a vCluster loses its state (idle-pause stranded its RWO volume), nothing in the CLI said anything was wrong. forge now returns `bundleHealth` on GET /v1/cluster (the components it installed vs. what is actually running). Surface it: a `bundle` row in the table, plus an error block naming the missing components and pointing at `grounds cluster reset`. --- internal/api/cluster.go | 17 +++++++++++++++- internal/render/status.go | 21 +++++++++++++++++++ internal/render/status_test.go | 37 ++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/internal/api/cluster.go b/internal/api/cluster.go index ed6f3a5..b4250ee 100644 --- a/internal/api/cluster.go +++ b/internal/api/cluster.go @@ -27,7 +27,22 @@ type ClusterStatus struct { // component breakdown and, when state=failed, the reconcile error. BundleResult *BundleResult `json:"bundleResult"` BundleProgress *BundleProgress `json:"bundleProgress"` - FailureReason string `json:"failureReason"` + // Drift check: forge compares the components it installed against what is + // actually running in the vCluster. Nil unless the workspace is an active + // platform-bundle one that has had a successful apply. + BundleHealth *BundleHealth `json:"bundleHealth"` + FailureReason string `json:"failureReason"` +} + +// BundleHealth reports whether an `active` workspace still contains the bundle +// components forge installed into it. A workspace can lose them without forge +// noticing (provisioning is request-driven, never drift-correcting), which used +// to surface as a healthy-looking workspace with no apps in it. +type BundleHealth struct { + // "ok" | "degraded" | "unreachable" + Status string `json:"status"` + // Components from the last successful apply that are no longer running. + Missing []string `json:"missing"` } // BundleResult is the { succeeded, failed } breakdown of a bundle apply, diff --git a/internal/render/status.go b/internal/render/status.go index be40fa0..68b7f40 100644 --- a/internal/render/status.go +++ b/internal/render/status.go @@ -3,6 +3,7 @@ package render import ( "fmt" "io" + "strings" "time" "github.com/jedib0t/go-pretty/v6/table" @@ -43,6 +44,15 @@ func Status(w io.Writer, s *api.ClusterStatus) { t.AppendRow(table.Row{"deployments", fmt.Sprintf("%d ready", s.DeploymentsReady)}) + if h := s.BundleHealth; h != nil { + switch h.Status { + case "degraded": + t.AppendRow(table.Row{"bundle", Red(fmt.Sprintf("degraded — %d missing", len(h.Missing)))}) + case "unreachable": + t.AppendRow(table.Row{"bundle", Yellow("unreachable")}) + } + } + if s.Quota != nil { cpu, mem, storage := s.Quota["cpu"], s.Quota["memory"], s.Quota["storage"] t.AppendRow(table.Row{"quota", fmt.Sprintf("%s CPU / %s / %s", cpu, mem, storage)}) @@ -54,6 +64,17 @@ func Status(w io.Writer, s *api.ClusterStatus) { StatusLine(w, StatusWarn, "Workspace", "Paused") DetailLine(w, StatusWarn, "Next push or "+Command("grounds cluster up")+" resumes it.") } + + // The workspace says `active`, but components forge installed are gone. This + // is what an emptied vCluster looks like — previously it just showed up as + // "0 deployments" and nothing said anything was wrong. + if h := s.BundleHealth; h != nil && h.Status == "degraded" { + fmt.Fprintln(w) + StatusLine(w, StatusError, "Workspace", "Bundle components missing") + DetailLine(w, StatusError, strings.Join(h.Missing, ", ")) + DetailLine(w, StatusError, "The workspace is running but its bundle base is gone. "+ + Command("grounds cluster reset")+" rebuilds it.") + } } func fmtUTC(t *time.Time) string { diff --git a/internal/render/status_test.go b/internal/render/status_test.go index f8b1ef8..644564d 100644 --- a/internal/render/status_test.go +++ b/internal/render/status_test.go @@ -50,3 +50,40 @@ func TestStatus_PausedShowsWarning(t *testing.T) { t.Errorf("no warning detail\n%s", out) } } + +// An emptied vCluster used to render as a perfectly healthy "active, 0 ready" +// workspace — nothing told the engineer their bundle base was gone. +func TestStatus_BundleDegraded(t *testing.T) { + SetEnabled(true) + buf := &bytes.Buffer{} + Status(buf, &api.ClusterStatus{ + Namespace: "vcluster-x", + State: "active", + Profile: "platform-bundle", + DeploymentsReady: 0, + BundleHealth: &api.BundleHealth{ + Status: "degraded", + Missing: []string{"velocity", "minestom-lobby"}, + }, + }) + out := buf.String() + for _, want := range []string{"degraded", "2 missing", "velocity", "minestom-lobby", "grounds cluster reset"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q\n%s", want, out) + } + } +} + +func TestStatus_BundleOkIsQuiet(t *testing.T) { + SetEnabled(true) + buf := &bytes.Buffer{} + Status(buf, &api.ClusterStatus{ + Namespace: "vcluster-x", + State: "active", + Profile: "platform-bundle", + BundleHealth: &api.BundleHealth{Status: "ok"}, + }) + if out := buf.String(); strings.Contains(out, "degraded") || strings.Contains(out, "reset") { + t.Errorf("healthy workspace must not warn:\n%s", out) + } +}