From d2df0f25bd8c6fcad9bd7d6cf8b460fae3242ae0 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Mon, 30 Mar 2026 16:51:38 -0500 Subject: [PATCH 1/4] feat(argocd-understack): split operator install from configs install For some of the operators we need to install them and sometimes install additional things into their namespace. Like the webhook component or additional drivers. Start on this support path with cert-manager and external-secrets as a first start but this will expand to others. --- .../argocd-understack/templates/_helpers.tpl | 53 +++++++++++++++++++ .../templates/application-cert-manager.yaml | 11 +++- .../application-external-secrets.yaml | 11 +++- charts/argocd-understack/values.yaml | 28 +++++++--- docs/deploy-guide/components/cert-manager.md | 35 +++++++++--- .../components/external-secrets.md | 39 +++++++++++--- 6 files changed, 155 insertions(+), 22 deletions(-) diff --git a/charts/argocd-understack/templates/_helpers.tpl b/charts/argocd-understack/templates/_helpers.tpl index 576bb74b2..2676d4550 100644 --- a/charts/argocd-understack/templates/_helpers.tpl +++ b/charts/argocd-understack/templates/_helpers.tpl @@ -155,3 +155,56 @@ Defaults to false if any path segment is missing. {{- $result = and $result $componentEnabled -}} {{- ternary "true" "false" $result -}} {{- end }} + +{{/* +Resolve whether a component sub-option (e.g. installApp, installConfigs) is active +within a single scope. + +Arguments: + - scope (.Values.global or .Values.site) + - component name (e.g., "external_secrets", "cert_manager") + - sub-option key (e.g., "installApp", "installConfigs") + - default value (true or false) used when the key is absent + +Returns "true" if the scope+component are enabled and the sub-option is active, +empty string otherwise. + +Usage: + {{ include "understack.componentOption" (list $.Values.global "external_secrets" "installApp" true) }} +*/}} +{{- define "understack.componentOption" -}} +{{- $scope := index . 0 -}} +{{- $componentName := index . 1 -}} +{{- $optionKey := index . 2 -}} +{{- $default := index . 3 -}} +{{- $scopeEnabled := get $scope "enabled" -}} +{{- $component := get $scope $componentName | default dict -}} +{{- $result := and $scopeEnabled (dig $optionKey $default $component) -}} +{{- ternary "true" "false" $result -}} +{{- end }} + +{{/* +Resolve whether a component sub-option is active across both global and site scopes. +Convenience wrapper around understack.componentOption for components that exist in +both scopes. + +Arguments: + - root ($) — the root context + - component name (e.g., "external_secrets", "cert_manager") + - sub-option key (e.g., "installApp", "installConfigs") + - default value (true or false) used when the key is absent + +Returns "true" if the sub-option is active in any enabled scope, empty string otherwise. + +Usage: + {{ include "understack.componentOptionAny" (list $ "external_secrets" "installApp" true) }} +*/}} +{{- define "understack.componentOptionAny" -}} +{{- $root := index . 0 -}} +{{- $componentName := index . 1 -}} +{{- $optionKey := index . 2 -}} +{{- $default := index . 3 -}} +{{- $globalActive := eq (include "understack.componentOption" (list $root.Values.global $componentName $optionKey $default)) "true" -}} +{{- $siteActive := eq (include "understack.componentOption" (list $root.Values.site $componentName $optionKey $default)) "true" -}} +{{- ternary "true" "false" (or $globalActive $siteActive) -}} +{{- end }} diff --git a/charts/argocd-understack/templates/application-cert-manager.yaml b/charts/argocd-understack/templates/application-cert-manager.yaml index c9bfeb73e..005c1734c 100644 --- a/charts/argocd-understack/templates/application-cert-manager.yaml +++ b/charts/argocd-understack/templates/application-cert-manager.yaml @@ -1,4 +1,6 @@ -{{- if or (eq (include "understack.isEnabled" (list $.Values.global "cert_manager")) "true") (eq (include "understack.isEnabled" (list $.Values.site "cert_manager")) "true") }} +{{- $installApp := eq (include "understack.componentOptionAny" (list $ "cert_manager" "installApp" true)) "true" }} +{{- $installConfigs := eq (include "understack.componentOptionAny" (list $ "cert_manager" "installConfigs" false)) "true" }} +{{- if or $installApp $installConfigs }} --- apiVersion: argoproj.io/v1alpha1 kind: Application @@ -17,6 +19,7 @@ spec: server: {{ $.Values.cluster_server }} project: understack-infra sources: + {{- if $installApp }} - chart: cert-manager helm: releaseName: cert-manager @@ -29,6 +32,12 @@ spec: enabled: true repoURL: https://charts.jetstack.io targetRevision: v1.20.0 + {{- end }} + {{- if $installConfigs }} + - path: {{ include "understack.deploy_path" $ }}/cert-manager + repoURL: {{ include "understack.deploy_url" $ }} + targetRevision: {{ include "understack.deploy_ref" $ }} + {{- end }} syncPolicy: automated: prune: true diff --git a/charts/argocd-understack/templates/application-external-secrets.yaml b/charts/argocd-understack/templates/application-external-secrets.yaml index f13074ba6..db9b694bd 100644 --- a/charts/argocd-understack/templates/application-external-secrets.yaml +++ b/charts/argocd-understack/templates/application-external-secrets.yaml @@ -1,4 +1,6 @@ -{{- if or (eq (include "understack.isEnabled" (list $.Values.global "external_secrets")) "true") (eq (include "understack.isEnabled" (list $.Values.site "external_secrets")) "true") }} +{{- $installApp := eq (include "understack.componentOptionAny" (list $ "external_secrets" "installApp" true)) "true" }} +{{- $installConfigs := eq (include "understack.componentOptionAny" (list $ "external_secrets" "installConfigs" false)) "true" }} +{{- if or $installApp $installConfigs }} --- apiVersion: argoproj.io/v1alpha1 kind: Application @@ -14,10 +16,17 @@ spec: server: {{ $.Values.cluster_server }} project: understack-operators sources: + {{- if $installApp }} - path: operators/external-secrets ref: understack repoURL: {{ include "understack.understack_url" $ }} targetRevision: {{ include "understack.understack_ref" $ }} + {{- end }} + {{- if $installConfigs }} + - path: {{ include "understack.deploy_path" $ }}/external-secrets + repoURL: {{ include "understack.deploy_url" $ }} + targetRevision: {{ include "understack.deploy_ref" $ }} + {{- end }} syncPolicy: automated: prune: true diff --git a/charts/argocd-understack/values.yaml b/charts/argocd-understack/values.yaml index 35d5f4734..e3d10b555 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -47,9 +47,12 @@ global: # -- Cert-Manager cert_manager: - # -- Enable/disable deploying Cert-Manager + # -- Enable/disable deploying the cert-manager Helm chart (operator) # @default -- false - enabled: false + installApp: false + # -- Enable/disable deploying site-specific cert-manager configs from the deploy repo + # @default -- false + installConfigs: false # Cilium configurations cilium: @@ -101,9 +104,12 @@ global: # -- External Secrets operator external_secrets: - # -- Enable/disable deploying External Secrets + # -- Enable/disable deploying the External Secrets Operator Helm chart # @default -- false - enabled: false + installApp: false + # -- Enable/disable deploying site-specific ESO configs from the deploy repo + # @default -- false + installConfigs: false # -- Global workflows for Argo Events and Workflows global_workflows: @@ -210,9 +216,12 @@ site: # -- Cert-Manager cert_manager: - # -- Enable/disable deploying Cert-Manager + # -- Enable/disable deploying the cert-manager Helm chart (operator) # @default -- false - enabled: false + installApp: false + # -- Enable/disable deploying site-specific cert-manager configs from the deploy repo + # @default -- false + installConfigs: false # Cilium configurations cilium: @@ -434,9 +443,12 @@ site: # -- External Secrets operator external_secrets: - # -- Enable/disable deploying External Secrets + # -- Enable/disable deploying the External Secrets Operator Helm chart # @default -- false - enabled: false + installApp: false + # -- Enable/disable deploying site-specific ESO configs from the deploy repo + # @default -- false + installConfigs: false # -- Alerts management (karma) karma: diff --git a/docs/deploy-guide/components/cert-manager.md b/docs/deploy-guide/components/cert-manager.md index f51b0eafe..003179596 100644 --- a/docs/deploy-guide/components/cert-manager.md +++ b/docs/deploy-guide/components/cert-manager.md @@ -10,7 +10,7 @@ deploy_overrides: # cert-manager -Certificate management operator installation. +Certificate management operator installation and site-specific cert-manager configuration. ## Deployment Scope @@ -24,25 +24,48 @@ Certificate management operator installation. ## How to Enable -Enable this component under the scope that matches your deployment model: +Enable this component by setting one or both options under the scope that matches your deployment model: ```yaml title="$CLUSTER_NAME/deploy.yaml" global: cert_manager: - enabled: true + installApp: true site: cert_manager: - enabled: true + installApp: true +``` + +### Options + +| Key | Default | Description | +|-----|---------|-------------| +| `installApp` | `false` | Deploy the cert-manager Helm chart | +| `installConfigs` | `false` | Deploy site-specific cert-manager configs from the deploy repo | + +To use an externally-managed cert-manager installation while still deploying your site's cert-manager resources: + +```yaml title="$CLUSTER_NAME/deploy.yaml" +global: + cert_manager: + installApp: false + installConfigs: true ``` ## Deployment Repo Content {{ secrets_disclaimer }} +When `installConfigs: true`, the Application reads from: + +```text +$DEPLOY_REPO//cert-manager/ +``` + Required or commonly required items: -- None for this Application today. It installs the upstream chart with inline values and does not consume deploy-repo `values.yaml` or overlay content. +- None required. With `installApp: true` the chart is installed with inline values and does not consume deploy-repo content. Optional additions: -- Document issuer manifests and challenge-credential Secrets in the `cluster-issuer` component page rather than here. +- For `ClusterIssuer` and `Issuer` resources, prefer the dedicated [`cluster-issuer`](cluster-issuer.md) component. +- Other cert-manager configuration resources can be placed in the `cert-manager/` deploy-repo path when `installConfigs: true`. diff --git a/docs/deploy-guide/components/external-secrets.md b/docs/deploy-guide/components/external-secrets.md index 3fba41599..b33c44d8f 100644 --- a/docs/deploy-guide/components/external-secrets.md +++ b/docs/deploy-guide/components/external-secrets.md @@ -10,7 +10,7 @@ deploy_overrides: # external-secrets -External Secrets operator installation. +External Secrets operator installation and site-specific ESO configuration. ## Deployment Scope @@ -24,25 +24,52 @@ External Secrets operator installation. ## How to Enable -Enable this component under the scope that matches your deployment model: +Enable this component by setting one or both options under the scope that matches your deployment model: ```yaml title="$CLUSTER_NAME/deploy.yaml" global: external_secrets: - enabled: true + installApp: true site: external_secrets: - enabled: true + installApp: true +``` + +### Options + +| Key | Default | Description | +|-----|---------|-------------| +| `installApp` | `false` | Deploy the External Secrets Operator from the understack repo | +| `installConfigs` | `false` | Deploy site-specific ESO configs from the deploy repo | + +To use an externally-managed ESO installation (e.g. the operator is already installed by another team) while still deploying your site's ESO resources: + +```yaml title="$CLUSTER_NAME/deploy.yaml" +global: + external_secrets: + installApp: false + installConfigs: true ``` ## Deployment Repo Content {{ secrets_disclaimer }} +When `installConfigs: true`, the Application reads from: + +```text +$DEPLOY_REPO//external-secrets/ +``` + +Place any site-specific ESO resources here, for example: + +- `ClusterSecretStore` manifests connecting to your secrets backend +- `ExternalSecret` objects for secrets that don't belong to a specific component + Required or commonly required items: -- None for this Application today. It deploys the shared operator manifests directly and does not read deploy-repo values or overlay manifests for this component. +- None required. With `installApp: true` the operator manifests are deployed directly from the understack repo with no deploy-repo content needed. Optional additions: -- Document provider-specific SecretStores and authentication material only where a consuming component needs the resulting Secret shape. +- Provider-specific `ClusterSecretStore` and authentication `Secret` objects in the `external-secrets/` deploy-repo path when `installConfigs: true`. From 1be17033b2ca2f2f017b95e2f8a7504db8dbd9a8 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Mon, 30 Mar 2026 16:53:11 -0500 Subject: [PATCH 2/4] feat(understackctl): support split config in argocd-understack Support the split configuration in the argocd-understack chart and handle the deploy configuration updates to match the behavior. --- go/understackctl/cmd/deploy/check.go | 18 +++--- go/understackctl/cmd/deploy/config.go | 29 ++++++++-- go/understackctl/cmd/deploy/deploy_test.go | 8 +-- go/understackctl/cmd/deploy/init.go | 17 +++++- go/understackctl/cmd/deploy/update.go | 58 +++++++++++++++---- .../internal/chartvalues/parse.go | 12 ++-- 6 files changed, 111 insertions(+), 31 deletions(-) diff --git a/go/understackctl/cmd/deploy/check.go b/go/understackctl/cmd/deploy/check.go index 0a085a2b7..fed96b14a 100644 --- a/go/understackctl/cmd/deploy/check.go +++ b/go/understackctl/cmd/deploy/check.go @@ -39,16 +39,20 @@ func runDeployCheck(clusterName string) error { missing := []string{} for _, comp := range components { - compDir := filepath.Join(clusterName, comp) - kustomPath := filepath.Join(compDir, "kustomization.yaml") - valuesPath := filepath.Join(compDir, "values.yaml") + compDir := filepath.Join(clusterName, comp.Name) - if _, err := os.Stat(kustomPath); os.IsNotExist(err) { - missing = append(missing, kustomPath) + if comp.InstallApp { + valuesPath := filepath.Join(compDir, "values.yaml") + if _, err := os.Stat(valuesPath); os.IsNotExist(err) { + missing = append(missing, valuesPath) + } } - if _, err := os.Stat(valuesPath); os.IsNotExist(err) { - missing = append(missing, valuesPath) + if comp.InstallConfigs { + kustomPath := filepath.Join(compDir, "kustomization.yaml") + if _, err := os.Stat(kustomPath); os.IsNotExist(err) { + missing = append(missing, kustomPath) + } } } diff --git a/go/understackctl/cmd/deploy/config.go b/go/understackctl/cmd/deploy/config.go index 43173d7c4..9c0a6c37f 100644 --- a/go/understackctl/cmd/deploy/config.go +++ b/go/understackctl/cmd/deploy/config.go @@ -49,8 +49,15 @@ func writeYAMLFile(path string, value any) error { return nil } -func enabledComponents(config map[string]any) []string { - var components []string +// ComponentConfig holds the name and deploy options for an enabled component. +type ComponentConfig struct { + Name string + InstallApp bool + InstallConfigs bool +} + +func enabledComponents(config map[string]any) []ComponentConfig { + var components []ComponentConfig for _, section := range []string{"global", "site"} { if sectionRaw, ok := config[section]; ok { @@ -61,8 +68,15 @@ func enabledComponents(config map[string]any) []string { continue } if compMap, ok := val.(map[string]any); ok { - if compEnabled, ok := compMap["enabled"].(bool); ok && compEnabled { - components = append(components, strings.ReplaceAll(key, "_", "-")) + compEnabled := boolOption(compMap, "enabled", false) + installApp := boolOption(compMap, "installApp", compEnabled) + installConfigs := boolOption(compMap, "installConfigs", compEnabled) + if compEnabled || installApp || installConfigs { + components = append(components, ComponentConfig{ + Name: strings.ReplaceAll(key, "_", "-"), + InstallApp: installApp, + InstallConfigs: installConfigs, + }) } } } @@ -73,3 +87,10 @@ func enabledComponents(config map[string]any) []string { return components } + +func boolOption(m map[string]any, key string, defaultVal bool) bool { + if v, ok := m[key].(bool); ok { + return v + } + return defaultVal +} diff --git a/go/understackctl/cmd/deploy/deploy_test.go b/go/understackctl/cmd/deploy/deploy_test.go index f757a73f5..3bc010096 100644 --- a/go/understackctl/cmd/deploy/deploy_test.go +++ b/go/understackctl/cmd/deploy/deploy_test.go @@ -82,8 +82,8 @@ func TestEnabledComponentsConvertsToHyphens(t *testing.T) { } for _, comp := range components { - if !expected[comp] { - t.Errorf("unexpected component: %s", comp) + if !expected[comp.Name] { + t.Errorf("unexpected component: %s", comp.Name) } } } @@ -265,9 +265,9 @@ func TestDeployWorkflowIntegration(t *testing.T) { components := enabledComponents(config) for _, comp := range components { - compDir := filepath.Join(clusterName, comp) + compDir := filepath.Join(clusterName, comp.Name) if _, err := os.Stat(compDir); os.IsNotExist(err) { - t.Errorf("directory not created for component: %s", comp) + t.Errorf("directory not created for component: %s", comp.Name) } } diff --git a/go/understackctl/cmd/deploy/init.go b/go/understackctl/cmd/deploy/init.go index 27ef3aed2..b90ef68d0 100644 --- a/go/understackctl/cmd/deploy/init.go +++ b/go/understackctl/cmd/deploy/init.go @@ -76,7 +76,7 @@ func runDeployInit(clusterName, clusterType, gitRemote string) error { globalMap := make(map[string]any) globalMap["enabled"] = true for _, c := range globalComponents { - globalMap[c.Key] = map[string]any{"enabled": true} + globalMap[c.Key] = initialComponentConfig(c.UsesInstallOpts) } config["global"] = globalMap } @@ -85,7 +85,7 @@ func runDeployInit(clusterName, clusterType, gitRemote string) error { siteMap := make(map[string]any) siteMap["enabled"] = true for _, c := range siteComponents { - siteMap[c.Key] = map[string]any{"enabled": true} + siteMap[c.Key] = initialComponentConfig(c.UsesInstallOpts) } config["site"] = siteMap } @@ -98,6 +98,19 @@ func runDeployInit(clusterName, clusterType, gitRemote string) error { return nil } +// initialComponentConfig returns the default config map for a component. +// Components using installApp/installConfigs get both set to false explicitly; +// legacy components get enabled: true. +func initialComponentConfig(usesInstallOpts bool) map[string]any { + if usesInstallOpts { + return map[string]any{ + "installApp": false, + "installConfigs": false, + } + } + return map[string]any{"enabled": true} +} + func getGitRemoteURL(remoteName string) (string, error) { cmd := exec.Command("git", "remote", "get-url", remoteName) output, err := cmd.Output() diff --git a/go/understackctl/cmd/deploy/update.go b/go/understackctl/cmd/deploy/update.go index 5e16b3af1..0bf7b0b52 100644 --- a/go/understackctl/cmd/deploy/update.go +++ b/go/understackctl/cmd/deploy/update.go @@ -42,7 +42,7 @@ func runDeployUpdate(clusterName string) error { enabledComps := enabledComponents(config) enabledSet := make(map[string]bool) for _, comp := range enabledComps { - enabledSet[comp] = true + enabledSet[comp.Name] = true } // Remove disabled components @@ -68,9 +68,7 @@ func runDeployUpdate(clusterName string) error { // Add enabled components and ensure files exist created := 0 for _, comp := range enabledComps { - compDir := filepath.Join(clusterName, comp) - kustomPath := filepath.Join(compDir, "kustomization.yaml") - valuesPath := filepath.Join(compDir, "values.yaml") + compDir := filepath.Join(clusterName, comp.Name) dirExists := false if _, err := os.Stat(compDir); err == nil { @@ -81,15 +79,31 @@ func runDeployUpdate(clusterName string) error { return fmt.Errorf("failed to create directory %s: %w", compDir, err) } - if _, err := os.Stat(kustomPath); os.IsNotExist(err) { - if err := os.WriteFile(kustomPath, []byte(kustomizationContent), 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", kustomPath, err) + valuesPath := filepath.Join(compDir, "values.yaml") + if comp.InstallApp { + if _, err := os.Stat(valuesPath); os.IsNotExist(err) { + if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", valuesPath, err) + } + } + } else { + if err := os.Remove(valuesPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove %s: %w", valuesPath, err) + } else if err == nil { + log.Infof("Removed %s", valuesPath) } } - if _, err := os.Stat(valuesPath); os.IsNotExist(err) { - if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", valuesPath, err) + kustomPath := filepath.Join(compDir, "kustomization.yaml") + if comp.InstallConfigs { + if _, err := os.Stat(kustomPath); os.IsNotExist(err) { + if err := os.WriteFile(kustomPath, []byte(kustomizationContent), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", kustomPath, err) + } + } + } else { + if err := removeAllExcept(compDir, "values.yaml"); err != nil { + return fmt.Errorf("failed to clean configs from %s: %w", compDir, err) } } @@ -102,3 +116,27 @@ func runDeployUpdate(clusterName string) error { log.Infof("Updated components: %d created", created) return nil } + +// removeAllExcept removes all entries in dir except the named file. +func removeAllExcept(dir, keep string) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.Name() == keep { + continue + } + path := filepath.Join(dir, entry.Name()) + if err := os.RemoveAll(path); err != nil { + return err + } + log.Infof("Removed %s", path) + } + + return nil +} diff --git a/go/understackctl/internal/chartvalues/parse.go b/go/understackctl/internal/chartvalues/parse.go index 313c2cbb4..0502f303b 100644 --- a/go/understackctl/internal/chartvalues/parse.go +++ b/go/understackctl/internal/chartvalues/parse.go @@ -15,8 +15,9 @@ const ( // ComponentKey represents a component found in the values.yaml. type ComponentKey struct { - Key string // original key, e.g. "cert_manager" - Name string // hyphenated name, e.g. "cert-manager" + Key string // original key, e.g. "cert_manager" + Name string // hyphenated name, e.g. "cert-manager" + UsesInstallOpts bool // true if component uses installApp/installConfigs instead of enabled } // FetchValues fetches the ArgoCD chart values.yaml from GitHub for the given version. @@ -102,9 +103,12 @@ func extractComponents(values map[string]any, scope string) ([]ComponentKey, err continue } + compMap := child.(map[string]any) + _, hasInstallApp := compMap["installApp"] components = append(components, ComponentKey{ - Key: key, - Name: strings.ReplaceAll(key, "_", "-"), + Key: key, + Name: strings.ReplaceAll(key, "_", "-"), + UsesInstallOpts: hasInstallApp, }) } From a065ee7834ff71ecee07add6290c284f8ea4fdd5 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Tue, 31 Mar 2026 08:39:31 -0500 Subject: [PATCH 3/4] fix(argocd-understack): add missing 'argo-events-workflows' to values --- charts/argocd-understack/values.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/charts/argocd-understack/values.yaml b/charts/argocd-understack/values.yaml index e3d10b555..37e2b126d 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -202,13 +202,19 @@ site: # @default -- false enabled: false - # -- Argo Events configuration for event-driven workflows + # -- Argo Events operator argo_events: # -- Enable/disable deploying Argo Events # @default -- false enabled: false - # -- Argo Workflows configuration + # -- Event-driven workflows that live in argo-events namespace (need to move) + argo_events_workflows: + # -- Enable/disable deploying Workflows + # @default -- false + enabled: false + + # -- Argo Workflows operator argo_workflows: # -- Enable/disable deploying Argo Workflows # @default -- false From 470461bfa0a3cf3f8a17854617a7aed8715f89d2 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Tue, 31 Mar 2026 10:52:15 -0500 Subject: [PATCH 4/4] fix(understackctl): fix race between global/site enable/disable If something was enabled in global but disabled in site due to the ordering it would be disabled which is not correct. Update the flow to handle this as an OR like the Helm chart does and add a test to validate this case. --- go/understackctl/cmd/deploy/config.go | 27 +++++++++-- go/understackctl/cmd/deploy/deploy_test.go | 56 ++++++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/go/understackctl/cmd/deploy/config.go b/go/understackctl/cmd/deploy/config.go index 9c0a6c37f..64ad7dfb5 100644 --- a/go/understackctl/cmd/deploy/config.go +++ b/go/understackctl/cmd/deploy/config.go @@ -57,7 +57,11 @@ type ComponentConfig struct { } func enabledComponents(config map[string]any) []ComponentConfig { - var components []ComponentConfig + // Use a map to merge components that appear in both global and site scopes. + // InstallApp and InstallConfigs are OR'd across scopes so that enabling an + // option in either scope takes effect. + seen := make(map[string]*ComponentConfig) + var order []string for _, section := range []string{"global", "site"} { if sectionRaw, ok := config[section]; ok { @@ -71,12 +75,21 @@ func enabledComponents(config map[string]any) []ComponentConfig { compEnabled := boolOption(compMap, "enabled", false) installApp := boolOption(compMap, "installApp", compEnabled) installConfigs := boolOption(compMap, "installConfigs", compEnabled) - if compEnabled || installApp || installConfigs { - components = append(components, ComponentConfig{ - Name: strings.ReplaceAll(key, "_", "-"), + if !compEnabled && !installApp && !installConfigs { + continue + } + name := strings.ReplaceAll(key, "_", "-") + if existing, found := seen[name]; found { + existing.InstallApp = existing.InstallApp || installApp + existing.InstallConfigs = existing.InstallConfigs || installConfigs + } else { + comp := &ComponentConfig{ + Name: name, InstallApp: installApp, InstallConfigs: installConfigs, - }) + } + seen[name] = comp + order = append(order, name) } } } @@ -85,6 +98,10 @@ func enabledComponents(config map[string]any) []ComponentConfig { } } + components := make([]ComponentConfig, 0, len(order)) + for _, name := range order { + components = append(components, *seen[name]) + } return components } diff --git a/go/understackctl/cmd/deploy/deploy_test.go b/go/understackctl/cmd/deploy/deploy_test.go index 3bc010096..d419c5ba2 100644 --- a/go/understackctl/cmd/deploy/deploy_test.go +++ b/go/understackctl/cmd/deploy/deploy_test.go @@ -189,6 +189,62 @@ func TestDeployUpdate(t *testing.T) { } } +func TestDeployUpdateAIOInstallOptionsAreMergedPerComponent(t *testing.T) { + tmpDir := t.TempDir() + clusterName := filepath.Join(tmpDir, "aio-cluster") + + config := map[string]any{ + "global": map[string]any{ + "enabled": true, + "cert_manager": map[string]any{ + "installApp": true, + "installConfigs": false, + }, + }, + "site": map[string]any{ + "enabled": true, + "cert_manager": map[string]any{ + "installApp": false, + "installConfigs": true, + }, + }, + } + + if err := os.MkdirAll(clusterName, 0755); err != nil { + t.Fatalf("failed to create cluster dir: %v", err) + } + + data, err := yaml.Marshal(&config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + + deployYaml := filepath.Join(clusterName, "deploy.yaml") + if err := os.WriteFile(deployYaml, data, 0644); err != nil { + t.Fatalf("failed to write deploy.yaml: %v", err) + } + + if err := runDeployUpdate(clusterName); err != nil { + t.Fatalf("runDeployUpdate failed: %v", err) + } + + compDir := filepath.Join(clusterName, "cert-manager") + valuesPath := filepath.Join(compDir, "values.yaml") + kustomPath := filepath.Join(compDir, "kustomization.yaml") + + if _, err := os.Stat(valuesPath); os.IsNotExist(err) { + t.Fatalf("expected values.yaml for merged installApp=true") + } + + if _, err := os.Stat(kustomPath); os.IsNotExist(err) { + t.Fatalf("expected kustomization.yaml for merged installConfigs=true") + } + + if err := runDeployCheck(clusterName); err != nil { + t.Fatalf("deploy check should pass for merged AIO config: %v", err) + } +} + func TestDeployCheck(t *testing.T) { tmpDir := t.TempDir() clusterName := filepath.Join(tmpDir, "test-cluster")