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
70 changes: 70 additions & 0 deletions cmd/grounds/commands/devspace/devspace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package devspace

import (
"context"
"net/http"
"os"
"time"

"github.com/spf13/cobra"

"github.com/groundsgg/grounds-cli/internal/api"
"github.com/groundsgg/grounds-cli/internal/auth"
"github.com/groundsgg/grounds-cli/internal/config"
)

func NewDevspaceCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "devspace",
Short: "DevSpace integration helpers",
}
cmd.AddCommand(newGenerate())
return cmd
}

// Mirrors cluster/cluster.go — same auth + config resolution, same
// shape so callers don't need to know which command-group they're in.
func buildClient(_ context.Context, cmd *cobra.Command) (*api.Client, *config.Config, error) {
cfg, err := config.Load("")
if err != nil {
return nil, nil, err
}
ts := api.NewEnvTokenSource()
if ts == nil {
ts = &auth.FileTokenSource{
Store: auth.NewStore(cfg.Dir),
Device: defaultDeviceClient(),
}
}
c := api.New(cfg.APIURL, ts)
c.ProjectID = resolveProjectID(cmd)
return c, cfg, nil
}

func resolveProjectID(cmd *cobra.Command) string {
if cmd != nil {
if p, _ := cmd.Flags().GetString("project"); p != "" {
return p
}
}
return envOr("GROUNDS_PROJECT", "")
}

func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

func defaultDeviceClient() *auth.DeviceClient {
return &auth.DeviceClient{
Issuer: "https://account.grounds.gg/realms/grounds",
ClientID: "grounds-cli",
HTTP: defaultHTTP(),
}
}

func defaultHTTP() *http.Client {
return &http.Client{Timeout: 30 * time.Second}
}
119 changes: 119 additions & 0 deletions cmd/grounds/commands/devspace/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package devspace

import (
"context"
"fmt"
"os"

"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/groundsgg/grounds-cli/internal/api"
)

func newGenerate() *cobra.Command {
var bundleRef string
var overridePath string
var outputPath string

cmd := &cobra.Command{
Use: "generate <component-name>",
Short: "Render a component-specific devspace.yaml from the bundle's workflow template",
Long: `Asks forge to substitute the engineer's override values into the
matching devspace template (jar-sync-pod-restart or quarkus-dev) and
writes the result to ./devspace.yaml (or --output).

The component name is the release-name from bundle.yaml (e.g.
plugin-social, service-config). The workflow type is read from the
bundle.

Examples:

# Pin against a bundle release, override file describes plugin-social.
grounds devspace generate plugin-social --bundle 0.4.0 --override ./me.yaml

# Override file alone — bundle ref read from its 'bundle:' field.
grounds devspace generate plugin-social --override ./me.yaml

# Defaults — no override, just template + bundle's component-name.
grounds devspace generate plugin-social --bundle main`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
componentName := args[0]
bundle, override, err := loadGenerateInputs(bundleRef, overridePath, componentName)
if err != nil {
return err
}

ctx := context.Background()
c, _, err := buildClient(ctx, cmd)
if err != nil {
return err
}

yaml, err := c.DevspaceGenerate(ctx, &api.DevspaceGenerateRequest{
Bundle: bundle,
ComponentName: componentName,
Override: override,
})
if err != nil {
return err
}

if outputPath == "-" {
cmd.OutOrStdout().Write(yaml)
return nil
}
if err := os.WriteFile(outputPath, yaml, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", outputPath, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "✔ Wrote %d bytes to %s\n", len(yaml), outputPath)
return nil
},
}
cmd.Flags().StringVar(&bundleRef, "bundle", "", "PlatformBundle ref (e.g. 0.4.0, main); inherits from --override file when not set")
cmd.Flags().StringVar(&overridePath, "override", "", "path to an Engineer-Override-File (YAML)")
cmd.Flags().StringVarP(&outputPath, "output", "o", "./devspace.yaml", "output path (use '-' for stdout)")
return cmd
}

// loadGenerateInputs picks the bundle-ref + per-component override
// out of the override file (when present), with --bundle on the
// command line winning over the file's `bundle:` field.
func loadGenerateInputs(bundleRef, overridePath, componentName string) (string, map[string]any, error) {
if overridePath == "" {
if bundleRef == "" {
return "", nil, fmt.Errorf("either --bundle or --override must be set")
}
return bundleRef, nil, nil
}
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]map[string]any `yaml:"overrides"`
}
if err := yaml.Unmarshal(raw, &parsed); err != nil {
return "", nil, fmt.Errorf("parsing override file: %w", err)
}
bundle := bundleRef
if bundle == "" {
bundle = parsed.Bundle
}
if bundle == "" {
return "", nil, fmt.Errorf("--bundle not set and override file has no `bundle:` field")
}
override := parsed.Overrides[componentName]
// Convert to map[string]any so api.DevspaceGenerateRequest's field
// type matches.
var overrideAny map[string]any
if override != nil {
overrideAny = make(map[string]any, len(override))
for k, v := range override {
overrideAny[k] = v
}
}
return bundle, overrideAny, nil
}
100 changes: 100 additions & 0 deletions cmd/grounds/commands/devspace/generate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package devspace

