diff --git a/cmd/grounds/commands/devspace/devspace.go b/cmd/grounds/commands/devspace/devspace.go new file mode 100644 index 0000000..0e91cb2 --- /dev/null +++ b/cmd/grounds/commands/devspace/devspace.go @@ -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} +} diff --git a/cmd/grounds/commands/devspace/generate.go b/cmd/grounds/commands/devspace/generate.go new file mode 100644 index 0000000..6d7d3f2 --- /dev/null +++ b/cmd/grounds/commands/devspace/generate.go @@ -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 ", + 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 +} diff --git a/cmd/grounds/commands/devspace/generate_test.go b/cmd/grounds/commands/devspace/generate_test.go new file mode 100644 index 0000000..28e63eb --- /dev/null +++ b/cmd/grounds/commands/devspace/generate_test.go @@ -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 +} diff --git a/cmd/grounds/main.go b/cmd/grounds/main.go index f8ce4d6..72b6977 100644 --- a/cmd/grounds/main.go +++ b/cmd/grounds/main.go @@ -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" @@ -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()) diff --git a/internal/api/devspace.go b/internal/api/devspace.go new file mode 100644 index 0000000..3d7265b --- /dev/null +++ b/internal/api/devspace.go @@ -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) +}