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) + } +}