import (
"os"
"path/filepath"
"testing"
)

func TestLoadGenerateInputs(t *testing.T) {
t.Run("bundle flag only, no override", func(t *testing.T) {
bundle, override, err := loadGenerateInputs("0.4.0", "", "plugin-social")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if bundle != "0.4.0" {
t.Errorf("bundle = %q, want 0.4.0", bundle)
}
if override != nil {
t.Errorf("override should be nil, got %v", override)
}
})

t.Run("override file with bundle field, no flag", func(t *testing.T) {
path := writeTempYAML(t, `
bundle: 1.5.0
overrides:
plugin-social:
mode: gradle-local
project: ./plugin-social
artifact: build/libs/*-all.jar
`)
bundle, override, err := loadGenerateInputs("", path, "plugin-social")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if bundle != "1.5.0" {
t.Errorf("bundle = %q, want 1.5.0", bundle)
}
if override["mode"] != "gradle-local" {
t.Errorf("override.mode = %v, want gradle-local", override["mode"])
}
if override["project"] != "./plugin-social" {
t.Errorf("override.project = %v", override["project"])
}
})

t.Run("flag wins over file's bundle field", func(t *testing.T) {
path := writeTempYAML(t, `
bundle: 1.5.0
overrides: {}
`)
bundle, _, err := loadGenerateInputs("0.4.0", path, "plugin-social")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if bundle != "0.4.0" {
t.Errorf("bundle = %q, want 0.4.0 (flag wins)", bundle)
}
})

t.Run("missing bundle ref errors", func(t *testing.T) {
path := writeTempYAML(t, `overrides: {}`)
if _, _, err := loadGenerateInputs("", path, "plugin-social"); err == nil {
t.Error("expected error when no bundle ref provided")
}
})

t.Run("missing both flag and file errors", func(t *testing.T) {
if _, _, err := loadGenerateInputs("", "", "plugin-social"); err == nil {
t.Error("expected error when neither --bundle nor --override given")
}
})

t.Run("component not in override file → override is nil", func(t *testing.T) {
path := writeTempYAML(t, `
bundle: 0.4.0
overrides:
plugin-chat:
mode: gradle-local
project: ./plugin-chat
artifact: build/libs/*-all.jar
`)
_, override, err := loadGenerateInputs("", path, "plugin-social")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if override != nil {
t.Errorf("expected nil override for component not in file, got %v", override)
}
})
}

func writeTempYAML(t *testing.T, content string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "override.yaml")
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatalf("write temp yaml: %v", err)
}
return p
}
2 changes: 2 additions & 0 deletions cmd/grounds/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/groundsgg/grounds-cli/cmd/grounds/commands"
"github.com/groundsgg/grounds-cli/cmd/grounds/commands/cluster"
"github.com/groundsgg/grounds-cli/cmd/grounds/commands/devspace"
"github.com/groundsgg/grounds-cli/cmd/grounds/commands/logs"
"github.com/groundsgg/grounds-cli/cmd/grounds/commands/preview"
"github.com/groundsgg/grounds-cli/cmd/grounds/commands/push"
Expand All @@ -30,6 +31,7 @@ func main() {
root.AddCommand(commands.NewDoctorCommand())
root.AddCommand(commands.NewInitCommand())
root.AddCommand(cluster.NewClusterCommand())
root.AddCommand(devspace.NewDevspaceCommand())
root.AddCommand(logs.NewLogsCommand())
root.AddCommand(push.NewPushCommand())
root.AddCommand(preview.NewPreviewCommand())
Expand Down
58 changes: 58 additions & 0 deletions internal/api/devspace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package api

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)

// DevspaceGenerateRequest mirrors the body shape forge accepts on
// POST /v1/devspace/generate.
type DevspaceGenerateRequest struct {
Bundle string `json:"bundle"`
ComponentName string `json:"componentName"`
// Override is opaque to the CLI — forge owns the per-component
// override union (image | gradle-local | enabled:false). The CLI
// just lifts it from the engineer's override-file and passes it
// through.
Override map[string]any `json:"override,omitempty"`
}

// DevspaceGenerate POSTs the request and returns the substituted YAML
// as bytes. Unlike most cluster routes it returns text/yaml rather
// than JSON — we keep the call inline rather than threading a raw
// body through doRequest.
func (c *Client) DevspaceGenerate(ctx context.Context, body *DevspaceGenerateRequest) ([]byte, error) {
blob, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+c.scopedPath("/v1/devspace/generate"), bytes.NewReader(blob))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/yaml")

if c.Tokens != nil {
tok, err := c.Tokens.Token(ctx)
if err != nil {
return nil, fmt.Errorf("auth: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok)
}

resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode >= 400 {
return nil, parseError(resp)
}
return io.ReadAll(resp.Body)
}
Loading