From 736fd7175a066fba78f574d678fa7f72ba03a1de Mon Sep 17 00:00:00 2001 From: omattsson Date: Fri, 15 May 2026 21:05:57 +0200 Subject: [PATCH 1/3] feat(template): add create, update, and clone subcommands Implements issue #60. - Add CreateTemplateRequest, UpdateTemplateRequest, CloneTemplateRequest types - Add CreateTemplate, UpdateTemplate, CloneTemplate client methods - Add template create (--name/--description/--from-file) - Add template update (--name/--description/--from-file) - Add template clone --name (required) - Extract printTemplate() helper to remove duplicated output logic - Path traversal protection on --from-file for create and update - Empty-name guard on clone (Cobra required only checks flag presence) Tests: 26 unit tests (cmd + client), 6 integration tests, 8 E2E tests --- cli/cmd/template.go | 241 +++++++- cli/cmd/template_test.go | 532 ++++++++++++++++++ cli/pkg/client/client.go | 38 +- cli/pkg/client/client_test.go | 137 +++++ cli/pkg/types/types.go | 57 +- cli/test/e2e/cli_e2e_test.go | 174 ++++++ .../template_definition_integration_test.go | 193 +++++++ 7 files changed, 1319 insertions(+), 53 deletions(-) diff --git a/cli/cmd/template.go b/cli/cmd/template.go index 286d30b..3a1dc13 100644 --- a/cli/cmd/template.go +++ b/cli/cmd/template.go @@ -1,8 +1,13 @@ package cmd import ( + "encoding/json" + "errors" "fmt" + "os" + "path/filepath" "strconv" + "strings" "github.com/omattsson/stackctl/cli/pkg/client" "github.com/omattsson/stackctl/cli/pkg/output" @@ -117,36 +122,7 @@ Examples: return err } - if printer.Quiet { - fmt.Fprintln(printer.Writer, tmpl.ID) - return nil - } - - switch printer.Format { - case output.FormatJSON: - return printer.PrintJSON(tmpl) - case output.FormatYAML: - return printer.PrintYAML(tmpl) - default: - published := "false" - if tmpl.Published { - published = "true" - } - fields := []output.KeyValue{ - {Key: "ID", Value: tmpl.ID}, - {Key: "Name", Value: tmpl.Name}, - {Key: "Description", Value: tmpl.Description}, - {Key: "Published", Value: published}, - {Key: "Owner", Value: tmpl.Owner}, - } - for _, ch := range tmpl.Charts { - fields = append(fields, output.KeyValue{ - Key: "Chart", - Value: fmt.Sprintf("%s (%s@%s)", ch.ChartName, ch.RepoURL, ch.ChartVersion), - }) - } - return printer.PrintSingle(tmpl, fields) - } + return printTemplate(tmpl) }, } @@ -253,6 +229,194 @@ Examples: }, } +var templateCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new stack template", + Long: `Create a new stack template from flags or a JSON file. + +Examples: + stackctl template create --name my-template --description "My template" + stackctl template create --from-file template.json`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + fromFile, _ := cmd.Flags().GetString(flagFromFile) + + var req types.CreateTemplateRequest + if fromFile != "" { + for _, segment := range strings.Split(filepath.ToSlash(fromFile), "/") { + if segment == ".." { + return errors.New(msgPathTraversal) + } + } + fromFile = filepath.Clean(fromFile) + data, err := os.ReadFile(fromFile) + if err != nil { + return readFileErr(fromFile, err) + } + if err := json.Unmarshal(data, &req); err != nil { + return fmt.Errorf("invalid JSON in file %s: %w", fromFile, err) + } + if req.Name == "" { + return fmt.Errorf("'name' field is required in the template file") + } + } else { + name, _ := cmd.Flags().GetString("name") + if name == "" { + return fmt.Errorf("--name is required (or use --from-file)") + } + description, _ := cmd.Flags().GetString("description") + req = types.CreateTemplateRequest{ + Name: name, + Description: description, + } + } + + c, err := newClient() + if err != nil { + return err + } + + tmpl, err := c.CreateTemplate(&req) + if err != nil { + return err + } + + return printTemplate(tmpl) + }, +} + +var templateUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a stack template", + Long: `Update an existing stack template from flags or a JSON file. + +Examples: + stackctl template update 1 --name new-name + stackctl template update 1 --description "Updated description" + stackctl template update 1 --from-file template.json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + fromFile, _ := cmd.Flags().GetString(flagFromFile) + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + + if fromFile == "" && name == "" && description == "" { + return fmt.Errorf("at least one of --name, --description, or --from-file must be specified") + } + + var req types.UpdateTemplateRequest + if fromFile != "" { + for _, segment := range strings.Split(filepath.ToSlash(fromFile), "/") { + if segment == ".." { + return errors.New(msgPathTraversal) + } + } + fromFile = filepath.Clean(fromFile) + data, err := os.ReadFile(fromFile) + if err != nil { + return readFileErr(fromFile, err) + } + if err := json.Unmarshal(data, &req); err != nil { + return fmt.Errorf("invalid JSON in file %s: %w", fromFile, err) + } + } else { + if name != "" { + req.Name = name + } + if description != "" { + req.Description = description + } + } + + c, err := newClient() + if err != nil { + return err + } + + tmpl, err := c.UpdateTemplate(id, &req) + if err != nil { + return err + } + + return printTemplate(tmpl) + }, +} + +var templateCloneCmd = &cobra.Command{ + Use: "clone ", + Short: "Clone a stack template", + Long: `Clone an existing stack template with a new name. + +Examples: + stackctl template clone 1 --name my-clone + stackctl template clone 1 --name my-clone -o json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + if name == "" { + return fmt.Errorf("--name must not be empty") + } + + c, err := newClient() + if err != nil { + return err + } + + tmpl, err := c.CloneTemplate(id, &types.CloneTemplateRequest{Name: name}) + if err != nil { + return err + } + + return printTemplate(tmpl) + }, +} + +// printTemplate outputs a StackTemplate in the active format. +func printTemplate(tmpl *types.StackTemplate) error { + if printer.Quiet { + fmt.Fprintln(printer.Writer, tmpl.ID) + return nil + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(tmpl) + case output.FormatYAML: + return printer.PrintYAML(tmpl) + default: + published := "false" + if tmpl.Published { + published = "true" + } + fields := []output.KeyValue{ + {Key: "ID", Value: tmpl.ID}, + {Key: "Name", Value: tmpl.Name}, + {Key: "Description", Value: tmpl.Description}, + {Key: "Published", Value: published}, + {Key: "Owner", Value: tmpl.Owner}, + } + for _, ch := range tmpl.Charts { + fields = append(fields, output.KeyValue{ + Key: "Chart", + Value: fmt.Sprintf("%s (%s@%s)", ch.ChartName, ch.RepoURL, ch.ChartVersion), + }) + } + return printer.PrintSingle(tmpl, fields) + } +} + func init() { // template list flags templateListCmd.Flags().Bool("published", false, "Show only published templates") @@ -275,11 +439,28 @@ func init() { templateDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") templateDeleteCmd.Flags().Bool("dry-run", false, "Show what would happen without executing") + // template create flags + templateCreateCmd.Flags().String("name", "", "Template name (required unless --from-file is used)") + templateCreateCmd.Flags().String("description", "", "Template description") + templateCreateCmd.Flags().String(flagFromFile, "", "Path to a JSON file containing the template definition") + + // template update flags + templateUpdateCmd.Flags().String("name", "", "New template name") + templateUpdateCmd.Flags().String("description", "", "New template description") + templateUpdateCmd.Flags().String(flagFromFile, "", "Path to a JSON file with updated template fields") + + // template clone flags + templateCloneCmd.Flags().String("name", "", "Name for the cloned template (required)") + _ = templateCloneCmd.MarkFlagRequired("name") + // Wire up subcommands templateCmd.AddCommand(templateListCmd) templateCmd.AddCommand(templateGetCmd) templateCmd.AddCommand(templateInstantiateCmd) templateCmd.AddCommand(templateQuickDeployCmd) templateCmd.AddCommand(templateDeleteCmd) + templateCmd.AddCommand(templateCreateCmd) + templateCmd.AddCommand(templateUpdateCmd) + templateCmd.AddCommand(templateCloneCmd) rootCmd.AddCommand(templateCmd) } diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go index 14b4c21..7135da6 100644 --- a/cli/cmd/template_test.go +++ b/cli/cmd/template_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" @@ -647,3 +648,534 @@ func TestTemplateDeleteCmd_QuietOutput(t *testing.T) { require.NoError(t, err) assert.Equal(t, "10\n", buf.String()) } + +// ---------- template create ---------- + +func TestTemplateCreateCmd_WithNameFlag(t *testing.T) { + created := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/templates", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + var body types.CreateTemplateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "my-template", body.Name) + assert.Equal(t, "A template", body.Description) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(created) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + templateCreateCmd.Flags().Set("name", "my-template") + templateCreateCmd.Flags().Set("description", "A template") + t.Cleanup(func() { + templateCreateCmd.Flags().Set("name", "") + templateCreateCmd.Flags().Set("description", "") + templateCreateCmd.Flags().Set("from-file", "") + }) + + err := templateCreateCmd.RunE(templateCreateCmd, []string{}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "10") + assert.Contains(t, out, "web-app-template") +} + +func TestTemplateCreateCmd_JSONOutput(t *testing.T) { + created := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(created) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + templateCreateCmd.Flags().Set("name", "my-template") + t.Cleanup(func() { + templateCreateCmd.Flags().Set("name", "") + templateCreateCmd.Flags().Set("description", "") + templateCreateCmd.Flags().Set("from-file", "") + }) + + err := templateCreateCmd.RunE(templateCreateCmd, []string{}) + require.NoError(t, err) + + var result types.StackTemplate + require.NoError(t, json.Unmarshal([]byte(buf.String()), &result)) + assert.Equal(t, "10", result.ID) + assert.Equal(t, "web-app-template", result.Name) +} + +func TestTemplateCreateCmd_YAMLOutput(t *testing.T) { + created := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(created) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatYAML + templateCreateCmd.Flags().Set("name", "my-template") + t.Cleanup(func() { + templateCreateCmd.Flags().Set("name", "") + templateCreateCmd.Flags().Set("description", "") + templateCreateCmd.Flags().Set("from-file", "") + }) + + err := templateCreateCmd.RunE(templateCreateCmd, []string{}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "web-app-template") +} + +func TestTemplateCreateCmd_QuietOutput(t *testing.T) { + created := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(created) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + templateCreateCmd.Flags().Set("name", "my-template") + t.Cleanup(func() { + templateCreateCmd.Flags().Set("name", "") + templateCreateCmd.Flags().Set("description", "") + templateCreateCmd.Flags().Set("from-file", "") + }) + + err := templateCreateCmd.RunE(templateCreateCmd, []string{}) + require.NoError(t, err) + assert.Equal(t, "10\n", buf.String()) +} + +func TestTemplateCreateCmd_FromFile(t *testing.T) { + tmpFile := t.TempDir() + "/template.json" + payload := `{"name":"file-template","description":"from file"}` + require.NoError(t, os.WriteFile(tmpFile, []byte(payload), 0o600)) + + created := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body types.CreateTemplateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "file-template", body.Name) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(created) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + templateCreateCmd.Flags().Set("from-file", tmpFile) + t.Cleanup(func() { + templateCreateCmd.Flags().Set("name", "") + templateCreateCmd.Flags().Set("description", "") + templateCreateCmd.Flags().Set("from-file", "") + }) + + err := templateCreateCmd.RunE(templateCreateCmd, []string{}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "10") +} + +func TestTemplateCreateCmd_MissingName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should not be called when --name is missing") + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + templateCreateCmd.Flags().Set("name", "") + templateCreateCmd.Flags().Set("from-file", "") + t.Cleanup(func() { + templateCreateCmd.Flags().Set("name", "") + templateCreateCmd.Flags().Set("description", "") + templateCreateCmd.Flags().Set("from-file", "") + }) + + err := templateCreateCmd.RunE(templateCreateCmd, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--name is required") +} + +func TestTemplateCreateCmd_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "internal server error"}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + templateCreateCmd.Flags().Set("name", "my-template") + t.Cleanup(func() { + templateCreateCmd.Flags().Set("name", "") + templateCreateCmd.Flags().Set("description", "") + templateCreateCmd.Flags().Set("from-file", "") + }) + + err := templateCreateCmd.RunE(templateCreateCmd, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "internal server error") +} + +// ---------- template update ---------- + +func TestTemplateUpdateCmd_WithNameFlag(t *testing.T) { + updated := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/templates/10", r.URL.Path) + require.Equal(t, http.MethodPut, r.Method) + + var body types.UpdateTemplateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "new-name", body.Name) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(updated) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + templateUpdateCmd.Flags().Set("name", "new-name") + t.Cleanup(func() { + templateUpdateCmd.Flags().Set("name", "") + templateUpdateCmd.Flags().Set("description", "") + templateUpdateCmd.Flags().Set("from-file", "") + }) + + err := templateUpdateCmd.RunE(templateUpdateCmd, []string{"10"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "10") + assert.Contains(t, out, "web-app-template") +} + +func TestTemplateUpdateCmd_JSONOutput(t *testing.T) { + updated := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(updated) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + templateUpdateCmd.Flags().Set("name", "new-name") + t.Cleanup(func() { + templateUpdateCmd.Flags().Set("name", "") + templateUpdateCmd.Flags().Set("description", "") + templateUpdateCmd.Flags().Set("from-file", "") + }) + + err := templateUpdateCmd.RunE(templateUpdateCmd, []string{"10"}) + require.NoError(t, err) + + var result types.StackTemplate + require.NoError(t, json.Unmarshal([]byte(buf.String()), &result)) + assert.Equal(t, "10", result.ID) +} + +func TestTemplateUpdateCmd_QuietOutput(t *testing.T) { + updated := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(updated) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + templateUpdateCmd.Flags().Set("name", "new-name") + t.Cleanup(func() { + templateUpdateCmd.Flags().Set("name", "") + templateUpdateCmd.Flags().Set("description", "") + templateUpdateCmd.Flags().Set("from-file", "") + }) + + err := templateUpdateCmd.RunE(templateUpdateCmd, []string{"10"}) + require.NoError(t, err) + assert.Equal(t, "10\n", buf.String()) +} + +func TestTemplateUpdateCmd_MissingFlags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should not be called when no update flags provided") + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + templateUpdateCmd.Flags().Set("name", "") + templateUpdateCmd.Flags().Set("description", "") + templateUpdateCmd.Flags().Set("from-file", "") + t.Cleanup(func() { + templateUpdateCmd.Flags().Set("name", "") + templateUpdateCmd.Flags().Set("description", "") + templateUpdateCmd.Flags().Set("from-file", "") + }) + + err := templateUpdateCmd.RunE(templateUpdateCmd, []string{"10"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one of") +} + +func TestTemplateUpdateCmd_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "template not found"}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + templateUpdateCmd.Flags().Set("name", "new-name") + t.Cleanup(func() { + templateUpdateCmd.Flags().Set("name", "") + templateUpdateCmd.Flags().Set("description", "") + templateUpdateCmd.Flags().Set("from-file", "") + }) + + err := templateUpdateCmd.RunE(templateUpdateCmd, []string{"999"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} + +// ---------- template clone ---------- + +func TestTemplateCloneCmd_Success(t *testing.T) { + cloned := sampleTemplate() + cloned.ID = "20" + cloned.Name = "my-clone" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/templates/10/clone", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + var body types.CloneTemplateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "my-clone", body.Name) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(cloned) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + templateCloneCmd.Flags().Set("name", "my-clone") + t.Cleanup(func() { + templateCloneCmd.Flags().Set("name", "") + }) + + err := templateCloneCmd.RunE(templateCloneCmd, []string{"10"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "20") + assert.Contains(t, out, "my-clone") +} + +func TestTemplateCloneCmd_JSONOutput(t *testing.T) { + cloned := sampleTemplate() + cloned.ID = "20" + cloned.Name = "my-clone" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(cloned) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + templateCloneCmd.Flags().Set("name", "my-clone") + t.Cleanup(func() { + templateCloneCmd.Flags().Set("name", "") + }) + + err := templateCloneCmd.RunE(templateCloneCmd, []string{"10"}) + require.NoError(t, err) + + var result types.StackTemplate + require.NoError(t, json.Unmarshal([]byte(buf.String()), &result)) + assert.Equal(t, "20", result.ID) + assert.Equal(t, "my-clone", result.Name) +} + +func TestTemplateCloneCmd_QuietOutput(t *testing.T) { + cloned := sampleTemplate() + cloned.ID = "20" + cloned.Name = "my-clone" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(cloned) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + templateCloneCmd.Flags().Set("name", "my-clone") + t.Cleanup(func() { + templateCloneCmd.Flags().Set("name", "") + }) + + err := templateCloneCmd.RunE(templateCloneCmd, []string{"10"}) + require.NoError(t, err) + assert.Equal(t, "20\n", buf.String()) +} + +func TestTemplateCloneCmd_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "template not found"}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + templateCloneCmd.Flags().Set("name", "my-clone") + t.Cleanup(func() { + templateCloneCmd.Flags().Set("name", "") + }) + + err := templateCloneCmd.RunE(templateCloneCmd, []string{"999"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} + +// ---------- template create – path traversal ---------- + +func TestTemplateCreateCmd_FromFilePathTraversal(t *testing.T) { + _ = setupStackTestCmd(t, "http://127.0.0.1:1") // no server needed + templateCreateCmd.Flags().Set("from-file", "../../etc/passwd") + t.Cleanup(func() { + templateCreateCmd.Flags().Set("from-file", "") + }) + + err := templateCreateCmd.RunE(templateCreateCmd, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "..") +} + +func TestTemplateUpdateCmd_FromFilePathTraversal(t *testing.T) { + _ = setupStackTestCmd(t, "http://127.0.0.1:1") + templateUpdateCmd.Flags().Set("from-file", "../../etc/passwd") + t.Cleanup(func() { + templateUpdateCmd.Flags().Set("from-file", "") + }) + + err := templateUpdateCmd.RunE(templateUpdateCmd, []string{"1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "..") +} + +// ---------- template update – from-file ---------- + +func TestTemplateUpdateCmd_FromFile(t *testing.T) { + updated := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/templates/10", r.URL.Path) + require.Equal(t, http.MethodPut, r.Method) + + var body types.UpdateTemplateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "from-file-name", body.Name) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(updated) + })) + defer server.Close() + + tmpFile, err := os.CreateTemp(t.TempDir(), "tmpl-*.json") + require.NoError(t, err) + _, err = tmpFile.WriteString(`{"name":"from-file-name"}`) + require.NoError(t, err) + require.NoError(t, tmpFile.Close()) + + buf := setupStackTestCmd(t, server.URL) + templateUpdateCmd.Flags().Set("from-file", tmpFile.Name()) + t.Cleanup(func() { + templateUpdateCmd.Flags().Set("name", "") + templateUpdateCmd.Flags().Set("description", "") + templateUpdateCmd.Flags().Set("from-file", "") + }) + + err = templateUpdateCmd.RunE(templateUpdateCmd, []string{"10"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "web-app-template") +} + +// ---------- template update – YAML output ---------- + +func TestTemplateUpdateCmd_YAMLOutput(t *testing.T) { + updated := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(updated) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatYAML + templateUpdateCmd.Flags().Set("name", "updated-name") + t.Cleanup(func() { + templateUpdateCmd.Flags().Set("name", "") + templateUpdateCmd.Flags().Set("description", "") + templateUpdateCmd.Flags().Set("from-file", "") + }) + + err := templateUpdateCmd.RunE(templateUpdateCmd, []string{"10"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "web-app-template") +} + +// ---------- template clone – YAML output ---------- + +func TestTemplateCloneCmd_YAMLOutput(t *testing.T) { + cloned := sampleTemplate() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(cloned) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatYAML + templateCloneCmd.Flags().Set("name", "my-clone") + t.Cleanup(func() { + templateCloneCmd.Flags().Set("name", "") + }) + + err := templateCloneCmd.RunE(templateCloneCmd, []string{"10"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "web-app-template") +} + +// ---------- template clone – empty name guard ---------- + +func TestTemplateCloneCmd_EmptyName(t *testing.T) { + _ = setupStackTestCmd(t, "http://127.0.0.1:1") + // --name set to empty string to bypass Cobra's required check + templateCloneCmd.Flags().Set("name", "") + t.Cleanup(func() { + templateCloneCmd.Flags().Set("name", "") + }) + + err := templateCloneCmd.RunE(templateCloneCmd, []string{"1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--name must not be empty") +} diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index 28b47b9..c418bd5 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -210,10 +210,10 @@ func (c *Client) do(method, path string, body interface{}) (*http.Response, erro } var retryableStatuses = map[int]bool{ - http.StatusTooManyRequests: true, - http.StatusBadGateway: true, - http.StatusServiceUnavailable: true, - http.StatusGatewayTimeout: true, + http.StatusTooManyRequests: true, + http.StatusBadGateway: true, + http.StatusServiceUnavailable: true, + http.StatusGatewayTimeout: true, } var idempotentMethods = map[string]bool{ @@ -608,6 +608,36 @@ func (c *Client) DeleteTemplate(id string) error { return c.Delete(fmt.Sprintf(pathTemplate, id)) } +// CreateTemplate creates a new stack template. +func (c *Client) CreateTemplate(req *types.CreateTemplateRequest) (*types.StackTemplate, error) { + var tmpl types.StackTemplate + err := c.Post("/api/v1/templates", req, &tmpl) + if err != nil { + return nil, err + } + return &tmpl, nil +} + +// UpdateTemplate updates an existing stack template by ID. +func (c *Client) UpdateTemplate(id string, req *types.UpdateTemplateRequest) (*types.StackTemplate, error) { + var tmpl types.StackTemplate + err := c.Put(fmt.Sprintf(pathTemplate, id), req, &tmpl) + if err != nil { + return nil, err + } + return &tmpl, nil +} + +// CloneTemplate clones a stack template by ID. +func (c *Client) CloneTemplate(id string, req *types.CloneTemplateRequest) (*types.StackTemplate, error) { + var tmpl types.StackTemplate + err := c.Post(fmt.Sprintf(pathTemplate+"/clone", id), req, &tmpl) + if err != nil { + return nil, err + } + return &tmpl, nil +} + // ListOrphanedNamespaces returns namespaces that have the stack-manager label but no matching DB record. func (c *Client) ListOrphanedNamespaces() ([]types.OrphanedNamespace, error) { var ns []types.OrphanedNamespace diff --git a/cli/pkg/client/client_test.go b/cli/pkg/client/client_test.go index db24ff4..41632bd 100644 --- a/cli/pkg/client/client_test.go +++ b/cli/pkg/client/client_test.go @@ -2679,3 +2679,140 @@ func TestCLIToken_Completed(t *testing.T) { assert.Equal(t, "sso-user", resp.Username) assert.Equal(t, "42", resp.UserID) } + +func TestCreateTemplate_Success(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/templates", r.URL.Path) + + var body types.CreateTemplateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "new-template", body.Name) + assert.Equal(t, "My new template", body.Description) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.StackTemplate{ + Base: types.Base{ID: "55"}, + Name: "new-template", + Description: "My new template", + Owner: "admin", + }) + })) + defer server.Close() + + c := New(server.URL) + tmpl, err := c.CreateTemplate(&types.CreateTemplateRequest{ + Name: "new-template", + Description: "My new template", + }) + require.NoError(t, err) + assert.Equal(t, "55", tmpl.ID) + assert.Equal(t, "new-template", tmpl.Name) +} + +func TestCreateTemplate_ServerError(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "internal server error"}) + })) + defer server.Close() + + c := New(server.URL) + tmpl, err := c.CreateTemplate(&types.CreateTemplateRequest{Name: "x"}) + require.Error(t, err) + assert.Nil(t, tmpl) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, http.StatusInternalServerError, apiErr.StatusCode) +} + +func TestUpdateTemplate_Success(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/api/v1/templates/10", r.URL.Path) + + var body types.UpdateTemplateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "updated-name", body.Name) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.StackTemplate{ + Base: types.Base{ID: "10"}, + Name: "updated-name", + Owner: "admin", + }) + })) + defer server.Close() + + c := New(server.URL) + tmpl, err := c.UpdateTemplate("10", &types.UpdateTemplateRequest{Name: "updated-name"}) + require.NoError(t, err) + assert.Equal(t, "10", tmpl.ID) + assert.Equal(t, "updated-name", tmpl.Name) +} + +func TestUpdateTemplate_NotFound(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "template not found"}) + })) + defer server.Close() + + c := New(server.URL) + tmpl, err := c.UpdateTemplate("999", &types.UpdateTemplateRequest{Name: "x"}) + require.Error(t, err) + assert.Nil(t, tmpl) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, http.StatusNotFound, apiErr.StatusCode) +} + +func TestCloneTemplate_Success(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/templates/10/clone", r.URL.Path) + + var body types.CloneTemplateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "cloned-template", body.Name) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.StackTemplate{ + Base: types.Base{ID: "20"}, + Name: "cloned-template", + Owner: "admin", + }) + })) + defer server.Close() + + c := New(server.URL) + tmpl, err := c.CloneTemplate("10", &types.CloneTemplateRequest{Name: "cloned-template"}) + require.NoError(t, err) + assert.Equal(t, "20", tmpl.ID) + assert.Equal(t, "cloned-template", tmpl.Name) +} + +func TestCloneTemplate_NotFound(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "template not found"}) + })) + defer server.Close() + + c := New(server.URL) + tmpl, err := c.CloneTemplate("999", &types.CloneTemplateRequest{Name: "x"}) + require.Error(t, err) + assert.Nil(t, tmpl) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, http.StatusNotFound, apiErr.StatusCode) +} diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index 695471e..31f4d30 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -7,24 +7,24 @@ import ( // Base fields shared by all API resources. type Base struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id" yaml:"id"` CreatedAt time.Time `json:"created_at" yaml:"created_at"` UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty" yaml:"deleted_at,omitempty"` - Version string `json:"version" yaml:"version"` + Version string `json:"version" yaml:"version"` } // StackInstance represents a deployed stack instance. type StackInstance struct { Base Name string `json:"name" yaml:"name"` - StackDefinitionID string `json:"stack_definition_id" yaml:"stack_definition_id"` + StackDefinitionID string `json:"stack_definition_id" yaml:"stack_definition_id"` DefinitionName string `json:"definition_name,omitempty" yaml:"definition_name,omitempty"` Owner string `json:"owner_id" yaml:"owner_id"` Branch string `json:"branch" yaml:"branch"` Namespace string `json:"namespace" yaml:"namespace"` Status string `json:"status" yaml:"status"` - ClusterID *string `json:"cluster_id,omitempty" yaml:"cluster_id,omitempty"` + ClusterID *string `json:"cluster_id,omitempty" yaml:"cluster_id,omitempty"` ClusterName string `json:"cluster_name,omitempty" yaml:"cluster_name,omitempty"` TTLMinutes int `json:"ttl_minutes,omitempty" yaml:"ttl_minutes,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty" yaml:"expires_at,omitempty"` @@ -44,10 +44,10 @@ type StackDefinition struct { // StackTemplate represents a reusable stack template. type StackTemplate struct { Base - Name string `json:"name" yaml:"name"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Published bool `json:"is_published" yaml:"is_published"` - Owner string `json:"owner_id" yaml:"owner_id"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Published bool `json:"is_published" yaml:"is_published"` + Owner string `json:"owner_id" yaml:"owner_id"` Charts []ChartConfig `json:"charts,omitempty" yaml:"charts,omitempty"` DefinitionCount int `json:"definition_count,omitempty" yaml:"definition_count,omitempty"` } @@ -86,9 +86,9 @@ type User struct { // namespace, status are excluded to avoid backend validation errors. type CreateStackRequest struct { Name string `json:"name" yaml:"name"` - StackDefinitionID string `json:"stack_definition_id" yaml:"stack_definition_id"` + StackDefinitionID string `json:"stack_definition_id" yaml:"stack_definition_id"` Branch string `json:"branch,omitempty" yaml:"branch,omitempty"` - ClusterID string `json:"cluster_id,omitempty" yaml:"cluster_id,omitempty"` + ClusterID string `json:"cluster_id,omitempty" yaml:"cluster_id,omitempty"` TTLMinutes int `json:"ttl_minutes,omitempty" yaml:"ttl_minutes,omitempty"` } @@ -160,7 +160,7 @@ type ListResponse[T any] struct { // BulkOperationResult represents the result of a bulk operation. type BulkOperationResult struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id" yaml:"id"` Success bool `json:"success" yaml:"success"` Error string `json:"error,omitempty" yaml:"error,omitempty"` } @@ -173,16 +173,16 @@ type BulkResponse struct { // ValueOverride represents a per-chart value override. type ValueOverride struct { Base - InstanceID string `json:"instance_id" yaml:"instance_id"` - ChartID string `json:"chart_id" yaml:"chart_id"` + InstanceID string `json:"instance_id" yaml:"instance_id"` + ChartID string `json:"chart_id" yaml:"chart_id"` Values string `json:"values" yaml:"values"` } // BranchOverride represents a per-chart branch override. type BranchOverride struct { Base - InstanceID string `json:"instance_id" yaml:"instance_id"` - ChartID string `json:"chart_id" yaml:"chart_id"` + InstanceID string `json:"instance_id" yaml:"instance_id"` + ChartID string `json:"chart_id" yaml:"chart_id"` Branch string `json:"branch" yaml:"branch"` } @@ -196,7 +196,7 @@ type GitBranch struct { type InstantiateTemplateRequest struct { Name string `json:"name" yaml:"name"` Branch string `json:"branch,omitempty" yaml:"branch,omitempty"` - ClusterID string `json:"cluster_id,omitempty" yaml:"cluster_id,omitempty"` + ClusterID string `json:"cluster_id,omitempty" yaml:"cluster_id,omitempty"` } // QuickDeployRequest is the request body for POST /api/v1/templates/:id/quick-deploy. @@ -288,7 +288,7 @@ type InstanceStatus struct { // QuotaOverride represents a per-instance resource quota override. // Unlike other override types, the API returns quota overrides without standard Base fields (ID, Version). type QuotaOverride struct { - InstanceID string `json:"instance_id" yaml:"instance_id"` + InstanceID string `json:"instance_id" yaml:"instance_id"` CPURequest string `json:"cpu_request,omitempty" yaml:"cpu_request,omitempty"` CPULimit string `json:"cpu_limit,omitempty" yaml:"cpu_limit,omitempty"` MemRequest string `json:"memory_request,omitempty" yaml:"memory_request,omitempty"` @@ -317,7 +317,7 @@ type SetQuotaOverrideRequest struct { // MergedValues represents the merged Helm values for an instance. type MergedValues struct { - InstanceID string `json:"instance_id" yaml:"instance_id"` + InstanceID string `json:"instance_id" yaml:"instance_id"` Charts map[string]map[string]interface{} `json:"charts" yaml:"charts"` } @@ -358,7 +358,7 @@ type CLITokenRequest struct { // CLITokenResponse is returned by POST /api/v1/auth/oidc/cli-token. type CLITokenResponse struct { - Status string `json:"status"` // "pending" or "completed" + Status string `json:"status"` // "pending" or "completed" Token string `json:"token,omitempty"` Username string `json:"username,omitempty"` UserID string `json:"user_id,omitempty"` @@ -397,3 +397,22 @@ type CompareResult struct { Right *StackInstance `json:"right" yaml:"right"` Diffs map[string]interface{} `json:"diffs,omitempty" yaml:"diffs,omitempty"` } + +// CreateTemplateRequest is the request body for POST /api/v1/templates. +type CreateTemplateRequest struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Charts []ChartConfig `json:"charts,omitempty" yaml:"charts,omitempty"` +} + +// UpdateTemplateRequest is the request body for PUT /api/v1/templates/:id. +type UpdateTemplateRequest struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Charts []ChartConfig `json:"charts,omitempty" yaml:"charts,omitempty"` +} + +// CloneTemplateRequest is the request body for POST /api/v1/templates/:id/clone. +type CloneTemplateRequest struct { + Name string `json:"name" yaml:"name"` +} diff --git a/cli/test/e2e/cli_e2e_test.go b/cli/test/e2e/cli_e2e_test.go index 6fdce78..06e413d 100644 --- a/cli/test/e2e/cli_e2e_test.go +++ b/cli/test/e2e/cli_e2e_test.go @@ -760,6 +760,61 @@ func startE2ETemplateDefMockServer(t *testing.T) *httptest.Server { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(exportData) + // Create template + case r.URL.Path == "/api/v1/templates" && r.Method == http.MethodPost: + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid body"}) + return + } + name, _ := req["name"].(string) + description, _ := req["description"].(string) + resp := map[string]interface{}{ + "id": "99", "name": name, "description": description, + "published": false, "owner": "admin", "charts": []interface{}{}, + "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "version": "1", + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(resp) + + // Update template + case r.URL.Path == "/api/v1/templates/1" && r.Method == http.MethodPut: + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid body"}) + return + } + name := "web-template" + if n, ok := req["name"].(string); ok && n != "" { + name = n + } + resp := map[string]interface{}{ + "id": "1", "name": name, "description": "Updated", + "published": true, "owner": "admin", "charts": []interface{}{}, + "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "version": "2", + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + + // Clone template + case r.URL.Path == "/api/v1/templates/1/clone" && r.Method == http.MethodPost: + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid body"}) + return + } + cloneName, _ := req["name"].(string) + resp := map[string]interface{}{ + "id": "100", "name": cloneName, "description": "Web app stack", + "published": false, "owner": "admin", "charts": []interface{}{}, + "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "version": "1", + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(resp) + default: w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) @@ -1181,3 +1236,122 @@ func TestE2E_QuietOutputFormat(t *testing.T) { assert.NotContains(t, stdout, "ID") assert.NotContains(t, stdout, "NAME") } + +func TestE2E_TemplateCreateFromFile(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + // Write template JSON to temp file + tmplFile := dir + "/template.json" + payload := `{"name":"my-new-template","description":"Created from file"}` + require.NoError(t, os.WriteFile(tmplFile, []byte(payload), 0o600)) + + stdout, _, err := runStackctl(t, dir, "template", "create", "--from-file", tmplFile) + require.NoError(t, err) + assert.Contains(t, stdout, "my-new-template") + assert.Contains(t, stdout, "99") +} + +func TestE2E_TemplateCreateWithFlags(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + stdout, _, err := runStackctl(t, dir, "template", "create", "--name", "flag-template", "--description", "via flags") + require.NoError(t, err) + assert.Contains(t, stdout, "flag-template") +} + +func TestE2E_TemplateCreateMissingName(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + _, stderr, err := runStackctl(t, dir, "template", "create") + require.Error(t, err) + assert.Contains(t, stderr, "--name is required") +} + +func TestE2E_TemplateUpdate(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + stdout, _, err := runStackctl(t, dir, "template", "update", "1", "--name", "renamed-template") + require.NoError(t, err) + assert.Contains(t, stdout, "renamed-template") +} + +func TestE2E_TemplateUpdateMissingFlags(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + _, stderr, err := runStackctl(t, dir, "template", "update", "1") + require.Error(t, err) + assert.Contains(t, stderr, "at least one of") +} + +func TestE2E_TemplateClone(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + stdout, _, err := runStackctl(t, dir, "template", "clone", "1", "--name", "my-clone") + require.NoError(t, err) + assert.Contains(t, stdout, "my-clone") + assert.Contains(t, stdout, "100") +} + +func TestE2E_TemplateClone_QuietOutput(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + stdout, _, err := runStackctl(t, dir, "template", "clone", "1", "--name", "my-clone", "--quiet") + require.NoError(t, err) + assert.Equal(t, "100\n", stdout) +} diff --git a/cli/test/integration/template_definition_integration_test.go b/cli/test/integration/template_definition_integration_test.go index 9520c32..4499410 100644 --- a/cli/test/integration/template_definition_integration_test.go +++ b/cli/test/integration/template_definition_integration_test.go @@ -79,6 +79,29 @@ func startTemplateDefMockServer(t *testing.T, state *templateDefMockState) *http Data: data, Total: len(data), Page: 1, PageSize: 20, TotalPages: 1, }) return + + case r.URL.Path == "/api/v1/templates" && r.Method == http.MethodPost: + var req types.CreateTemplateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "invalid body"}) + return + } + state.mu.Lock() + nextID := len(state.templates) + 10 + tmpl := types.StackTemplate{ + Base: types.Base{ID: fmt.Sprintf("%d", nextID), Version: "1"}, + Name: req.Name, + Description: req.Description, + Published: false, + Owner: "admin", + Charts: req.Charts, + } + state.templates = append(state.templates, tmpl) + state.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(tmpl) + return } // Template get/instantiate/quick-deploy @@ -114,6 +137,47 @@ func startTemplateDefMockServer(t *testing.T, state *templateDefMockState) *http json.NewEncoder(w).Encode(tmpl) return + case tmplAction == "" && r.Method == http.MethodPut: + var req types.UpdateTemplateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "invalid body"}) + return + } + state.mu.Lock() + if req.Name != "" { + tmpl.Name = req.Name + } + if req.Description != "" { + tmpl.Description = req.Description + } + state.mu.Unlock() + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(tmpl) + return + + case tmplAction == "clone" && r.Method == http.MethodPost: + var req types.CloneTemplateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "invalid body"}) + return + } + state.mu.Lock() + cloned := types.StackTemplate{ + Base: types.Base{ID: fmt.Sprintf("%d", len(state.templates)+10), Version: "1"}, + Name: req.Name, + Description: tmpl.Description, + Published: false, + Owner: tmpl.Owner, + Charts: tmpl.Charts, + } + state.templates = append(state.templates, cloned) + state.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(cloned) + return + case tmplAction == "instantiate" && r.Method == http.MethodPost: var req types.InstantiateTemplateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -571,3 +635,132 @@ func TestDefinitionWorkflow_MultipleDefinitions(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, resp.Total) } + +// ---------- Template create/update/clone integration tests ---------- + +func TestTemplateCreate_Success(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + tmpl, err := c.CreateTemplate(&types.CreateTemplateRequest{ + Name: "my-new-template", + Description: "Created via API", + }) + require.NoError(t, err) + assert.Equal(t, "my-new-template", tmpl.Name) + assert.Equal(t, "Created via API", tmpl.Description) + assert.False(t, tmpl.Published) + assert.NotEmpty(t, tmpl.ID) + + // Verify it appears in list + resp, err := c.ListTemplates(nil) + require.NoError(t, err) + assert.Equal(t, 3, resp.Total) +} + +func TestTemplateCreate_InvalidBody(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + // Sending empty name — mock returns 400 + server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "name is required"}) + })) + defer server2.Close() + + c2 := client.New(server2.URL) + _, err := c2.CreateTemplate(&types.CreateTemplateRequest{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required") +} + +func TestTemplateUpdate_Success(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + // Update first template (ID "1") + tmpl, err := c.UpdateTemplate("1", &types.UpdateTemplateRequest{ + Name: "renamed-template", + Description: "Updated description", + }) + require.NoError(t, err) + assert.Equal(t, "1", tmpl.ID) + assert.Equal(t, "renamed-template", tmpl.Name) + assert.Equal(t, "Updated description", tmpl.Description) +} + +func TestTemplateUpdate_NotFound(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + _, err := c.UpdateTemplate("999", &types.UpdateTemplateRequest{Name: "new-name"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} + +func TestTemplateClone_Success(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + cloned, err := c.CloneTemplate("1", &types.CloneTemplateRequest{Name: "cloned-template"}) + require.NoError(t, err) + assert.Equal(t, "cloned-template", cloned.Name) + assert.False(t, cloned.Published) + assert.NotEmpty(t, cloned.ID) + + // Verify clone appears in list + resp, err := c.ListTemplates(nil) + require.NoError(t, err) + assert.Equal(t, 3, resp.Total) +} + +func TestTemplateClone_NotFound(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + _, err := c.CloneTemplate("999", &types.CloneTemplateRequest{Name: "clone"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} From 1de0d3683ba094658af649be9e0d367804d07bbd Mon Sep 17 00:00:00 2001 From: omattsson Date: Fri, 15 May 2026 21:10:39 +0200 Subject: [PATCH 2/3] fix(lint): use buf.Bytes() instead of []byte(buf.String()) (S1030) --- cli/cmd/template_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go index 7135da6..56a2846 100644 --- a/cli/cmd/template_test.go +++ b/cli/cmd/template_test.go @@ -706,7 +706,7 @@ func TestTemplateCreateCmd_JSONOutput(t *testing.T) { require.NoError(t, err) var result types.StackTemplate - require.NoError(t, json.Unmarshal([]byte(buf.String()), &result)) + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) assert.Equal(t, "10", result.ID) assert.Equal(t, "web-app-template", result.Name) } @@ -883,7 +883,7 @@ func TestTemplateUpdateCmd_JSONOutput(t *testing.T) { require.NoError(t, err) var result types.StackTemplate - require.NoError(t, json.Unmarshal([]byte(buf.String()), &result)) + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) assert.Equal(t, "10", result.ID) } @@ -1007,7 +1007,7 @@ func TestTemplateCloneCmd_JSONOutput(t *testing.T) { require.NoError(t, err) var result types.StackTemplate - require.NoError(t, json.Unmarshal([]byte(buf.String()), &result)) + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) assert.Equal(t, "20", result.ID) assert.Equal(t, "my-clone", result.Name) } From 0a64917cd45f925c8790bbc2f92d68d68eca8ea2 Mon Sep 17 00:00:00 2001 From: omattsson Date: Fri, 15 May 2026 21:23:45 +0200 Subject: [PATCH 3/3] feat(template): add publish and unpublish subcommands Implements issue #61. - Add PublishTemplate and UnpublishTemplate client methods (POST /api/v1/templates/:id/publish and unpublish) - Add 'template publish ' and 'template unpublish ' commands - Both commands use printTemplate() for consistent output (table/JSON/YAML/quiet) - 403 Forbidden renders as 'Permission denied' via existing APIError mapping - Unit tests: all output modes + 403 for both commands - Integration tests: publish/unpublish state reflected on subsequent GetTemplate - E2E tests: happy path, quiet output --- cli/cmd/template.go | 62 +++++++++ cli/cmd/template_test.go | 127 ++++++++++++++++++ cli/pkg/client/client.go | 20 +++ cli/pkg/client/client_test.go | 106 +++++++++++++++ cli/test/e2e/cli_e2e_test.go | 73 ++++++++++ .../template_definition_integration_test.go | 76 +++++++++++ 6 files changed, 464 insertions(+) diff --git a/cli/cmd/template.go b/cli/cmd/template.go index 3a1dc13..3f0081d 100644 --- a/cli/cmd/template.go +++ b/cli/cmd/template.go @@ -383,6 +383,66 @@ Examples: }, } +var templatePublishCmd = &cobra.Command{ + Use: "publish ", + Short: "Publish a stack template", + Long: `Publish a stack template to make it available for use. + +Examples: + stackctl template publish 1 + stackctl template publish 1 -o json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + c, err := newClient() + if err != nil { + return err + } + + tmpl, err := c.PublishTemplate(id) + if err != nil { + return err + } + + return printTemplate(tmpl) + }, +} + +var templateUnpublishCmd = &cobra.Command{ + Use: "unpublish ", + Short: "Unpublish a stack template", + Long: `Unpublish a stack template to prevent new instantiations. + +Examples: + stackctl template unpublish 1 + stackctl template unpublish 1 -o json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + c, err := newClient() + if err != nil { + return err + } + + tmpl, err := c.UnpublishTemplate(id) + if err != nil { + return err + } + + return printTemplate(tmpl) + }, +} + // printTemplate outputs a StackTemplate in the active format. func printTemplate(tmpl *types.StackTemplate) error { if printer.Quiet { @@ -462,5 +522,7 @@ func init() { templateCmd.AddCommand(templateCreateCmd) templateCmd.AddCommand(templateUpdateCmd) templateCmd.AddCommand(templateCloneCmd) + templateCmd.AddCommand(templatePublishCmd) + templateCmd.AddCommand(templateUnpublishCmd) rootCmd.AddCommand(templateCmd) } diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go index 56a2846..f511b38 100644 --- a/cli/cmd/template_test.go +++ b/cli/cmd/template_test.go @@ -1179,3 +1179,130 @@ func TestTemplateCloneCmd_EmptyName(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "--name must not be empty") } + +// ---------- template publish ---------- + +func TestTemplatePublishCmd_Success(t *testing.T) { + tmpl := sampleTemplate() + tmpl.Published = true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/templates/10/publish", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(tmpl) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + err := templatePublishCmd.RunE(templatePublishCmd, []string{"10"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "10") + assert.Contains(t, out, "web-app-template") +} + +func TestTemplatePublishCmd_JSONOutput(t *testing.T) { + tmpl := sampleTemplate() + tmpl.Published = true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(tmpl) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + err := templatePublishCmd.RunE(templatePublishCmd, []string{"10"}) + require.NoError(t, err) + + var result types.StackTemplate + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + assert.Equal(t, "10", result.ID) + assert.True(t, result.Published) +} + +func TestTemplatePublishCmd_YAMLOutput(t *testing.T) { + tmpl := sampleTemplate() + tmpl.Published = true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(tmpl) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatYAML + err := templatePublishCmd.RunE(templatePublishCmd, []string{"10"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "web-app-template") +} + +func TestTemplatePublishCmd_QuietOutput(t *testing.T) { + tmpl := sampleTemplate() + tmpl.Published = true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(tmpl) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + err := templatePublishCmd.RunE(templatePublishCmd, []string{"10"}) + require.NoError(t, err) + assert.Equal(t, "10\n", buf.String()) +} + +func TestTemplatePublishCmd_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "Permission denied"}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + err := templatePublishCmd.RunE(templatePublishCmd, []string{"10"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Permission denied") +} + +// ---------- template unpublish ---------- + +func TestTemplateUnpublishCmd_Success(t *testing.T) { + tmpl := sampleTemplate() + tmpl.Published = false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/templates/10/unpublish", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(tmpl) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + err := templateUnpublishCmd.RunE(templateUnpublishCmd, []string{"10"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "10") + assert.Contains(t, out, "web-app-template") +} + +func TestTemplateUnpublishCmd_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "Permission denied"}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + err := templateUnpublishCmd.RunE(templateUnpublishCmd, []string{"10"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Permission denied") +} diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index c418bd5..a88c32e 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -638,6 +638,26 @@ func (c *Client) CloneTemplate(id string, req *types.CloneTemplateRequest) (*typ return &tmpl, nil } +// PublishTemplate publishes a stack template by ID. +func (c *Client) PublishTemplate(id string) (*types.StackTemplate, error) { + var tmpl types.StackTemplate + err := c.Post(fmt.Sprintf(pathTemplate+"/publish", id), nil, &tmpl) + if err != nil { + return nil, err + } + return &tmpl, nil +} + +// UnpublishTemplate unpublishes a stack template by ID. +func (c *Client) UnpublishTemplate(id string) (*types.StackTemplate, error) { + var tmpl types.StackTemplate + err := c.Post(fmt.Sprintf(pathTemplate+"/unpublish", id), nil, &tmpl) + if err != nil { + return nil, err + } + return &tmpl, nil +} + // ListOrphanedNamespaces returns namespaces that have the stack-manager label but no matching DB record. func (c *Client) ListOrphanedNamespaces() ([]types.OrphanedNamespace, error) { var ns []types.OrphanedNamespace diff --git a/cli/pkg/client/client_test.go b/cli/pkg/client/client_test.go index 41632bd..9d4d50e 100644 --- a/cli/pkg/client/client_test.go +++ b/cli/pkg/client/client_test.go @@ -2816,3 +2816,109 @@ func TestCloneTemplate_NotFound(t *testing.T) { require.True(t, ok) assert.Equal(t, http.StatusNotFound, apiErr.StatusCode) } + +func TestPublishTemplate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + statusCode int + body interface{} + wantErr bool + wantStatus int + }{ + { + name: "Success", + statusCode: http.StatusOK, + body: types.StackTemplate{Base: types.Base{ID: "1"}, Name: "web-app", Published: true}, + wantErr: false, + }, + { + name: "Forbidden", + statusCode: http.StatusForbidden, + body: types.ErrorResponse{Error: "Permission denied"}, + wantErr: true, + wantStatus: http.StatusForbidden, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/templates/1/publish", r.URL.Path) + w.WriteHeader(tt.statusCode) + json.NewEncoder(w).Encode(tt.body) + })) + defer server.Close() + + c := New(server.URL) + tmpl, err := c.PublishTemplate("1") + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, tmpl) + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, tt.wantStatus, apiErr.StatusCode) + } else { + require.NoError(t, err) + require.NotNil(t, tmpl) + assert.Equal(t, "1", tmpl.ID) + assert.True(t, tmpl.Published) + } + }) + } +} + +func TestUnpublishTemplate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + statusCode int + body interface{} + wantErr bool + wantStatus int + }{ + { + name: "Success", + statusCode: http.StatusOK, + body: types.StackTemplate{Base: types.Base{ID: "1"}, Name: "web-app", Published: false}, + wantErr: false, + }, + { + name: "Forbidden", + statusCode: http.StatusForbidden, + body: types.ErrorResponse{Error: "Permission denied"}, + wantErr: true, + wantStatus: http.StatusForbidden, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/templates/1/unpublish", r.URL.Path) + w.WriteHeader(tt.statusCode) + json.NewEncoder(w).Encode(tt.body) + })) + defer server.Close() + + c := New(server.URL) + tmpl, err := c.UnpublishTemplate("1") + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, tmpl) + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, tt.wantStatus, apiErr.StatusCode) + } else { + require.NoError(t, err) + require.NotNil(t, tmpl) + assert.Equal(t, "1", tmpl.ID) + assert.False(t, tmpl.Published) + } + }) + } +} diff --git a/cli/test/e2e/cli_e2e_test.go b/cli/test/e2e/cli_e2e_test.go index 06e413d..6148372 100644 --- a/cli/test/e2e/cli_e2e_test.go +++ b/cli/test/e2e/cli_e2e_test.go @@ -815,6 +815,30 @@ func startE2ETemplateDefMockServer(t *testing.T) *httptest.Server { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) + // Publish template + case r.URL.Path == "/api/v1/templates/1/publish" && r.Method == http.MethodPost: + resp := map[string]interface{}{ + "id": "1", "name": "web-template", "description": "Web app stack", + "published": true, "owner": "admin", "charts": []map[string]interface{}{ + {"id": "1", "name": "frontend", "repo_url": "https://charts.example.com", "chart_version": "1.0.0"}, + }, + "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "version": "1", + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + + // Unpublish template + case r.URL.Path == "/api/v1/templates/1/unpublish" && r.Method == http.MethodPost: + resp := map[string]interface{}{ + "id": "1", "name": "web-template", "description": "Web app stack", + "published": false, "owner": "admin", "charts": []map[string]interface{}{ + {"id": "1", "name": "frontend", "repo_url": "https://charts.example.com", "chart_version": "1.0.0"}, + }, + "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "version": "1", + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + default: w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) @@ -1355,3 +1379,52 @@ func TestE2E_TemplateClone_QuietOutput(t *testing.T) { require.NoError(t, err) assert.Equal(t, "100\n", stdout) } + +func TestE2E_TemplatePublish(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + stdout, _, err := runStackctl(t, dir, "template", "publish", "1") + require.NoError(t, err) + assert.Contains(t, stdout, "web-template") + assert.Contains(t, stdout, "true") +} + +func TestE2E_TemplateUnpublish(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + stdout, _, err := runStackctl(t, dir, "template", "unpublish", "1") + require.NoError(t, err) + assert.Contains(t, stdout, "web-template") +} + +func TestE2E_TemplatePublish_QuietOutput(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + stdout, _, err := runStackctl(t, dir, "template", "publish", "1", "--quiet") + require.NoError(t, err) + assert.Equal(t, "1\n", stdout) +} diff --git a/cli/test/integration/template_definition_integration_test.go b/cli/test/integration/template_definition_integration_test.go index 4499410..435bcd1 100644 --- a/cli/test/integration/template_definition_integration_test.go +++ b/cli/test/integration/template_definition_integration_test.go @@ -226,6 +226,22 @@ func startTemplateDefMockServer(t *testing.T, state *templateDefMockState) *http w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(inst) return + + case tmplAction == "publish" && r.Method == http.MethodPost: + state.mu.Lock() + tmpl.Published = true + state.mu.Unlock() + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(tmpl) + return + + case tmplAction == "unpublish" && r.Method == http.MethodPost: + state.mu.Lock() + tmpl.Published = false + state.mu.Unlock() + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(tmpl) + return } } } @@ -764,3 +780,63 @@ func TestTemplateClone_NotFound(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "template not found") } + +func TestTemplatePublish_Success(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + tmpl, err := c.PublishTemplate("1") + require.NoError(t, err) + assert.True(t, tmpl.Published) + + fetched, err := c.GetTemplate("1") + require.NoError(t, err) + assert.True(t, fetched.Published) +} + +func TestTemplateUnpublish_Success(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + // Ensure published first + _, err := c.PublishTemplate("1") + require.NoError(t, err) + + tmpl, err := c.UnpublishTemplate("1") + require.NoError(t, err) + assert.False(t, tmpl.Published) + + fetched, err := c.GetTemplate("1") + require.NoError(t, err) + assert.False(t, fetched.Published) +} + +func TestTemplatePublish_NotFound(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + _, err := c.PublishTemplate("999") + require.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +}