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..37e2b126d 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: @@ -196,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 @@ -210,9 +222,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 +449,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`. 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..64ad7dfb5 100644 --- a/go/understackctl/cmd/deploy/config.go +++ b/go/understackctl/cmd/deploy/config.go @@ -49,8 +49,19 @@ 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 { + // 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 { @@ -61,8 +72,24 @@ 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 { + 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) } } } @@ -71,5 +98,16 @@ func enabledComponents(config map[string]any) []string { } } + components := make([]ComponentConfig, 0, len(order)) + for _, name := range order { + components = append(components, *seen[name]) + } 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..d419c5ba2 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) } } } @@ -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") @@ -265,9 +321,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, }) }