diff --git a/README.md b/README.md index 5e4f64b..d0d625c 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ stackctl override branch set 42 3 feature/hotfix # Quota overrides stackctl override quota get 42 -stackctl override quota set 42 --cpu 4 --memory 8Gi +stackctl override quota set 42 --cpu-request 200m --cpu-limit 500m --memory-request 256Mi --memory-limit 1Gi stackctl override quota delete 42 # View merged values @@ -289,6 +289,12 @@ stackctl stack list --cluster 1 -q | xargs stackctl bulk delete --yes ```bash stackctl cluster list stackctl cluster get 1 + +# Cluster-level shared Helm values (applied to all deploys on a cluster) +stackctl cluster shared-values list 1 +stackctl cluster shared-values set 1 --name "local-dev-defaults" --file values.yaml +stackctl cluster shared-values set 1 --name "local-dev-defaults" --set persistence.storageClass=local-path --priority 10 +stackctl cluster shared-values delete 1 5 ``` ### Git @@ -373,7 +379,7 @@ cli/ 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 + cluster.go # cluster list/get + shared-values list/set/delete completion.go # shell completion (bash/zsh/fish/powershell) pkg/ client/ # HTTP client (auth, error handling) diff --git a/cli/cmd/cluster.go b/cli/cmd/cluster.go index ec001ea..7359c5e 100644 --- a/cli/cmd/cluster.go +++ b/cli/cmd/cluster.go @@ -1,14 +1,19 @@ 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" "github.com/omattsson/stackctl/cli/pkg/types" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) var clusterCmd = &cobra.Command{ @@ -165,8 +170,240 @@ Examples: }, } +// --- Shared Values --- + +var clusterSharedValuesCmd = &cobra.Command{ + Use: "shared-values", + Short: "Manage cluster-level shared Helm values", + Long: "List, create, and delete shared Helm values that apply to all deployments on a cluster.", +} + +var clusterSharedValuesListCmd = &cobra.Command{ + Use: "list ", + Short: "List shared values for a cluster", + Long: `List all shared Helm values configured for a cluster. + +Examples: + stackctl cluster shared-values list 1 + stackctl cluster shared-values list 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 + } + + svList, err := c.ListSharedValues(id) + if err != nil { + return err + } + + if printer.Quiet { + for _, sv := range svList { + fmt.Fprintln(printer.Writer, sv.ID) + } + return nil + } + + if len(svList) == 0 { + printer.PrintMessage("No shared values found for cluster %s", id) + return nil + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(svList) + case output.FormatYAML: + return printer.PrintYAML(svList) + default: + headers := []string{"ID", "NAME", "PRIORITY", "HAS VALUES", "UPDATED AT"} + rows := make([][]string, len(svList)) + for i, sv := range svList { + hasValues := "false" + if sv.Values != "" { + hasValues = "true" + } + rows[i] = []string{ + sv.ID, + sv.Name, + strconv.Itoa(sv.Priority), + hasValues, + sv.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + return printer.PrintTable(headers, rows) + } + }, +} + +var clusterSharedValuesSetCmd = &cobra.Command{ + Use: "set ", + Short: "Create or update shared values for a cluster", + Long: `Create or update shared Helm values for a cluster. + +Values are provided via --file (JSON or YAML) and/or --set key=value flags, +following the same syntax as 'override set'. + +Examples: + stackctl cluster shared-values set 1 --name "local-dev-defaults" --file values.yaml + stackctl cluster shared-values set 1 --name "local-dev-defaults" --set persistence.storageClass=local-path + stackctl cluster shared-values set 1 --name "local-dev-defaults" --file values.yaml --priority 10`, + 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") + file, _ := cmd.Flags().GetString("file") + setFlags, _ := cmd.Flags().GetStringSlice("set") + priority, _ := cmd.Flags().GetInt("priority") + + if file == "" && len(setFlags) == 0 { + return fmt.Errorf("at least one of --file or --set is required") + } + + values := map[string]interface{}{} + + if file != "" { + for _, segment := range strings.Split(filepath.ToSlash(file), "/") { + if segment == ".." { + return fmt.Errorf("file path must not contain '..' segments") + } + } + file = filepath.Clean(file) + data, err := os.ReadFile(file) + if err != nil { + return fmt.Errorf("reading file %s: %w", file, err) + } + if err := json.Unmarshal(data, &values); err != nil { + if yamlErr := yaml.Unmarshal(data, &values); yamlErr != nil { + return fmt.Errorf("invalid JSON/YAML in file %s (json: %v): %w", file, err, yamlErr) + } + } + } + + for _, kv := range setFlags { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid --set format %q: expected key=value", kv) + } + setNestedValue(values, parts[0], parseScalarValue(parts[1])) + } + + yamlBytes, err := yaml.Marshal(values) + if err != nil { + return fmt.Errorf("serializing values to YAML: %w", err) + } + + c, err := newClient() + if err != nil { + return err + } + + sv, err := c.SetSharedValues(id, &types.SetSharedValuesRequest{ + Name: name, + Values: string(yamlBytes), + Priority: priority, + }) + if err != nil { + return err + } + + if printer.Quiet { + fmt.Fprintln(printer.Writer, sv.ID) + return nil + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(sv) + case output.FormatYAML: + return printer.PrintYAML(sv) + default: + printer.PrintMessage("Set shared values %q for cluster %s", sv.Name, id) + return nil + } + }, +} + +var clusterSharedValuesDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete shared values from a cluster", + Long: `Delete shared Helm values from a cluster. + +This is a destructive operation. You will be prompted for confirmation +unless --yes is specified. + +Examples: + stackctl cluster shared-values delete 1 5 + stackctl cluster shared-values delete 1 5 --yes`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + clusterID, err := parseID(args[0]) + if err != nil { + return err + } + svID, err := parseID(args[1]) + if err != nil { + return err + } + + confirmed, err := confirmAction(cmd, fmt.Sprintf("This will delete shared values %s from cluster %s. Continue? (y/n): ", svID, clusterID)) + if err != nil { + return err + } + if !confirmed { + printer.PrintMessage(msgAborted) + return nil + } + + c, err := newClient() + if err != nil { + return err + } + + if err := c.DeleteSharedValues(clusterID, svID); err != nil { + return err + } + + if printer.Quiet { + fmt.Fprintln(printer.Writer, svID) + return nil + } + + printer.PrintMessage("Deleted shared values %s from cluster %s", svID, clusterID) + return nil + }, +} + func init() { + // shared-values set flags + clusterSharedValuesSetCmd.Flags().String("name", "", "Name for the shared values entry (required)") + clusterSharedValuesSetCmd.Flags().String("file", "", "JSON or YAML file with values") + clusterSharedValuesSetCmd.Flags().StringSlice("set", nil, "Set a value (key=value), repeatable") + clusterSharedValuesSetCmd.Flags().Int("priority", 0, "Merge priority (higher = applied later)") + _ = clusterSharedValuesSetCmd.MarkFlagRequired("name") + + // shared-values delete flags + clusterSharedValuesDeleteCmd.Flags().BoolP("yes", "y", false, flagDescSkipConfirm) + + // Wire up shared-values subcommands + clusterSharedValuesCmd.AddCommand(clusterSharedValuesListCmd) + clusterSharedValuesCmd.AddCommand(clusterSharedValuesSetCmd) + clusterSharedValuesCmd.AddCommand(clusterSharedValuesDeleteCmd) + clusterCmd.AddCommand(clusterListCmd) clusterCmd.AddCommand(clusterGetCmd) + clusterCmd.AddCommand(clusterSharedValuesCmd) rootCmd.AddCommand(clusterCmd) } diff --git a/cli/cmd/cluster_test.go b/cli/cmd/cluster_test.go index 1e323f5..136e8db 100644 --- a/cli/cmd/cluster_test.go +++ b/cli/cmd/cluster_test.go @@ -5,11 +5,15 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" "time" "github.com/omattsson/stackctl/cli/pkg/output" "github.com/omattsson/stackctl/cli/pkg/types" + "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -355,3 +359,406 @@ func TestClusterGetCmd_Unauthorized(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "Not authenticated") } + +// ---------- shared values helpers ---------- + +func sampleSharedValues() types.SharedValues { + now := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC) + return types.SharedValues{ + Base: types.Base{ID: "5", CreatedAt: now, UpdatedAt: now, Version: "1"}, + ClusterID: "1", + Name: "local-dev-defaults", + Values: "persistence:\n storageClass: local-path\n", + Priority: 10, + } +} + +func resetSharedValuesSetFlags(t *testing.T) { + t.Helper() + clusterSharedValuesSetCmd.Flags().Set("name", "") + clusterSharedValuesSetCmd.Flags().Set("file", "") + clusterSharedValuesSetCmd.Flags().Set("priority", "0") + if f := clusterSharedValuesSetCmd.Flags().Lookup("set"); f != nil { + if sv, ok := f.Value.(pflag.SliceValue); ok { + sv.Replace([]string{}) + } + f.Changed = false + } +} + +// ---------- shared-values list ---------- + +func TestClusterSharedValuesListCmd_TableOutput(t *testing.T) { + sv := sampleSharedValues() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/clusters/1/shared-values", r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]types.SharedValues{sv}) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + + err := clusterSharedValuesListCmd.RunE(clusterSharedValuesListCmd, []string{"1"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "ID") + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "PRIORITY") + assert.Contains(t, out, "local-dev-defaults") + assert.Contains(t, out, "10") +} + +func TestClusterSharedValuesListCmd_JSONOutput(t *testing.T) { + sv := sampleSharedValues() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]types.SharedValues{sv}) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + printer.Format = output.FormatJSON + + err := clusterSharedValuesListCmd.RunE(clusterSharedValuesListCmd, []string{"1"}) + require.NoError(t, err) + + var result []types.SharedValues + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result, 1) + assert.Equal(t, "local-dev-defaults", result[0].Name) +} + +func TestClusterSharedValuesListCmd_QuietOutput(t *testing.T) { + sv1 := sampleSharedValues() + sv2 := sampleSharedValues() + sv2.ID = "6" + sv2.Name = "acr-pull-secrets" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]types.SharedValues{sv1, sv2}) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + printer.Quiet = true + + err := clusterSharedValuesListCmd.RunE(clusterSharedValuesListCmd, []string{"1"}) + require.NoError(t, err) + + lines := strings.TrimSpace(buf.String()) + assert.Equal(t, "5\n6", lines) +} + +func TestClusterSharedValuesListCmd_YAMLOutput(t *testing.T) { + sv := sampleSharedValues() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]types.SharedValues{sv}) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + printer.Format = output.FormatYAML + + err := clusterSharedValuesListCmd.RunE(clusterSharedValuesListCmd, []string{"1"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "name: local-dev-defaults") + assert.Contains(t, out, "cluster_id: \"1\"") +} + +func TestClusterSharedValuesListCmd_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.SharedValues{}) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + + err := clusterSharedValuesListCmd.RunE(clusterSharedValuesListCmd, []string{"1"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "No shared values found") +} + +func TestClusterSharedValuesListCmd_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.StatusNotFound) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "cluster not found"}) + })) + defer server.Close() + + _ = setupClusterTestCmd(t, server.URL) + + err := clusterSharedValuesListCmd.RunE(clusterSharedValuesListCmd, []string{"999"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "cluster not found") +} + +// ---------- shared-values set ---------- + +func TestClusterSharedValuesSetCmd_WithFile(t *testing.T) { + sv := sampleSharedValues() + + tmpDir := t.TempDir() + fp := filepath.Join(tmpDir, "values.yaml") + require.NoError(t, os.WriteFile(fp, []byte("persistence:\n storageClass: local-path\n"), 0644)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/clusters/1/shared-values", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + var body types.SetSharedValuesRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "local-dev-defaults", body.Name) + assert.Contains(t, body.Values, "storageClass") + assert.Equal(t, 10, body.Priority) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(sv) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + + clusterSharedValuesSetCmd.Flags().Set("name", "local-dev-defaults") + clusterSharedValuesSetCmd.Flags().Set("file", fp) + clusterSharedValuesSetCmd.Flags().Set("priority", "10") + t.Cleanup(func() { resetSharedValuesSetFlags(t) }) + + err := clusterSharedValuesSetCmd.RunE(clusterSharedValuesSetCmd, []string{"1"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Set shared values") + assert.Contains(t, buf.String(), "local-dev-defaults") +} + +func TestClusterSharedValuesSetCmd_WithSetFlag(t *testing.T) { + sv := sampleSharedValues() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body types.SetSharedValuesRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "test-values", body.Name) + assert.Contains(t, body.Values, "storageClass") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(sv) + })) + defer server.Close() + + _ = setupClusterTestCmd(t, server.URL) + + clusterSharedValuesSetCmd.Flags().Set("name", "test-values") + clusterSharedValuesSetCmd.Flags().Set("set", "persistence.storageClass=local-path") + t.Cleanup(func() { resetSharedValuesSetFlags(t) }) + + err := clusterSharedValuesSetCmd.RunE(clusterSharedValuesSetCmd, []string{"1"}) + require.NoError(t, err) +} + +func TestClusterSharedValuesSetCmd_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() + + _ = setupClusterTestCmd(t, server.URL) + + clusterSharedValuesSetCmd.Flags().Set("name", "test") + clusterSharedValuesSetCmd.Flags().Set("file", "../../etc/passwd") + t.Cleanup(func() { resetSharedValuesSetFlags(t) }) + + err := clusterSharedValuesSetCmd.RunE(clusterSharedValuesSetCmd, []string{"1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain '..'") +} + +func TestClusterSharedValuesSetCmd_NoFileOrSet(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should not be called when no --file or --set provided") + })) + defer server.Close() + + _ = setupClusterTestCmd(t, server.URL) + + resetSharedValuesSetFlags(t) + clusterSharedValuesSetCmd.Flags().Set("name", "test") + t.Cleanup(func() { resetSharedValuesSetFlags(t) }) + + err := clusterSharedValuesSetCmd.RunE(clusterSharedValuesSetCmd, []string{"1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one of --file or --set is required") +} + +func TestClusterSharedValuesSetCmd_JSONOutput(t *testing.T) { + sv := sampleSharedValues() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(sv) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + printer.Format = output.FormatJSON + + clusterSharedValuesSetCmd.Flags().Set("name", "test") + clusterSharedValuesSetCmd.Flags().Set("set", "key=val") + t.Cleanup(func() { resetSharedValuesSetFlags(t) }) + + err := clusterSharedValuesSetCmd.RunE(clusterSharedValuesSetCmd, []string{"1"}) + require.NoError(t, err) + + var result types.SharedValues + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + assert.Equal(t, "local-dev-defaults", result.Name) +} + +func TestClusterSharedValuesSetCmd_QuietOutput(t *testing.T) { + sv := sampleSharedValues() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(sv) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + printer.Quiet = true + + clusterSharedValuesSetCmd.Flags().Set("name", "test") + clusterSharedValuesSetCmd.Flags().Set("set", "key=val") + t.Cleanup(func() { resetSharedValuesSetFlags(t) }) + + err := clusterSharedValuesSetCmd.RunE(clusterSharedValuesSetCmd, []string{"1"}) + require.NoError(t, err) + assert.Equal(t, "5\n", buf.String()) +} + +func TestClusterSharedValuesSetCmd_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 error"}) + })) + defer server.Close() + + _ = setupClusterTestCmd(t, server.URL) + + clusterSharedValuesSetCmd.Flags().Set("name", "test") + clusterSharedValuesSetCmd.Flags().Set("set", "key=val") + t.Cleanup(func() { resetSharedValuesSetFlags(t) }) + + err := clusterSharedValuesSetCmd.RunE(clusterSharedValuesSetCmd, []string{"1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "internal error") +} + +// ---------- shared-values delete ---------- + +func TestClusterSharedValuesDeleteCmd_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/clusters/1/shared-values/5", r.URL.Path) + require.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + + clusterSharedValuesDeleteCmd.Flags().Set("yes", "true") + t.Cleanup(func() { clusterSharedValuesDeleteCmd.Flags().Set("yes", "false") }) + + err := clusterSharedValuesDeleteCmd.RunE(clusterSharedValuesDeleteCmd, []string{"1", "5"}) + require.NoError(t, err) + assert.True(t, called) + assert.Contains(t, buf.String(), "Deleted shared values 5 from cluster 1") +} + +func TestClusterSharedValuesDeleteCmd_ConfirmAccept(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 := setupClusterTestCmd(t, server.URL) + + clusterSharedValuesDeleteCmd.Flags().Set("yes", "false") + t.Cleanup(func() { + clusterSharedValuesDeleteCmd.Flags().Set("yes", "false") + clusterSharedValuesDeleteCmd.SetIn(nil) + clusterSharedValuesDeleteCmd.SetErr(nil) + }) + + clusterSharedValuesDeleteCmd.SetIn(strings.NewReader("y\n")) + clusterSharedValuesDeleteCmd.SetErr(&bytes.Buffer{}) + + err := clusterSharedValuesDeleteCmd.RunE(clusterSharedValuesDeleteCmd, []string{"1", "5"}) + require.NoError(t, err) + assert.True(t, called) + assert.Contains(t, buf.String(), "Deleted shared values") +} + +func TestClusterSharedValuesDeleteCmd_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 := setupClusterTestCmd(t, server.URL) + + clusterSharedValuesDeleteCmd.Flags().Set("yes", "false") + t.Cleanup(func() { + clusterSharedValuesDeleteCmd.Flags().Set("yes", "false") + clusterSharedValuesDeleteCmd.SetIn(nil) + clusterSharedValuesDeleteCmd.SetErr(nil) + }) + + clusterSharedValuesDeleteCmd.SetIn(strings.NewReader("n\n")) + clusterSharedValuesDeleteCmd.SetErr(&bytes.Buffer{}) + + err := clusterSharedValuesDeleteCmd.RunE(clusterSharedValuesDeleteCmd, []string{"1", "5"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Aborted") +} + +func TestClusterSharedValuesDeleteCmd_QuietOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + buf := setupClusterTestCmd(t, server.URL) + printer.Quiet = true + + clusterSharedValuesDeleteCmd.Flags().Set("yes", "true") + t.Cleanup(func() { clusterSharedValuesDeleteCmd.Flags().Set("yes", "false") }) + + err := clusterSharedValuesDeleteCmd.RunE(clusterSharedValuesDeleteCmd, []string{"1", "5"}) + require.NoError(t, err) + assert.Equal(t, "5\n", buf.String()) +} + +func TestClusterSharedValuesDeleteCmd_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: "shared values not found"}) + })) + defer server.Close() + + _ = setupClusterTestCmd(t, server.URL) + + clusterSharedValuesDeleteCmd.Flags().Set("yes", "true") + t.Cleanup(func() { clusterSharedValuesDeleteCmd.Flags().Set("yes", "false") }) + + err := clusterSharedValuesDeleteCmd.RunE(clusterSharedValuesDeleteCmd, []string{"1", "999"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "shared values not found") +} diff --git a/cli/cmd/override.go b/cli/cmd/override.go index 6d39e9c..5c43905 100644 --- a/cli/cmd/override.go +++ b/cli/cmd/override.go @@ -153,8 +153,13 @@ Examples: return err } + yamlBytes, err := yaml.Marshal(values) + if err != nil { + return fmt.Errorf("serializing values to YAML: %w", err) + } + override, err := c.SetValueOverride(instanceID, chartID, &types.SetValueOverrideRequest{ - Values: values, + Values: string(yamlBytes), }) if err != nil { return err diff --git a/cli/cmd/override_test.go b/cli/cmd/override_test.go index 42c605a..59ad0be 100644 --- a/cli/cmd/override_test.go +++ b/cli/cmd/override_test.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) // Tests in this file are NOT parallelized because they mutate package-level @@ -226,7 +227,9 @@ func TestOverrideSetCmd_WithSetFlag(t *testing.T) { var body types.SetValueOverrideRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.Equal(t, float64(3), body.Values["replicas"]) + var parsed map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(body.Values), &parsed)) + assert.Equal(t, 3, parsed["replicas"]) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -256,7 +259,9 @@ func TestOverrideSetCmd_WithFile(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body types.SetValueOverrideRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.Equal(t, float64(5), body.Values["replicas"]) + var parsed map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(body.Values), &parsed)) + assert.Equal(t, 5, parsed["replicas"]) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -284,8 +289,10 @@ func TestOverrideSetCmd_FileAndSetCombined(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body types.SetValueOverrideRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + var parsed map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(body.Values), &parsed)) // --set should override the file value for replicas - assert.Equal(t, float64(5), body.Values["replicas"]) + assert.Equal(t, 5, parsed["replicas"]) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -1354,11 +1361,11 @@ func TestOverrideSetCmd_WithYAMLFile(t *testing.T) { fp := filepath.Join(tmpDir, "values.yaml") require.NoError(t, os.WriteFile(fp, []byte("replicas: 3\nimage:\n tag: v2\n"), 0644)) - var captured map[string]interface{} + var capturedYAML string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body types.SetValueOverrideRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - captured = body.Values + capturedYAML = body.Values w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -1375,8 +1382,9 @@ func TestOverrideSetCmd_WithYAMLFile(t *testing.T) { require.NoError(t, err) assert.Contains(t, buf.String(), "Set value override for chart 1 on instance 42") - // YAML integers round-trip through JSON as float64 - assert.Equal(t, float64(3), captured["replicas"]) + var captured map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(capturedYAML), &captured)) + assert.Equal(t, 3, captured["replicas"]) imageMap, ok := captured["image"].(map[string]interface{}) require.True(t, ok, "image should be a nested map") assert.Equal(t, "v2", imageMap["tag"]) @@ -1387,11 +1395,11 @@ func TestOverrideSetCmd_WithYAMLFile(t *testing.T) { func TestOverrideSetCmd_ScalarTypeParsing(t *testing.T) { override := sampleValueOverride() - var captured map[string]interface{} + var capturedYAML string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body types.SetValueOverrideRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - captured = body.Values + capturedYAML = body.Values w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -1409,11 +1417,10 @@ func TestOverrideSetCmd_ScalarTypeParsing(t *testing.T) { err := overrideSetCmd.RunE(overrideSetCmd, []string{"42", "1"}) require.NoError(t, err) - // Integer: JSON round-trips int64 as float64 - assert.Equal(t, float64(3), captured["replicas"]) - // Boolean + var captured map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(capturedYAML), &captured)) + assert.Equal(t, 3, captured["replicas"]) assert.Equal(t, true, captured["enabled"]) - // Nested key with string value imageMap, ok := captured["image"].(map[string]interface{}) require.True(t, ok, "image should be a nested map") assert.Equal(t, "v2", imageMap["tag"]) diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index ef7f59e..ea69c56 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -23,6 +23,8 @@ const ( pathOverride = "/api/v1/stack-instances/%s/overrides/%s" pathBranchOverride = "/api/v1/stack-instances/%s/branches/%s" pathQuotaOverride = "/api/v1/stack-instances/%s/quota-overrides" + pathSharedValues = "/api/v1/clusters/%s/shared-values" + pathSharedValuesID = "/api/v1/clusters/%s/shared-values/%s" ) // Client is the HTTP client for the k8s-stack-manager API. @@ -778,3 +780,28 @@ func (c *Client) GetClusterHealth(id string) (*types.ClusterHealthSummary, error } return &health, nil } + +// ListSharedValues returns all shared values for a cluster. +func (c *Client) ListSharedValues(clusterID string) ([]types.SharedValues, error) { + var sv []types.SharedValues + err := c.Get(fmt.Sprintf(pathSharedValues, clusterID), &sv) + if err != nil { + return nil, err + } + return sv, nil +} + +// SetSharedValues creates or updates shared values for a cluster. +func (c *Client) SetSharedValues(clusterID string, req *types.SetSharedValuesRequest) (*types.SharedValues, error) { + var sv types.SharedValues + err := c.Post(fmt.Sprintf(pathSharedValues, clusterID), req, &sv) + if err != nil { + return nil, err + } + return &sv, nil +} + +// DeleteSharedValues deletes shared values from a cluster. +func (c *Client) DeleteSharedValues(clusterID, sharedValuesID string) error { + return c.Delete(fmt.Sprintf(pathSharedValuesID, clusterID, sharedValuesID)) +} diff --git a/cli/pkg/client/client_test.go b/cli/pkg/client/client_test.go index 81f1b09..ad93611 100644 --- a/cli/pkg/client/client_test.go +++ b/cli/pkg/client/client_test.go @@ -1342,18 +1342,18 @@ func TestSetValueOverride_Success(t *testing.T) { var body types.SetValueOverrideRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.Equal(t, float64(5), body.Values["replicas"]) + assert.Contains(t, body.Values, "replicas") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.ValueOverride{ - Base: types.Base{ID: "1"}, InstanceID: "42", ChartID: "1", Values: `{"replicas":5}`, + Base: types.Base{ID: "1"}, InstanceID: "42", ChartID: "1", Values: `replicas: 5`, }) })) defer server.Close() c := New(server.URL) override, err := c.SetValueOverride("42", "1", &types.SetValueOverrideRequest{ - Values: map[string]interface{}{"replicas": float64(5)}, + Values: "replicas: 5\n", }) require.NoError(t, err) assert.Equal(t, "1", override.ChartID) @@ -1369,7 +1369,7 @@ func TestSetValueOverride_Error(t *testing.T) { c := New(server.URL) override, err := c.SetValueOverride("42", "1", &types.SetValueOverrideRequest{ - Values: map[string]interface{}{"key": "val"}, + Values: "key: val\n", }) require.Error(t, err) assert.Nil(t, override) @@ -2129,6 +2129,112 @@ func TestGetClusterHealth_Error(t *testing.T) { assert.Nil(t, health) } +// ---------- shared values ---------- + +func TestListSharedValues_Success(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/clusters/1/shared-values", r.URL.Path) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]types.SharedValues{ + {Base: types.Base{ID: "5"}, ClusterID: "1", Name: "defaults", Values: "key: val\n"}, + }) + })) + defer server.Close() + + c := New(server.URL) + sv, err := c.ListSharedValues("1") + require.NoError(t, err) + require.Len(t, sv, 1) + assert.Equal(t, "defaults", sv[0].Name) +} + +func TestListSharedValues_Error(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: "cluster not found"}) + })) + defer server.Close() + + c := New(server.URL) + sv, err := c.ListSharedValues("999") + require.Error(t, err) + assert.Nil(t, sv) +} + +func TestSetSharedValues_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/clusters/1/shared-values", r.URL.Path) + + var body types.SetSharedValuesRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "defaults", body.Name) + assert.Contains(t, body.Values, "storageClass") + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.SharedValues{ + Base: types.Base{ID: "5"}, ClusterID: "1", Name: "defaults", + }) + })) + defer server.Close() + + c := New(server.URL) + sv, err := c.SetSharedValues("1", &types.SetSharedValuesRequest{ + Name: "defaults", + Values: "persistence:\n storageClass: local-path\n", + }) + require.NoError(t, err) + assert.Equal(t, "5", sv.ID) +} + +func TestSetSharedValues_Error(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: "set failed"}) + })) + defer server.Close() + + c := New(server.URL) + sv, err := c.SetSharedValues("1", &types.SetSharedValuesRequest{ + Name: "test", Values: "key: val\n", + }) + require.Error(t, err) + assert.Nil(t, sv) +} + +func TestDeleteSharedValues_Success(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v1/clusters/1/shared-values/5", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := New(server.URL) + err := c.DeleteSharedValues("1", "5") + require.NoError(t, err) +} + +func TestDeleteSharedValues_Error(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: "not found"}) + })) + defer server.Close() + + c := New(server.URL) + err := c.DeleteSharedValues("1", "999") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + // ---------- malformed / empty response body ---------- func TestClient_MalformedJSON(t *testing.T) { diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index cd7c897..28f1355 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -292,8 +292,9 @@ type QuotaOverride struct { } // SetValueOverrideRequest is the request body for setting value overrides. +// The backend expects Values as a YAML string, not a structured map. type SetValueOverrideRequest struct { - Values map[string]interface{} `json:"values"` + Values string `json:"values"` } // SetBranchOverrideRequest is the request body for setting a branch override. @@ -315,6 +316,22 @@ type MergedValues struct { Charts map[string]map[string]interface{} `json:"charts" yaml:"charts"` } +// SharedValues represents cluster-level shared Helm values. +type SharedValues struct { + Base + ClusterID string `json:"cluster_id" yaml:"cluster_id"` + Name string `json:"name" yaml:"name"` + Values string `json:"values" yaml:"values"` + Priority int `json:"priority" yaml:"priority"` +} + +// SetSharedValuesRequest is the request body for creating/updating shared values. +type SetSharedValuesRequest struct { + Name string `json:"name"` + Values string `json:"values"` + Priority int `json:"priority,omitempty"` +} + // CompareResult represents the comparison between two stack instances. type CompareResult struct { Left *StackInstance `json:"left" yaml:"left"` diff --git a/cli/test/integration/edge_case_integration_test.go b/cli/test/integration/edge_case_integration_test.go index 254cc84..60bda18 100644 --- a/cli/test/integration/edge_case_integration_test.go +++ b/cli/test/integration/edge_case_integration_test.go @@ -205,7 +205,7 @@ func TestEdgeCase_InvalidInputValidation(t *testing.T) { return } values, ok := body["values"] - if !ok || values == nil { + if !ok || values == nil || values == "" { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(types.ErrorResponse{Error: "values are required"}) return @@ -244,20 +244,19 @@ func TestEdgeCase_InvalidInputValidation(t *testing.T) { assert.Contains(t, err.Error(), "invalid stack ID") }) - t.Run("SetValueOverrideNilValues", func(t *testing.T) { + t.Run("SetValueOverrideEmptyValues", func(t *testing.T) { t.Parallel() _, err := c.SetValueOverride("1", "1", &types.SetValueOverrideRequest{ - Values: nil, + Values: "", }) require.Error(t, err) assert.Contains(t, err.Error(), "values are required") }) - t.Run("SetValueOverrideEmptyValues", func(t *testing.T) { + t.Run("SetValueOverrideWithValues", func(t *testing.T) { t.Parallel() - // Empty map (not nil) should succeed override, err := c.SetValueOverride("1", "1", &types.SetValueOverrideRequest{ - Values: map[string]interface{}{}, + Values: "replicas: 1\n", }) require.NoError(t, err) assert.Equal(t, "10", override.ID) diff --git a/cli/test/integration/override_integration_test.go b/cli/test/integration/override_integration_test.go index 081f940..2cb89e6 100644 --- a/cli/test/integration/override_integration_test.go +++ b/cli/test/integration/override_integration_test.go @@ -116,18 +116,12 @@ func startOverrideMockServer(t *testing.T, state *overrideMockState) *httptest.S json.NewEncoder(w).Encode(types.ErrorResponse{Error: "invalid body"}) return } - valBytes, err := json.Marshal(req.Values) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(types.ErrorResponse{Error: "invalid values: " + err.Error()}) - return - } state.mu.Lock() vo := &types.ValueOverride{ Base: types.Base{ID: chartID, Version: "1"}, InstanceID: instanceID, ChartID: chartID, - Values: string(valBytes), + Values: req.Values, } state.valueOverrides[key] = vo state.mu.Unlock() @@ -323,7 +317,7 @@ func TestValueOverrideWorkflow_CRUDLifecycle(t *testing.T) { // 2. Set a value override vo, err := c.SetValueOverride("42", "1", &types.SetValueOverrideRequest{ - Values: map[string]interface{}{"replicas": float64(5)}, + Values: "replicas: 5\n", }) require.NoError(t, err) assert.Equal(t, "1", vo.ChartID) @@ -343,7 +337,7 @@ func TestValueOverrideWorkflow_CRUDLifecycle(t *testing.T) { // 5. Set another override on different chart _, err = c.SetValueOverride("42", "2", &types.SetValueOverrideRequest{ - Values: map[string]interface{}{"debug": true}, + Values: "debug: true\n", }) require.NoError(t, err) diff --git a/cli/test/live/live_test.go b/cli/test/live/live_test.go index fce020d..aa63584 100644 --- a/cli/test/live/live_test.go +++ b/cli/test/live/live_test.go @@ -120,7 +120,7 @@ func TestLiveWorkflow_FullLifecycle(t *testing.T) { t.Log("Step 6: Set overrides") _, err = c.SetValueOverride(instance.ID, chartID, &types.SetValueOverrideRequest{ - Values: map[string]interface{}{"replicas": 2}, + Values: "replicas: 2\n", }) require.NoError(t, err, "set value override")