From e3a85f043cae7c3b67e153139fa287454cf35e9c Mon Sep 17 00:00:00 2001 From: OAharoni-RedHat Date: Mon, 9 Nov 2026 11:06:52 -0500 Subject: [PATCH 1/2] feat: add automated cluster import --- Chart.yaml | 2 +- README.md | 3 +- templates/_helpers.tpl | 25 +++ templates/provision/imported-clusters.yaml | 74 +++++++++ tests/imported_clusters_test.yaml | 185 +++++++++++++++++++++ values.yaml | 9 + 6 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 templates/provision/imported-clusters.yaml create mode 100644 tests/imported_clusters_test.yaml diff --git a/Chart.yaml b/Chart.yaml index d35fbb4..7aca481 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -3,4 +3,4 @@ description: A Helm chart to configure Advanced Cluster Manager for OpenShift. keywords: - pattern name: acm -version: 0.2.11 +version: 0.2.12 diff --git a/README.md b/README.md index 85cbd69..89268cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # acm -![Version: 0.2.11](https://img.shields.io/badge/Version-0.2.11-informational?style=flat-square) +![Version: 0.2.12](https://img.shields.io/badge/Version-0.2.12-informational?style=flat-square) A Helm chart to configure Advanced Cluster Manager for OpenShift. @@ -12,6 +12,7 @@ This chart is used by the Validated Patterns to configure ACM and manage remote |-----|------|---------|-------------| | acm.customArgoYaml | object | empty dict (no overrides) | Custom overrides for the spoke ArgoCD instance spec. Keys are deep-merged into the default spec (maps merge, lists replace). | | acm.extraResourceHealthChecks | list | empty list | Extra resource health checks to add to the spoke ArgoCD instance. Each entry needs group, kind, and check (Lua script) fields. | +| acm.importedClusters | list | empty list (feature disabled) | Bring-your-own clusters to auto-import into ACM using kubeconfigs from Vault. Each entry needs secretName (must end with `_cluster_kubeconfig`) and clusterGroup (value for the ManagedCluster `clusterGroup` label). The ManagedCluster name is derived by stripping the `_cluster_kubeconfig` suffix from secretName. Example: - secretName: mcg-primary_cluster_kubeconfig clusterGroup: group-one | | acm.mce_operator | object | Uses the official redhat sources | Just used for IIB testing, drives the source and channel for the MCE subscription triggered by ACM | | acm.mch_spec | object | `{}` | MultiClusterHub spec (empty by default) | | acm.spokeGitops.channel | string | `"gitops-1.21"` | Default gitops channel to install on remote clusters | diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index 2eedb76..0a468c9 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -470,3 +470,28 @@ sso: tls: ca: {} {{- end }} + +{{/* +Derive ManagedCluster name from a values-secret name by stripping _cluster_kubeconfig. +Fails if the secret name does not end with that suffix. +*/}} +{{- define "acm.imported.clusterName" -}} +{{- $secretName := . -}} +{{- $suffix := "_cluster_kubeconfig" -}} +{{- if not (hasSuffix $suffix $secretName) -}} +{{- fail (printf "acm.importedClusters secretName %q must end with %q" $secretName $suffix) -}} +{{- end -}} +{{- trimSuffix $suffix $secretName -}} +{{- end -}} + +{{/* +Validate a single acm.importedClusters entry has secretName and clusterGroup. +*/}} +{{- define "acm.imported.validateEntry" -}} +{{- if or (not .secretName) (eq .secretName "") -}} +{{- fail "acm.importedClusters entry is missing required field secretName" -}} +{{- end -}} +{{- if or (not .clusterGroup) (eq .clusterGroup "") -}} +{{- fail (printf "acm.importedClusters entry for secretName %q is missing required field clusterGroup" .secretName) -}} +{{- end -}} +{{- end -}} diff --git a/templates/provision/imported-clusters.yaml b/templates/provision/imported-clusters.yaml new file mode 100644 index 0000000..c3855ce --- /dev/null +++ b/templates/provision/imported-clusters.yaml @@ -0,0 +1,74 @@ +{{- range .Values.acm.importedClusters }} +{{- include "acm.imported.validateEntry" . }} +{{- $secretName := .secretName }} +{{- $clusterGroup := .clusterGroup }} +{{- $clusterName := include "acm.imported.clusterName" $secretName }} +--- +apiVersion: v1 +kind: Namespace +metadata: + name: {{ $clusterName }} +--- +apiVersion: cluster.open-cluster-management.io/v1 +kind: ManagedCluster +metadata: + name: {{ $clusterName }} + labels: + # Only manage clusterGroup here. ACM controllers overwrite cloud/vendor and + # add discovery/addon labels after import, which would otherwise leave Argo OutOfSync. + clusterGroup: {{ $clusterGroup }} + annotations: + argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true,RespectIgnoreDifferences=true +spec: + hubAcceptsClient: true + leaseDurationSeconds: 60 +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: {{ $clusterName }}-auto-import + namespace: {{ $clusterName }} + annotations: + argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true +spec: + data: + - secretKey: kubeconfig + remoteRef: + key: secret/data/hub/{{ $secretName }} + property: kubeconfig + refreshInterval: 1h0m0s + secretStoreRef: + name: {{ $.Values.secretStore.name }} + kind: {{ $.Values.secretStore.kind }} + target: + name: auto-import-secret + creationPolicy: Owner + template: + type: Opaque + data: + autoImportRetry: "5" + kubeconfig: |- + {{ "{{ .kubeconfig | toString }}" }} +--- +apiVersion: agent.open-cluster-management.io/v1 +kind: KlusterletAddonConfig +metadata: + name: {{ $clusterName }} + namespace: {{ $clusterName }} + annotations: + argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true +spec: + clusterName: {{ $clusterName }} + clusterNamespace: {{ $clusterName }} + clusterLabels: + cloud: auto-detect + vendor: auto-detect + applicationManager: + enabled: true + policyController: + enabled: true + searchCollector: + enabled: true + certPolicyController: + enabled: true +{{- end }} diff --git a/tests/imported_clusters_test.yaml b/tests/imported_clusters_test.yaml new file mode 100644 index 0000000..a192953 --- /dev/null +++ b/tests/imported_clusters_test.yaml @@ -0,0 +1,185 @@ +suite: Test ACM BYOC imported clusters +templates: + - templates/provision/imported-clusters.yaml +release: + name: release-test +tests: + - it: should not render when importedClusters is empty + set: + acm: + importedClusters: [] + asserts: + - hasDocuments: + count: 0 + + - it: should not render when importedClusters is unset + set: + acm: + importedClusters: null + asserts: + - hasDocuments: + count: 0 + + - it: should render Namespace, ManagedCluster, ExternalSecret, and KlusterletAddonConfig + set: + acm: + importedClusters: + - secretName: mcg-primary_cluster_kubeconfig + clusterGroup: group-one + asserts: + - hasDocuments: + count: 4 + - documentIndex: 0 + isKind: + of: Namespace + - documentIndex: 0 + equal: + path: metadata.name + value: mcg-primary + - documentIndex: 1 + isKind: + of: ManagedCluster + - documentIndex: 1 + equal: + path: metadata.name + value: mcg-primary + - documentIndex: 1 + equal: + path: metadata.labels.clusterGroup + value: group-one + - documentIndex: 1 + isNull: + path: metadata.labels.cloud + - documentIndex: 1 + isNull: + path: metadata.labels.vendor + - documentIndex: 1 + equal: + path: spec.hubAcceptsClient + value: true + - documentIndex: 1 + equal: + path: spec.leaseDurationSeconds + value: 60 + - documentIndex: 2 + isKind: + of: ExternalSecret + - documentIndex: 2 + equal: + path: metadata.name + value: mcg-primary-auto-import + - documentIndex: 2 + equal: + path: metadata.namespace + value: mcg-primary + - documentIndex: 2 + equal: + path: spec.target.name + value: auto-import-secret + - documentIndex: 2 + equal: + path: spec.data[0].remoteRef.key + value: secret/data/hub/mcg-primary_cluster_kubeconfig + - documentIndex: 2 + equal: + path: spec.data[0].remoteRef.property + value: kubeconfig + - documentIndex: 2 + equal: + path: spec.secretStoreRef.name + value: vault-backend + - documentIndex: 2 + equal: + path: spec.secretStoreRef.kind + value: ClusterSecretStore + - documentIndex: 2 + equal: + path: spec.target.template.data.autoImportRetry + value: "5" + - documentIndex: 3 + isKind: + of: KlusterletAddonConfig + - documentIndex: 3 + equal: + path: metadata.name + value: mcg-primary + - documentIndex: 3 + equal: + path: metadata.namespace + value: mcg-primary + - documentIndex: 3 + equal: + path: spec.clusterName + value: mcg-primary + - documentIndex: 3 + equal: + path: spec.clusterNamespace + value: mcg-primary + - documentIndex: 3 + equal: + path: spec.applicationManager.enabled + value: true + - documentIndex: 3 + equal: + path: spec.policyController.enabled + value: true + - documentIndex: 3 + equal: + path: spec.searchCollector.enabled + value: true + - documentIndex: 3 + equal: + path: spec.certPolicyController.enabled + value: true + + - it: should render resources for multiple imported clusters + set: + acm: + importedClusters: + - secretName: mcg-primary_cluster_kubeconfig + clusterGroup: group-one + - secretName: mcg-secondary_cluster_kubeconfig + clusterGroup: group-one + asserts: + - hasDocuments: + count: 8 + - documentIndex: 0 + equal: + path: metadata.name + value: mcg-primary + - documentIndex: 4 + equal: + path: metadata.name + value: mcg-secondary + - documentIndex: 5 + equal: + path: metadata.labels.clusterGroup + value: group-one + + - it: should fail when secretName does not end with _cluster_kubeconfig + set: + acm: + importedClusters: + - secretName: mcg-primary-kubeconfig + clusterGroup: group-one + asserts: + - failedTemplate: + errorMessage: acm.importedClusters secretName "mcg-primary-kubeconfig" must end with "_cluster_kubeconfig" + + - it: should fail when clusterGroup is missing + set: + acm: + importedClusters: + - secretName: mcg-primary_cluster_kubeconfig + asserts: + - failedTemplate: + errorMessage: acm.importedClusters entry for secretName "mcg-primary_cluster_kubeconfig" is missing required field clusterGroup + + - it: should fail when secretName is missing + set: + acm: + importedClusters: + - clusterGroup: group-one + asserts: + - failedTemplate: + errorMessage: acm.importedClusters entry is missing required field secretName diff --git a/values.yaml b/values.yaml index e668564..c3a0d9f 100644 --- a/values.yaml +++ b/values.yaml @@ -100,6 +100,15 @@ acm: # -- Default gitops channel to install on remote clusters channel: "gitops-1.21" + # -- Bring-your-own clusters to auto-import into ACM using kubeconfigs from Vault. + # Each entry needs secretName (must end with `_cluster_kubeconfig`) and clusterGroup + # (value for the ManagedCluster `clusterGroup` label). The ManagedCluster name is + # derived by stripping the `_cluster_kubeconfig` suffix from secretName. + # Example: + # - secretName: mcg-primary_cluster_kubeconfig + # clusterGroup: group-one + # @default -- empty list (feature disabled) + importedClusters: [] # -- Default secretstore configuration variables # @default -- depends on the individual settings From 8577803e87b1b7107ee81d416ff8bfb4deeea235 Mon Sep 17 00:00:00 2001 From: OAharoni-RedHat Date: Thu, 12 Nov 2026 11:58:11 -0500 Subject: [PATCH 2/2] update provisioning path to match import --- templates/_helpers.tpl | 73 ++++++++++++++++++++++ templates/provision/clusterdeployment.yaml | 23 ++----- templates/provision/imported-clusters.yaml | 37 +---------- tests/clusterdeployment_test.yaml | 49 ++++++++++++--- tests/imported_clusters_test.yaml | 5 ++ 5 files changed, 124 insertions(+), 63 deletions(-) diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index 0a468c9..3092217 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -495,3 +495,76 @@ Validate a single acm.importedClusters entry has secretName and clusterGroup. {{- fail (printf "acm.importedClusters entry for secretName %q is missing required field clusterGroup" .secretName) -}} {{- end -}} {{- end -}} + +{{/* +Render a ManagedCluster used by both clusterDeployments and importedClusters. + +Expected dict keys: + - name (required) + - labels (required map) + - syncOptions (optional; default includes SkipDryRunOnMissingResource + RespectIgnoreDifferences) + - leaseDurationSeconds (optional; default 60) +*/}} +{{- define "acm.managedCluster" -}} +apiVersion: cluster.open-cluster-management.io/v1 +kind: ManagedCluster +metadata: + name: {{ .name }} + labels: +{{- range $k, $v := .labels }} + {{ $k }}: {{ $v }} +{{- end }} + annotations: + argocd.argoproj.io/sync-options: {{ .syncOptions | default "SkipDryRunOnMissingResource=true,RespectIgnoreDifferences=true" }} +spec: + hubAcceptsClient: true + leaseDurationSeconds: {{ .leaseDurationSeconds | default 60 }} +{{- end -}} + +{{/* +Build ManagedCluster labels for a managedClusterGroups entry used by clusterDeployments. +Always sets clusterset to the group name. Uses acmlabels when present; otherwise +sets clusterGroup to the group name. +*/}} +{{- define "acm.managedCluster.labels.fromGroup" -}} +{{- $labels := dict "cluster.open-cluster-management.io/clusterset" .name -}} +{{- if or (not .acmlabels) (eq (len .acmlabels) 0) -}} +{{- $_ := set $labels "clusterGroup" .name -}} +{{- else -}} +{{- range .acmlabels -}} +{{- $_ := set $labels .name .value -}} +{{- end -}} +{{- end -}} +{{- mustToJson $labels -}} +{{- end -}} + +{{/* +Render a KlusterletAddonConfig with ACM console-like default addons enabled. +Used by both clusterDeployments and importedClusters. + +Expected dict keys: + - name (required; used for metadata name/namespace and spec.clusterName/clusterNamespace) +*/}} +{{- define "acm.klusterletAddonConfig" -}} +apiVersion: agent.open-cluster-management.io/v1 +kind: KlusterletAddonConfig +metadata: + name: {{ .name }} + namespace: {{ .name }} + annotations: + argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true +spec: + clusterName: {{ .name }} + clusterNamespace: {{ .name }} + clusterLabels: + cloud: auto-detect + vendor: auto-detect + applicationManager: + enabled: true + policyController: + enabled: true + searchCollector: + enabled: true + certPolicyController: + enabled: true +{{- end -}} diff --git a/templates/provision/clusterdeployment.yaml b/templates/provision/clusterdeployment.yaml index f7f71a5..221ead8 100644 --- a/templates/provision/clusterdeployment.yaml +++ b/templates/provision/clusterdeployment.yaml @@ -60,24 +60,9 @@ spec: name: {{ $deploymentName }}-pull-secret --- -apiVersion: cluster.open-cluster-management.io/v1 -kind: ManagedCluster -metadata: - labels: - cluster.open-cluster-management.io/clusterset: {{ $group.name }} - {{- if (not $group.acmlabels) }} - clusterGroup: {{ $group.name }} - {{- else if eq (len $group.acmlabels) 0 }} - clusterGroup: {{ $group.name }} - {{- else }} - {{- range $group.acmlabels }} - {{ .name }}: {{ .value }} - {{- end }} - {{- end }} - name: {{ $deploymentName }} - annotations: - argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true -spec: - hubAcceptsClient: true +{{- $mcLabels := include "acm.managedCluster.labels.fromGroup" $group | fromJson }} +{{ include "acm.managedCluster" (dict "name" $deploymentName "labels" $mcLabels) }} +--- +{{ include "acm.klusterletAddonConfig" (dict "name" $deploymentName) }} {{- end }}{{- /* range $group.clusterDeployments */}} {{- end }}{{- /* range .Values.clusterGroup.managedClusterGroups */}} diff --git a/templates/provision/imported-clusters.yaml b/templates/provision/imported-clusters.yaml index c3855ce..8dca750 100644 --- a/templates/provision/imported-clusters.yaml +++ b/templates/provision/imported-clusters.yaml @@ -9,19 +9,8 @@ kind: Namespace metadata: name: {{ $clusterName }} --- -apiVersion: cluster.open-cluster-management.io/v1 -kind: ManagedCluster -metadata: - name: {{ $clusterName }} - labels: - # Only manage clusterGroup here. ACM controllers overwrite cloud/vendor and - # add discovery/addon labels after import, which would otherwise leave Argo OutOfSync. - clusterGroup: {{ $clusterGroup }} - annotations: - argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true,RespectIgnoreDifferences=true -spec: - hubAcceptsClient: true - leaseDurationSeconds: 60 +{{/* Only manage clusterGroup here. ACM overwrites cloud/vendor and adds discovery/addon labels after import. */}} +{{ include "acm.managedCluster" (dict "name" $clusterName "labels" (dict "clusterGroup" $clusterGroup)) }} --- apiVersion: external-secrets.io/v1 kind: ExternalSecret @@ -50,25 +39,5 @@ spec: kubeconfig: |- {{ "{{ .kubeconfig | toString }}" }} --- -apiVersion: agent.open-cluster-management.io/v1 -kind: KlusterletAddonConfig -metadata: - name: {{ $clusterName }} - namespace: {{ $clusterName }} - annotations: - argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true -spec: - clusterName: {{ $clusterName }} - clusterNamespace: {{ $clusterName }} - clusterLabels: - cloud: auto-detect - vendor: auto-detect - applicationManager: - enabled: true - policyController: - enabled: true - searchCollector: - enabled: true - certPolicyController: - enabled: true +{{ include "acm.klusterletAddonConfig" (dict "name" $clusterName) }} {{- end }} diff --git a/tests/clusterdeployment_test.yaml b/tests/clusterdeployment_test.yaml index d020434..aeb8bab 100644 --- a/tests/clusterdeployment_test.yaml +++ b/tests/clusterdeployment_test.yaml @@ -14,7 +14,7 @@ tests: - hasDocuments: count: 0 - - it: should render Namespace, ClusterDeployment, and ManagedCluster for AWS + - it: should render Namespace, ClusterDeployment, ManagedCluster, and KlusterletAddonConfig for AWS set: clusterGroup: managedClusterGroups: @@ -30,7 +30,7 @@ tests: region: us-east-1 asserts: - hasDocuments: - count: 3 + count: 4 - documentIndex: 0 isKind: of: Namespace @@ -81,6 +81,21 @@ tests: equal: path: spec.pullSecretRef.name value: cluster1-region-one-pull-secret + - documentIndex: 3 + isKind: + of: KlusterletAddonConfig + - documentIndex: 3 + equal: + path: metadata.name + value: cluster1-region-one + - documentIndex: 3 + equal: + path: metadata.namespace + value: cluster1-region-one + - documentIndex: 3 + equal: + path: spec.policyController.enabled + value: true - it: should render correctly for Azure platform set: @@ -99,7 +114,7 @@ tests: region: eastus asserts: - hasDocuments: - count: 3 + count: 4 - documentIndex: 1 equal: path: spec.platform.azure.region @@ -139,6 +154,14 @@ tests: equal: path: spec.hubAcceptsClient value: true + - documentIndex: 2 + equal: + path: spec.leaseDurationSeconds + value: 60 + - documentIndex: 2 + equal: + path: metadata.annotations["argocd.argoproj.io/sync-options"] + value: SkipDryRunOnMissingResource=true,RespectIgnoreDifferences=true - it: should use acmlabels on ManagedCluster when specified as list set: @@ -213,7 +236,7 @@ tests: region: us-west-2 asserts: - hasDocuments: - count: 6 + count: 8 - it: should render resources for multiple managedClusterGroups set: @@ -242,7 +265,7 @@ tests: region: westus2 asserts: - hasDocuments: - count: 6 + count: 8 - documentIndex: 0 isKind: of: Namespace @@ -265,23 +288,29 @@ tests: path: metadata.labels["cluster.open-cluster-management.io/clusterset"] value: east - documentIndex: 3 + isKind: + of: KlusterletAddonConfig + - documentIndex: 4 isKind: of: Namespace - - documentIndex: 3 + - documentIndex: 4 equal: path: metadata.name value: cluster-west-west - - documentIndex: 4 + - documentIndex: 5 isKind: of: ClusterDeployment - - documentIndex: 4 + - documentIndex: 5 equal: path: spec.platform.azure.region value: westus2 - - documentIndex: 5 + - documentIndex: 6 isKind: of: ManagedCluster - - documentIndex: 5 + - documentIndex: 6 equal: path: metadata.labels["cluster.open-cluster-management.io/clusterset"] value: west + - documentIndex: 7 + isKind: + of: KlusterletAddonConfig diff --git a/tests/imported_clusters_test.yaml b/tests/imported_clusters_test.yaml index a192953..bdf04de 100644 --- a/tests/imported_clusters_test.yaml +++ b/tests/imported_clusters_test.yaml @@ -61,9 +61,14 @@ tests: equal: path: spec.leaseDurationSeconds value: 60 + - documentIndex: 1 + equal: + path: metadata.annotations["argocd.argoproj.io/sync-options"] + value: SkipDryRunOnMissingResource=true,RespectIgnoreDifferences=true - documentIndex: 2 isKind: of: ExternalSecret + - documentIndex: 2 equal: path: metadata.name