From 557d15ab68f3ab3211769e87aa0cf139b762ecec Mon Sep 17 00:00:00 2001 From: Olof Mattsson Date: Wed, 22 Apr 2026 20:12:48 +0200 Subject: [PATCH 1/2] feat: add template delete, orphaned namespace mgmt, definition update-chart - template delete: confirmation-prompted deletion via deleteByID pattern (#43) - orphaned list/delete: manage namespaces with stack-manager label but no matching DB record (#44) - definition update: add --branch flag for default_branch (#46) - definition update-chart: GET-merge-PUT pattern to preserve unspecified fields; supports --chart-path, --chart-version, --deploy-order, --file (#46) Closes #43, closes #44, closes #46 Co-Authored-By: Claude Opus 4.6 --- cli/cmd/definition.go | 127 ++++++++++++++++- cli/cmd/definition_test.go | 275 ++++++++++++++++++++++++++++++++++++- cli/cmd/orphaned.go | 117 ++++++++++++++++ cli/cmd/orphaned_test.go | 223 ++++++++++++++++++++++++++++++ cli/cmd/template.go | 28 ++++ cli/cmd/template_test.go | 108 +++++++++++++++ cli/pkg/client/client.go | 50 ++++++- cli/pkg/types/types.go | 23 +++- 8 files changed, 941 insertions(+), 10 deletions(-) create mode 100644 cli/cmd/orphaned.go create mode 100644 cli/cmd/orphaned_test.go diff --git a/cli/cmd/definition.go b/cli/cmd/definition.go index da36fc6..ce41e4a 100644 --- a/cli/cmd/definition.go +++ b/cli/cmd/definition.go @@ -189,6 +189,7 @@ var definitionUpdateCmd = &cobra.Command{ Examples: stackctl definition update 1 --name new-name + stackctl definition update 1 --branch develop stackctl definition update 1 --from-file definition.json`, Args: cobra.ExactArgs(1), SilenceUsage: true, @@ -201,9 +202,10 @@ Examples: fromFile, _ := cmd.Flags().GetString(flagFromFile) name, _ := cmd.Flags().GetString("name") description, _ := cmd.Flags().GetString("description") + branch, _ := cmd.Flags().GetString("branch") - if fromFile == "" && name == "" && description == "" { - return fmt.Errorf("at least one of --name, --description, or --from-file must be specified") + if fromFile == "" && name == "" && description == "" && branch == "" { + return fmt.Errorf("at least one of --name, --description, --branch, or --from-file must be specified") } var req types.UpdateDefinitionRequest @@ -228,6 +230,9 @@ Examples: if description != "" { req.Description = description } + if branch != "" { + req.DefaultBranch = branch + } } c, err := newClient() @@ -368,6 +373,116 @@ Examples: }, } +var definitionUpdateChartCmd = &cobra.Command{ + Use: "update-chart ", + Short: "Update a chart config within a definition", + Long: `Update a chart configuration's settings within a stack definition. + +The command fetches the current chart config and merges your changes, +so unspecified fields are preserved. + +Examples: + stackctl definition update-chart 1 5 --chart-version 0.3.0 + stackctl definition update-chart 1 5 --chart-path /charts/kvk-core + stackctl definition update-chart 1 5 --deploy-order 6 + stackctl definition update-chart 1 5 --file values.yaml`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + defID, err := parseID(args[0]) + if err != nil { + return fmt.Errorf("invalid definition ID: %w", err) + } + chartID, err := parseID(args[1]) + if err != nil { + return fmt.Errorf("invalid chart ID: %w", err) + } + + chartPath, _ := cmd.Flags().GetString("chart-path") + chartVersion, _ := cmd.Flags().GetString("chart-version") + deployOrder, _ := cmd.Flags().GetInt("deploy-order") + valuesFile, _ := cmd.Flags().GetString("file") + + if chartPath == "" && chartVersion == "" && deployOrder < 0 && valuesFile == "" { + return fmt.Errorf("at least one of --chart-path, --chart-version, --deploy-order, or --file must be specified") + } + + if valuesFile != "" { + for _, segment := range strings.Split(filepath.ToSlash(valuesFile), "/") { + if segment == ".." { + return errors.New(msgPathTraversal) + } + } + } + + c, err := newClient() + if err != nil { + return err + } + + current, err := c.GetDefinitionChart(defID, chartID) + if err != nil { + return fmt.Errorf("fetching current chart config: %w", err) + } + + req := types.UpdateChartConfigRequest{ + ChartName: current.ChartName, + ChartPath: current.RepoURL, + ChartVersion: current.ChartVersion, + DefaultValues: current.DefaultValues, + } + + if chartPath != "" { + req.ChartPath = chartPath + } + if chartVersion != "" { + req.ChartVersion = chartVersion + } + if deployOrder >= 0 { + req.DeployOrder = &deployOrder + } + if valuesFile != "" { + valuesFile = filepath.Clean(valuesFile) + data, err := os.ReadFile(valuesFile) + if err != nil { + return readFileErr(valuesFile, err) + } + req.DefaultValues = string(data) + } + + updated, err := c.UpdateDefinitionChart(defID, chartID, &req) + if err != nil { + return err + } + + return printChartConfig(updated) + }, +} + +func printChartConfig(ch *types.ChartConfig) error { + if printer.Quiet { + fmt.Fprintln(printer.Writer, ch.ID) + return nil + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(ch) + case output.FormatYAML: + return printer.PrintYAML(ch) + default: + fields := []output.KeyValue{ + {Key: "ID", Value: ch.ID}, + {Key: "Name", Value: ch.Name}, + {Key: "Chart", Value: ch.ChartName}, + {Key: "Repository", Value: ch.RepoURL}, + {Key: "Version", Value: ch.ChartVersion}, + {Key: "Release Name", Value: ch.ReleaseName}, + } + return printer.PrintSingle(ch, fields) + } +} + // printDefinition prints a stack definition in the configured output format. func printDefinition(def *types.StackDefinition) error { if printer.Quiet { @@ -412,8 +527,15 @@ func init() { // definition update flags definitionUpdateCmd.Flags().String("name", "", "New definition name") definitionUpdateCmd.Flags().String("description", "", "New definition description") + definitionUpdateCmd.Flags().String("branch", "", "New default branch") definitionUpdateCmd.Flags().String(flagFromFile, "", "Update from JSON file") + // definition update-chart flags + definitionUpdateChartCmd.Flags().String("chart-path", "", "Chart path (e.g. /charts/kvk-core)") + definitionUpdateChartCmd.Flags().String("chart-version", "", "Chart version") + definitionUpdateChartCmd.Flags().Int("deploy-order", -1, "Deploy order (0+)") + definitionUpdateChartCmd.Flags().String("file", "", "File containing default values") + // definition delete flags definitionDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") @@ -432,6 +554,7 @@ func init() { definitionCmd.AddCommand(definitionDeleteCmd) definitionCmd.AddCommand(definitionExportCmd) definitionCmd.AddCommand(definitionImportCmd) + definitionCmd.AddCommand(definitionUpdateChartCmd) rootCmd.AddCommand(definitionCmd) } diff --git a/cli/cmd/definition_test.go b/cli/cmd/definition_test.go index 7e3e93a..cc701d7 100644 --- a/cli/cmd/definition_test.go +++ b/cli/cmd/definition_test.go @@ -879,7 +879,7 @@ func TestDefinitionUpdateCmd_NoFlagsSpecified(t *testing.T) { err := definitionUpdateCmd.RunE(definitionUpdateCmd, []string{"1"}) require.Error(t, err) - assert.Contains(t, err.Error(), "at least one of --name, --description, or --from-file must be specified") + assert.Contains(t, err.Error(), "at least one of --name, --description, --branch, or --from-file must be specified") } // ---------- create --from-file requires name field ---------- @@ -953,3 +953,276 @@ func TestDefinitionImportCmd_InvalidJSON(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "invalid JSON") } + +// ---------- definition update --branch ---------- + +func TestDefinitionUpdateCmd_WithBranch(t *testing.T) { + updated := sampleDefinition() + updated.DefaultBranch = "develop" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/stack-definitions/5", r.URL.Path) + require.Equal(t, http.MethodPut, r.Method) + + var body types.UpdateDefinitionRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "develop", body.DefaultBranch) + assert.Empty(t, body.Name) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(updated) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + definitionUpdateCmd.Flags().Set("branch", "develop") + t.Cleanup(func() { + definitionUpdateCmd.Flags().Set("name", "") + definitionUpdateCmd.Flags().Set("description", "") + definitionUpdateCmd.Flags().Set("branch", "") + definitionUpdateCmd.Flags().Set("from-file", "") + }) + + err := definitionUpdateCmd.RunE(definitionUpdateCmd, []string{"5"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "develop") +} + +func TestDefinitionUpdateCmd_NoFlags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should not be called when no flags provided") + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + + t.Cleanup(func() { + definitionUpdateCmd.Flags().Set("name", "") + definitionUpdateCmd.Flags().Set("description", "") + definitionUpdateCmd.Flags().Set("branch", "") + definitionUpdateCmd.Flags().Set("from-file", "") + }) + + err := definitionUpdateCmd.RunE(definitionUpdateCmd, []string{"5"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one of") +} + +// ---------- definition update-chart ---------- + +func sampleChartConfig() types.ChartConfig { + return types.ChartConfig{ + Base: types.Base{ID: "1", Version: "1"}, + Name: "api", + RepoURL: "https://charts.example.com", + ChartName: "api-chart", + ChartVersion: "2.0.0", + ReleaseName: "api-release", + DefaultValues: "replicas: 1\nimage: app:latest", + } +} + +func TestDefinitionUpdateChartCmd_ChartVersion(t *testing.T) { + chart := sampleChartConfig() + reqCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Contains(t, r.URL.Path, "/api/v1/stack-definitions/5/charts/1") + reqCount++ + w.Header().Set("Content-Type", "application/json") + + if r.Method == http.MethodGet { + json.NewEncoder(w).Encode(chart) + return + } + + require.Equal(t, http.MethodPut, r.Method) + var body types.UpdateChartConfigRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "3.0.0", body.ChartVersion) + assert.Equal(t, chart.ChartName, body.ChartName) + + chart.ChartVersion = "3.0.0" + json.NewEncoder(w).Encode(chart) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + definitionUpdateChartCmd.Flags().Set("chart-version", "3.0.0") + t.Cleanup(func() { + definitionUpdateChartCmd.Flags().Set("chart-version", "") + definitionUpdateChartCmd.Flags().Set("chart-path", "") + definitionUpdateChartCmd.Flags().Set("deploy-order", "-1") + definitionUpdateChartCmd.Flags().Set("file", "") + }) + + err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"}) + require.NoError(t, err) + assert.Equal(t, 2, reqCount) + assert.Contains(t, buf.String(), "api-chart") +} + +func TestDefinitionUpdateChartCmd_ValuesFromFile(t *testing.T) { + chart := sampleChartConfig() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + json.NewEncoder(w).Encode(chart) + return + } + var body types.UpdateChartConfigRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "replicas: 3\nimage: app:v2\n", body.DefaultValues) + + chart.DefaultValues = body.DefaultValues + json.NewEncoder(w).Encode(chart) + })) + defer server.Close() + + tmpDir := t.TempDir() + valuesPath := filepath.Join(tmpDir, "values.yaml") + require.NoError(t, os.WriteFile(valuesPath, []byte("replicas: 3\nimage: app:v2\n"), 0644)) + + buf := setupStackTestCmd(t, server.URL) + + definitionUpdateChartCmd.Flags().Set("file", valuesPath) + t.Cleanup(func() { + definitionUpdateChartCmd.Flags().Set("chart-version", "") + definitionUpdateChartCmd.Flags().Set("chart-path", "") + definitionUpdateChartCmd.Flags().Set("deploy-order", "-1") + definitionUpdateChartCmd.Flags().Set("file", "") + }) + + err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "api-chart") +} + +func TestDefinitionUpdateChartCmd_DeployOrder(t *testing.T) { + chart := sampleChartConfig() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + json.NewEncoder(w).Encode(chart) + return + } + var body types.UpdateChartConfigRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.DeployOrder) + assert.Equal(t, 6, *body.DeployOrder) + + json.NewEncoder(w).Encode(chart) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + + definitionUpdateChartCmd.Flags().Set("deploy-order", "6") + t.Cleanup(func() { + definitionUpdateChartCmd.Flags().Set("chart-version", "") + definitionUpdateChartCmd.Flags().Set("chart-path", "") + definitionUpdateChartCmd.Flags().Set("deploy-order", "-1") + definitionUpdateChartCmd.Flags().Set("file", "") + }) + + err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"}) + require.NoError(t, err) +} + +func TestDefinitionUpdateChartCmd_NoFlags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should not be called when no flags provided") + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + + t.Cleanup(func() { + definitionUpdateChartCmd.Flags().Set("chart-version", "") + definitionUpdateChartCmd.Flags().Set("chart-path", "") + definitionUpdateChartCmd.Flags().Set("deploy-order", "-1") + definitionUpdateChartCmd.Flags().Set("file", "") + }) + + err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one of") +} + +func TestDefinitionUpdateChartCmd_JSONOutput(t *testing.T) { + chart := sampleChartConfig() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + json.NewEncoder(w).Encode(chart) + return + } + chart.ChartVersion = "3.0.0" + json.NewEncoder(w).Encode(chart) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + + definitionUpdateChartCmd.Flags().Set("chart-version", "3.0.0") + t.Cleanup(func() { + definitionUpdateChartCmd.Flags().Set("chart-version", "") + definitionUpdateChartCmd.Flags().Set("chart-path", "") + definitionUpdateChartCmd.Flags().Set("deploy-order", "-1") + definitionUpdateChartCmd.Flags().Set("file", "") + }) + + err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"}) + require.NoError(t, err) + + var result types.ChartConfig + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + assert.Equal(t, "1", result.ID) +} + +func TestDefinitionUpdateChartCmd_QuietOutput(t *testing.T) { + chart := sampleChartConfig() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + json.NewEncoder(w).Encode(chart) + return + } + json.NewEncoder(w).Encode(chart) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + + definitionUpdateChartCmd.Flags().Set("chart-version", "3.0.0") + t.Cleanup(func() { + definitionUpdateChartCmd.Flags().Set("chart-version", "") + definitionUpdateChartCmd.Flags().Set("chart-path", "") + definitionUpdateChartCmd.Flags().Set("deploy-order", "-1") + definitionUpdateChartCmd.Flags().Set("file", "") + }) + + err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"}) + require.NoError(t, err) + assert.Equal(t, "1\n", buf.String()) +} + +func TestDefinitionUpdateChartCmd_PathTraversal(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should not be called for path traversal") + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + + definitionUpdateChartCmd.Flags().Set("file", "../../../etc/passwd") + t.Cleanup(func() { + definitionUpdateChartCmd.Flags().Set("file", "") + }) + + err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "path") +} diff --git a/cli/cmd/orphaned.go b/cli/cmd/orphaned.go new file mode 100644 index 0000000..10b424f --- /dev/null +++ b/cli/cmd/orphaned.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/omattsson/stackctl/cli/pkg/output" + "github.com/spf13/cobra" +) + +var orphanedCmd = &cobra.Command{ + Use: "orphaned", + Short: "Manage orphaned Kubernetes namespaces", + Long: "List and clean up namespaces that have the stack-manager label but no matching database record.", +} + +var orphanedListCmd = &cobra.Command{ + Use: "list", + Short: "List orphaned namespaces", + Long: `List Kubernetes namespaces that have the stack-manager label but no matching stack instance in the database. + +Examples: + stackctl orphaned list + stackctl orphaned list -o json`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + + namespaces, err := c.ListOrphanedNamespaces() + if err != nil { + return err + } + + if printer.Quiet { + for _, ns := range namespaces { + fmt.Fprintln(printer.Writer, ns.Namespace) + } + return nil + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(namespaces) + case output.FormatYAML: + return printer.PrintYAML(namespaces) + default: + if len(namespaces) == 0 { + printer.PrintMessage("No orphaned namespaces found.") + return nil + } + headers := []string{"NAMESPACE", "CLUSTER", "CREATED"} + rows := make([][]string, len(namespaces)) + for i, ns := range namespaces { + rows[i] = []string{ns.Namespace, ns.Cluster, ns.CreatedAt} + } + return printer.PrintTable(headers, rows) + } + }, +} + +var orphanedDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an orphaned namespace", + Long: `Remove an orphaned Kubernetes namespace. + +This is a destructive operation. You will be prompted for confirmation +unless --yes is specified. + +Examples: + stackctl orphaned delete stack-old-namespace + stackctl orphaned delete stack-old-namespace --yes`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + namespace := strings.TrimSpace(args[0]) + if namespace == "" { + return fmt.Errorf("namespace must not be empty") + } + + confirmed, err := confirmAction(cmd, fmt.Sprintf("This will delete orphaned namespace %q. Continue? (y/n): ", namespace)) + if err != nil { + return err + } + if !confirmed { + printer.PrintMessage("Aborted.") + return nil + } + + c, err := newClient() + if err != nil { + return err + } + + if err := c.DeleteOrphanedNamespace(namespace); err != nil { + return err + } + + if printer.Quiet { + fmt.Fprintln(printer.Writer, namespace) + return nil + } + + printer.PrintMessage("Deleted orphaned namespace %q", namespace) + return nil + }, +} + +func init() { + orphanedDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + orphanedCmd.AddCommand(orphanedListCmd) + orphanedCmd.AddCommand(orphanedDeleteCmd) + rootCmd.AddCommand(orphanedCmd) +} diff --git a/cli/cmd/orphaned_test.go b/cli/cmd/orphaned_test.go new file mode 100644 index 0000000..e67b31e --- /dev/null +++ b/cli/cmd/orphaned_test.go @@ -0,0 +1,223 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/omattsson/stackctl/cli/pkg/output" + "github.com/omattsson/stackctl/cli/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Tests in this file are NOT parallelized because they mutate package-level +// globals (cfg, printer, flagAPIURL) via setupStackTestCmd. Do not add t.Parallel(). + +func sampleOrphanedNamespaces() []types.OrphanedNamespace { + return []types.OrphanedNamespace{ + {Namespace: "stack-old-app", Cluster: "dev-cluster", CreatedAt: "2025-06-01T10:00:00Z"}, + {Namespace: "stack-leftover", Cluster: "dev-cluster", CreatedAt: "2025-05-20T08:00:00Z"}, + } +} + +// ---------- orphaned list ---------- + +func TestOrphanedListCmd_TableOutput(t *testing.T) { + ns := sampleOrphanedNamespaces() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/orphaned-namespaces", r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ns) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + err := orphanedListCmd.RunE(orphanedListCmd, []string{}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "NAMESPACE") + assert.Contains(t, out, "CLUSTER") + assert.Contains(t, out, "stack-old-app") + assert.Contains(t, out, "stack-leftover") +} + +func TestOrphanedListCmd_JSONOutput(t *testing.T) { + ns := sampleOrphanedNamespaces() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ns) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + + err := orphanedListCmd.RunE(orphanedListCmd, []string{}) + require.NoError(t, err) + + var result []types.OrphanedNamespace + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + assert.Len(t, result, 2) + assert.Equal(t, "stack-old-app", result[0].Namespace) +} + +func TestOrphanedListCmd_QuietOutput(t *testing.T) { + ns := sampleOrphanedNamespaces() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ns) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + + err := orphanedListCmd.RunE(orphanedListCmd, []string{}) + require.NoError(t, err) + + lines := strings.TrimSpace(buf.String()) + assert.Equal(t, "stack-old-app\nstack-leftover", lines) +} + +func TestOrphanedListCmd_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]types.OrphanedNamespace{}) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + err := orphanedListCmd.RunE(orphanedListCmd, []string{}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "No orphaned namespaces found") +} + +func TestOrphanedListCmd_APIError(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.StatusUnauthorized) + json.NewEncoder(w).Encode(types.ErrorResponse{}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + + err := orphanedListCmd.RunE(orphanedListCmd, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Not authenticated") +} + +// ---------- orphaned delete ---------- + +func TestOrphanedDeleteCmd_WithYesFlag(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + require.Equal(t, "/api/v1/orphaned-namespaces/stack-old-app", r.URL.Path) + require.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + orphanedDeleteCmd.Flags().Set("yes", "true") + t.Cleanup(func() { orphanedDeleteCmd.Flags().Set("yes", "false") }) + + err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"stack-old-app"}) + require.NoError(t, err) + assert.True(t, called) + assert.Contains(t, buf.String(), "Deleted orphaned namespace") +} + +func TestOrphanedDeleteCmd_WithConfirmation(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + orphanedDeleteCmd.Flags().Set("yes", "false") + t.Cleanup(func() { + orphanedDeleteCmd.Flags().Set("yes", "false") + orphanedDeleteCmd.SetIn(nil) + orphanedDeleteCmd.SetErr(nil) + }) + + orphanedDeleteCmd.SetIn(strings.NewReader("y\n")) + orphanedDeleteCmd.SetErr(&bytes.Buffer{}) + + err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"stack-old-app"}) + require.NoError(t, err) + assert.True(t, called) + assert.Contains(t, buf.String(), "Deleted orphaned namespace") +} + +func TestOrphanedDeleteCmd_Declined(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should NOT be called when user declines") + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + orphanedDeleteCmd.Flags().Set("yes", "false") + t.Cleanup(func() { + orphanedDeleteCmd.Flags().Set("yes", "false") + orphanedDeleteCmd.SetIn(nil) + orphanedDeleteCmd.SetErr(nil) + }) + + orphanedDeleteCmd.SetIn(strings.NewReader("n\n")) + orphanedDeleteCmd.SetErr(&bytes.Buffer{}) + + err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"stack-old-app"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Aborted") +} + +func TestOrphanedDeleteCmd_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: "namespace not found"}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + + orphanedDeleteCmd.Flags().Set("yes", "true") + t.Cleanup(func() { orphanedDeleteCmd.Flags().Set("yes", "false") }) + + err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"nonexistent"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "namespace not found") +} + +func TestOrphanedDeleteCmd_QuietOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + + orphanedDeleteCmd.Flags().Set("yes", "true") + t.Cleanup(func() { orphanedDeleteCmd.Flags().Set("yes", "false") }) + + err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"stack-old-app"}) + require.NoError(t, err) + assert.Equal(t, "stack-old-app\n", buf.String()) +} diff --git a/cli/cmd/template.go b/cli/cmd/template.go index 2d7c452..cd37146 100644 --- a/cli/cmd/template.go +++ b/cli/cmd/template.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + "github.com/omattsson/stackctl/cli/pkg/client" "github.com/omattsson/stackctl/cli/pkg/output" "github.com/omattsson/stackctl/cli/pkg/types" "github.com/spf13/cobra" @@ -229,6 +230,29 @@ Examples: }, } +var templateDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a stack template", + Long: `Permanently delete a stack template. + +This is a destructive operation. You will be prompted for confirmation +unless --yes is specified. + +Examples: + stackctl template delete 1 + stackctl template delete 1 --yes`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return deleteByID(cmd, args, + "This will permanently delete template %s. Continue? (y/n): ", + passthroughID, + func(c *client.Client, id string) error { return c.DeleteTemplate(id) }, + "Deleted template %s", + ) + }, +} + func init() { // template list flags templateListCmd.Flags().Bool("published", false, "Show only published templates") @@ -247,10 +271,14 @@ func init() { templateQuickDeployCmd.Flags().String("cluster", "", "Target cluster ID") _ = templateQuickDeployCmd.MarkFlagRequired("name") + // template delete flags + templateDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + // Wire up subcommands templateCmd.AddCommand(templateListCmd) templateCmd.AddCommand(templateGetCmd) templateCmd.AddCommand(templateInstantiateCmd) templateCmd.AddCommand(templateQuickDeployCmd) + templateCmd.AddCommand(templateDeleteCmd) rootCmd.AddCommand(templateCmd) } diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go index 1b1ea65..14b4c21 100644 --- a/cli/cmd/template_test.go +++ b/cli/cmd/template_test.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -539,3 +540,110 @@ func TestTemplateInstantiateCmd_Forbidden(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "Permission denied") } + +// ---------- template delete ---------- + +func TestTemplateDeleteCmd_WithYesFlag(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + require.Equal(t, "/api/v1/templates/10", r.URL.Path) + require.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + templateDeleteCmd.Flags().Set("yes", "true") + t.Cleanup(func() { templateDeleteCmd.Flags().Set("yes", "false") }) + + err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"10"}) + require.NoError(t, err) + assert.True(t, called) + assert.Contains(t, buf.String(), "Deleted template 10") +} + +func TestTemplateDeleteCmd_WithConfirmation(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + templateDeleteCmd.Flags().Set("yes", "false") + t.Cleanup(func() { + templateDeleteCmd.Flags().Set("yes", "false") + templateDeleteCmd.SetIn(nil) + templateDeleteCmd.SetErr(nil) + }) + + templateDeleteCmd.SetIn(strings.NewReader("y\n")) + templateDeleteCmd.SetErr(&bytes.Buffer{}) + + err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"10"}) + require.NoError(t, err) + assert.True(t, called) + assert.Contains(t, buf.String(), "Deleted template 10") +} + +func TestTemplateDeleteCmd_Declined(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should NOT be called when user declines") + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + templateDeleteCmd.Flags().Set("yes", "false") + t.Cleanup(func() { + templateDeleteCmd.Flags().Set("yes", "false") + templateDeleteCmd.SetIn(nil) + templateDeleteCmd.SetErr(nil) + }) + + templateDeleteCmd.SetIn(strings.NewReader("n\n")) + templateDeleteCmd.SetErr(&bytes.Buffer{}) + + err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"10"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Aborted") +} + +func TestTemplateDeleteCmd_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) + + templateDeleteCmd.Flags().Set("yes", "true") + t.Cleanup(func() { templateDeleteCmd.Flags().Set("yes", "false") }) + + err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"999"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} + +func TestTemplateDeleteCmd_QuietOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + + templateDeleteCmd.Flags().Set("yes", "true") + t.Cleanup(func() { templateDeleteCmd.Flags().Set("yes", "false") }) + + err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"10"}) + require.NoError(t, err) + assert.Equal(t, "10\n", buf.String()) +} diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index fea5b8f..ef7f59e 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -17,10 +17,12 @@ const defaultTimeout = 30 * time.Second const maxServerMessageLen = 256 const ( - pathDefinition = "/api/v1/stack-definitions/%s" - pathOverride = "/api/v1/stack-instances/%s/overrides/%s" - pathBranchOverride = "/api/v1/stack-instances/%s/branches/%s" - pathQuotaOverride = "/api/v1/stack-instances/%s/quota-overrides" + pathDefinition = "/api/v1/stack-definitions/%s" + pathDefinitionChart = "/api/v1/stack-definitions/%s/charts/%s" + pathTemplate = "/api/v1/templates/%s" + pathOverride = "/api/v1/stack-instances/%s/overrides/%s" + pathBranchOverride = "/api/v1/stack-instances/%s/branches/%s" + pathQuotaOverride = "/api/v1/stack-instances/%s/quota-overrides" ) // Client is the HTTP client for the k8s-stack-manager API. @@ -444,6 +446,46 @@ func (c *Client) QuickDeployTemplate(id string, req *types.QuickDeployRequest) ( return &instance, nil } +// DeleteTemplate deletes a stack template by ID. +func (c *Client) DeleteTemplate(id string) error { + return c.Delete(fmt.Sprintf(pathTemplate, id)) +} + +// 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 + err := c.Get("/api/v1/orphaned-namespaces", &ns) + if err != nil { + return nil, err + } + return ns, nil +} + +// DeleteOrphanedNamespace removes an orphaned namespace. +func (c *Client) DeleteOrphanedNamespace(namespace string) error { + return c.Delete(fmt.Sprintf("/api/v1/orphaned-namespaces/%s", namespace)) +} + +// GetDefinitionChart returns a single chart config within a definition. +func (c *Client) GetDefinitionChart(defID, chartID string) (*types.ChartConfig, error) { + var chart types.ChartConfig + err := c.Get(fmt.Sprintf(pathDefinitionChart, defID, chartID), &chart) + if err != nil { + return nil, err + } + return &chart, nil +} + +// UpdateDefinitionChart updates a chart config within a definition. +func (c *Client) UpdateDefinitionChart(defID, chartID string, req *types.UpdateChartConfigRequest) (*types.ChartConfig, error) { + var chart types.ChartConfig + err := c.Put(fmt.Sprintf(pathDefinitionChart, defID, chartID), req, &chart) + if err != nil { + return nil, err + } + return &chart, nil +} + // ListDefinitions returns a paginated list of stack definitions, filtered by query params. func (c *Client) ListDefinitions(params map[string]string) (*types.ListResponse[types.StackDefinition], error) { var resp types.ListResponse[types.StackDefinition] diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index ac0b252..cd7c897 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -211,9 +211,26 @@ type CreateDefinitionRequest struct { // UpdateDefinitionRequest is the request body for PUT /api/v1/stack-definitions/:id. type UpdateDefinitionRequest 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"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty" yaml:"default_branch,omitempty"` + Charts []ChartConfig `json:"charts,omitempty" yaml:"charts,omitempty"` +} + +// UpdateChartConfigRequest is the request body for PUT /api/v1/stack-definitions/:id/charts/:chartID. +type UpdateChartConfigRequest struct { + ChartName string `json:"chart_name,omitempty" yaml:"chart_name,omitempty"` + ChartPath string `json:"chart_path,omitempty" yaml:"chart_path,omitempty"` + ChartVersion string `json:"chart_version,omitempty" yaml:"chart_version,omitempty"` + DeployOrder *int `json:"deploy_order,omitempty" yaml:"deploy_order,omitempty"` + DefaultValues string `json:"default_values,omitempty" yaml:"default_values,omitempty"` +} + +// OrphanedNamespace represents a Kubernetes namespace with no matching stack record. +type OrphanedNamespace struct { + Namespace string `json:"namespace" yaml:"namespace"` + Cluster string `json:"cluster,omitempty" yaml:"cluster,omitempty"` + CreatedAt string `json:"created_at,omitempty" yaml:"created_at,omitempty"` } // BulkRequest is the request body for bulk operations. From a17c67d48dec6878d81fe96ff824594007c9d67b Mon Sep 17 00:00:00 2001 From: Olof Mattsson Date: Wed, 22 Apr 2026 20:16:49 +0200 Subject: [PATCH 2/2] docs: update README with new commands and name-based resolution - Add template delete, orphaned namespace list/delete sections - Add definition update (--branch), update-chart examples - Document name-based resolution in stack and bulk commands - Add stack history/rollback/history-values examples - Update project structure (orphaned.go, resolve.go) Co-Authored-By: Claude Opus 4.6 --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index de5969a..5e4f64b 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ Configuration values are resolved in this order (highest priority first): stackctl stack commands

+All stack commands accept a **name or ID** — e.g. `stackctl stack deploy my-app` or `stackctl stack deploy 42`. + ```bash # List instances stackctl stack list @@ -138,22 +140,27 @@ stackctl stack list --cluster 1 -o json # Create and deploy stackctl stack create --definition 1 --name my-app --branch feature/xyz --ttl 480 -stackctl stack deploy 42 +stackctl stack deploy my-app # Monitor -stackctl stack status 42 -stackctl stack logs 42 +stackctl stack status my-app +stackctl stack logs my-app # Lifecycle -stackctl stack stop 42 -stackctl stack clean 42 -stackctl stack delete 42 +stackctl stack stop my-app +stackctl stack clean my-app +stackctl stack delete my-app # Clone an existing instance -stackctl stack clone 42 +stackctl stack clone my-app # Extend TTL -stackctl stack extend 42 --minutes 120 +stackctl stack extend my-app --minutes 120 + +# Deployment history and rollback +stackctl stack history my-app +stackctl stack history-values my-app +stackctl stack rollback my-app --target ``` ### Templates @@ -168,6 +175,9 @@ stackctl template quick-deploy 1 # Or step by step stackctl template instantiate 1 --name my-stack --branch main + +# Delete a template +stackctl template delete 1 ``` ### Stack Definitions @@ -180,6 +190,20 @@ stackctl definition get 5 # Create from file stackctl definition create --from-file definition.json +# Update metadata +stackctl definition update 5 --name new-name +stackctl definition update 5 --branch develop +stackctl definition update 5 --description "Updated description" + +# Update a chart config (GET-merge-PUT preserves unspecified fields) +stackctl definition update-chart 5 1 --chart-version 0.3.0 +stackctl definition update-chart 5 1 --chart-path /charts/kvk-core +stackctl definition update-chart 5 1 --deploy-order 6 +stackctl definition update-chart 5 1 --file values.yaml + +# Delete +stackctl definition delete 5 + # Export / import stackctl definition export 5 > backup.json stackctl definition import --file backup.json @@ -212,10 +236,12 @@ stackctl stack compare 42 43 ### Bulk Operations +Bulk commands accept **names or IDs** (up to 50 at a time). + ```bash -# Bulk deploy/stop/clean/delete (up to 50 instances) -stackctl bulk deploy --ids 1,2,3,4,5 -stackctl bulk deploy 1 2 3 4 5 # positional args also work +# Bulk deploy/stop/clean/delete +stackctl bulk deploy --ids my-app,other-app,3 +stackctl bulk deploy my-app other-app 3 # positional args also work stackctl bulk stop --ids 1,2,3 stackctl bulk clean --ids 1,2,3 @@ -223,6 +249,19 @@ stackctl bulk clean --ids 1,2,3 stackctl stack list --status stopped --mine -q | xargs stackctl bulk deploy ``` +### Orphaned Namespaces + +Manage Kubernetes namespaces that have the stack-manager label but no matching database record. + +```bash +# List orphaned namespaces +stackctl orphaned list +stackctl orphaned list -o json + +# Delete an orphaned namespace +stackctl orphaned delete stack-old-namespace +``` + ### Scripting Examples ```bash @@ -326,11 +365,13 @@ cli/ cmd/ # Cobra commands (one file per command group) config.go # config set/get/list/use-context/current-context/delete-context login.go # login, logout, whoami - stack.go # stack lifecycle (13 subcommands) - template.go # template list/get/instantiate/quick-deploy - definition.go # definition CRUD + export/import + stack.go # stack lifecycle (create, deploy, stop, clean, delete, clone, extend, status, logs, history, rollback, compare, values) + template.go # template list/get/instantiate/quick-deploy/delete + definition.go # definition CRUD + export/import + update-chart override.go # value, branch, and quota overrides - bulk.go # bulk deploy/stop/clean/delete + orphaned.go # orphaned namespace list/delete + bulk.go # bulk deploy/stop/clean/delete (names or IDs) + resolve.go # name/ID resolution helpers git.go # git branches/validate cluster.go # cluster list/get completion.go # shell completion (bash/zsh/fish/powershell)