diff --git a/README.md b/README.md index 6ba993b..00d6dae 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,17 @@ vim definition.yml # (or any other text editor you like) conduktor apply -f ``` +You can also fetch a server-side template curated in Console by passing its name as a second argument. This is currently supported for `Topic` and `Connector` kinds only: +``` +conduktor template Topic high-partition-topic +``` + +To discover what's available, use `--list` (script-friendly) or `--interactive` (numbered prompt): +``` +conduktor template Topic --list +conduktor template Topic --interactive +``` + ### Development #### How to run: diff --git a/cmd/template.go b/cmd/template.go index 6601a86..4b42ce5 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -4,8 +4,12 @@ import ( "bufio" "fmt" "os" + "strconv" + "strings" "github.com/conduktor/ctl/internal/cli" + "github.com/conduktor/ctl/internal/printutils" + "github.com/conduktor/ctl/internal/utils" "github.com/spf13/cobra" ) @@ -25,17 +29,21 @@ func initTemplate(rootContext cli.RootContext) { var file *string var edit *bool var apply *bool + var interactive *bool + var list *bool file = templateCmd.PersistentFlags().StringP("output", "o", "", "Write example to file") edit = templateCmd.PersistentFlags().BoolP("edit", "e", false, "Edit the YAML file post-creation; this works only with --output. It will the EDITOR environment variable or nano if not set.") apply = templateCmd.PersistentFlags().BoolP("apply", "a", false, "Apply the YAML file post-editing; this works only with --edit.") + interactive = templateCmd.PersistentFlags().BoolP("interactive", "i", false, "List server-side templates for the kind and prompt to pick one. Cannot be combined with a template name.") + list = templateCmd.PersistentFlags().BoolP("list", "l", false, "List available server-side template names for the kind, one per line, and exit. Cannot be combined with a template name or --interactive.") // Add all kinds to the 'template' command for name, kind := range rootContext.Catalog.Kind { kindCmd := &cobra.Command{ - Use: name, + Use: name + " [template-name]", Short: "Get a yaml example for resource of kind " + name, - Args: cobra.NoArgs, - Long: `If name not provided it will list all resource`, + Args: cobra.MaximumNArgs(1), + Long: `Without a name, returns a built-in example. With a name, fetches the matching server-side template (requires a Conduktor Console that supports resource templates).`, Aliases: buildAlias(name), PreRun: func(cmd *cobra.Command, args []string) { if edit != nil && *edit && (file == nil || *file == "") { @@ -46,16 +54,56 @@ func initTemplate(rootContext cli.RootContext) { fmt.Fprintln(os.Stderr, "Cannot use --apply without --edit") os.Exit(11) } + if interactive != nil && *interactive && len(args) > 0 { + fmt.Fprintln(os.Stderr, "Cannot use --interactive with a template name") + os.Exit(12) + } + if list != nil && *list && len(args) > 0 { + fmt.Fprintln(os.Stderr, "Cannot use --list with a template name") + os.Exit(13) + } + if list != nil && *list && interactive != nil && *interactive { + fmt.Fprintln(os.Stderr, "Cannot use --list with --interactive") + os.Exit(14) + } }, Run: func(cmd *cobra.Command, args []string) { - example := kind.GetLatestKindVersion().GetApplyExample() + if list != nil && *list { + if err := listServerTemplates(rootContext, name); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + return + } + var example string + if len(args) == 1 { + var err error + example, err = fetchTemplateByName(rootContext, name, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + } else if interactive != nil && *interactive { + picked, err := pickServerTemplate(rootContext, name) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + example, err = fetchTemplateByName(rootContext, name, picked) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + } else { + example = kind.GetLatestKindVersion().GetApplyExample() + } if example == "" { fmt.Fprintf(os.Stderr, "No template for kind %s\n", name) os.Exit(1) } else { if file == nil || *file == "" { fmt.Println("---") - fmt.Println(kind.GetLatestKindVersion().GetApplyExample()) + fmt.Println(example) } else { _, err := os.Stat(*file) if err == nil { @@ -81,7 +129,7 @@ func initTemplate(rootContext cli.RootContext) { fmt.Fprintf(os.Stderr, "Error writing to file %s: %s\n", *file, err) os.Exit(4) } - _, err = w.WriteString(kind.GetLatestKindVersion().GetApplyExample()) + _, err = w.WriteString(example) if err != nil { fmt.Fprintf(os.Stderr, "Error writing to file %s: %s\n", *file, err) os.Exit(4) @@ -100,6 +148,84 @@ func initTemplate(rootContext cli.RootContext) { } } +// kindsSupportingTemplates lists the resource kinds Console exposes through the +// server-side template API. Other kinds don't have a `*-template` endpoint, so +// we fail fast instead of hitting the API and producing a confusing parse error. +var kindsSupportingTemplates = map[string]bool{ + "Topic": true, + "Connector": true, +} + +// fetchTemplateByName fetches an admin-curated server-side template named +// `templateName` for the given resource kind (e.g. "Topic") and renders it as a YAML +// resource of that kind. The template's `spec.defaults` carries the metadata + spec +// that should be used when instantiating the underlying resource. +func fetchTemplateByName(rootContext cli.RootContext, kindName, templateName string) (string, error) { + baseKind, ok := rootContext.Catalog.Kind[kindName] + if !ok { + return "", fmt.Errorf("Unknown kind %s", kindName) + } + + if !kindsSupportingTemplates[kindName] { + return "", fmt.Errorf("kind %s does not support resource templates (supported kinds: Topic, Connector)", kindName) + } + + res, err := consoleAPIClient().GetTemplate(utils.CamelToKebab(kindName), templateName) + if err != nil { + return "", err + } + + return printutils.RenderTemplateAsKind(res.Spec, baseKind.GetName(), baseKind.MaxVersion()) +} + +// listServerTemplates prints the names of all server-side templates for the +// given kind to stdout, one per line. +func listServerTemplates(rootContext cli.RootContext, kindName string) error { + if !kindsSupportingTemplates[kindName] { + return fmt.Errorf("kind %s does not support resource templates (supported kinds: Topic, Connector)", kindName) + } + templates, err := consoleAPIClient().ListTemplates(utils.CamelToKebab(kindName)) + if err != nil { + return err + } + for _, t := range templates { + fmt.Println(t.Name) + } + return nil +} + +// pickServerTemplate fetches the list of server-side templates for the given +// kind and prompts the user to pick one by index. +func pickServerTemplate(rootContext cli.RootContext, kindName string) (string, error) { + if !kindsSupportingTemplates[kindName] { + return "", fmt.Errorf("kind %s does not support resource templates (supported kinds: Topic, Connector)", kindName) + } + templates, err := consoleAPIClient().ListTemplates(utils.CamelToKebab(kindName)) + if err != nil { + return "", err + } + if len(templates) == 0 { + return "", fmt.Errorf("no %s templates available", kindName) + } + + fmt.Fprintf(os.Stderr, "%d templates fetched for %s templates\n", len(templates), kindName) + for i, t := range templates { + fmt.Fprintf(os.Stderr, "%d. %s\n", i+1, t.Name) + } + fmt.Fprintf(os.Stderr, "Please enter a choice from (1-%d): ", len(templates)) + + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("could not read choice: %s", err) + } + choice, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || choice < 1 || choice > len(templates) { + return "", fmt.Errorf("invalid choice: %s", strings.TrimSpace(line)) + } + return templates[choice-1].Name, nil +} + func editAndApply(rootContext cli.RootContext, edit *bool, file *string, apply *bool) { if edit != nil && *edit { // Run editor on the file diff --git a/docs/README.md b/docs/README.md index 668694b..4b0776d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -134,15 +134,19 @@ conduktor edit consumergroup my-group #### `template` Generate YAML templates for resources. +Without a name, returns a built-in example for the kind. With a name, fetches a server-side resource template curated in Console — currently supported for `Topic` and `Connector` only. `--list` and `--interactive` discover available server-side templates. + **Usage:** ```bash -conduktor template +conduktor template [template-name] ``` **Flags:** - `-o, --output`: Write template to file - `-e, --edit`: Edit template after creation (requires --output) - `-a, --apply`: Apply template after editing (requires --edit) +- `-l, --list`: Print available server-side template names (one per line) and exit. Mutually exclusive with a template name and `--interactive`. +- `-i, --interactive`: List server-side templates and prompt to pick one. Mutually exclusive with a template name and `--list`. **Examples:** ```bash @@ -154,6 +158,15 @@ conduktor template topic -o topic-template.yaml # Create, edit, and apply template conduktor template topic -o topic.yaml -e -a + +# Fetch a server-side template by name (Topic or Connector only) +conduktor template Topic high-partition-topic + +# List available server-side templates +conduktor template Topic --list + +# Pick a server-side template interactively +conduktor template Topic --interactive ``` ### Utility Commands diff --git a/internal/printutils/template.go b/internal/printutils/template.go new file mode 100644 index 0000000..3f843b2 --- /dev/null +++ b/internal/printutils/template.go @@ -0,0 +1,33 @@ +package printutils + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// RenderTemplateAsKind takes a template's `spec` (with a `defaults` key holding +// metadata and spec) and turns it into a YAML resource of the given kind/version. +func RenderTemplateAsKind(templateSpec map[string]interface{}, kindName string, apiVersion int) (string, error) { + defaults, ok := templateSpec["defaults"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("Template response is missing spec.defaults") + } + + out := map[string]interface{}{ + "apiVersion": fmt.Sprintf("v%d", apiVersion), + "kind": kindName, + } + if metadata, ok := defaults["metadata"]; ok { + out["metadata"] = metadata + } + if spec, ok := defaults["spec"]; ok { + out["spec"] = spec + } + + data, err := yaml.Marshal(out) + if err != nil { + return "", fmt.Errorf("Error marshaling template as YAML: %s", err) + } + return string(data), nil +} diff --git a/internal/printutils/template_test.go b/internal/printutils/template_test.go new file mode 100644 index 0000000..724a9f2 --- /dev/null +++ b/internal/printutils/template_test.go @@ -0,0 +1,42 @@ +package printutils + +import "testing" + +func TestRenderTemplateAsKindBuildsResourceFromDefaults(t *testing.T) { + templateSpec := map[string]interface{}{ + "displayName": "High Partition Topic", + "description": "Optimised for high throughput", + "defaults": map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-topic-${dept}", + "labels": map[string]interface{}{"throughput": "high"}, + }, + "spec": map[string]interface{}{ + "partitions": 24, + "replicationFactor": 3, + "configs": map[string]interface{}{"retention.ms": "604800000"}, + }, + }, + } + + got, err := RenderTemplateAsKind(templateSpec, "Topic", 2) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + want := `apiVersion: v2 +kind: Topic +metadata: + labels: + throughput: high + name: my-topic-${dept} +spec: + configs: + retention.ms: "604800000" + partitions: 24 + replicationFactor: 3 +` + if got != want { + t.Errorf("rendered template mismatch.\nwant:\n%s\ngot:\n%s", want, got) + } +} diff --git a/pkg/client/console_client.go b/pkg/client/console_client.go index 223356e..8ef17ac 100644 --- a/pkg/client/console_client.go +++ b/pkg/client/console_client.go @@ -366,6 +366,43 @@ func (client *Client) GetFromResource(res *resource.Resource) (resource.Resource return resource.Resource{}, fmt.Errorf("could not find any matching resource") } +// ListTemplates fetches all admin-curated resource templates of a given kind +// from the Console template API. Endpoint: /public/console/v2/{kebab-kind}-template. +func (client *Client) ListTemplates(kindKebabCase string) ([]resource.Resource, error) { + var result []resource.Resource + client.setAuthMethodFromEnvIfNeeded() + url := fmt.Sprintf("%s/public/console/v2/%s-template", client.baseURL, kindKebabCase) + resp, err := client.client.R().Get(url) + if err != nil { + return result, err + } else if resp.IsError() { + return result, fmt.Errorf("error listing templates %s-template, got status code: %d:\n %s", kindKebabCase, resp.StatusCode(), string(resp.Body())) + } + err = json.Unmarshal(resp.Body(), &result) + return result, err +} + +// GetTemplate fetches an admin-curated resource template by name from the new +// Console template API. The endpoint is /public/console/v2/{kebab-kind}-template/{name} +// (e.g. /public/console/v2/topic-template/high-partition-topic). +// +// Returns 404 when the server doesn't support resource templates yet, which the +// caller surfaces as a clear error — older Console versions stay supported because +// the offline template fallback (without a name arg) doesn't go through this path. +func (client *Client) GetTemplate(kindKebabCase, templateName string) (resource.Resource, error) { + var result resource.Resource + client.setAuthMethodFromEnvIfNeeded() + url := fmt.Sprintf("%s/public/console/v2/%s-template/%s", client.baseURL, kindKebabCase, templateName) + resp, err := client.client.R().Get(url) + if err != nil { + return result, err + } else if resp.IsError() { + return result, fmt.Errorf("error fetching template %s-template/%s, got status code: %d:\n %s", kindKebabCase, templateName, resp.StatusCode(), string(resp.Body())) + } + err = json.Unmarshal(resp.Body(), &result) + return result, err +} + func (client *Client) Run(run schema.Run, pathValue []string, queryParams map[string]string, body interface{}) ([]byte, error) { if run.BackendType != schema.CONSOLE { return nil, fmt.Errorf("Only console backend type is supported by console client") diff --git a/pkg/client/console_client_test.go b/pkg/client/console_client_test.go index 7f86ad0..2c1c835 100644 --- a/pkg/client/console_client_test.go +++ b/pkg/client/console_client_test.go @@ -454,6 +454,126 @@ func TestDescribeShouldFailIfNo2xx(t *testing.T) { } } +func TestGetTemplateShouldWork(t *testing.T) { + defer httpmock.Reset() + baseURL := "http://baseUrl" + apiKey := "aToken" + client, err := Make(APIParameter{ + APIKey: apiKey, + BaseURL: baseURL, + }) + if err != nil { + panic(err) + } + httpmock.ActivateNonDefault( + client.client.GetClient(), + ) + templateResource := resource.Resource{ + Json: []byte(`{"apiVersion":"v2","kind":"TopicTemplate","metadata":{"name":"high-partition-topic"},"spec":{"displayName":"High Partition Topic","defaults":{"metadata":{"name":"my-topic-${dept}"},"spec":{"partitions":24}}}}`), + } + responder, err := httpmock.NewJsonResponder(200, templateResource) + if err != nil { + panic(err) + } + httpmock.RegisterMatcherResponderWithQuery( + "GET", + "http://baseUrl/api/public/console/v2/topic-template/high-partition-topic", + nil, + httpmock.HeaderIs("Authorization", "Bearer "+apiKey), + responder, + ) + + result, err := client.GetTemplate("topic", "high-partition-topic") + if err != nil { + t.Fatal(err) + } + defaults, ok := result.Spec["defaults"].(map[string]interface{}) + if !ok { + t.Fatalf("expected spec.defaults to be a map, got %T", result.Spec["defaults"]) + } + specMap, ok := defaults["spec"].(map[string]interface{}) + if !ok { + t.Fatalf("expected spec.defaults.spec to be a map, got %T", defaults["spec"]) + } + if specMap["partitions"] != float64(24) { + t.Errorf("expected partitions=24, got %v", specMap["partitions"]) + } +} + +func TestGetTemplateShouldFailIfNo2xx(t *testing.T) { + defer httpmock.Reset() + baseURL := "http://baseUrl" + apiKey := "aToken" + client, err := Make(APIParameter{ + APIKey: apiKey, + BaseURL: baseURL, + }) + if err != nil { + panic(err) + } + httpmock.ActivateNonDefault( + client.client.GetClient(), + ) + responder := httpmock.NewStringResponder(404, "not found") + httpmock.RegisterMatcherResponderWithQuery( + "GET", + "http://baseUrl/api/public/console/v2/topic-template/missing", + nil, + httpmock.HeaderIs("Authorization", "Bearer "+apiKey), + responder, + ) + + _, err = client.GetTemplate("topic", "missing") + if err == nil { + t.Error("expected an error for 404 response, got nil") + } +} + +func TestListTemplatesShouldWork(t *testing.T) { + defer httpmock.Reset() + baseURL := "http://baseUrl" + apiKey := "aToken" + client, err := Make(APIParameter{ + APIKey: apiKey, + BaseURL: baseURL, + }) + if err != nil { + panic(err) + } + httpmock.ActivateNonDefault( + client.client.GetClient(), + ) + templates := []resource.Resource{ + {Json: []byte(`{"apiVersion":"v2","kind":"TopicTemplate","metadata":{"name":"high-throughput-topic"},"spec":{"defaults":{"metadata":{"name":"t"},"spec":{"partitions":24}}}}`)}, + {Json: []byte(`{"apiVersion":"v2","kind":"TopicTemplate","metadata":{"name":"staging"},"spec":{"defaults":{"metadata":{"name":"t"},"spec":{"partitions":3}}}}`)}, + } + responder, err := httpmock.NewJsonResponder(200, templates) + if err != nil { + panic(err) + } + httpmock.RegisterMatcherResponderWithQuery( + "GET", + "http://baseUrl/api/public/console/v2/topic-template", + nil, + httpmock.HeaderIs("Authorization", "Bearer "+apiKey), + responder, + ) + + result, err := client.ListTemplates("topic") + if err != nil { + t.Fatal(err) + } + if len(result) != 2 { + t.Fatalf("expected 2 templates, got %d", len(result)) + } + if result[0].Name != "high-throughput-topic" { + t.Errorf("expected first template name=high-throughput-topic, got %q", result[0].Name) + } + if result[1].Name != "staging" { + t.Errorf("expected second template name=staging, got %q", result[1].Name) + } +} + func TestDeleteShouldWork(t *testing.T) { defer httpmock.Reset() baseURL := "http://baseUrl"