From 297c4e6cc8d5bcc28a167a4ee09d00931754bb49 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Mon, 29 Jun 2026 16:32:47 -0400 Subject: [PATCH] use mapping for namespaces rather than list --- README.md | 2 +- src/cmd/init_test.go | 190 +++++++++++++++++++-------- src/internal/pattern/pattern.go | 17 +-- src/internal/pattern/pattern_test.go | 6 + src/internal/types/clustergroup.go | 151 +++++++++++---------- 5 files changed, 229 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index e40907d..33b5e29 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Patternizer -![Version: 2.1.0](https://img.shields.io/badge/Version-2.1.0-informational?style=flat-square) +![Version: 2.2.0](https://img.shields.io/badge/Version-2.2.0-informational?style=flat-square) [![Quay Repository](https://img.shields.io/badge/Quay.io-patternizer-blue?logo=quay)](https://quay.io/repository/validatedpatterns/patternizer) [![CI Pipeline](https://github.com/validatedpatterns/patternizer/actions/workflows/build-push.yaml/badge.svg?branch=main)](https://github.com/validatedpatterns/patternizer/actions/workflows/build-push.yaml) diff --git a/src/cmd/init_test.go b/src/cmd/init_test.go index 3b8b392..f9e4d17 100644 --- a/src/cmd/init_test.go +++ b/src/cmd/init_test.go @@ -95,7 +95,7 @@ var _ = Describe("patternizer init", func() { expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "prod", - Namespaces: []types.NamespaceEntry{types.NewNamespaceEntry(filepath.Base(tempDir))}, + Namespaces: map[string]interface{}{filepath.Base(tempDir): nil}, Subscriptions: map[string]types.Subscription{}, Applications: map[string]types.Application{}, }, @@ -145,7 +145,7 @@ var _ = Describe("patternizer init", func() { expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "prod", - Namespaces: []types.NamespaceEntry{types.NewNamespaceEntry(expectedNamespace)}, + Namespaces: map[string]interface{}{expectedNamespace: nil}, Subscriptions: map[string]types.Subscription{}, Applications: map[string]types.Application{ "test-app1": { @@ -204,7 +204,7 @@ var _ = Describe("patternizer init", func() { expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "test", - Namespaces: []types.NamespaceEntry{types.NewNamespaceEntry("test-pattern")}, + Namespaces: map[string]interface{}{"test-pattern": nil}, Subscriptions: map[string]types.Subscription{}, Applications: map[string]types.Application{}, }, @@ -253,7 +253,7 @@ var _ = Describe("patternizer init", func() { expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "test", - Namespaces: []types.NamespaceEntry{types.NewNamespaceEntry("test-pattern")}, + Namespaces: map[string]interface{}{"test-pattern": nil}, Subscriptions: map[string]types.Subscription{}, Applications: map[string]types.Application{ "custom-user-app": { @@ -363,16 +363,14 @@ var _ = Describe("patternizer init --with-secrets", func() { expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "prod", - Namespaces: []types.NamespaceEntry{ - types.NewNamespaceEntry(filepath.Base(tempDir)), - types.NewNamespaceEntry("vault"), - types.NewMapNamespaceEntry(map[string]interface{}{ - "external-secrets-operator": map[string]interface{}{ - "operatorGroup": true, - "targetNamespaces": []interface{}{}, - }, - }), - types.NewNamespaceEntry("external-secrets"), + Namespaces: map[string]interface{}{ + filepath.Base(tempDir): nil, + "vault": nil, + "external-secrets-operator": map[string]interface{}{ + "operatorGroup": true, + "targetNamespaces": []interface{}{}, + }, + "external-secrets": nil, }, Subscriptions: map[string]types.Subscription{ "eso": { @@ -446,16 +444,14 @@ var _ = Describe("patternizer init --with-secrets", func() { expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "prod", - Namespaces: []types.NamespaceEntry{ - types.NewNamespaceEntry(expectedNamespace), - types.NewNamespaceEntry("vault"), - types.NewMapNamespaceEntry(map[string]interface{}{ - "external-secrets-operator": map[string]interface{}{ - "operatorGroup": true, - "targetNamespaces": []interface{}{}, - }, - }), - types.NewNamespaceEntry("external-secrets"), + Namespaces: map[string]interface{}{ + expectedNamespace: nil, + "vault": nil, + "external-secrets-operator": map[string]interface{}{ + "operatorGroup": true, + "targetNamespaces": []interface{}{}, + }, + "external-secrets": nil, }, Subscriptions: map[string]types.Subscription{ "eso": { @@ -537,16 +533,14 @@ var _ = Describe("patternizer init --with-secrets", func() { expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "test", - Namespaces: []types.NamespaceEntry{ - types.NewNamespaceEntry("test-pattern"), - types.NewNamespaceEntry("vault"), - types.NewMapNamespaceEntry(map[string]interface{}{ - "external-secrets-operator": map[string]interface{}{ - "operatorGroup": true, - "targetNamespaces": []interface{}{}, - }, - }), - types.NewNamespaceEntry("external-secrets"), + Namespaces: map[string]interface{}{ + "test-pattern": nil, + "vault": nil, + "external-secrets-operator": map[string]interface{}{ + "operatorGroup": true, + "targetNamespaces": []interface{}{}, + }, + "external-secrets": nil, }, Subscriptions: map[string]types.Subscription{ "eso": { @@ -617,16 +611,14 @@ var _ = Describe("patternizer init --with-secrets", func() { expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "prod", - Namespaces: []types.NamespaceEntry{ - types.NewNamespaceEntry(expectedNamespace), - types.NewNamespaceEntry("vault"), - types.NewMapNamespaceEntry(map[string]interface{}{ - "external-secrets-operator": map[string]interface{}{ - "operatorGroup": true, - "targetNamespaces": []interface{}{}, - }, - }), - types.NewNamespaceEntry("external-secrets"), + Namespaces: map[string]interface{}{ + expectedNamespace: nil, + "vault": nil, + "external-secrets-operator": map[string]interface{}{ + "operatorGroup": true, + "targetNamespaces": []interface{}{}, + }, + "external-secrets": nil, }, Subscriptions: map[string]types.Subscription{ "eso": { @@ -709,16 +701,14 @@ var _ = Describe("patternizer init --with-secrets", func() { expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "test", - Namespaces: []types.NamespaceEntry{ - types.NewNamespaceEntry("test-pattern"), - types.NewNamespaceEntry("vault"), - types.NewMapNamespaceEntry(map[string]interface{}{ - "external-secrets-operator": map[string]interface{}{ - "operatorGroup": true, - "targetNamespaces": []interface{}{}, - }, - }), - types.NewNamespaceEntry("external-secrets"), + Namespaces: map[string]interface{}{ + "test-pattern": nil, + "vault": nil, + "external-secrets-operator": map[string]interface{}{ + "operatorGroup": true, + "targetNamespaces": []interface{}{}, + }, + "external-secrets": nil, }, Subscriptions: map[string]types.Subscription{ "eso": { @@ -772,3 +762,95 @@ var _ = Describe("patternizer init --with-secrets", func() { }) }) }) + +var _ = Describe("patternizer init namespace migration", func() { + Context("on a directory with list-style namespaces", Ordered, func() { + var tempDir string + + BeforeAll(func() { + tempDir = createTestDir() + listStyleValues := ` +clusterGroup: + name: prod + namespaces: + - custom-ns + - vault + - external-secrets-operator: + operatorGroup: true + targetNamespaces: [] + - external-secrets + subscriptions: {} + applications: {} +` + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte("global:\n pattern: custom-ns\nmain:\n clusterGroupName: prod\n"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tempDir, "values-prod.yaml"), []byte(listStyleValues), 0o644)).To(Succeed()) + _ = runCLI(tempDir, "init") + }) + + It("should migrate list-style namespaces to map-style", func() { + clusterGroupValuesFile := filepath.Join(tempDir, "values-prod.yaml") + expectedClusterGroupValues := types.ValuesClusterGroup{ + ClusterGroup: types.ClusterGroup{ + Name: "prod", + Namespaces: map[string]interface{}{ + "custom-ns": nil, + "vault": nil, + "external-secrets-operator": map[string]interface{}{ + "operatorGroup": true, + "targetNamespaces": []interface{}{}, + }, + "external-secrets": nil, + }, + Subscriptions: map[string]types.Subscription{}, + Applications: map[string]types.Application{}, + }, + } + verifyClusterGroupValues(clusterGroupValuesFile, &expectedClusterGroupValues) + }) + }) + + Context("on a directory with map-style namespaces", Ordered, func() { + var tempDir string + + BeforeAll(func() { + tempDir = createTestDir() + mapStyleValues := ` +clusterGroup: + name: prod + namespaces: + custom-ns: + vault: + external-secrets-operator: + operatorGroup: true + targetNamespaces: [] + external-secrets: + subscriptions: {} + applications: {} +` + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte("global:\n pattern: custom-ns\nmain:\n clusterGroupName: prod\n"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tempDir, "values-prod.yaml"), []byte(mapStyleValues), 0o644)).To(Succeed()) + _ = runCLI(tempDir, "init") + }) + + It("should preserve map-style namespaces", func() { + clusterGroupValuesFile := filepath.Join(tempDir, "values-prod.yaml") + expectedClusterGroupValues := types.ValuesClusterGroup{ + ClusterGroup: types.ClusterGroup{ + Name: "prod", + Namespaces: map[string]interface{}{ + "custom-ns": nil, + "vault": nil, + "external-secrets-operator": map[string]interface{}{ + "operatorGroup": true, + "targetNamespaces": []interface{}{}, + }, + "external-secrets": nil, + }, + Subscriptions: map[string]types.Subscription{}, + Applications: map[string]types.Application{}, + }, + } + verifyClusterGroupValues(clusterGroupValuesFile, &expectedClusterGroupValues) + }) + }) +}) diff --git a/src/internal/pattern/pattern.go b/src/internal/pattern/pattern.go index 2c8e40a..f8bd47a 100644 --- a/src/internal/pattern/pattern.go +++ b/src/internal/pattern/pattern.go @@ -88,21 +88,8 @@ func mergeClusterGroupValues(defaults, existing *types.ValuesClusterGroup) { defaults.ClusterGroup.Applications[key] = app } - existingNamespaceMap := make(map[string]bool) - for _, ns := range existing.ClusterGroup.Namespaces { - found := false - for _, defaultNs := range defaults.ClusterGroup.Namespaces { - if ns.Equal(defaultNs) { - found = true - break - } - } - if !found { - defaults.ClusterGroup.Namespaces = append(defaults.ClusterGroup.Namespaces, ns) - } - if nsStr, ok := ns.GetString(); ok { - existingNamespaceMap[nsStr] = true - } + for nsName, nsConfig := range existing.ClusterGroup.Namespaces { + defaults.ClusterGroup.Namespaces[nsName] = nsConfig } existingProjectMap := make(map[string]bool) diff --git a/src/internal/pattern/pattern_test.go b/src/internal/pattern/pattern_test.go index d21be66..c5ba7e0 100644 --- a/src/internal/pattern/pattern_test.go +++ b/src/internal/pattern/pattern_test.go @@ -237,6 +237,12 @@ var _ = Describe("ProcessClusterGroupValues", func() { Expect(getNestedValue(processedValues, tt.path)).To(Equal(tt.expected), "Field %v should be %v", tt.path, tt.expected) } + + clusterGroup := processedValues["clusterGroup"].(map[string]interface{}) + namespaces := clusterGroup["namespaces"].(map[string]interface{}) + Expect(namespaces).To(HaveKey("custom-ns1")) + Expect(namespaces).To(HaveKey("custom-ns2")) + Expect(namespaces).To(HaveKey("test-pattern")) }) It("should preserve custom application fields", func() { diff --git a/src/internal/types/clustergroup.go b/src/internal/types/clustergroup.go index 7c8d73f..b37c5bc 100644 --- a/src/internal/types/clustergroup.go +++ b/src/internal/types/clustergroup.go @@ -3,63 +3,10 @@ package types import ( "fmt" "path/filepath" - "reflect" "gopkg.in/yaml.v3" ) -// NamespaceEntry represents a namespace that can be either a string or a map with additional configuration -type NamespaceEntry struct { - value interface{} -} - -// MarshalYAML implements the yaml.Marshaler interface for NamespaceEntry -func (ne NamespaceEntry) MarshalYAML() (interface{}, error) { - return ne.value, nil -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface for NamespaceEntry -func (ne *NamespaceEntry) UnmarshalYAML(value *yaml.Node) error { - ne.value = nil - - var str string - if err := value.Decode(&str); err == nil { - ne.value = str - return nil - } - - var m map[string]interface{} - if err := value.Decode(&m); err == nil { - ne.value = m - return nil - } - - return fmt.Errorf("namespaces entry at line %d, column %d must be either a string or a map", value.Line, value.Column) -} - -// GetString returns the string value if this NamespaceEntry is a string, and a boolean indicating success -func (ne NamespaceEntry) GetString() (string, bool) { - if str, ok := ne.value.(string); ok { - return str, true - } - return "", false -} - -// Equal compares two NamespaceEntry values for equality -func (ne NamespaceEntry) Equal(other NamespaceEntry) bool { - return reflect.DeepEqual(ne.value, other.value) -} - -// NewNamespaceEntry creates a new NamespaceEntry from a string -func NewNamespaceEntry(namespace string) NamespaceEntry { - return NamespaceEntry{value: namespace} -} - -// NewMapNamespaceEntry creates a new NamespaceEntry from a map -func NewMapNamespaceEntry(m map[string]interface{}) NamespaceEntry { - return NamespaceEntry{value: m} -} - // Application defines the structure for an ArgoCD application entry. type Application struct { Name string `yaml:"name"` @@ -83,13 +30,86 @@ type Subscription struct { // ClusterGroup holds the detailed configuration for the cluster group. type ClusterGroup struct { Name string `yaml:"name"` - Namespaces []NamespaceEntry `yaml:"namespaces"` + Namespaces map[string]interface{} `yaml:"namespaces"` Projects []string `yaml:"projects,omitempty"` Subscriptions map[string]Subscription `yaml:"subscriptions"` Applications map[string]Application `yaml:"applications"` OtherFields map[string]interface{} `yaml:",inline"` } +// MarshalYAML implements the yaml.Marshaler interface for ClusterGroup. +// It produces compact null representations (key: instead of key: null) for simple namespaces. +func (cg ClusterGroup) MarshalYAML() (interface{}, error) { + type clusterGroupAlias ClusterGroup + var doc yaml.Node + if err := doc.Encode(clusterGroupAlias(cg)); err != nil { + return nil, err + } + + node := &doc + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { + node = doc.Content[0] + } + + if node.Kind == yaml.MappingNode { + for i := 0; i < len(node.Content)-1; i += 2 { + if node.Content[i].Value == "namespaces" && node.Content[i+1].Kind == yaml.MappingNode { + for j := 1; j < len(node.Content[i+1].Content); j += 2 { + v := node.Content[i+1].Content[j] + if v.Kind == yaml.ScalarNode && v.Tag == "!!null" { + v.Value = "" + } + } + break + } + } + } + + return node, nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for ClusterGroup. +// It handles backward compatibility by converting list-style namespaces to map-style. +func (cg *ClusterGroup) UnmarshalYAML(value *yaml.Node) error { + if value.Kind != yaml.MappingNode { + return fmt.Errorf("expected mapping node for ClusterGroup, got %d", value.Kind) + } + + for i := 0; i < len(value.Content)-1; i += 2 { + keyNode := value.Content[i] + valNode := value.Content[i+1] + + if keyNode.Value == "namespaces" && valNode.Kind == yaml.SequenceNode { + newContent := make([]*yaml.Node, 0, len(valNode.Content)*2) + for _, item := range valNode.Content { + switch item.Kind { + case yaml.ScalarNode: + newContent = append(newContent, + &yaml.Node{Kind: yaml.ScalarNode, Value: item.Value, Tag: "!!str"}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null"}, + ) + case yaml.MappingNode: + if len(item.Content) >= 2 { + newContent = append(newContent, item.Content[0], item.Content[1]) + } + } + } + valNode.Kind = yaml.MappingNode + valNode.Content = newContent + valNode.Tag = "!!map" + break + } + } + + type clusterGroupAlias ClusterGroup + var alias clusterGroupAlias + if err := value.Decode(&alias); err != nil { + return err + } + *cg = ClusterGroup(alias) + return nil +} + // ValuesClusterGroup is the top-level struct for the cluster group values file. type ValuesClusterGroup struct { ClusterGroup ClusterGroup `yaml:"clusterGroup"` @@ -99,23 +119,20 @@ type ValuesClusterGroup struct { // NewDefaultValuesClusterGroup creates a default configuration for a cluster group. // It conditionally includes secrets-related resources based on the useSecrets flag. func NewDefaultValuesClusterGroup(patternName, clusterGroupName string, chartPaths []string, useSecrets bool) *ValuesClusterGroup { - namespaces := []NamespaceEntry{NewNamespaceEntry(patternName)} + namespaces := map[string]interface{}{ + patternName: nil, + } applications := make(map[string]Application) subscriptions := make(map[string]Subscription) if useSecrets { - namespaces = append( - namespaces, - NewNamespaceEntry("vault"), - NamespaceEntry{map[string]interface{}{ - "external-secrets-operator": map[string]interface{}{ - "operatorGroup": true, - "targetNamespaces": []string{}, - }, - }, - }, - NewNamespaceEntry("external-secrets"), - ) + namespaces["vault"] = nil + namespaces["external-secrets-operator"] = map[string]interface{}{ + "operatorGroup": true, + "targetNamespaces": []string{}, + } + namespaces["external-secrets"] = nil + subscriptions["eso"] = Subscription{ Name: "openshift-external-secrets-operator", Namespace: "external-secrets-operator",