From f8f3a25a25e012a434e4287b8185b2abed83006d Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Wed, 1 Jul 2026 16:28:46 +0200 Subject: [PATCH] feat(cluster): default to platform-bundle + add `cluster reset` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two workspace-UX changes: 1. platform-bundle is now the default profile. A bare `grounds cluster up` provisions a platform-bundle workspace at bundle ref `main` — no `--bundle=platform-bundle` needed (that ref never existed and 404'd). `--bundle=`/`--override` still drive bundle mode; the legacy sandbox/vCluster profiles are opt-in via `--profile=minigame|platform`. loadBundleRequest defaults a missing ref to `main` instead of erroring. 2. `grounds cluster reset` wipes a platform-bundle workspace back to a clean bundle base (services + lobby + velocity + infra), discarding pushed apps and drift. It POSTs /v1/cluster/reset and polls the same creating→active progress as `up`. Destructive → confirm prompt (type-the-namespace, like delete); non-interactive requires --yes . Optional --bundle picks the ref to reset to. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0129qbiq4BUpaLZao8BHB6UJ --- cmd/grounds/commands/cluster/cluster.go | 4 +- cmd/grounds/commands/cluster/reset.go | 84 +++++++++++++++++++++++++ cmd/grounds/commands/cluster/up.go | 55 ++++++++-------- cmd/grounds/commands/cluster/up_test.go | 25 +++++++- internal/api/cluster.go | 28 ++++++++- internal/api/cluster_test.go | 42 +++++++++++++ 6 files changed, 206 insertions(+), 32 deletions(-) create mode 100644 cmd/grounds/commands/cluster/reset.go diff --git a/cmd/grounds/commands/cluster/cluster.go b/cmd/grounds/commands/cluster/cluster.go index f2753c9..042dd7f 100644 --- a/cmd/grounds/commands/cluster/cluster.go +++ b/cmd/grounds/commands/cluster/cluster.go @@ -17,9 +17,9 @@ func NewClusterCommand() *cobra.Command { cmd := &cobra.Command{ Use: "cluster", Short: "Manage your dev workspace lifecycle", - Example: " grounds cluster status\n grounds cluster up\n grounds cluster down\n grounds cluster delete", + Example: " grounds cluster status\n grounds cluster up\n grounds cluster reset\n grounds cluster down\n grounds cluster delete", } - cmd.AddCommand(newUp(), newDown(), newDelete(), newStatus()) + cmd.AddCommand(newUp(), newDown(), newReset(), newDelete(), newStatus()) return cmd } diff --git a/cmd/grounds/commands/cluster/reset.go b/cmd/grounds/commands/cluster/reset.go new file mode 100644 index 0000000..2f12412 --- /dev/null +++ b/cmd/grounds/commands/cluster/reset.go @@ -0,0 +1,84 @@ +package cluster + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/groundsgg/grounds-cli/internal/api" + "github.com/groundsgg/grounds-cli/internal/render" + "github.com/groundsgg/grounds-cli/internal/ui" +) + +func newReset() *cobra.Command { + var yes string + var bundleRef string + cmd := &cobra.Command{ + Use: "reset", + Short: "Wipe the workspace back to a clean bundle base", + Long: `Reset a platform-bundle workspace back to a clean bundle base: +services + lobby + velocity + infra, discarding pushed apps and any drift. + +Forge tears down the vCluster namespace, then re-provisions from the bundle +ref (--bundle, default main) with no engineer overrides. The workspace keeps +its namespace and profile — only the contents are reset to zero.`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + c, _, err := buildClient(ctx, cmd) + if err != nil { + return err + } + w := cmd.OutOrStdout() + + // Need the namespace (for the confirm) and profile (only + // platform-bundle can be reset — forge enforces this too). + s, err := c.GetCluster(ctx) + if err != nil { + return err + } + if s.Profile != "platform-bundle" { + return fmt.Errorf( + "reset only supports platform-bundle workspaces (this is %q); use `grounds cluster delete` then `grounds cluster up`", + s.Profile, + ) + } + + // Destructive: pushed apps + everything in the vCluster are discarded. + if !term.IsTerminal(int(os.Stdin.Fd())) { + if yes != s.Namespace { + return errors.New("non-interactive reset requires --yes ") + } + } else { + render.StatusLine(w, render.StatusWarn, "Workspace", + "This will wipe "+s.Namespace+" back to a clean bundle base (pushed apps + data discarded)") + if err := ui.AskTypeName(os.Stdin, w, s.Namespace, s.Namespace); err != nil { + return err + } + } + + if _, err := c.ClusterReset(ctx, &api.ClusterResetRequest{Bundle: bundleRef}); err != nil { + return err + } + render.StatusLine(w, render.StatusOK, "Workspace", + "resetting — tearing down and re-provisioning the bundle base…") + // Forge holds the row at `creating` across teardown + re-provision, + // so this is the same single creating→active poll as `cluster up`. + final, err := waitForBundle(ctx, c, w) + if err != nil { + return err + } + renderBundleStatus(w, final) + if final.State == "failed" { + return fmt.Errorf("reset failed") + } + return nil + }, + } + cmd.Flags().StringVar(&yes, "yes", "", "namespace to reset (required in non-interactive mode)") + cmd.Flags().StringVar(&bundleRef, "bundle", "", "PlatformBundle ref to reset to (default main)") + return cmd +} diff --git a/cmd/grounds/commands/cluster/up.go b/cmd/grounds/commands/cluster/up.go index 4150758..0a105dd 100644 --- a/cmd/grounds/commands/cluster/up.go +++ b/cmd/grounds/commands/cluster/up.go @@ -21,29 +21,29 @@ func newUp() *cobra.Command { var bundleRef string var overridePath string cmd := &cobra.Command{ - Use: "up [--profile=minigame|platform] [--bundle= [--override=]]", + Use: "up [--bundle=] [--override=] [--profile=minigame|platform]", Short: "Spawn or resume the workspace", - Example: " grounds cluster up\n grounds cluster up --profile=platform\n grounds cluster up --bundle=0.4.0 --override=./overrides/me.yaml", + Example: " grounds cluster up\n grounds cluster up --bundle=0.6.4\n grounds cluster up --profile=minigame", Long: `Create the workspace if it doesn't exist, or resume it from a paused state. -Profiles: - minigame — namespace-scoped sandbox (default). Cheap, fast, isolated by RBAC. - platform — full per-developer vCluster with the Grounds platform chart - installed inside. Heavier (one-time ~90s spawn) but lets you - run platform plugins / agones / mc-router locally. +By default ` + "`up`" + ` provisions a **platform-bundle** workspace: a per-developer +vCluster driven by a versioned bundle in groundsgg/library-platform-bundle +(velocity proxy + lobby + grpc-services + nats/postgres/agones). Forge spins +up the vCluster, fetches bundle.yaml @ (default ` + "`main`" + `), applies the +optional override file, and helm-installs each component. -Bundle mode (` + "`--bundle`" + `): - Drives a platform-test environment from a versioned bundle in - groundsgg/library-platform-bundle. Forge spins up your vCluster, - fetches bundle.yaml @ , applies the optional override file, and - helm-installs each component. Implies profile=platform-bundle. + grounds cluster up # platform-bundle @ main + grounds cluster up --bundle=0.6.4 # pin a bundle release + grounds cluster up --override=./me.yaml # local overrides (ref from file, else main) - Examples: - grounds cluster up --bundle=0.4.0 - grounds cluster up --bundle=0.4.0 --override=./overrides/me.yaml - grounds cluster up --override=./overrides/me.yaml # bundle ref read from file +Legacy profiles (opt in with ` + "`--profile`" + `): + minigame — namespace-scoped sandbox. Cheap, fast, isolated by RBAC. + platform — bare per-developer vCluster with the Grounds platform chart, + without the bundle components. -Profile is locked once a workspace exists. To switch, ` + "`grounds cluster delete`" + ` and re-up.`, +Profile is locked once a workspace exists. To switch, ` + "`grounds cluster delete`" + ` and +re-up. To wipe a platform-bundle workspace back to a clean bundle base without +changing profile, use ` + "`grounds cluster reset`" + `.`, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() c, _, err := buildClient(ctx, cmd) @@ -51,9 +51,12 @@ Profile is locked once a workspace exists. To switch, ` + "`grounds cluster dele return err } - if bundleRef != "" || overridePath != "" { - if profile != "" { - return fmt.Errorf("--profile is implicit when using --bundle/--override (always platform-bundle); drop --profile") + // platform-bundle is the default: a bare `up`, `--bundle`, or + // `--override` all drive bundle mode. Opt out to the legacy + // sandbox / vCluster profiles with --profile=minigame|platform. + if profile == "" || profile == "platform-bundle" || bundleRef != "" || overridePath != "" { + if profile != "" && profile != "platform-bundle" { + return fmt.Errorf("--profile=%s can't be combined with --bundle/--override (those imply platform-bundle); drop --profile, or use --profile=minigame|platform for the legacy profiles", profile) } body, err := loadBundleRequest(bundleRef, overridePath) if err != nil { @@ -78,8 +81,8 @@ Profile is locked once a workspace exists. To switch, ` + "`grounds cluster dele return nil } - if profile != "" && profile != "minigame" && profile != "platform" { - return fmt.Errorf("invalid --profile %q: must be \"minigame\" or \"platform\"", profile) + if profile != "minigame" && profile != "platform" { + return fmt.Errorf("invalid --profile %q: must be \"minigame\" or \"platform\" (omit --profile for the default platform-bundle)", profile) } s, err := c.ClusterUp(ctx, profile) if err != nil { @@ -90,8 +93,8 @@ Profile is locked once a workspace exists. To switch, ` + "`grounds cluster dele return nil }, } - cmd.Flags().StringVar(&profile, "profile", "", "workspace profile: minigame (default) or platform (vCluster)") - cmd.Flags().StringVar(&bundleRef, "bundle", "", "PlatformBundle ref (e.g. 0.4.0, main); implies platform-bundle profile") + cmd.Flags().StringVar(&profile, "profile", "", "legacy profile: minigame (sandbox) or platform (bare vCluster); default is platform-bundle") + cmd.Flags().StringVar(&bundleRef, "bundle", "", "PlatformBundle ref (e.g. 0.6.4, main); default main") cmd.Flags().StringVar(&overridePath, "override", "", "path to an Engineer-Override-File (YAML); implies platform-bundle profile") return cmd } @@ -120,7 +123,9 @@ func loadBundleRequest(bundleRef, overridePath string) (*api.BundleUpRequest, er body.Overrides = parsed.Overrides } if body.Bundle == "" { - return nil, fmt.Errorf("--bundle is required (or set `bundle: ` in the override file)") + // platform-bundle is the default profile; with no ref given on the flag + // or in the override file, track the bundle's main branch. + body.Bundle = "main" } return body, nil } diff --git a/cmd/grounds/commands/cluster/up_test.go b/cmd/grounds/commands/cluster/up_test.go index 3e2d179..d34e9fa 100644 --- a/cmd/grounds/commands/cluster/up_test.go +++ b/cmd/grounds/commands/cluster/up_test.go @@ -66,13 +66,32 @@ overrides: } }) - t.Run("missing bundle ref errors", func(t *testing.T) { + t.Run("missing bundle ref defaults to main", func(t *testing.T) { + // platform-bundle is the default profile: no ref on the flag or in the + // override file → track the bundle's main branch (not an error). path := writeTempYAML(t, ` overrides: velocity: {mode: image} `) - if _, err := loadBundleRequest("", path); err == nil { - t.Error("expected error when no bundle ref provided") + body, err := loadBundleRequest("", path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body.Bundle != "main" { + t.Errorf("Bundle = %q, want main (default)", body.Bundle) + } + if body.Overrides["velocity"] == nil { + t.Error("expected velocity override to survive the ref default") + } + }) + + t.Run("bare up (no flag, no file) defaults to main", func(t *testing.T) { + body, err := loadBundleRequest("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body.Bundle != "main" { + t.Errorf("Bundle = %q, want main (default)", body.Bundle) } }) diff --git a/internal/api/cluster.go b/internal/api/cluster.go index 73c3cfe..ed6f3a5 100644 --- a/internal/api/cluster.go +++ b/internal/api/cluster.go @@ -99,8 +99,8 @@ type ClusterDeleteResult struct { // per-component union (image | gradle-local | enabled:false) — the CLI // just passes through whatever YAML the engineer wrote. type BundleUpRequest struct { - Bundle string `json:"bundle"` - Overrides map[string]any `json:"overrides,omitempty"` + Bundle string `json:"bundle"` + Overrides map[string]any `json:"overrides,omitempty"` } // BundleUpResult mirrors the success body of POST /v1/cluster/bundle. @@ -132,6 +132,30 @@ func (c *Client) ClusterUpBundle(ctx context.Context, body *BundleUpRequest) (*B return out, nil } +// ClusterResetRequest is the (optional) body for POST /v1/cluster/reset. +// An empty Bundle lets forge pick the default ref (main). +type ClusterResetRequest struct { + Bundle string `json:"bundle,omitempty"` +} + +// ClusterResetResult mirrors the 202 body of POST /v1/cluster/reset. +type ClusterResetResult struct { + State string `json:"state"` + Poll string `json:"poll,omitempty"` +} + +// ClusterReset wipes a platform-bundle workspace back to a clean bundle base: +// forge tears down the vCluster namespace and re-provisions from the bundle ref +// (body.Bundle ?? "main") with no engineer overrides. Returns 202 immediately; +// the caller polls GET /v1/cluster until the workspace is active again. +func (c *Client) ClusterReset(ctx context.Context, body *ClusterResetRequest) (*ClusterResetResult, error) { + out := &ClusterResetResult{} + if err := c.doRequest(ctx, http.MethodPost, "/v1/cluster/reset", body, out); err != nil { + return nil, err + } + return out, nil +} + func (c *Client) ClusterDelete(ctx context.Context, namespace string) (*ClusterDeleteResult, error) { // We can't reuse doRequest because we need a custom header. Inline. req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+c.scopedPath("/v1/cluster"), nil) diff --git a/internal/api/cluster_test.go b/internal/api/cluster_test.go index 10aefc5..fc800d2 100644 --- a/internal/api/cluster_test.go +++ b/internal/api/cluster_test.go @@ -53,6 +53,48 @@ func TestGetCluster(t *testing.T) { } } +func TestClusterReset(t *testing.T) { + var gotBody ClusterResetRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/cluster/reset" { + t.Fatalf("got %s %s", r.Method, r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]string{"state": "creating", "poll": "/v1/cluster"}) + })) + defer srv.Close() + c := New(srv.URL, nil) + res, err := c.ClusterReset(context.Background(), &ClusterResetRequest{Bundle: "0.6.4"}) + if err != nil { + t.Fatalf("err: %v", err) + } + if gotBody.Bundle != "0.6.4" { + t.Errorf("request Bundle = %q, want 0.6.4", gotBody.Bundle) + } + if res.State != "creating" || res.Poll != "/v1/cluster" { + t.Errorf("result = %+v", res) + } +} + +func TestClusterReset_EmptyBundleOmitted(t *testing.T) { + var raw map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&raw) + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]string{"state": "creating"}) + })) + defer srv.Close() + c := New(srv.URL, nil) + if _, err := c.ClusterReset(context.Background(), &ClusterResetRequest{}); err != nil { + t.Fatalf("err: %v", err) + } + // omitempty → forge sees no `bundle` and defaults to main. + if _, ok := raw["bundle"]; ok { + t.Errorf("empty bundle should be omitted, got %v", raw) + } +} + func TestClusterDelete_Header(t *testing.T) { var got string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {