Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/grounds/commands/cluster/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
84 changes: 84 additions & 0 deletions cmd/grounds/commands/cluster/reset.go
Original file line number Diff line number Diff line change
@@ -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 <namespace>")
}
} 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
}
55 changes: 30 additions & 25 deletions cmd/grounds/commands/cluster/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,39 +21,42 @@ func newUp() *cobra.Command {
var bundleRef string
var overridePath string
cmd := &cobra.Command{
Use: "up [--profile=minigame|platform] [--bundle=<ref> [--override=<file>]]",
Use: "up [--bundle=<ref>] [--override=<file>] [--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 @ <ref> (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 @ <ref>, 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)
if err != nil {
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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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: <ref>` 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
}
Expand Down
25 changes: 22 additions & 3 deletions cmd/grounds/commands/cluster/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})

Expand Down
28 changes: 26 additions & 2 deletions internal/api/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions internal/api/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading