From b0a326b52d9d260bb70392d182344cccaf39638d Mon Sep 17 00:00:00 2001 From: Romain Lecomte Date: Tue, 19 May 2026 16:26:29 +0200 Subject: [PATCH 1/8] Template cmd: Fetch custom template from API --- cmd/template.go | 69 +++++++++++++++++++++++++--- cmd/template_test.go | 51 +++++++++++++++++++++ pkg/client/console_client.go | 21 +++++++++ pkg/client/console_client_test.go | 75 +++++++++++++++++++++++++++++++ template.yml | 23 ++++++++++ 5 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 cmd/template_test.go create mode 100644 template.yml diff --git a/cmd/template.go b/cmd/template.go index 6601a86..dcee7de 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -6,7 +6,10 @@ import ( "os" "github.com/conduktor/ctl/internal/cli" + "github.com/conduktor/ctl/internal/utils" + "github.com/conduktor/ctl/pkg/schema" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) var templateCmd = &cobra.Command{ @@ -32,10 +35,10 @@ func initTemplate(rootContext cli.RootContext) { // 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 == "") { @@ -48,14 +51,24 @@ func initTemplate(rootContext cli.RootContext) { } }, Run: func(cmd *cobra.Command, args []string) { - example := kind.GetLatestKindVersion().GetApplyExample() + 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 { + 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 +94,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 +113,50 @@ func initTemplate(rootContext cli.RootContext) { } } +// 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) + } + + res, err := consoleAPIClient().GetTemplate(utils.CamelToKebab(kindName), templateName) + if err != nil { + return "", err + } + + return renderTemplateAsKind(res.Spec, &baseKind) +} + +// renderTemplateAsKind takes a template's `spec` (with a `defaults` key holding +// metadata and spec) and turns it into a YAML resource of the given base kind. +func renderTemplateAsKind(templateSpec map[string]interface{}, baseKind *schema.Kind) (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", baseKind.MaxVersion()), + "kind": baseKind.GetName(), + } + 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 +} + func editAndApply(rootContext cli.RootContext, edit *bool, file *string, apply *bool) { if edit != nil && *edit { // Run editor on the file diff --git a/cmd/template_test.go b/cmd/template_test.go new file mode 100644 index 0000000..c157d3b --- /dev/null +++ b/cmd/template_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "testing" + + "github.com/conduktor/ctl/pkg/schema" +) + +func TestRenderTemplateAsKindBuildsResourceFromDefaults(t *testing.T) { + topic := schema.NewKind(2, &schema.ConsoleKindVersion{ + Name: "Topic", + ListPath: "/public/kafka/v2/cluster/{cluster}/topic", + }) + + 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) + 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..34dbf0c 100644 --- a/pkg/client/console_client.go +++ b/pkg/client/console_client.go @@ -366,6 +366,27 @@ func (client *Client) GetFromResource(res *resource.Resource) (resource.Resource return resource.Resource{}, fmt.Errorf("could not find any matching resource") } +// 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..e86266e 100644 --- a/pkg/client/console_client_test.go +++ b/pkg/client/console_client_test.go @@ -454,6 +454,81 @@ 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 TestDeleteShouldWork(t *testing.T) { defer httpmock.Reset() baseURL := "http://baseUrl" diff --git a/template.yml b/template.yml new file mode 100644 index 0000000..6f2aae2 --- /dev/null +++ b/template.yml @@ -0,0 +1,23 @@ +--- +apiVersion: v2 +kind: TopicTemplate +metadata: + labels: + category: template + name: high-partition-topic +spec: + defaults: + metadata: + labels: + throughput: high + name: my-topic-$${department} + spec: + configs: + cleanup.policy: delete + min.insync.replicas: "2" + retention.ms: "604800000" + partitions: 24 + replicationFactor: 3 + description: Optimised for high-throughput workloads. 24 partitions, 3× replication, 7-day retention. + displayName: High Partition Topic + From 6a634517eac53f86a4f523ddbfeac3e2670790d0 Mon Sep 17 00:00:00 2001 From: Romain Lecomte Date: Wed, 20 May 2026 09:34:05 +0200 Subject: [PATCH 2/8] Template cmd: Reject kinds without server-side templates Only Topic and Connector kinds expose the `*-template` endpoint. Fail fast with a clear message instead of hitting the API and surfacing a confusing JSON parse error for unsupported kinds. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/template.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/template.go b/cmd/template.go index dcee7de..8cb599a 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -113,6 +113,14 @@ 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 @@ -123,6 +131,10 @@ func fetchTemplateByName(rootContext cli.RootContext, kindName, templateName str 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 From 986d2834280d47a4d101db6ad87ca719c54f0871 Mon Sep 17 00:00:00 2001 From: Romain Lecomte Date: Wed, 20 May 2026 09:34:55 +0200 Subject: [PATCH 3/8] drop --- template.yml | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 template.yml diff --git a/template.yml b/template.yml deleted file mode 100644 index 6f2aae2..0000000 --- a/template.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -apiVersion: v2 -kind: TopicTemplate -metadata: - labels: - category: template - name: high-partition-topic -spec: - defaults: - metadata: - labels: - throughput: high - name: my-topic-$${department} - spec: - configs: - cleanup.policy: delete - min.insync.replicas: "2" - retention.ms: "604800000" - partitions: 24 - replicationFactor: 3 - description: Optimised for high-throughput workloads. 24 partitions, 3× replication, 7-day retention. - displayName: High Partition Topic - From cebc6dff5309cc667ae2dae43271d23d40fb9a38 Mon Sep 17 00:00:00 2001 From: Romain Lecomte Date: Wed, 20 May 2026 09:37:45 +0200 Subject: [PATCH 4/8] Docs: Document server-side template fetch by name Note the new `template ` form and the Topic/Connector restriction so users know when the API is reachable. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 5 +++++ docs/README.md | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ba993b..1317403 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,11 @@ 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 +``` + ### Development #### How to run: diff --git a/docs/README.md b/docs/README.md index 668694b..7398ede 100644 --- a/docs/README.md +++ b/docs/README.md @@ -134,9 +134,11 @@ 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. + **Usage:** ```bash -conduktor template +conduktor template [template-name] ``` **Flags:** @@ -154,6 +156,9 @@ 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 ``` ### Utility Commands From 8ed997697bf45b4d6f4894689d05fb44ae429ab6 Mon Sep 17 00:00:00 2001 From: Romain Lecomte Date: Wed, 20 May 2026 09:41:18 +0200 Subject: [PATCH 5/8] Move RenderTemplateAsKind to internal/printutils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouples the template-to-YAML rendering from the cmd layer so it can be reused. Signature takes kindName/apiVersion as primitives to keep printutils free of pkg/schema (avoids a printutils → schema → utils → resource → printutils cycle). Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/template.go | 31 ++--------------- internal/printutils/template.go | 33 +++++++++++++++++++ {cmd => internal/printutils}/template_test.go | 15 ++------- 3 files changed, 38 insertions(+), 41 deletions(-) create mode 100644 internal/printutils/template.go rename {cmd => internal/printutils}/template_test.go (77%) diff --git a/cmd/template.go b/cmd/template.go index 8cb599a..d9682e2 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -6,10 +6,9 @@ import ( "os" "github.com/conduktor/ctl/internal/cli" + "github.com/conduktor/ctl/internal/printutils" "github.com/conduktor/ctl/internal/utils" - "github.com/conduktor/ctl/pkg/schema" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ) var templateCmd = &cobra.Command{ @@ -140,33 +139,7 @@ func fetchTemplateByName(rootContext cli.RootContext, kindName, templateName str return "", err } - return renderTemplateAsKind(res.Spec, &baseKind) -} - -// renderTemplateAsKind takes a template's `spec` (with a `defaults` key holding -// metadata and spec) and turns it into a YAML resource of the given base kind. -func renderTemplateAsKind(templateSpec map[string]interface{}, baseKind *schema.Kind) (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", baseKind.MaxVersion()), - "kind": baseKind.GetName(), - } - 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 + return printutils.RenderTemplateAsKind(res.Spec, baseKind.GetName(), baseKind.MaxVersion()) } func editAndApply(rootContext cli.RootContext, edit *bool, file *string, apply *bool) { 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/cmd/template_test.go b/internal/printutils/template_test.go similarity index 77% rename from cmd/template_test.go rename to internal/printutils/template_test.go index c157d3b..724a9f2 100644 --- a/cmd/template_test.go +++ b/internal/printutils/template_test.go @@ -1,17 +1,8 @@ -package cmd +package printutils -import ( - "testing" - - "github.com/conduktor/ctl/pkg/schema" -) +import "testing" func TestRenderTemplateAsKindBuildsResourceFromDefaults(t *testing.T) { - topic := schema.NewKind(2, &schema.ConsoleKindVersion{ - Name: "Topic", - ListPath: "/public/kafka/v2/cluster/{cluster}/topic", - }) - templateSpec := map[string]interface{}{ "displayName": "High Partition Topic", "description": "Optimised for high throughput", @@ -28,7 +19,7 @@ func TestRenderTemplateAsKindBuildsResourceFromDefaults(t *testing.T) { }, } - got, err := renderTemplateAsKind(templateSpec, &topic) + got, err := RenderTemplateAsKind(templateSpec, "Topic", 2) if err != nil { t.Fatalf("unexpected error: %s", err) } From 8e748b64a20ca00bf1dde63a9ada8616f6eff313 Mon Sep 17 00:00:00 2001 From: Romain Lecomte Date: Wed, 20 May 2026 11:31:25 +0200 Subject: [PATCH 6/8] Template cmd: Add --interactive flag to pick server-side template Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/template.go | 51 +++++++++++++++++++++++++++++++ pkg/client/console_client.go | 16 ++++++++++ pkg/client/console_client_test.go | 45 +++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/cmd/template.go b/cmd/template.go index d9682e2..63271ff 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -4,6 +4,8 @@ import ( "bufio" "fmt" "os" + "strconv" + "strings" "github.com/conduktor/ctl/internal/cli" "github.com/conduktor/ctl/internal/printutils" @@ -27,9 +29,11 @@ func initTemplate(rootContext cli.RootContext) { var file *string var edit *bool var apply *bool + var interactive *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.") // Add all kinds to the 'template' command for name, kind := range rootContext.Catalog.Kind { @@ -48,6 +52,10 @@ 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) + } }, Run: func(cmd *cobra.Command, args []string) { var example string @@ -58,6 +66,17 @@ func initTemplate(rootContext cli.RootContext) { 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() } @@ -142,6 +161,38 @@ func fetchTemplateByName(rootContext cli.RootContext, kindName, templateName str return printutils.RenderTemplateAsKind(res.Spec, baseKind.GetName(), baseKind.MaxVersion()) } +// 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/pkg/client/console_client.go b/pkg/client/console_client.go index 34dbf0c..8ef17ac 100644 --- a/pkg/client/console_client.go +++ b/pkg/client/console_client.go @@ -366,6 +366,22 @@ 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). diff --git a/pkg/client/console_client_test.go b/pkg/client/console_client_test.go index e86266e..2c1c835 100644 --- a/pkg/client/console_client_test.go +++ b/pkg/client/console_client_test.go @@ -529,6 +529,51 @@ func TestGetTemplateShouldFailIfNo2xx(t *testing.T) { } } +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" From baa7b0bc1016f397e3d47481268295f1b0f52273 Mon Sep 17 00:00:00 2001 From: Romain Lecomte Date: Wed, 20 May 2026 11:35:37 +0200 Subject: [PATCH 7/8] Template cmd: Add --list flag to print server-side template names Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/template.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/cmd/template.go b/cmd/template.go index 63271ff..4b42ce5 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -30,10 +30,12 @@ func initTemplate(rootContext cli.RootContext) { 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 { @@ -56,8 +58,23 @@ func initTemplate(rootContext cli.RootContext) { 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) { + 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 @@ -161,6 +178,22 @@ func fetchTemplateByName(rootContext cli.RootContext, kindName, templateName str 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) { From e7665acca0f38f897f1c5858924b168bbe0fd9ca Mon Sep 17 00:00:00 2001 From: Romain Lecomte Date: Wed, 20 May 2026 11:46:12 +0200 Subject: [PATCH 8/8] Docs: Document template --list and --interactive flags Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 ++++++ docs/README.md | 10 +++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1317403..00d6dae 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,12 @@ You can also fetch a server-side template curated in Console by passing its name 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/docs/README.md b/docs/README.md index 7398ede..4b0776d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -134,7 +134,7 @@ 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. +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 @@ -145,6 +145,8 @@ conduktor template [template-name] - `-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 @@ -159,6 +161,12 @@ 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