diff --git a/docs/book/src/cronjob-tutorial/testdata/project/dist/chart/templates/manager/manager.yaml b/docs/book/src/cronjob-tutorial/testdata/project/dist/chart/templates/manager/manager.yaml index 635890c0934..ac0dbf7ab1e 100644 --- a/docs/book/src/cronjob-tutorial/testdata/project/dist/chart/templates/manager/manager.yaml +++ b/docs/book/src/cronjob-tutorial/testdata/project/dist/chart/templates/manager/manager.yaml @@ -21,6 +21,15 @@ spec: app.kubernetes.io/name: project control-plane: controller-manager spec: + {{- with .Values.manager.tolerations }} + tolerations: {{ toYaml . | nindent 16 }} + {{- end }} + {{- with .Values.manager.affinity }} + affinity: {{ toYaml . | nindent 16 }} + {{- end }} + {{- with .Values.manager.nodeSelector }} + nodeSelector: {{ toYaml . | nindent 16 }} + {{- end }} containers: - args: {{- if .Values.metrics.enable }} diff --git a/docs/book/src/cronjob-tutorial/testdata/project/dist/chart/values.yaml b/docs/book/src/cronjob-tutorial/testdata/project/dist/chart/values.yaml index e20c77f4953..7fde2692162 100644 --- a/docs/book/src/cronjob-tutorial/testdata/project/dist/chart/values.yaml +++ b/docs/book/src/cronjob-tutorial/testdata/project/dist/chart/values.yaml @@ -40,6 +40,15 @@ manager: cpu: 10m memory: 64Mi + # Manager pod's affinity + affinity: {} + + # Manager pod's node selector + nodeSelector: {} + + # Manager pod's tolerations + tolerations: [] + # Essential RBAC permissions (required for controller operation) # These include ServiceAccount, controller permissions, leader election, and metrics access # Note: Essential RBAC is always enabled as it's required for the controller to function diff --git a/docs/book/src/getting-started/testdata/project/dist/chart/templates/manager/manager.yaml b/docs/book/src/getting-started/testdata/project/dist/chart/templates/manager/manager.yaml index 8d4e7514bae..de2d5a28332 100644 --- a/docs/book/src/getting-started/testdata/project/dist/chart/templates/manager/manager.yaml +++ b/docs/book/src/getting-started/testdata/project/dist/chart/templates/manager/manager.yaml @@ -21,6 +21,15 @@ spec: app.kubernetes.io/name: project control-plane: controller-manager spec: + {{- with .Values.manager.tolerations }} + tolerations: {{ toYaml . | nindent 16 }} + {{- end }} + {{- with .Values.manager.affinity }} + affinity: {{ toYaml . | nindent 16 }} + {{- end }} + {{- with .Values.manager.nodeSelector }} + nodeSelector: {{ toYaml . | nindent 16 }} + {{- end }} containers: - args: {{- if .Values.metrics.enable }} diff --git a/docs/book/src/getting-started/testdata/project/dist/chart/values.yaml b/docs/book/src/getting-started/testdata/project/dist/chart/values.yaml index 1bb5ce80370..0d26311cfca 100644 --- a/docs/book/src/getting-started/testdata/project/dist/chart/values.yaml +++ b/docs/book/src/getting-started/testdata/project/dist/chart/values.yaml @@ -40,6 +40,15 @@ manager: cpu: 10m memory: 64Mi + # Manager pod's affinity + affinity: {} + + # Manager pod's node selector + nodeSelector: {} + + # Manager pod's tolerations + tolerations: [] + # Essential RBAC permissions (required for controller operation) # These include ServiceAccount, controller permissions, leader election, and metrics access # Note: Essential RBAC is always enabled as it's required for the controller to function diff --git a/docs/book/src/multiversion-tutorial/testdata/project/dist/chart/templates/manager/manager.yaml b/docs/book/src/multiversion-tutorial/testdata/project/dist/chart/templates/manager/manager.yaml index 635890c0934..ac0dbf7ab1e 100644 --- a/docs/book/src/multiversion-tutorial/testdata/project/dist/chart/templates/manager/manager.yaml +++ b/docs/book/src/multiversion-tutorial/testdata/project/dist/chart/templates/manager/manager.yaml @@ -21,6 +21,15 @@ spec: app.kubernetes.io/name: project control-plane: controller-manager spec: + {{- with .Values.manager.tolerations }} + tolerations: {{ toYaml . | nindent 16 }} + {{- end }} + {{- with .Values.manager.affinity }} + affinity: {{ toYaml . | nindent 16 }} + {{- end }} + {{- with .Values.manager.nodeSelector }} + nodeSelector: {{ toYaml . | nindent 16 }} + {{- end }} containers: - args: {{- if .Values.metrics.enable }} diff --git a/docs/book/src/multiversion-tutorial/testdata/project/dist/chart/values.yaml b/docs/book/src/multiversion-tutorial/testdata/project/dist/chart/values.yaml index e20c77f4953..7fde2692162 100644 --- a/docs/book/src/multiversion-tutorial/testdata/project/dist/chart/values.yaml +++ b/docs/book/src/multiversion-tutorial/testdata/project/dist/chart/values.yaml @@ -40,6 +40,15 @@ manager: cpu: 10m memory: 64Mi + # Manager pod's affinity + affinity: {} + + # Manager pod's node selector + nodeSelector: {} + + # Manager pod's tolerations + tolerations: [] + # Essential RBAC permissions (required for controller operation) # These include ServiceAccount, controller permissions, leader election, and metrics access # Note: Essential RBAC is always enabled as it's required for the controller to function diff --git a/docs/book/src/plugins/available/helm-v2-alpha.md b/docs/book/src/plugins/available/helm-v2-alpha.md index 16d236b1579..c0ce2869798 100644 --- a/docs/book/src/plugins/available/helm-v2-alpha.md +++ b/docs/book/src/plugins/available/helm-v2-alpha.md @@ -188,6 +188,35 @@ controllerManager: cpu: 10m memory: 64Mi + # Manager pod's affinity + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: + - amd64 + - arm64 + - ppc64le + - s390x + - key: kubernetes.io/os + operator: In + values: + - linux + + # Manager pod's node selector + nodeSelector: + kubernetes.io/os: linux + + # Manager pod's tolerations + tolerations: + - key: "node.kubernetes.io/unreachable" + operator: "Exists" + effect: "NoExecute" + tolerationSeconds: 6000 + # Essential RBAC permissions (required for controller operation) # These include ServiceAccount, controller permissions, leader election, and metrics access # Note: Essential RBAC is always enabled as it's required for the controller to function diff --git a/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/kustomize/chart_converter.go b/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/kustomize/chart_converter.go index bedab3dccc8..e2d2dde4557 100644 --- a/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/kustomize/chart_converter.go +++ b/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/kustomize/chart_converter.go @@ -106,6 +106,10 @@ func (c *ChartConverter) ExtractDeploymentConfig() map[string]any { extractPodSecurityContext(specMap, config) extractImagePullSecrets(specMap, config) + extractPodNodeSelector(specMap, config) + extractPodTolerations(specMap, config) + extractPodAffinity(specMap, config) + container := firstManagerContainer(specMap) if container == nil { return config @@ -163,6 +167,48 @@ func extractPodSecurityContext(specMap map[string]any, config map[string]any) { config["podSecurityContext"] = podSecurityContext } +func extractPodNodeSelector(specMap map[string]any, config map[string]any) { + raw, found, err := unstructured.NestedFieldNoCopy(specMap, "nodeSelector") + if !found || err != nil { + return + } + + result, ok := raw.(map[string]any) + if !ok || len(result) == 0 { + return + } + + config["podNodeSelector"] = result +} + +func extractPodTolerations(specMap map[string]any, config map[string]any) { + raw, found, err := unstructured.NestedFieldNoCopy(specMap, "tolerations") + if !found || err != nil { + return + } + + result, ok := raw.([]any) + if !ok || len(result) == 0 { + return + } + + config["podTolerations"] = result +} + +func extractPodAffinity(specMap map[string]any, config map[string]any) { + raw, found, err := unstructured.NestedFieldNoCopy(specMap, "affinity") + if !found || err != nil { + return + } + + result, ok := raw.(map[string]any) + if !ok || len(result) == 0 { + return + } + + config["podAffinity"] = result +} + func firstManagerContainer(specMap map[string]any) map[string]any { containers, found, err := unstructured.NestedFieldNoCopy(specMap, "containers") if !found || err != nil { diff --git a/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/kustomize/helm_templater.go b/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/kustomize/helm_templater.go index a5013fc75db..867963a70b5 100644 --- a/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/kustomize/helm_templater.go +++ b/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/kustomize/helm_templater.go @@ -191,6 +191,24 @@ func (t *HelmTemplater) templateDeploymentFields(yamlContent string) string { yamlContent = t.templateVolumeMounts(yamlContent) yamlContent = t.templateVolumes(yamlContent) yamlContent = t.templateControllerManagerArgs(yamlContent) + yamlContent = t.templateBasicWithStatement( + yamlContent, + "nodeSelector", + "spec.template.spec", + ".Values.manager.nodeSelector", + ) + yamlContent = t.templateBasicWithStatement( + yamlContent, + "affinity", + "spec.template.spec", + ".Values.manager.affinity", + ) + yamlContent = t.templateBasicWithStatement( + yamlContent, + "tolerations", + "spec.template.spec", + ".Values.manager.tolerations", + ) return yamlContent } @@ -669,6 +687,88 @@ func (t *HelmTemplater) templateImageReference(yamlContent string) string { return yamlContent } +func (t *HelmTemplater) templateBasicWithStatement( + yamlContent string, + key string, + parentKey string, + valuePath string, +) string { + lines := strings.Split(yamlContent, "\n") + yamlKey := fmt.Sprintf("%s:", key) + + var start, end int + var indentLen int + if !strings.Contains(yamlContent, yamlKey) { + // Find parent block start if the key is missing + pKeyParts := strings.Split(parentKey, ".") + pKeyIdx := 0 + pKeyInit := false + currIndent := 0 + for i := range len(lines) { + _, lineIndent := leadingWhitespace(lines[i]) + if pKeyInit && lineIndent <= currIndent { + return yamlContent + } + if !strings.HasPrefix(strings.TrimSpace(lines[i]), pKeyParts[pKeyIdx]) { + continue + } + + // Parent key part found + pKeyIdx++ + pKeyInit = true + if pKeyIdx >= len(pKeyParts) { + start = i + 1 + end = start + break + } + } + _, indentLen = leadingWhitespace(lines[start]) + } else { + // Find the existing block + for i := range len(lines) { + if !strings.HasPrefix(strings.TrimSpace(lines[i]), key) { + continue + } + start = i + end = i + 1 + trimmed := strings.TrimSpace(lines[i]) + if len(trimmed) == len(yamlKey) { + _, indentLenSearch := leadingWhitespace(lines[i]) + for j := end; j < len(lines); j++ { + _, indentLenLine := leadingWhitespace(lines[j]) + if indentLenLine <= indentLenSearch { + end = j + break + } + } + } + } + _, indentLen = leadingWhitespace(lines[start]) + } + + indentStr := strings.Repeat(" ", indentLen) + + var builder strings.Builder + builder.WriteString(indentStr) + builder.WriteString("{{- with ") + builder.WriteString(valuePath) + builder.WriteString(" }}\n") + builder.WriteString(indentStr) + builder.WriteString(yamlKey) + builder.WriteString(" {{ toYaml . | nindent ") + builder.WriteString(strconv.Itoa(indentLen + 4)) + builder.WriteString(" }}\n") + builder.WriteString(indentStr) + builder.WriteString("{{- end }}\n") + + newBlock := strings.TrimRight(builder.String(), "\n") + + newLines := append([]string{}, lines[:start]...) + newLines = append(newLines, strings.Split(newBlock, "\n")...) + newLines = append(newLines, lines[end:]...) + return strings.Join(newLines, "\n") +} + // makeWebhookAnnotationsConditional makes only cert-manager annotations conditional, not the entire webhook func (t *HelmTemplater) makeWebhookAnnotationsConditional(yamlContent string) string { // Find cert-manager.io/inject-ca-from annotation and make it conditional diff --git a/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/templates/values_basic.go b/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/templates/values_basic.go index 148856824b8..86a68d15656 100644 --- a/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/templates/values_basic.go +++ b/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/templates/values_basic.go @@ -210,15 +210,7 @@ func (f *HelmValuesBasic) addDeploymentConfig(buf *bytes.Buffer) { buf.WriteString(" # Environment variables\n") buf.WriteString(" env:\n") if envYaml, err := yaml.Marshal(env); err == nil { - // Indent the YAML properly - lines := bytes.SplitSeq(envYaml, []byte("\n")) - for line := range lines { - if len(line) > 0 { - buf.WriteString(" ") - buf.Write(line) - buf.WriteString("\n") - } - } + f.IndentYamlProperly(buf, envYaml) } else { buf.WriteString(" []\n") } @@ -252,14 +244,7 @@ func (f *HelmValuesBasic) addDeploymentConfig(buf *bytes.Buffer) { buf.WriteString(" # Pod-level security settings\n") buf.WriteString(" podSecurityContext:\n") if secYaml, err := yaml.Marshal(podSecCtx); err == nil { - lines := bytes.SplitSeq(secYaml, []byte("\n")) - for line := range lines { - if len(line) > 0 { - buf.WriteString(" ") - buf.Write(line) - buf.WriteString("\n") - } - } + f.IndentYamlProperly(buf, secYaml) } buf.WriteString("\n") } else { @@ -271,14 +256,7 @@ func (f *HelmValuesBasic) addDeploymentConfig(buf *bytes.Buffer) { buf.WriteString(" # Container-level security settings\n") buf.WriteString(" securityContext:\n") if secYaml, err := yaml.Marshal(secCtx); err == nil { - lines := bytes.SplitSeq(secYaml, []byte("\n")) - for line := range lines { - if len(line) > 0 { - buf.WriteString(" ") - buf.Write(line) - buf.WriteString("\n") - } - } + f.IndentYamlProperly(buf, secYaml) } buf.WriteString("\n") } else { @@ -290,19 +268,59 @@ func (f *HelmValuesBasic) addDeploymentConfig(buf *bytes.Buffer) { buf.WriteString(" # Resource limits and requests\n") buf.WriteString(" resources:\n") if resYaml, err := yaml.Marshal(resources); err == nil { - lines := bytes.SplitSeq(resYaml, []byte("\n")) - for line := range lines { - if len(line) > 0 { - buf.WriteString(" ") - buf.Write(line) - buf.WriteString("\n") - } - } + f.IndentYamlProperly(buf, resYaml) } buf.WriteString("\n") } else { f.addDefaultResources(buf) } + + buf.WriteString(" # Manager pod's affinity\n") + if affinity, exists := f.DeploymentConfig["podAffinity"]; exists && affinity != nil { + buf.WriteString(" affinity:\n") + if affYaml, err := yaml.Marshal(affinity); err == nil { + f.IndentYamlProperly(buf, affYaml) + } + buf.WriteString("\n") + } else { + buf.WriteString(" affinity: {}\n") + buf.WriteString("\n") + } + + buf.WriteString(" # Manager pod's node selector\n") + if nodeSelector, exists := f.DeploymentConfig["podNodeSelector"]; exists && nodeSelector != nil { + buf.WriteString(" nodeSelector:\n") + if nodYaml, err := yaml.Marshal(nodeSelector); err == nil { + f.IndentYamlProperly(buf, nodYaml) + } + buf.WriteString("\n") + } else { + buf.WriteString(" nodeSelector: {}\n") + buf.WriteString("\n") + } + + buf.WriteString(" # Manager pod's tolerations\n") + if tolerations, exists := f.DeploymentConfig["podTolerations"]; exists && tolerations != nil { + buf.WriteString(" tolerations:\n") + if tolYaml, err := yaml.Marshal(tolerations); err == nil { + f.IndentYamlProperly(buf, tolYaml) + } + buf.WriteString("\n") + } else { + buf.WriteString(" tolerations: []\n") + buf.WriteString("\n") + } +} + +func (f *HelmValuesBasic) IndentYamlProperly(buf *bytes.Buffer, envYaml []byte) { + lines := bytes.SplitSeq(envYaml, []byte("\n")) + for line := range lines { + if len(line) > 0 { + buf.WriteString(" ") + buf.Write(line) + buf.WriteString("\n") + } + } } // addDefaultDeploymentSections adds default sections when no deployment config is available diff --git a/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/templates/values_basic_test.go b/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/templates/values_basic_test.go index bfd27f14494..abeb5876be3 100644 --- a/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/templates/values_basic_test.go +++ b/pkg/plugins/optional/helm/v2alpha/scaffolds/internal/templates/values_basic_test.go @@ -161,6 +161,76 @@ var _ = Describe("HelmValuesBasic", func() { Expect(content).To(ContainSubstring("resources:")) Expect(content).To(ContainSubstring("cpu: 100m")) Expect(content).To(ContainSubstring("memory: 128Mi")) + Expect(content).To(ContainSubstring("affinity: {}")) + Expect(content).To(ContainSubstring("nodeSelector: {}")) + Expect(content).To(ContainSubstring("tolerations: []")) + }) + }) + + Context("with nodeSelector, affinity and tolerations configuration", func() { + BeforeEach(func() { + deploymentConfig := map[string]any{ + "podNodeSelector": map[string]string{ + "kubernetes.io/os": "linux", + }, + "podTolerations": []map[string]string{ + { + "key": "key1", + "operator": "Equal", + "effect": "NoSchedule", + }, + }, + "podAffinity": map[string]any{ + "nodeAffinity": map[string]any{ + "requiredDuringSchedulingIgnoredDuringExecution": map[string]any{ + "nodeSelectorTerms": []any{ + map[string]any{ + "matchExpressions": []any{ + map[string]any{ + "key": "topology.kubernetes.io/zone", + "operator": "In", + "values": []string{"antarctica-east1", "antarctica-east2"}, + }, + }, + }, + }, + }, + }, + }, + } + + valuesTemplate = &HelmValuesBasic{ + HasWebhooks: false, + DeploymentConfig: deploymentConfig, + } + valuesTemplate.InjectProjectName("test-project") + err := valuesTemplate.SetTemplateDefaults() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should include default values", func() { + content := valuesTemplate.GetBody() + Expect(content).To(ContainSubstring(` # Manager pod's node selector + nodeSelector: + kubernetes.io/os: linux`)) + + Expect(content).To(ContainSubstring(` # Manager pod's tolerations + tolerations: + - effect: NoSchedule + key: key1 + operator: Equal`)) + + Expect(content).To(ContainSubstring(` # Manager pod's affinity + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: topology.kubernetes.io/zone + operator: In + values: + - antarctica-east1 + - antarctica-east2`)) }) }) diff --git a/testdata/project-v4-with-plugins/dist/chart/templates/manager/manager.yaml b/testdata/project-v4-with-plugins/dist/chart/templates/manager/manager.yaml index d4725a43979..a320fed9579 100644 --- a/testdata/project-v4-with-plugins/dist/chart/templates/manager/manager.yaml +++ b/testdata/project-v4-with-plugins/dist/chart/templates/manager/manager.yaml @@ -21,6 +21,15 @@ spec: app.kubernetes.io/name: project-v4-with-plugins control-plane: controller-manager spec: + {{- with .Values.manager.tolerations }} + tolerations: {{ toYaml . | nindent 16 }} + {{- end }} + {{- with .Values.manager.affinity }} + affinity: {{ toYaml . | nindent 16 }} + {{- end }} + {{- with .Values.manager.nodeSelector }} + nodeSelector: {{ toYaml . | nindent 16 }} + {{- end }} containers: - args: {{- if .Values.metrics.enable }} diff --git a/testdata/project-v4-with-plugins/dist/chart/values.yaml b/testdata/project-v4-with-plugins/dist/chart/values.yaml index 13f65a202e7..2e255741c2c 100644 --- a/testdata/project-v4-with-plugins/dist/chart/values.yaml +++ b/testdata/project-v4-with-plugins/dist/chart/values.yaml @@ -44,6 +44,15 @@ manager: cpu: 10m memory: 64Mi + # Manager pod's affinity + affinity: {} + + # Manager pod's node selector + nodeSelector: {} + + # Manager pod's tolerations + tolerations: [] + # Essential RBAC permissions (required for controller operation) # These include ServiceAccount, controller permissions, leader election, and metrics access # Note: Essential RBAC is always enabled as it's required for the controller to function