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
86 changes: 82 additions & 4 deletions cmd/grounds/commands/cluster/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<ref> [--override=<file>]]",
Short: "Spawn or resume the workspace",
Long: `Create the workspace if it doesn't exist, or resume it from a paused state.

Expand All @@ -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 @ <ref>, 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
Expand All @@ -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: <ref>` 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)
}
}
}
91 changes: 91 additions & 0 deletions cmd/grounds/commands/cluster/up_test.go
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 38 additions & 0 deletions internal/api/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading