From b47da17381ffc1a33c2e648b4be2505e15ea9c46 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Wed, 14 Oct 2026 14:15:42 -0400 Subject: [PATCH 1/3] update clusterGroupName to variant in values-global.yaml --- README.md | 3 +- src/cmd/init_test.go | 20 ++-- src/cmd/upgrade.go | 10 ++ src/cmd/upgrade_test.go | 45 +++++++++ .../skills/pattern-author/reference.md | 6 +- src/internal/pattern/pattern.go | 65 ++++++++++++- src/internal/pattern/pattern_test.go | 91 ++++++++++++++++++- src/internal/types/global.go | 4 +- src/main_test.go | 2 +- 9 files changed, 224 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 33b5e29..e8ca6fa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Patternizer -![Version: 2.2.0](https://img.shields.io/badge/Version-2.2.0-informational?style=flat-square) +![Version: 2.3.0](https://img.shields.io/badge/Version-2.3.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) @@ -132,6 +132,7 @@ What upgrade does: - If no `Makefile` exists: copies the default `Makefile` - If a `Makefile` exists and already contains `include Makefile-common` anywhere: leaves it unchanged - Otherwise: prepends `include Makefile-common` to the first line so your existing targets are preserved +- Prints a deprecation warning if `values-global.yaml` still uses `main.clusterGroupName`, which the Validated Patterns operator deprecated in favor of `main.variant` in version 0.0.79 (`upgrade` does not modify the file; run `patternizer init` to migrate automatically) ### Understanding Secrets Management diff --git a/src/cmd/init_test.go b/src/cmd/init_test.go index f9e4d17..7aa2eca 100644 --- a/src/cmd/init_test.go +++ b/src/cmd/init_test.go @@ -80,7 +80,7 @@ var _ = Describe("patternizer init", func() { }, }, Main: types.Main{ - ClusterGroupName: "prod", + Variant: "prod", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", @@ -129,7 +129,7 @@ var _ = Describe("patternizer init", func() { }, }, Main: types.Main{ - ClusterGroupName: "prod", + Variant: "prod", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", @@ -189,7 +189,7 @@ var _ = Describe("patternizer init", func() { }, }, Main: types.Main{ - ClusterGroupName: "test", + Variant: "test", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", @@ -238,7 +238,7 @@ var _ = Describe("patternizer init", func() { }, }, Main: types.Main{ - ClusterGroupName: "test", + Variant: "test", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", @@ -308,7 +308,7 @@ var _ = Describe("patternizer init", func() { }, }, Main: types.Main{ - ClusterGroupName: "test", + Variant: "test", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", @@ -348,7 +348,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }, }, Main: types.Main{ - ClusterGroupName: "prod", + Variant: "prod", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", @@ -428,7 +428,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }, }, Main: types.Main{ - ClusterGroupName: "prod", + Variant: "prod", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", @@ -518,7 +518,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }, }, Main: types.Main{ - ClusterGroupName: "test", + Variant: "test", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", @@ -595,7 +595,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }, }, Main: types.Main{ - ClusterGroupName: "prod", + Variant: "prod", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", @@ -686,7 +686,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }, }, Main: types.Main{ - ClusterGroupName: "test", + Variant: "test", MultiSourceConfig: types.MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", diff --git a/src/cmd/upgrade.go b/src/cmd/upgrade.go index 777c390..0170806 100644 --- a/src/cmd/upgrade.go +++ b/src/cmd/upgrade.go @@ -70,6 +70,16 @@ func runUpgrade(replaceMakefile bool) error { return fmt.Errorf("error installing skills: %w", err) } + deprecated, err := pattern.UsesDeprecatedClusterGroupName(repoRoot) + if err != nil { + return fmt.Errorf("error checking for deprecated fields: %w", err) + } + if deprecated { + fmt.Println("Warning: values-global.yaml uses the deprecated 'main.clusterGroupName' field.") + fmt.Println("The Validated Patterns operator deprecated 'main.clusterGroupName' in favor of 'main.variant' in version 0.0.79.") + fmt.Println("Please update values-global.yaml to use 'main.variant'.") + } + fmt.Printf("Successfully upgraded pattern repository in %s\n", repoRoot) return nil } diff --git a/src/cmd/upgrade_test.go b/src/cmd/upgrade_test.go index 2721a3f..1f2ed54 100644 --- a/src/cmd/upgrade_test.go +++ b/src/cmd/upgrade_test.go @@ -115,6 +115,51 @@ var _ = Describe("patternizer update (upgrade alias)", func() { }) }) +var _ = Describe("patternizer upgrade deprecation warning", func() { + const legacyGlobal = "global:\n pattern: test-pattern\nmain:\n clusterGroupName: hub\n" + const variantGlobal = "global:\n pattern: test-pattern\nmain:\n variant: hub\n" + + Context("when values-global.yaml uses the deprecated main.clusterGroupName", func() { + var tempDir string + var session *gexec.Session + + BeforeEach(func() { + tempDir = createTestDir() + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(legacyGlobal), 0o644)).To(Succeed()) + session = runCLI(tempDir, "upgrade") + }) + + It("should print a deprecation warning mentioning main.variant", func() { + out := string(session.Out.Contents()) + Expect(out).To(ContainSubstring("main.clusterGroupName")) + Expect(out).To(ContainSubstring("main.variant")) + Expect(out).To(ContainSubstring("0.0.79")) + }) + + It("should leave values-global.yaml unchanged", func() { + f, err := os.ReadFile(filepath.Join(tempDir, "values-global.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(f)).To(Equal(legacyGlobal)) + }) + }) + + Context("when values-global.yaml already uses main.variant", func() { + var tempDir string + var session *gexec.Session + + BeforeEach(func() { + tempDir = createTestDir() + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(variantGlobal), 0o644)).To(Succeed()) + session = runCLI(tempDir, "upgrade") + }) + + It("should not print a deprecation warning", func() { + out := string(session.Out.Contents()) + Expect(out).NotTo(ContainSubstring("deprecated")) + }) + }) +}) + var _ = Describe("patternizer upgrade --replace-makefile", func() { Context("on a repo using common", Ordered, func() { var tempDir string diff --git a/src/internal/embedded/skills/pattern-author/reference.md b/src/internal/embedded/skills/pattern-author/reference.md index 1a24dda..49859ec 100644 --- a/src/internal/embedded/skills/pattern-author/reference.md +++ b/src/internal/embedded/skills/pattern-author/reference.md @@ -79,7 +79,7 @@ global: secretLoader: disabled: false main: - clusterGroupName: prod + variant: prod multiSourceConfig: enabled: true clusterGroupChartVersion: 0.9.* @@ -93,7 +93,7 @@ main: `global.secretLoader.disabled` — When `true`, skip loading secrets. Useful for Patterns that don't use the secrets framework. -`main.clusterGroupName` — The name of the primary clustergroup. Determines which `values-.yaml` file defines the main cluster. If you change this value, the next `pattern init` run creates the corresponding values file (you would need to manually delete the old unused one). +`main.variant` — The name of the primary clustergroup. Determines which `values-.yaml` file defines the main cluster. If you change this value, the next `pattern init` run creates the corresponding values file (you would need to manually delete the old unused one). This replaces the deprecated `main.clusterGroupName` field (deprecated by the Validated Patterns operator in version 0.0.79); patternizer automatically migrates `main.clusterGroupName` to `main.variant` on `init`. `main.multiSourceConfig.enabled` — Enable ArgoCD multi-source applications. Should always be `true` for modern Patterns. @@ -184,7 +184,7 @@ global: pattern: ai-quickstart-rag device: cpu # one of 'cpu' (no GPU) or 'gpu' (NVIDIA GPU) main: - clusterGroupName: prod + variant: prod multiSourceConfig: enabled: true clusterGroupChartVersion: 0.9.* diff --git a/src/internal/pattern/pattern.go b/src/internal/pattern/pattern.go index f8bd47a..afca7da 100644 --- a/src/internal/pattern/pattern.go +++ b/src/internal/pattern/pattern.go @@ -23,9 +23,14 @@ func GetPatternNameAndRepoRoot() (patternName, repoRoot string, err error) { return patternName, repoRoot, nil } +// deprecatedClusterGroupNameField is the legacy key under 'main' that has been +// replaced by 'main.variant'. +const deprecatedClusterGroupNameField = "clusterGroupName" + // ProcessGlobalValues processes the global values YAML file. -// It returns the pattern name and cluster group name that should be used (from the file if they exist, or the detected/default names). -func ProcessGlobalValues(patternName, repoRoot string, withSecrets bool) (actualPatternName, clusterGroupName string, err error) { +// It returns the pattern name and variant that should be used (from the file if they exist, or the detected/default names). +// If the existing file uses the deprecated 'main.clusterGroupName' field, it is automatically migrated to 'main.variant'. +func ProcessGlobalValues(patternName, repoRoot string, withSecrets bool) (actualPatternName, variant string, err error) { globalValuesPath := filepath.Join(repoRoot, "values-global.yaml") values := types.NewDefaultValuesGlobal() @@ -34,12 +39,18 @@ func ProcessGlobalValues(patternName, repoRoot string, withSecrets bool) (actual return "", "", fmt.Errorf("failed to read %s: %w", globalValuesPath, err) } + var fileValues types.ValuesGlobal if err == nil { if err = yaml.Unmarshal(yamlFile, values); err != nil { return "", "", fmt.Errorf("failed to unmarshal YAML from %s: %w", globalValuesPath, err) } + if err = yaml.Unmarshal(yamlFile, &fileValues); err != nil { + return "", "", fmt.Errorf("failed to unmarshal YAML from %s: %w", globalValuesPath, err) + } } + migrateDeprecatedVariant(values, fileValues.Main.Variant != "") + if values.Global.Pattern == "" { values.Global.Pattern = patternName } @@ -53,7 +64,55 @@ func ProcessGlobalValues(patternName, repoRoot string, withSecrets bool) (actual return "", "", fmt.Errorf("failed to write to %s: %w", globalValuesPath, err) } - return values.Global.Pattern, values.Main.ClusterGroupName, nil + return values.Global.Pattern, values.Main.Variant, nil +} + +// migrateDeprecatedVariant migrates a legacy 'main.clusterGroupName' field to +// 'main.variant'. An explicit 'main.variant' value (variantExplicit) always wins; +// otherwise the deprecated value is adopted so the existing values-.yaml +// keeps being used. The deprecated key is always removed so it is not written back. +func migrateDeprecatedVariant(values *types.ValuesGlobal, variantExplicit bool) { + if values.Main.OtherFields == nil { + return + } + + legacy, ok := values.Main.OtherFields[deprecatedClusterGroupNameField] + if !ok { + return + } + + delete(values.Main.OtherFields, deprecatedClusterGroupNameField) + + if !variantExplicit { + if legacyName, ok := legacy.(string); ok { + values.Main.Variant = legacyName + } + } + + fmt.Printf("Migrated deprecated 'main.clusterGroupName' to 'main.variant' (%s) in values-global.yaml.\n", values.Main.Variant) +} + +// UsesDeprecatedClusterGroupName reports whether the pattern's values-global.yaml +// still uses the deprecated 'main.clusterGroupName' field. It returns false without +// error when the file does not exist. +func UsesDeprecatedClusterGroupName(repoRoot string) (bool, error) { + globalValuesPath := filepath.Join(repoRoot, "values-global.yaml") + + yamlFile, err := os.ReadFile(globalValuesPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to read %s: %w", globalValuesPath, err) + } + + var values types.ValuesGlobal + if err = yaml.Unmarshal(yamlFile, &values); err != nil { + return false, fmt.Errorf("failed to unmarshal YAML from %s: %w", globalValuesPath, err) + } + + _, ok := values.Main.OtherFields[deprecatedClusterGroupNameField] + return ok, nil } // ProcessClusterGroupValues processes the cluster group values YAML file. diff --git a/src/internal/pattern/pattern_test.go b/src/internal/pattern/pattern_test.go index c5ba7e0..fc072cf 100644 --- a/src/internal/pattern/pattern_test.go +++ b/src/internal/pattern/pattern_test.go @@ -87,7 +87,7 @@ var _ = Describe("ProcessGlobalValues", func() { {[]string{"global", "customField"}, "customValue"}, {[]string{"global", "nestedCustom", "key1"}, "value1"}, {[]string{"global", "nestedCustom", "key2"}, 42}, - {[]string{"main", "clusterGroupName"}, "custom-cluster-group"}, + {[]string{"main", "variant"}, "custom-cluster-group"}, {[]string{"main", "multiSourceConfig", "enabled"}, false}, {[]string{"main", "multiSourceConfig", "clusterGroupChartVersion"}, "1.0.*"}, {[]string{"main", "multiSourceConfig", "customMultiSource"}, "customValue"}, @@ -100,6 +100,9 @@ var _ = Describe("ProcessGlobalValues", func() { "Field %v should be %v", tt.path, tt.expected) } + // The deprecated clusterGroupName field should be migrated away. + Expect(getNestedValue(processedValues, []string{"main", "clusterGroupName"})).To(BeNil()) + // Verify array field is preserved customTopLevel, ok := processedValues["customTopLevel"].(map[string]interface{}) Expect(ok).To(BeTrue(), "customTopLevel should be a map") @@ -132,7 +135,7 @@ var _ = Describe("ProcessGlobalValues", func() { Expect(yaml.Unmarshal(data, &values)).To(Succeed()) Expect(values.Global.Pattern).To(Equal("test-pattern")) - Expect(values.Main.ClusterGroupName).To(Equal("prod")) + Expect(values.Main.Variant).To(Equal("prod")) Expect(values.Main.MultiSourceConfig.Enabled).To(BeTrue()) Expect(values.Main.MultiSourceConfig.ClusterGroupChartVersion).To(Equal("0.9.*")) Expect(values.Global.SecretLoader.Disabled).To(BeTrue()) @@ -298,3 +301,87 @@ var _ = Describe("ProcessClusterGroupValues", func() { }) }) }) + +var _ = Describe("ProcessGlobalValues variant migration", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + writeGlobalValues := func(content string) string { + valuesPath := filepath.Join(tempDir, "values-global.yaml") + Expect(os.WriteFile(valuesPath, []byte(content), 0o644)).To(Succeed()) + return valuesPath + } + + readMain := func(valuesPath string) map[string]interface{} { + data, err := os.ReadFile(valuesPath) + Expect(err).NotTo(HaveOccurred()) + var values map[string]interface{} + Expect(yaml.Unmarshal(data, &values)).To(Succeed()) + main, ok := values["main"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + return main + } + + Context("when only the deprecated clusterGroupName is set", func() { + It("adopts its value as the variant and removes the deprecated field", func() { + valuesPath := writeGlobalValues("global:\n pattern: existing\nmain:\n clusterGroupName: hub\n") + + _, variant, err := ProcessGlobalValues("existing", tempDir, false) + Expect(err).NotTo(HaveOccurred()) + Expect(variant).To(Equal("hub")) + + main := readMain(valuesPath) + Expect(main["variant"]).To(Equal("hub")) + Expect(main).NotTo(HaveKey("clusterGroupName")) + }) + }) + + Context("when both variant and the deprecated clusterGroupName are set", func() { + It("keeps variant and removes the deprecated field", func() { + valuesPath := writeGlobalValues("global:\n pattern: existing\nmain:\n variant: real\n clusterGroupName: legacy\n") + + _, variant, err := ProcessGlobalValues("existing", tempDir, false) + Expect(err).NotTo(HaveOccurred()) + Expect(variant).To(Equal("real")) + + main := readMain(valuesPath) + Expect(main["variant"]).To(Equal("real")) + Expect(main).NotTo(HaveKey("clusterGroupName")) + }) + }) +}) + +var _ = Describe("UsesDeprecatedClusterGroupName", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + It("returns false when values-global.yaml does not exist", func() { + used, err := UsesDeprecatedClusterGroupName(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(used).To(BeFalse()) + }) + + It("returns true when main.clusterGroupName is present", func() { + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), + []byte("main:\n clusterGroupName: hub\n"), 0o644)).To(Succeed()) + + used, err := UsesDeprecatedClusterGroupName(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(used).To(BeTrue()) + }) + + It("returns false when main.variant is used", func() { + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), + []byte("main:\n variant: hub\n"), 0o644)).To(Succeed()) + + used, err := UsesDeprecatedClusterGroupName(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(used).To(BeFalse()) + }) +}) diff --git a/src/internal/types/global.go b/src/internal/types/global.go index ab5109e..27e3350 100644 --- a/src/internal/types/global.go +++ b/src/internal/types/global.go @@ -16,7 +16,7 @@ type Global struct { // Main represents the 'main' section of the YAML file. type Main struct { - ClusterGroupName string `yaml:"clusterGroupName"` + Variant string `yaml:"variant"` MultiSourceConfig MultiSourceConfig `yaml:"multiSourceConfig"` OtherFields map[string]interface{} `yaml:",inline"` } @@ -45,7 +45,7 @@ func NewDefaultValuesGlobal() *ValuesGlobal { }, }, Main: Main{ - ClusterGroupName: "prod", + Variant: "prod", MultiSourceConfig: MultiSourceConfig{ Enabled: true, ClusterGroupChartVersion: "0.9.*", diff --git a/src/main_test.go b/src/main_test.go index 4f4e49f..f1433c1 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -11,7 +11,7 @@ var _ = Describe("NewDefaultValuesGlobal", func() { It("should create default global values with expected defaults", func() { values := types.NewDefaultValuesGlobal() - Expect(values.Main.ClusterGroupName).To(Equal("prod")) + Expect(values.Main.Variant).To(Equal("prod")) Expect(values.Main.MultiSourceConfig.Enabled).To(BeTrue()) Expect(values.Main.MultiSourceConfig.ClusterGroupChartVersion).To(Equal("0.9.*")) }) From 996912f284bb78bb318357ad93172e15de0e98f1 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Wed, 14 Oct 2026 15:06:05 -0400 Subject: [PATCH 2/3] add support for variants folder structure --- README.md | 5 +- src/cmd/init_test.go | 26 +++-- src/cmd/upgrade.go | 12 +- src/cmd/upgrade_test.go | 53 ++++++++- .../embedded/skills/pattern-author/SKILL.md | 4 +- .../skills/pattern-author/reference.md | 25 +++- src/internal/pattern/pattern.go | 107 ++++++++++++++++-- src/internal/pattern/pattern_test.go | 80 ++++++++++++- 8 files changed, 279 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index e8ca6fa..ec9b5ce 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,8 @@ What upgrade does: - If no `Makefile` exists: copies the default `Makefile` - If a `Makefile` exists and already contains `include Makefile-common` anywhere: leaves it unchanged - Otherwise: prepends `include Makefile-common` to the first line so your existing targets are preserved -- Prints a deprecation warning if `values-global.yaml` still uses `main.clusterGroupName`, which the Validated Patterns operator deprecated in favor of `main.variant` in version 0.0.79 (`upgrade` does not modify the file; run `patternizer init` to migrate automatically) +- Prints a deprecation warning if `values-global.yaml` still uses `main.clusterGroupName`, which the Validated Patterns operator deprecated in favor of `main.variant` in version 0.0.78 (`upgrade` does not modify the file; run `patternizer init` to migrate automatically) +- Prints a deprecation warning if the pattern does not use the [variants folder structure](https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/) (introduced in operator version 0.0.78) — that is, if `variants//values-.yaml` is missing for the configured variant (`upgrade` does not move any files; run `patternizer init` to migrate automatically) ### Understanding Secrets Management @@ -149,7 +150,7 @@ For more details on how secrets work in the framework, see the [Secrets Manageme Running `patternizer init` creates the following: - `values-global.yaml`: Global pattern configuration. -- `values-.yaml`: Cluster group-specific values. +- `variants//values-.yaml`: Variant-specific (cluster group) values, using the [variants folder structure](https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/). A legacy root-level `values-.yaml` is migrated into this folder automatically. - `pattern.sh`: A utility script for common pattern operations (`install`, `upgrade`, etc.). - `Makefile`: A simple Makefile that includes `Makefile-common`. - `Makefile-common`: The core Makefile with all pattern-related targets. diff --git a/src/cmd/init_test.go b/src/cmd/init_test.go index 7aa2eca..165c5d2 100644 --- a/src/cmd/init_test.go +++ b/src/cmd/init_test.go @@ -91,7 +91,7 @@ var _ = Describe("patternizer init", func() { }) It("should create an appropriate clustergroup values file", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-prod.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "prod", "values-prod.yaml") expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "prod", @@ -140,7 +140,7 @@ var _ = Describe("patternizer init", func() { }) It("should create an appropriate clustergroup values file", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-prod.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "prod", "values-prod.yaml") expectedNamespace := filepath.Base(tempDir) expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ @@ -200,7 +200,7 @@ var _ = Describe("patternizer init", func() { }) It("should create an appropriate clustergroup values file", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-test.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "test", "values-test.yaml") expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "test", @@ -249,7 +249,7 @@ var _ = Describe("patternizer init", func() { }) It("should create an appropriate clustergroup values file", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-test.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "test", "values-test.yaml") expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "test", @@ -270,6 +270,10 @@ var _ = Describe("patternizer init", func() { } verifyClusterGroupValues(clusterGroupValuesFile, &expectedClusterGroupValues) }) + + It("should remove the legacy root-level values file after migration", func() { + Expect(filepath.Join(tempDir, "values-test.yaml")).NotTo(BeAnExistingFile()) + }) }) for _, alias := range []string{"create", "bootstrap"} { @@ -359,7 +363,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }) It("should create an appropriate clustergroup values file", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-prod.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "prod", "values-prod.yaml") expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "prod", @@ -439,7 +443,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }) It("should create an appropriate clustergroup values file", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-prod.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "prod", "values-prod.yaml") expectedNamespace := filepath.Base(tempDir) expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ @@ -529,7 +533,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }) It("should create an appropriate clustergroup values file", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-test.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "test", "values-test.yaml") expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "test", @@ -606,7 +610,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }) It("should update the clustergroup values file to include secrets", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-prod.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "prod", "values-prod.yaml") expectedNamespace := filepath.Base(tempDir) expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ @@ -697,7 +701,7 @@ var _ = Describe("patternizer init --with-secrets", func() { }) It("should create an appropriate clustergroup values file", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-test.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "test", "values-test.yaml") expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "test", @@ -788,7 +792,7 @@ clusterGroup: }) It("should migrate list-style namespaces to map-style", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-prod.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "prod", "values-prod.yaml") expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "prod", @@ -833,7 +837,7 @@ clusterGroup: }) It("should preserve map-style namespaces", func() { - clusterGroupValuesFile := filepath.Join(tempDir, "values-prod.yaml") + clusterGroupValuesFile := filepath.Join(tempDir, "variants", "prod", "values-prod.yaml") expectedClusterGroupValues := types.ValuesClusterGroup{ ClusterGroup: types.ClusterGroup{ Name: "prod", diff --git a/src/cmd/upgrade.go b/src/cmd/upgrade.go index 0170806..bf014f2 100644 --- a/src/cmd/upgrade.go +++ b/src/cmd/upgrade.go @@ -76,10 +76,20 @@ func runUpgrade(replaceMakefile bool) error { } if deprecated { fmt.Println("Warning: values-global.yaml uses the deprecated 'main.clusterGroupName' field.") - fmt.Println("The Validated Patterns operator deprecated 'main.clusterGroupName' in favor of 'main.variant' in version 0.0.79.") + fmt.Println("The Validated Patterns operator deprecated 'main.clusterGroupName' in favor of 'main.variant' in version 0.0.78.") fmt.Println("Please update values-global.yaml to use 'main.variant'.") } + variant, folderMissing, err := pattern.VariantFolderFileMissing(repoRoot) + if err != nil { + return fmt.Errorf("error checking for variants folder structure: %w", err) + } + if folderMissing { + fmt.Printf("Warning: this pattern does not use the variants folder structure (variants/%s/values-%s.yaml not found).\n", variant, variant) + fmt.Println("The Validated Patterns operator introduced the variants folder structure in version 0.0.78.") + fmt.Println("See https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/ for migration guidance.") + } + fmt.Printf("Successfully upgraded pattern repository in %s\n", repoRoot) return nil } diff --git a/src/cmd/upgrade_test.go b/src/cmd/upgrade_test.go index 1f2ed54..27cda6e 100644 --- a/src/cmd/upgrade_test.go +++ b/src/cmd/upgrade_test.go @@ -133,7 +133,7 @@ var _ = Describe("patternizer upgrade deprecation warning", func() { out := string(session.Out.Contents()) Expect(out).To(ContainSubstring("main.clusterGroupName")) Expect(out).To(ContainSubstring("main.variant")) - Expect(out).To(ContainSubstring("0.0.79")) + Expect(out).To(ContainSubstring("0.0.78")) }) It("should leave values-global.yaml unchanged", func() { @@ -143,19 +143,64 @@ var _ = Describe("patternizer upgrade deprecation warning", func() { }) }) - Context("when values-global.yaml already uses main.variant", func() { + Context("when values-global.yaml already uses main.variant and the variants folder", func() { var tempDir string var session *gexec.Session BeforeEach(func() { tempDir = createTestDir() Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(variantGlobal), 0o644)).To(Succeed()) + variantDir := filepath.Join(tempDir, "variants", "hub") + Expect(os.MkdirAll(variantDir, 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(variantDir, "values-hub.yaml"), []byte("clusterGroup:\n name: hub\n"), 0o644)).To(Succeed()) session = runCLI(tempDir, "upgrade") }) - It("should not print a deprecation warning", func() { + It("should not print any deprecation warning", func() { out := string(session.Out.Contents()) - Expect(out).NotTo(ContainSubstring("deprecated")) + Expect(out).NotTo(ContainSubstring("Warning:")) + }) + }) +}) + +var _ = Describe("patternizer upgrade variants folder warning", func() { + const variantGlobal = "global:\n pattern: test-pattern\nmain:\n variant: hub\n" + + Context("when the variants folder file is missing", func() { + var tempDir string + var session *gexec.Session + + BeforeEach(func() { + tempDir = createTestDir() + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(variantGlobal), 0o644)).To(Succeed()) + session = runCLI(tempDir, "upgrade") + }) + + It("should print a variants folder structure warning linking to the blog", func() { + out := string(session.Out.Contents()) + Expect(out).To(ContainSubstring("variants folder structure")) + Expect(out).To(ContainSubstring("variants/hub/values-hub.yaml")) + Expect(out).To(ContainSubstring("0.0.78")) + Expect(out).To(ContainSubstring("https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/")) + }) + }) + + Context("when the variants folder file exists", func() { + var tempDir string + var session *gexec.Session + + BeforeEach(func() { + tempDir = createTestDir() + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(variantGlobal), 0o644)).To(Succeed()) + variantDir := filepath.Join(tempDir, "variants", "hub") + Expect(os.MkdirAll(variantDir, 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(variantDir, "values-hub.yaml"), []byte("clusterGroup:\n name: hub\n"), 0o644)).To(Succeed()) + session = runCLI(tempDir, "upgrade") + }) + + It("should not print a variants folder structure warning", func() { + out := string(session.Out.Contents()) + Expect(out).NotTo(ContainSubstring("variants folder structure")) }) }) }) diff --git a/src/internal/embedded/skills/pattern-author/SKILL.md b/src/internal/embedded/skills/pattern-author/SKILL.md index 281eef3..0634687 100644 --- a/src/internal/embedded/skills/pattern-author/SKILL.md +++ b/src/internal/embedded/skills/pattern-author/SKILL.md @@ -26,7 +26,7 @@ When creating a new Pattern: 1. **Initialize** — Determine the Pattern name and whether secrets are needed. Run `pattern init` or `pattern init --with-secrets` in the Pattern directory. 2. **Identify requirements** — What operators, applications, and custom helm charts does this Pattern need? -3. **Define namespaces** — Add all required namespaces to the clustergroup values file (`values-.yaml`). Include OperatorGroup configuration for operator namespaces. +3. **Define namespaces** — Add all required namespaces to the clustergroup values file (`variants//values-.yaml`). Include OperatorGroup configuration for operator namespaces. 4. **Define subscriptions** — Add operator subscriptions with at minimum the operator `name`. Set `namespace`, `channel`, and `source` as needed. 5. **Define applications** — Wire in helm charts as applications: - Local charts: set `path` to the chart location in the repository @@ -234,7 +234,7 @@ Define a custom global variable and use `sharedValueFiles`: global: device: gpu -# values-.yaml +# variants//values-.yaml clusterGroup: sharedValueFiles: - /overrides/values-{{ $.Values.global.device }}.yaml diff --git a/src/internal/embedded/skills/pattern-author/reference.md b/src/internal/embedded/skills/pattern-author/reference.md index 49859ec..ffd4e05 100644 --- a/src/internal/embedded/skills/pattern-author/reference.md +++ b/src/internal/embedded/skills/pattern-author/reference.md @@ -93,7 +93,7 @@ main: `global.secretLoader.disabled` — When `true`, skip loading secrets. Useful for Patterns that don't use the secrets framework. -`main.variant` — The name of the primary clustergroup. Determines which `values-.yaml` file defines the main cluster. If you change this value, the next `pattern init` run creates the corresponding values file (you would need to manually delete the old unused one). This replaces the deprecated `main.clusterGroupName` field (deprecated by the Validated Patterns operator in version 0.0.79); patternizer automatically migrates `main.clusterGroupName` to `main.variant` on `init`. +`main.variant` — The name of the primary clustergroup. Determines which `values-.yaml` file defines the main cluster, located at `variants//values-.yaml` (see [Variants folder structure](#variants-folder-structure)). If you change this value, the next `pattern init` run creates the corresponding values file (you would need to manually delete the old unused one). This replaces the deprecated `main.clusterGroupName` field (deprecated by the Validated Patterns operator in version 0.0.78); patternizer automatically migrates `main.clusterGroupName` to `main.variant` on `init`. `main.multiSourceConfig.enabled` — Enable ArgoCD multi-source applications. Should always be `true` for modern Patterns. @@ -111,6 +111,27 @@ There are also some global options that can be set in `values-global.yaml` but a `values-prod.yaml` is the values file that defines the main clustergroup of the Pattern. In hub/spoke Patterns (see [Spoke clusters](#spoke-clusters)) this would be the hub cluster. In single cluster Patterns this defines the solitary cluster. If there is one file to look at which defines the Pattern, it is this one. Its contents are explored in [The clustergroup values](#the-clustergroup-values) section. +With the [variants folder structure](#variants-folder-structure) (the layout patternizer now generates), this file lives at `variants//values-.yaml` (e.g. `variants/prod/values-prod.yaml`) rather than at the repository root. + +### Variants folder structure + +Per-cluster (per-variant) values files live under a top-level `variants/` directory, with one subfolder per variant named after the variant: + +``` +. +├── values-global.yaml +├── charts/ +└── variants/ + ├── prod/ + │ └── values-prod.yaml + └── standalone/ + └── values-standalone.yaml +``` + +The Validated Patterns operator auto-detects a top-level `variants/` directory and, when `main.variant` is set, looks for per-cluster values files under `variants//` instead of the repository root. This keeps the root uncluttered as you add more variants. This structure was introduced in operator version 0.0.78; see the [variants folder structure blog post](https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/) for details. + +`patternizer init` creates and uses this structure automatically, migrating a legacy root-level `values-.yaml` into `variants//` when present. The flat (root-level) layout is still supported by the operator; `patternizer upgrade` does not move files but prints a deprecation warning when the variant's file is missing from the variants folder. + ### The Pattern CR When you run `./pattern.sh make install` to deploy the Pattern, the values in `values-global.yaml` are passed to the [pattern-install chart](https://github.com/validatedpatterns/pattern-install-chart) by the [rhvp.cluster_utils.install](https://github.com/validatedpatterns/rhvp.cluster_utils/blob/main/playbooks/install.yml) playbook. This chart creates a subscription for the Validated Patterns Operator, a configmap with default operator configuration, and the Pattern CR: @@ -152,6 +173,8 @@ The following values files are automatically included for each application, in t ArgoCD is configured with `ignoreMissingValueFiles: true`, so it silently skips any of these files that do not exist. Only create the files you actually need. +When the [variants folder structure](#variants-folder-structure) is in use, the variant-specific files above (those named after the clustergroup/variant, such as `values-.yaml`) are resolved from `variants//` instead of the repository root. + In multi-source mode (the default), values files from the Pattern repository are prefixed with `$patternref/` to tell ArgoCD which source they come from. This is handled automatically by the clustergroup chart. Some of these values files are cross-clustergroup. For example, `values-AWS.yaml` would apply to both hub and spoke clustergroups running on AWS. diff --git a/src/internal/pattern/pattern.go b/src/internal/pattern/pattern.go index afca7da..fa35602 100644 --- a/src/internal/pattern/pattern.go +++ b/src/internal/pattern/pattern.go @@ -115,32 +115,123 @@ func UsesDeprecatedClusterGroupName(repoRoot string) (bool, error) { return ok, nil } +// variantValuesFileName returns the values file name for a given variant. +func variantValuesFileName(variant string) string { + return fmt.Sprintf("values-%s.yaml", variant) +} + +// variantFolderValuesPath returns the path to a variant's values file in the +// variants folder structure: variants//values-.yaml. +func variantFolderValuesPath(repoRoot, variant string) string { + return filepath.Join(repoRoot, "variants", variant, variantValuesFileName(variant)) +} + // ProcessClusterGroupValues processes the cluster group values YAML file. -func ProcessClusterGroupValues(patternName, clusterGroupName, repoRoot string, chartPaths []string, useSecrets bool) error { - clusterGroupValuesPath := filepath.Join(repoRoot, fmt.Sprintf("values-%s.yaml", clusterGroupName)) - values := types.NewDefaultValuesClusterGroup(patternName, clusterGroupName, chartPaths, useSecrets) +// The file is written to the variants folder structure at +// variants//values-.yaml. If a legacy flat-layout file exists at the +// repository root (values-.yaml), it is migrated into the variants folder and +// the old file is removed. +func ProcessClusterGroupValues(patternName, variant, repoRoot string, chartPaths []string, useSecrets bool) error { + newPath := variantFolderValuesPath(repoRoot, variant) + oldPath := filepath.Join(repoRoot, variantValuesFileName(variant)) + + values := types.NewDefaultValuesClusterGroup(patternName, variant, chartPaths, useSecrets) - yamlFile, err := os.ReadFile(clusterGroupValuesPath) + oldExists, err := fileExists(oldPath) + if err != nil { + return err + } + + mergeSource := newPath + if sourceExists, srcErr := fileExists(newPath); srcErr != nil { + return srcErr + } else if !sourceExists && oldExists { + mergeSource = oldPath + } + + yamlFile, err := os.ReadFile(mergeSource) if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read %s: %w", clusterGroupValuesPath, err) + return fmt.Errorf("failed to read %s: %w", mergeSource, err) } if err == nil { var existingValues types.ValuesClusterGroup if err = yaml.Unmarshal(yamlFile, &existingValues); err != nil { - return fmt.Errorf("failed to unmarshal YAML from %s: %w", clusterGroupValuesPath, err) + return fmt.Errorf("failed to unmarshal YAML from %s: %w", mergeSource, err) } mergeClusterGroupValues(values, &existingValues) } - if err = fileutils.WriteYAMLWithIndent(values, clusterGroupValuesPath); err != nil { - return fmt.Errorf("failed to write to %s: %w", clusterGroupValuesPath, err) + if err = os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", newPath, err) + } + + if err = fileutils.WriteYAMLWithIndent(values, newPath); err != nil { + return fmt.Errorf("failed to write to %s: %w", newPath, err) + } + + if oldExists { + if err = fileutils.RemovePathIfExists(oldPath); err != nil { + return fmt.Errorf("failed to remove legacy %s: %w", oldPath, err) + } + fmt.Printf("Migrated %s to variants/%s/.\n", variantValuesFileName(variant), variant) } return nil } +// fileExists reports whether a regular file exists at the given path. +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to stat %s: %w", path, err) +} + +// VariantFolderFileMissing reports whether the pattern is missing the variants folder +// structure file for its configured variant. It resolves the variant from +// 'main.variant' (or the legacy 'main.clusterGroupName', defaulting to "prod") and +// checks for variants//values-.yaml. It returns missing=false when +// values-global.yaml does not exist (nothing to warn about). +func VariantFolderFileMissing(repoRoot string) (variant string, missing bool, err error) { + globalValuesPath := filepath.Join(repoRoot, "values-global.yaml") + + yamlFile, err := os.ReadFile(globalValuesPath) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, fmt.Errorf("failed to read %s: %w", globalValuesPath, err) + } + + var values types.ValuesGlobal + if err = yaml.Unmarshal(yamlFile, &values); err != nil { + return "", false, fmt.Errorf("failed to unmarshal YAML from %s: %w", globalValuesPath, err) + } + + variant = values.Main.Variant + if variant == "" { + if legacy, ok := values.Main.OtherFields[deprecatedClusterGroupNameField].(string); ok { + variant = legacy + } + } + if variant == "" { + variant = "prod" + } + + exists, err := fileExists(variantFolderValuesPath(repoRoot, variant)) + if err != nil { + return "", false, err + } + + return variant, !exists, nil +} + // mergeClusterGroupValues intelligently merges existing values with new defaults func mergeClusterGroupValues(defaults, existing *types.ValuesClusterGroup) { for key, app := range existing.ClusterGroup.Applications { diff --git a/src/internal/pattern/pattern_test.go b/src/internal/pattern/pattern_test.go index fc072cf..8a4cc33 100644 --- a/src/internal/pattern/pattern_test.go +++ b/src/internal/pattern/pattern_test.go @@ -173,8 +173,9 @@ var _ = Describe("ProcessGlobalValues", func() { var _ = Describe("ProcessClusterGroupValues", func() { Context("with an existing values file containing custom fields", func() { var ( - tempDir string - valuesPath string + tempDir string + valuesPath string + oldValuesPath string ) BeforeEach(func() { @@ -210,10 +211,19 @@ var _ = Describe("ProcessClusterGroupValues", func() { }, } - valuesPath = filepath.Join(tempDir, "values-prod.yaml") + oldValuesPath = filepath.Join(tempDir, "values-prod.yaml") + valuesPath = filepath.Join(tempDir, "variants", "prod", "values-prod.yaml") initialYaml, err := yaml.Marshal(initialValues) Expect(err).NotTo(HaveOccurred()) - Expect(os.WriteFile(valuesPath, initialYaml, 0o644)).To(Succeed()) + Expect(os.WriteFile(oldValuesPath, initialYaml, 0o644)).To(Succeed()) + }) + + It("should migrate the legacy root file into the variants folder", func() { + chartPaths := []string{"charts/app1", "charts/app2"} + Expect(ProcessClusterGroupValues("test-pattern", "prod", tempDir, chartPaths, false)).To(Succeed()) + + Expect(valuesPath).To(BeAnExistingFile()) + Expect(oldValuesPath).NotTo(BeAnExistingFile()) }) It("should preserve custom fields", func() { @@ -385,3 +395,65 @@ var _ = Describe("UsesDeprecatedClusterGroupName", func() { Expect(used).To(BeFalse()) }) }) + +var _ = Describe("VariantFolderFileMissing", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + writeGlobal := func(content string) { + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(content), 0o644)).To(Succeed()) + } + + writeVariantFile := func(variant string) { + dir := filepath.Join(tempDir, "variants", variant) + Expect(os.MkdirAll(dir, 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "values-"+variant+".yaml"), []byte("clusterGroup:\n name: "+variant+"\n"), 0o644)).To(Succeed()) + } + + It("returns missing=false when values-global.yaml does not exist", func() { + variant, missing, err := VariantFolderFileMissing(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(variant).To(Equal("")) + Expect(missing).To(BeFalse()) + }) + + It("returns missing=true when the variant folder file is absent", func() { + writeGlobal("main:\n variant: hub\n") + + variant, missing, err := VariantFolderFileMissing(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(variant).To(Equal("hub")) + Expect(missing).To(BeTrue()) + }) + + It("returns missing=false when the variant folder file exists", func() { + writeGlobal("main:\n variant: hub\n") + writeVariantFile("hub") + + variant, missing, err := VariantFolderFileMissing(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(variant).To(Equal("hub")) + Expect(missing).To(BeFalse()) + }) + + It("resolves the variant from the legacy clusterGroupName field", func() { + writeGlobal("main:\n clusterGroupName: legacy\n") + + variant, missing, err := VariantFolderFileMissing(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(variant).To(Equal("legacy")) + Expect(missing).To(BeTrue()) + }) + + It("defaults the variant to prod when none is configured", func() { + writeGlobal("global:\n pattern: test\n") + + variant, missing, err := VariantFolderFileMissing(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(variant).To(Equal("prod")) + Expect(missing).To(BeTrue()) + }) +}) From aa07b30fee260bc6a4cb62e04b91e4a5ca30752c Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Wed, 14 Oct 2026 15:57:22 -0400 Subject: [PATCH 3/3] add singleargocd deprecation warning --- README.md | 3 + src/cmd/upgrade.go | 52 ++++++++++++---- src/cmd/upgrade_test.go | 90 +++++++++++++++++++--------- src/internal/pattern/pattern.go | 28 +++++++++ src/internal/pattern/pattern_test.go | 50 ++++++++++++++++ 5 files changed, 186 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index ec9b5ce..6ebe8d7 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,9 @@ What upgrade does: - Otherwise: prepends `include Makefile-common` to the first line so your existing targets are preserved - Prints a deprecation warning if `values-global.yaml` still uses `main.clusterGroupName`, which the Validated Patterns operator deprecated in favor of `main.variant` in version 0.0.78 (`upgrade` does not modify the file; run `patternizer init` to migrate automatically) - Prints a deprecation warning if the pattern does not use the [variants folder structure](https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/) (introduced in operator version 0.0.78) — that is, if `variants//values-.yaml` is missing for the configured variant (`upgrade` does not move any files; run `patternizer init` to migrate automatically) +- Prints a warning if `global.singleArgoCD` is not set in `values-global.yaml`, recommending you set `global.singleArgoCD: true` if possible (see the [single ArgoCD instance](https://validatedpatterns.io/blog/2026-06-04-single-argocd-option/) blog post) + +These warnings are written to stderr, grouped after the success message, so they stand out as a call to action. ### Understanding Secrets Management diff --git a/src/cmd/upgrade.go b/src/cmd/upgrade.go index bf014f2..4f2143f 100644 --- a/src/cmd/upgrade.go +++ b/src/cmd/upgrade.go @@ -70,26 +70,58 @@ func runUpgrade(replaceMakefile bool) error { return fmt.Errorf("error installing skills: %w", err) } + warnings, err := collectUpgradeWarnings(repoRoot) + if err != nil { + return err + } + + fmt.Printf("Successfully upgraded pattern repository in %s\n", repoRoot) + + emitUpgradeWarnings(warnings) + return nil +} + +// collectUpgradeWarnings inspects the pattern's values-global.yaml and returns a list of +// compact deprecation warnings that should be surfaced to the user as a call to action. +func collectUpgradeWarnings(repoRoot string) ([]string, error) { + var warnings []string + deprecated, err := pattern.UsesDeprecatedClusterGroupName(repoRoot) if err != nil { - return fmt.Errorf("error checking for deprecated fields: %w", err) + return nil, fmt.Errorf("error checking for deprecated fields: %w", err) } if deprecated { - fmt.Println("Warning: values-global.yaml uses the deprecated 'main.clusterGroupName' field.") - fmt.Println("The Validated Patterns operator deprecated 'main.clusterGroupName' in favor of 'main.variant' in version 0.0.78.") - fmt.Println("Please update values-global.yaml to use 'main.variant'.") + warnings = append(warnings, "WARNING: values-global.yaml uses deprecated 'main.clusterGroupName' (operator 0.0.78). Set 'main.variant' instead.") } variant, folderMissing, err := pattern.VariantFolderFileMissing(repoRoot) if err != nil { - return fmt.Errorf("error checking for variants folder structure: %w", err) + return nil, fmt.Errorf("error checking for variants folder structure: %w", err) } if folderMissing { - fmt.Printf("Warning: this pattern does not use the variants folder structure (variants/%s/values-%s.yaml not found).\n", variant, variant) - fmt.Println("The Validated Patterns operator introduced the variants folder structure in version 0.0.78.") - fmt.Println("See https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/ for migration guidance.") + warnings = append(warnings, fmt.Sprintf("WARNING: not using the variants folder structure (variants/%s/values-%s.yaml not found; operator 0.0.78). See https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/", variant, variant)) } - fmt.Printf("Successfully upgraded pattern repository in %s\n", repoRoot) - return nil + missingSingleArgoCD, err := pattern.MissingSingleArgoCD(repoRoot) + if err != nil { + return nil, fmt.Errorf("error checking for singleArgoCD: %w", err) + } + if missingSingleArgoCD { + warnings = append(warnings, "WARNING: 'global.singleArgoCD' is not set. Set 'global.singleArgoCD: true' if possible. See https://validatedpatterns.io/blog/2026-06-04-single-argocd-option/") + } + + return warnings, nil +} + +// emitUpgradeWarnings prints deprecation warnings to stderr as a grouped block, separated +// from the normal stdout logs so they stand out as a call to action. +func emitUpgradeWarnings(warnings []string) { + if len(warnings) == 0 { + return + } + + fmt.Fprintln(os.Stderr) + for _, warning := range warnings { + fmt.Fprintln(os.Stderr, warning) + } } diff --git a/src/cmd/upgrade_test.go b/src/cmd/upgrade_test.go index 27cda6e..4d1d3f9 100644 --- a/src/cmd/upgrade_test.go +++ b/src/cmd/upgrade_test.go @@ -115,9 +115,15 @@ var _ = Describe("patternizer update (upgrade alias)", func() { }) }) +func writeVariantFolderFile(dir, variant string) { + variantDir := filepath.Join(dir, "variants", variant) + Expect(os.MkdirAll(variantDir, 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(variantDir, "values-"+variant+".yaml"), []byte("clusterGroup:\n name: "+variant+"\n"), 0o644)).To(Succeed()) +} + var _ = Describe("patternizer upgrade deprecation warning", func() { - const legacyGlobal = "global:\n pattern: test-pattern\nmain:\n clusterGroupName: hub\n" - const variantGlobal = "global:\n pattern: test-pattern\nmain:\n variant: hub\n" + const legacyGlobal = "global:\n pattern: test-pattern\n singleArgoCD: true\nmain:\n clusterGroupName: hub\n" + const upToDateGlobal = "global:\n pattern: test-pattern\n singleArgoCD: true\nmain:\n variant: hub\n" Context("when values-global.yaml uses the deprecated main.clusterGroupName", func() { var tempDir string @@ -129,11 +135,11 @@ var _ = Describe("patternizer upgrade deprecation warning", func() { session = runCLI(tempDir, "upgrade") }) - It("should print a deprecation warning mentioning main.variant", func() { - out := string(session.Out.Contents()) - Expect(out).To(ContainSubstring("main.clusterGroupName")) - Expect(out).To(ContainSubstring("main.variant")) - Expect(out).To(ContainSubstring("0.0.78")) + It("should print a deprecation warning to stderr mentioning main.variant", func() { + errOut := string(session.Err.Contents()) + Expect(errOut).To(ContainSubstring("main.clusterGroupName")) + Expect(errOut).To(ContainSubstring("main.variant")) + Expect(errOut).To(ContainSubstring("0.0.78")) }) It("should leave values-global.yaml unchanged", func() { @@ -143,28 +149,25 @@ var _ = Describe("patternizer upgrade deprecation warning", func() { }) }) - Context("when values-global.yaml already uses main.variant and the variants folder", func() { + Context("when values-global.yaml is fully up to date", func() { var tempDir string var session *gexec.Session BeforeEach(func() { tempDir = createTestDir() - Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(variantGlobal), 0o644)).To(Succeed()) - variantDir := filepath.Join(tempDir, "variants", "hub") - Expect(os.MkdirAll(variantDir, 0o755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(variantDir, "values-hub.yaml"), []byte("clusterGroup:\n name: hub\n"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(upToDateGlobal), 0o644)).To(Succeed()) + writeVariantFolderFile(tempDir, "hub") session = runCLI(tempDir, "upgrade") }) - It("should not print any deprecation warning", func() { - out := string(session.Out.Contents()) - Expect(out).NotTo(ContainSubstring("Warning:")) + It("should not print any warnings to stderr", func() { + Expect(string(session.Err.Contents())).NotTo(ContainSubstring("WARNING:")) }) }) }) var _ = Describe("patternizer upgrade variants folder warning", func() { - const variantGlobal = "global:\n pattern: test-pattern\nmain:\n variant: hub\n" + const variantGlobal = "global:\n pattern: test-pattern\n singleArgoCD: true\nmain:\n variant: hub\n" Context("when the variants folder file is missing", func() { var tempDir string @@ -176,12 +179,12 @@ var _ = Describe("patternizer upgrade variants folder warning", func() { session = runCLI(tempDir, "upgrade") }) - It("should print a variants folder structure warning linking to the blog", func() { - out := string(session.Out.Contents()) - Expect(out).To(ContainSubstring("variants folder structure")) - Expect(out).To(ContainSubstring("variants/hub/values-hub.yaml")) - Expect(out).To(ContainSubstring("0.0.78")) - Expect(out).To(ContainSubstring("https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/")) + It("should print a variants folder structure warning to stderr linking to the blog", func() { + errOut := string(session.Err.Contents()) + Expect(errOut).To(ContainSubstring("variants folder structure")) + Expect(errOut).To(ContainSubstring("variants/hub/values-hub.yaml")) + Expect(errOut).To(ContainSubstring("0.0.78")) + Expect(errOut).To(ContainSubstring("https://validatedpatterns.io/blog/2026-08-04-variants-folder-structure/")) }) }) @@ -192,15 +195,48 @@ var _ = Describe("patternizer upgrade variants folder warning", func() { BeforeEach(func() { tempDir = createTestDir() Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(variantGlobal), 0o644)).To(Succeed()) - variantDir := filepath.Join(tempDir, "variants", "hub") - Expect(os.MkdirAll(variantDir, 0o755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(variantDir, "values-hub.yaml"), []byte("clusterGroup:\n name: hub\n"), 0o644)).To(Succeed()) + writeVariantFolderFile(tempDir, "hub") session = runCLI(tempDir, "upgrade") }) It("should not print a variants folder structure warning", func() { - out := string(session.Out.Contents()) - Expect(out).NotTo(ContainSubstring("variants folder structure")) + Expect(string(session.Err.Contents())).NotTo(ContainSubstring("variants folder structure")) + }) + }) +}) + +var _ = Describe("patternizer upgrade singleArgoCD warning", func() { + Context("when global.singleArgoCD is not set", func() { + var tempDir string + var session *gexec.Session + + BeforeEach(func() { + tempDir = createTestDir() + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte("global:\n pattern: test-pattern\nmain:\n variant: hub\n"), 0o644)).To(Succeed()) + writeVariantFolderFile(tempDir, "hub") + session = runCLI(tempDir, "upgrade") + }) + + It("should print a singleArgoCD warning to stderr linking to the blog", func() { + errOut := string(session.Err.Contents()) + Expect(errOut).To(ContainSubstring("global.singleArgoCD")) + Expect(errOut).To(ContainSubstring("https://validatedpatterns.io/blog/2026-06-04-single-argocd-option/")) + }) + }) + + Context("when global.singleArgoCD is set", func() { + var tempDir string + var session *gexec.Session + + BeforeEach(func() { + tempDir = createTestDir() + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte("global:\n pattern: test-pattern\n singleArgoCD: true\nmain:\n variant: hub\n"), 0o644)).To(Succeed()) + writeVariantFolderFile(tempDir, "hub") + session = runCLI(tempDir, "upgrade") + }) + + It("should not print a singleArgoCD warning", func() { + Expect(string(session.Err.Contents())).NotTo(ContainSubstring("singleArgoCD")) }) }) }) diff --git a/src/internal/pattern/pattern.go b/src/internal/pattern/pattern.go index fa35602..279a3f0 100644 --- a/src/internal/pattern/pattern.go +++ b/src/internal/pattern/pattern.go @@ -126,6 +126,34 @@ func variantFolderValuesPath(repoRoot, variant string) string { return filepath.Join(repoRoot, "variants", variant, variantValuesFileName(variant)) } +// MissingSingleArgoCD reports whether the pattern's values-global.yaml is missing the +// 'global.singleArgoCD' field. It returns false without error when the file does not +// exist (nothing to warn about). +func MissingSingleArgoCD(repoRoot string) (bool, error) { + globalValuesPath := filepath.Join(repoRoot, "values-global.yaml") + + yamlFile, err := os.ReadFile(globalValuesPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to read %s: %w", globalValuesPath, err) + } + + var raw map[string]interface{} + if err = yaml.Unmarshal(yamlFile, &raw); err != nil { + return false, fmt.Errorf("failed to unmarshal YAML from %s: %w", globalValuesPath, err) + } + + global, ok := raw["global"].(map[string]interface{}) + if !ok { + return true, nil + } + + _, ok = global["singleArgoCD"] + return !ok, nil +} + // ProcessClusterGroupValues processes the cluster group values YAML file. // The file is written to the variants folder structure at // variants//values-.yaml. If a legacy flat-layout file exists at the diff --git a/src/internal/pattern/pattern_test.go b/src/internal/pattern/pattern_test.go index 8a4cc33..81ad231 100644 --- a/src/internal/pattern/pattern_test.go +++ b/src/internal/pattern/pattern_test.go @@ -457,3 +457,53 @@ var _ = Describe("VariantFolderFileMissing", func() { Expect(missing).To(BeTrue()) }) }) + +var _ = Describe("MissingSingleArgoCD", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + writeGlobal := func(content string) { + Expect(os.WriteFile(filepath.Join(tempDir, "values-global.yaml"), []byte(content), 0o644)).To(Succeed()) + } + + It("returns false when values-global.yaml does not exist", func() { + missing, err := MissingSingleArgoCD(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(missing).To(BeFalse()) + }) + + It("returns false when global.singleArgoCD is set to true", func() { + writeGlobal("global:\n pattern: test\n singleArgoCD: true\n") + + missing, err := MissingSingleArgoCD(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(missing).To(BeFalse()) + }) + + It("returns false when global.singleArgoCD is set to false", func() { + writeGlobal("global:\n pattern: test\n singleArgoCD: false\n") + + missing, err := MissingSingleArgoCD(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(missing).To(BeFalse()) + }) + + It("returns true when global exists without singleArgoCD", func() { + writeGlobal("global:\n pattern: test\n") + + missing, err := MissingSingleArgoCD(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(missing).To(BeTrue()) + }) + + It("returns true when there is no global section", func() { + writeGlobal("main:\n variant: hub\n") + + missing, err := MissingSingleArgoCD(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(missing).To(BeTrue()) + }) +})