From a997bb51fa5c51f78d2a867afbbc533ca6aa62b0 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 3 May 2026 20:45:42 +0200 Subject: [PATCH] feat(cluster): add --bundle / --override to drive PlatformBundle deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forge POST /v1/cluster/bundle endpoint already exists but had no CLI hook — engineers were stuck using curl. This wires it into the familiar `cluster up` command: 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 from file `--bundle` and `--override` imply profile=platform-bundle (the only profile the endpoint supports), so combining them with `--profile` is an error rather than a silent contradiction. The override YAML is read lazily — its `bundle:` field is the default ref, but `--bundle` on the command line wins. The `overrides:` map is passed through verbatim; forge owns the per-component union schema (image | gradle-local | enabled:false) and validates server-side. Result rendering shows the resolved/succeeded/failed component breakdown directly so engineers see immediately which charts didn't land: ✔ active — bundle 0.4.0 — vcluster-lusu components: 15 resolved, 14 succeeded, 1 failed failed: - paper-game: chart oci://...:0.1.0 not found Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/grounds/commands/cluster/up.go | 86 +++++++++++++++++++++-- cmd/grounds/commands/cluster/up_test.go | 91 +++++++++++++++++++++++++ internal/api/cluster.go | 38 +++++++++++ 3 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 cmd/grounds/commands/cluster/up_test.go diff --git a/cmd/grounds/commands/cluster/up.go b/cmd/grounds/commands/cluster/up.go index 5cdbbee..52b40e9 100644 --- a/cmd/grounds/commands/cluster/up.go +++ b/cmd/grounds/commands/cluster/up.go @@ -3,16 +3,21 @@ package cluster import ( "context" "fmt" + "os" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + "github.com/groundsgg/grounds-cli/internal/api" "github.com/groundsgg/grounds-cli/internal/render" ) func newUp() *cobra.Command { var profile string + var bundleRef string + var overridePath string cmd := &cobra.Command{ - Use: "up [--profile=minigame|platform]", + Use: "up [--profile=minigame|platform] [--bundle= [--override=]]", Short: "Spawn or resume the workspace", Long: `Create the workspace if it doesn't exist, or resume it from a paused state. @@ -22,16 +27,44 @@ Profiles: installed inside. Heavier (one-time ~90s spawn) but lets you run platform plugins / agones / mc-router locally. +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. + + 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 + Profile is locked once a workspace exists. To switch, ` + "`grounds cluster delete`" + ` and re-up.`, RunE: func(cmd *cobra.Command, _ []string) error { - if profile != "" && profile != "minigame" && profile != "platform" { - return fmt.Errorf("invalid --profile %q: must be \"minigame\" or \"platform\"", profile) - } 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") + } + body, err := loadBundleRequest(bundleRef, overridePath) + if err != nil { + return err + } + res, err := c.ClusterUpBundle(ctx, body) + if err != nil { + return err + } + renderBundleResult(cmd.OutOrStdout(), res) + return nil + } + + if profile != "" && profile != "minigame" && profile != "platform" { + return fmt.Errorf("invalid --profile %q: must be \"minigame\" or \"platform\"", profile) + } s, err := c.ClusterUp(ctx, profile) if err != nil { return err @@ -42,5 +75,50 @@ Profile is locked once a workspace exists. To switch, ` + "`grounds cluster dele }, } 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(&overridePath, "override", "", "path to an Engineer-Override-File (YAML); implies platform-bundle profile") return cmd } + +// loadBundleRequest builds the POST /v1/cluster/bundle body from +// --bundle and/or --override. If --override is set, the file is parsed +// for its `overrides:` map (and `bundle:` if --bundle wasn't given). +// --bundle wins over the override-file's bundle field when both are set. +func loadBundleRequest(bundleRef, overridePath string) (*api.BundleUpRequest, error) { + body := &api.BundleUpRequest{Bundle: bundleRef} + if overridePath != "" { + raw, err := os.ReadFile(overridePath) + if err != nil { + return nil, fmt.Errorf("reading override file: %w", err) + } + var parsed struct { + Bundle string `yaml:"bundle"` + Overrides map[string]any `yaml:"overrides"` + } + if err := yaml.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("parsing override file: %w", err) + } + if body.Bundle == "" { + body.Bundle = parsed.Bundle + } + body.Overrides = parsed.Overrides + } + if body.Bundle == "" { + return nil, fmt.Errorf("--bundle is required (or set `bundle: ` in the override file)") + } + return body, nil +} + +func renderBundleResult(w interface { + Write(p []byte) (int, error) +}, res *api.BundleUpResult) { + fmt.Fprintf(w, "✔ %s — bundle %s — %s\n", res.State, res.BundleVersion, res.Namespace) + fmt.Fprintf(w, " components: %d resolved, %d succeeded, %d failed\n", + res.Components.Resolved, len(res.Components.Succeeded), len(res.Components.Failed)) + if len(res.Components.Failed) > 0 { + fmt.Fprintln(w, " failed:") + for _, f := range res.Components.Failed { + fmt.Fprintf(w, " - %s: %s\n", f.Name, f.Error) + } + } +} diff --git a/cmd/grounds/commands/cluster/up_test.go b/cmd/grounds/commands/cluster/up_test.go new file mode 100644 index 0000000..418bcfd --- /dev/null +++ b/cmd/grounds/commands/cluster/up_test.go @@ -0,0 +1,91 @@ +package cluster + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadBundleRequest(t *testing.T) { + t.Run("bundle flag only", func(t *testing.T) { + body, err := loadBundleRequest("0.4.0", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body.Bundle != "0.4.0" { + t.Errorf("Bundle = %q, want 0.4.0", body.Bundle) + } + if body.Overrides != nil { + t.Errorf("Overrides should be nil, got %v", body.Overrides) + } + }) + + t.Run("override file with bundle field", func(t *testing.T) { + path := writeTempYAML(t, ` +bundle: 1.5.0 +overrides: + plugin-social: + mode: gradle-local + project: ./plugin-social +`) + body, err := loadBundleRequest("", path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body.Bundle != "1.5.0" { + t.Errorf("Bundle = %q, want 1.5.0 (read from file)", body.Bundle) + } + if body.Overrides["plugin-social"] == nil { + t.Error("expected plugin-social override to be set") + } + }) + + t.Run("flag wins over file bundle field", func(t *testing.T) { + path := writeTempYAML(t, ` +bundle: 1.5.0 +overrides: + velocity: + mode: image + tag: dev-fix-routing +`) + body, err := loadBundleRequest("0.4.0", path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body.Bundle != "0.4.0" { + t.Errorf("Bundle = %q, want 0.4.0 (flag wins)", body.Bundle) + } + }) + + t.Run("missing bundle ref errors", func(t *testing.T) { + path := writeTempYAML(t, ` +overrides: + velocity: {mode: image} +`) + if _, err := loadBundleRequest("", path); err == nil { + t.Error("expected error when no bundle ref provided") + } + }) + + t.Run("missing file errors", func(t *testing.T) { + if _, err := loadBundleRequest("0.4.0", "/nonexistent/path.yaml"); err == nil { + t.Error("expected error for nonexistent override file") + } + }) + + t.Run("invalid yaml errors", func(t *testing.T) { + path := writeTempYAML(t, "this: is: not: valid: yaml::") + if _, err := loadBundleRequest("0.4.0", path); err == nil { + t.Error("expected error for malformed yaml") + } + }) +} + +func writeTempYAML(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "override.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp yaml: %v", err) + } + return path +} diff --git a/internal/api/cluster.go b/internal/api/cluster.go index f14960c..2b45bd5 100644 --- a/internal/api/cluster.go +++ b/internal/api/cluster.go @@ -64,6 +64,44 @@ type ClusterDeleteResult struct { Poll string `json:"poll,omitempty"` } +// BundleUpRequest is the body shape forge expects on POST /v1/cluster/bundle. +// `Overrides` is intentionally a free-form map: forge validates the +// 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"` +} + +// BundleUpResult mirrors the success body of POST /v1/cluster/bundle. +type BundleUpResult struct { + State string `json:"state"` + DevClusterID string `json:"devClusterId"` + Namespace string `json:"namespace"` + Created bool `json:"created"` + BundleVersion string `json:"bundleVersion"` + Components struct { + Resolved int `json:"resolved"` + Succeeded []string `json:"succeeded"` + Failed []struct { + Name string `json:"name"` + Error string `json:"error"` + } `json:"failed"` + } `json:"components"` +} + +// ClusterUpBundle drives a `platform-bundle` profile DevCluster: forge +// resolves the bundle ref, applies the engineer's overrides, and +// best-effort helm-installs each component into the vCluster. The +// breakdown of succeeded/failed components is in the result. +func (c *Client) ClusterUpBundle(ctx context.Context, body *BundleUpRequest) (*BundleUpResult, error) { + out := &BundleUpResult{} + if err := c.doRequest(ctx, http.MethodPost, "/v1/cluster/bundle", 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)