From e47066cfefe1837a1da1f5f79e36bd141f80181a Mon Sep 17 00:00:00 2001 From: Alexandr Vnukov Date: Sun, 16 Oct 2022 17:39:53 +0300 Subject: [PATCH 01/45] [fix] add custom selectors --- README.md | 6 ++--- charts/helm-apps/Chart.yaml | 3 +-- .../helm-apps/templates/_apps-components.tpl | 10 +++++++-- charts/helm-apps/templates/_apps-specs.tpl | 9 ++++++-- tests/.helm/values.yaml | 8 +++++++ tests/test_render.yaml | 22 +++++++++++++++++-- 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bc01795..2371249 100644 --- a/README.md +++ b/README.md @@ -20,18 +20,18 @@ #### Вручную: * Добавить в .gitlab-ci.yml строку подключения библиотеки общих чартов ```bash - werf helm repo add --force-update helm-apps https://flant.github.io/helm-apps + werf helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps ``` + к примеру так: ```yaml before_script: - type trdl && source $(trdl use werf ${WERF_VERSION:-1.2 ea}) - type werf && source $(werf ci-env gitlab --as-file) - - werf helm repo add --force-update helm-apps https://flant.github.io/helm-apps + - werf helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps ``` у себя на компьютере добавляем репозиторий helm-apps: ```yaml - werf helm repo add --force-update helm-apps https://flant.github.io/helm-apps + werf helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps ``` и обновляем зависимости: ```yaml diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 2cd2d1c..746d504 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,8 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.1.2 +version: 1.1.3 maintainers: - name: alvnukov - email: alexandr.vnukov@flant.com url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-components.tpl b/charts/helm-apps/templates/_apps-components.tpl index 9ea0e7c..6342c6d 100644 --- a/charts/helm-apps/templates/_apps-components.tpl +++ b/charts/helm-apps/templates/_apps-components.tpl @@ -71,7 +71,11 @@ kind: PodDisruptionBudget spec: selector: matchLabels: -{{- include "fl.generateSelectorLabels" (list $ . $.CurrentApp.name) | trim | nindent 6 }} +{{- if empty (include "fl.value" (list $ . $.CurrentApp.selector)) }} +{{- include "fl.generateSelectorLabels" (list $ . $.CurrentApp.name) | trim | nindent 6 }} +{{- else }} +{{- $.CurrentApp.selector | trim | nindent 6 }} +{{- end }} {{- with include "fl.value" (list $ . .maxUnavailable) }} maxUnavailable: {{ . }} {{- end }} @@ -93,7 +97,9 @@ spec: {{- include "apps-utils.enterScope" (list $ "service") }} --- {{- include "apps-utils.printPath" $ }} -{{- $_ := set $service "selector" (include "fl.generateSelectorLabels" (list $ . $.CurrentApp.name) | trim) }} +{{- if empty (include "fl.value" (list $ . $service.selector)) }} +{{- $_ := set $service "selector" (include "fl.generateSelectorLabels" (list $ . $.CurrentApp.name) | trim) }} +{{- end }} {{- if include "fl.isTrue" (list $ . $service.headless) }} {{- $_ := set $service "clusterIP" "None" }} {{- end }} diff --git a/charts/helm-apps/templates/_apps-specs.tpl b/charts/helm-apps/templates/_apps-specs.tpl index a44ed95..23c0eef 100644 --- a/charts/helm-apps/templates/_apps-specs.tpl +++ b/charts/helm-apps/templates/_apps-specs.tpl @@ -12,8 +12,13 @@ {{- $ := index . 0 }} {{- $relativeScope := index . 1 }} {{- with $relativeScope }} -matchLabels: {{- include "fl.generateSelectorLabels" (list $ . .name) | nindent 2 }} -{{- $_ := set . "__specName__" "selector"}} +matchLabels: +{{- if empty (include "fl.value" (list $ . .selector)) }} +{{- include "fl.generateSelectorLabels" (list $ . .name) | nindent 2 }} +{{- else }} +{{- .selector | nindent 2}} +{{- end }} +{{- $_ := set . "__specName__" "selector" }} {{- end }} {{- end }} diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index e50bb86..ea433d4 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -1923,6 +1923,8 @@ test-hpa: type: apps-stateless hpa-app: _include: ["apps-stateless-defaultApp"] + selector: | + app: my-selector containers: main: image: @@ -1978,7 +1980,13 @@ test-hpa: # https://deckhouse.io/ru/documentation/v1/modules/301-prometheus-metrics-adapter/cr.html query: 'sum(rate(sidekiq_jobs_enqueued_total{<<.LabelMatchers>>, queue="default"}[1m])) by (<<.GroupBy>>)' service: + enabled: true name: "{{ $.CurrentApp.name }}" + ports: | + - name: http + port: 80 + selector: | + app: my-selector apps-services: service-1: diff --git a/tests/test_render.yaml b/tests/test_render.yaml index fac9c71..976760d 100644 --- a/tests/test_render.yaml +++ b/tests/test_render.yaml @@ -117,7 +117,7 @@ metadata: spec: selector: matchLabels: - app: "hpa-app" + app: my-selector maxUnavailable: 15% --- # Helm Apps Library: apps-stateless.app-1.podDisruptionBudget @@ -816,6 +816,24 @@ subjects: name: test-app-2 namespace: test-prod --- +# Helm Apps Library: test-hpa.hpa-app.service +apiVersion: v1 +kind: Service +metadata: + name: "hpa-app" + annotations: + helm-apps/version: "TEST" + labels: + app: "hpa-app" + chart: "tests" + repo: "" +spec: + ports: + - name: http + port: 80 + selector: + app: my-selector +--- # Helm Apps Library: apps-stateless.app-1.service apiVersion: v1 kind: Service @@ -1212,7 +1230,7 @@ spec: priorityClassName: "production-medium" selector: matchLabels: - app: "hpa-app" + app: my-selector revisionHistoryLimit: 3 --- # Source: tests/templates/init-helm-apps-library.yaml From 1d32ee83e9f06c0e3a20e07384c98018c257b28a Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 17 Oct 2022 10:24:17 +0300 Subject: [PATCH 02/45] fix example ci --- docs/example/.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/example/.gitlab-ci.yml b/docs/example/.gitlab-ci.yml index e7d056d..f13c8ad 100644 --- a/docs/example/.gitlab-ci.yml +++ b/docs/example/.gitlab-ci.yml @@ -11,4 +11,4 @@ before_script: - set -eo pipefail - type trdl && source $(trdl use werf ${WERF_VERSION:-1.2 ea}) - type werf && source $(werf ci-env gitlab --as-file) -- werf helm repo add --force-update helm-apps https://flant.github.io/helm-apps +- werf helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps From 3c1e5549e3a997c19ace103cb7f822750fe5f721 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 17 Oct 2022 18:24:54 +0300 Subject: [PATCH 03/45] [feature] add includes from files (#3) * [feature] add includes from files * fix * fix Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- .../helm-apps/templates/_apps-components.tpl | 49 ++++++- charts/helm-apps/templates/_apps-helpers.tpl | 102 +++++++++----- charts/helm-apps/templates/_apps-utils.tpl | 22 +++ tests/.helm/config/test-include.yaml | 7 + tests/.helm/helm-apps-defaults.yaml | 101 ++++++++++++++ tests/.helm/values.yaml | 125 ++++-------------- tests/test_render.yaml | 68 +++++++++- 8 files changed, 335 insertions(+), 141 deletions(-) create mode 100644 tests/.helm/config/test-include.yaml create mode 100644 tests/.helm/helm-apps-defaults.yaml diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 746d504..f124a20 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.1.3 +version: 1.2.0 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-components.tpl b/charts/helm-apps/templates/_apps-components.tpl index 6342c6d..6609186 100644 --- a/charts/helm-apps/templates/_apps-components.tpl +++ b/charts/helm-apps/templates/_apps-components.tpl @@ -208,6 +208,29 @@ data: {{- end }} {{- end }} {{- include "apps-utils.leaveScope" $ }} +{{- /* ConfigMaps created by "configFilesYAML:" option */ -}} +{{- include "apps-utils.enterScope" (list $ "configFilesYAML") }} +{{- range $configFileName, $configFile := .configFilesYAML }} +{{- if kindIs "map" .content }} +{{- include "apps-utils.enterScope" (list $ $configFileName) }} +--- +{{- include "apps-utils.printPath" $ }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ print "config-yaml-" $containersType "-" $.CurrentApp.name "-" $.CurrentContainer.name "-" $configFileName | include "fl.formatStringAsDNSLabel" | quote }} + {{- with include "apps-helpers.generateAnnotations" (list $ .) | trim }} + {{- . | nindent 2 }} + {{- end }} + labels: {{ include "fl.generateLabels" (list $ . $.CurrentApp.name) | trim | nindent 4 }} +data: +{{- $_ := set $ "CurrentConfigYAML" (dict "local" . "content" .) }} +{{- include "apps-helpers.generateConfigYAML" $ }} + {{ $configFileName | quote }}: | {{ toYaml .content | trim | nindent 4 }} +{{- include "apps-utils.leaveScope" $ }} +{{- end }} +{{- end }} +{{- include "apps-utils.leaveScope" $ }} {{- /* Secrets created by "secretConfigFiles:" option */ -}} {{- range $secretConfigFileName, $secretConfigFile := .secretConfigFiles }} {{- if include "fl.value" (list $ . .content) }} @@ -259,19 +282,33 @@ data: {{ include "fl.generateSecretEnvVars" (list $ . .secretEnvVars) | trim | n {{- $allConfigMaps := "" }} {{- range $_, $containersType := list "initContainers" "containers" }} {{- range $_containerName, $_container := index $.CurrentApp $containersType }} +{{- $_ := set $ "CurrentContainer" . }} {{- if hasKey . "enabled" }} {{- if include "fl.isTrue" (list $ . .enabled) }} -{{- range $configFileName, $configFile := .configFiles }} -{{- $allConfigMaps = print $allConfigMaps (include "fl.value" (list $ $RelatedScope $configFile.content)) }} -{{- end }} +{{- $allConfigMaps = print $allConfigMaps (include "apps-components._generate-config-checksum" $) }} {{- end }} {{- else }} -{{- range $configFileName, $configFile := .configFiles }} -{{- $allConfigMaps = print $allConfigMaps (include "fl.value" (list $ $RelatedScope $configFile.content)) }} -{{- end }} +{{- $allConfigMaps = print $allConfigMaps (include "apps-components._generate-config-checksum" $) }} {{- end }} {{- end }} {{- end }} {{- printf "checksum/config: '%s'" ($allConfigMaps | sha256sum) }} {{- end }} + +{{- define "apps-components._generate-config-checksum" }} +{{- $ := . }} +{{- with $.CurrentApp }} +{{- range $_, $configFile := $.CurrentContainer.configFiles }} +{{- print (include "fl.value" (list $ . $configFile.content)) }} +{{- end }} +{{- range $_, $configFile := $.CurrentContainer.secretFiles }} +{{- print (include "fl.value" (list $ . $configFile.content)) }} +{{- end }} +{{- range $_, $configFile := $.CurrentContainer.configFilesYAML }} +{{- $_ := set $ "CurrentConfigYAML" (dict "local" $.CurrentApp "content" $configFile.content) }} +{{- include "apps-helpers.generateConfigYAML" $ }} +{{- $configFile.content | toYaml }} +{{- end }} +{{- end }} +{{- end }} diff --git a/charts/helm-apps/templates/_apps-helpers.tpl b/charts/helm-apps/templates/_apps-helpers.tpl index a327268..fe70b7f 100644 --- a/charts/helm-apps/templates/_apps-helpers.tpl +++ b/charts/helm-apps/templates/_apps-helpers.tpl @@ -19,9 +19,24 @@ - name: {{ print "config-" $containersType "-" $.CurrentApp.name "-" $.CurrentContainer.name "-" $configFileName | include "fl.formatStringAsDNSLabel" | quote }} configMap: name: {{ .name | quote }} - {{- with include "fl.value" (list $ . .defaultMode) }} + {{- with include "fl.value" (list $ . .defaultMode) }} defaultMode: {{ . }} - {{- end }} + {{- end }} + {{- end }} + {{- range $configFileName, $_ := .configFilesYAML }} + {{- if kindIs "map" .content }} + {{- $_ := set . "name" (print "config-yaml-" $containersType "-" $.CurrentApp.name "-" $.CurrentContainer.name "-" $configFileName | include "fl.formatStringAsDNSLabel") }} + {{- else }} + {{- if not ( include "fl.value" (list $ . .name)) }} + {{- fail (printf "Для app '%s' %s '%s' в configFiles '%s' нет content и забыли указать .name" $.CurrentApp.name $containersType $.CurrentContainer.name $configFileName) }} + {{- end }} + {{- end }} +- name: {{ print "config-yaml-" $containersType "-" $.CurrentApp.name "-" $.CurrentContainer.name "-" $configFileName | include "fl.formatStringAsDNSLabel" | quote }} + configMap: + name: {{ .name | quote }} + {{- with include "fl.value" (list $ . .defaultMode) }} + defaultMode: {{ . }} + {{- end }} {{- end }} {{- /* Mount Secrets created by "secretConfigFiles:" option as volumes */ -}} {{- range $secretConfigFileName, $_ := .secretConfigFiles }} @@ -52,8 +67,14 @@ subPath: {{ $configFileName | quote }} mountPath: {{ include "fl.valueQuoted" (list $ . .mountPath) }} {{- end }} - {{- end -}} - + {{- end }} + {{- range $configFileName, $configFile := $.CurrentContainer.configFilesYAML }} + {{- if or (kindIs "map" .content) (include "fl.value" (list $ . .name)) }} +- name: {{ print "config-yaml-" $.CurrentApp._currentContainersType "-" $.CurrentApp.name "-" $.CurrentContainer.name "-" $configFileName | include "fl.formatStringAsDNSLabel" | quote }} + subPath: {{ $configFileName | quote }} + mountPath: {{ include "fl.valueQuoted" (list $ . .mountPath) }} + {{- end }} + {{- end }} {{- /* Mount secret files from ConfigMaps created by "secretConfigFiles:" option */ -}} {{- range $secretConfigFileName, $secretConfigFile := $.CurrentContainer.secretConfigFiles }} - name: {{ print "config-" $.CurrentApp._currentContainersType "-" $.CurrentApp.name "-" $.CurrentContainer.name "-" $secretConfigFileName | include "fl.formatStringAsDNSLabel" | quote }} @@ -188,34 +209,6 @@ spec: {{- end }} {{- end }} -{{- define "apps-helpers.generateEnvYAML" }} - {{- $ := . }} - {{- $envs := $.CurrentEnvYAML.envs }} - {{- range $CurrentEnvKey, $CurrentEnvDict := $envs }} - {{- include "apps-utils.enterScope" (list $ $CurrentEnvKey) }} - {{- if kindIs "map" $CurrentEnvDict }} - {{- if hasKey $CurrentEnvDict "_default" }} - {{- if not (kindIs "map" $.CurrentContainer.envVars) }} - {{- $_ := set $.CurrentContainer "envVars" dict }} - {{- end }} - {{- $envName := slice $.CurrentPath $.CurrentEnvYAML.startPathLength | join "_" | upper }} - {{- if hasKey $CurrentEnvDict "name" }} - {{- $envName = $CurrentEnvDict.name }} - {{- end }} - {{- if hasKey $.CurrentContainer.envVars $envName }} - {{- $_ := set $.CurrentContainer.envVars $envName (mergeOverwrite $CurrentEnvDict (index $.CurrentContainer.envVars $envName)) }} - {{- else }} - {{- $_ := set $.CurrentContainer.envVars $envName $CurrentEnvDict }} - {{- end }} - {{- end }} - {{- $_ := set $.CurrentEnvYAML "envs" $CurrentEnvDict }} - {{- include "apps-helpers.generateEnvYAML" $ }} - {{- else }} - {{- end }} - {{- include "apps-utils.leaveScope" $ }} - {{- end }} -{{- end }} - {{- define "apps-helpers.jobTemplate" }} {{- $ := index . 0 }} {{- $RelatedScope := index . 1 }} @@ -320,3 +313,48 @@ metadata: {{- include "apps-utils.leaveScope" $ }} {{- end -}} +{{- define "apps-helpers.generateEnvYAML" }} + {{- $ := . }} + {{- $envs := $.CurrentEnvYAML.envs }} + {{- range $CurrentEnvKey, $CurrentEnvDict := $envs }} + {{- include "apps-utils.enterScope" (list $ $CurrentEnvKey) }} + {{- if kindIs "map" $CurrentEnvDict }} + {{- if hasKey $CurrentEnvDict "_default" }} + {{- if not (kindIs "map" $.CurrentContainer.envVars) }} + {{- $_ := set $.CurrentContainer "envVars" dict }} + {{- end }} + {{- $envName := slice $.CurrentPath $.CurrentEnvYAML.startPathLength | join "_" | upper }} + {{- if hasKey $CurrentEnvDict "name" }} + {{- $envName = $CurrentEnvDict.name }} + {{- end }} + {{- if hasKey $.CurrentContainer.envVars $envName }} + {{- $_ := set $.CurrentContainer.envVars $envName (mergeOverwrite $CurrentEnvDict (index $.CurrentContainer.envVars $envName)) }} + {{- else }} + {{- $_ := set $.CurrentContainer.envVars $envName $CurrentEnvDict }} + {{- end }} + {{- end }} + {{- $_ := set $.CurrentEnvYAML "envs" $CurrentEnvDict }} + {{- include "apps-helpers.generateEnvYAML" $ }} + {{- else }} + {{- end }} + {{- include "apps-utils.leaveScope" $ }} + {{- end }} +{{- end }} + +{{- define "apps-helpers.generateConfigYAML" }} + {{- $ := . }} + {{- $content := $.CurrentConfigYAML.content }} + {{- range $CurrentKey, $CurrentDict := $content }} + {{- include "apps-utils.enterScope" (list $ $CurrentKey) }} + {{- if kindIs "map" $CurrentDict }} + {{- if hasKey $CurrentDict "_default" }} + {{- $_ := set $content $CurrentKey (include "fl.value" (list $ . $CurrentDict))}} + {{- else }} + {{- $_ := set $.CurrentConfigYAML "content" $CurrentDict }} + {{- include "apps-helpers.generateConfigYAML" $ }} + {{- end }} + {{- else }} + {{- end }} + {{- include "apps-utils.leaveScope" $ }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index 0fdb9e4..10b3d2d 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -200,6 +200,7 @@ {{- define "apps-utils.init-library" }} {{- $ := . }} +{{- include "apps-utils.includesFromFiles" $ }} {{- $_ := include "fl.expandIncludesInValues" (list $ $.Values) }} {{- include "apps-utils.findApps" $ }} --- @@ -252,3 +253,24 @@ {{- define "apps-utils.printPath" }} {{- printf "\n---\n# Helm Apps Library: %s" (.CurrentPath | join ".") }} {{- end }} + +{{- define "apps-utils.includesFromFiles" }} +{{- $_ := set $ "HelmAppsArgs" (dict "owner" . "current" .Values "currentName" "Values")}} +{{- include "apps-utils._includesFromFiles" . }} +{{- end }} + +{{- define "apps-utils._includesFromFiles" }} +{{- $ := . }} +{{- if kindIs "map" $.HelmAppsArgs.current }} +{{- if hasKey $.HelmAppsArgs.current "_include_from_file" }} +{{- $_ := set $.HelmAppsArgs.owner $.HelmAppsArgs.currentName (mergeOverwrite ($.Files.Get $.HelmAppsArgs.current._include_from_file | fromYaml) $.HelmAppsArgs.current) }} +{{- $_ = unset $.HelmAppsArgs.current "_include_from_file" }} +{{- end }} +{{- range $k, $v := $.HelmAppsArgs.current }} +{{- if kindIs "map" $v }} +{{- $_ := set $ "HelmAppsArgs" (dict "owner" $.HelmAppsArgs.current "current" $v "currentName" $k) }} +{{- include "apps-utils._includesFromFiles" $ }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} diff --git a/tests/.helm/config/test-include.yaml b/tests/.helm/config/test-include.yaml new file mode 100644 index 0000000..831ddb0 --- /dev/null +++ b/tests/.helm/config/test-include.yaml @@ -0,0 +1,7 @@ +testIncludeVar: + _default: redaultvalue from file + prod: prod value from file +testIncludeVarMustOverwrited: + _default: default from file + prod: prod value from file +overwrited: value from file diff --git a/tests/.helm/helm-apps-defaults.yaml b/tests/.helm/helm-apps-defaults.yaml new file mode 100644 index 0000000..abcb8cb --- /dev/null +++ b/tests/.helm/helm-apps-defaults.yaml @@ -0,0 +1,101 @@ + +apps-default-library-app: + _include: ["apps-defaults"] + # CLIENT: ask if this is ok for a defaul + imagePullSecrets: | + - name: registrysecret +## Конфигурация по умолчанию для CronJob в целом. +apps-cronjobs-defaultCronJob: + _include: ["apps-default-library-app"] + concurrencyPolicy: "Forbid" + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 1 + backoffLimit: 0 + priorityClassName: + prod: "production-high" + restartPolicy: "Never" + startingDeadlineSeconds: 60 + verticalPodAutoscaler: + enabled: true + updateMode: "Off" + resourcePolicy: | + {} + +apps-secrets-defaultSecret: + _include: ["apps-defaults"] + +apps-ingresses-defaultIngress: + _include: ["apps-defaults"] + class: "nginx" + +apps-jobs-defaultJob: + _include: ["apps-default-library-app"] + backoffLimit: 0 + priorityClassName: + prod: "production-high" + restartPolicy: "Never" + verticalPodAutoscaler: + enabled: true + updateMode: "Off" + resourcePolicy: | + {} + +apps-stateful-defaultApp: + _include: ["apps-default-library-app"] + revisionHistoryLimit: 3 + terminationGracePeriodSeconds: + _default: 30 + prod: 60 + affinity: | + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: {{ include "fl.generateSelectorLabels" (list $ . .name) | nindent 22 }} + topologyKey: kubernetes.io/hostname + weight: 10 + priorityClassName: + prod: "production-medium" + podDisruptionBudget: + enabled: true + maxUnavailable: "15%" + verticalPodAutoscaler: + enabled: true + updateMode: "Off" + service: + enabled: false + name: "{{ $.CurrentApp.name }}" + headless: true + +apps-stateless-defaultApp: + _include: ["apps-default-library-app"] + revisionHistoryLimit: 3 + strategy: + _default: | + rollingUpdate: + maxSurge: 20% + maxUnavailable: 50% + type: RollingUpdate + prod: | + rollingUpdate: + maxSurge: 20% + maxUnavailable: 25% + type: RollingUpdate + priorityClassName: + prod: "production-medium" + podDisruptionBudget: + enabled: true + maxUnavailable: "15%" + verticalPodAutoscaler: + enabled: true + updateMode: "Off" + resourcePolicy: | + {} + horizontalPodAutoscaler: + enabled: false + service: + enabled: false + name: "{{ $.CurrentApp.name }}" + +apps-configmaps-defaultConfigmap: + _include: ["apps-defaults"] diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index ea433d4..3521b0f 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -21,106 +21,9 @@ global: _includes: apps-defaults: enabled: false - apps-default-library-app: - _include: ["apps-defaults"] - # CLIENT: ask if this is ok for a defaul - imagePullSecrets: | - - name: registrysecret - ## Конфигурация по умолчанию для CronJob в целом. - apps-cronjobs-defaultCronJob: - _include: ["apps-default-library-app"] - concurrencyPolicy: "Forbid" - successfulJobsHistoryLimit: 1 - failedJobsHistoryLimit: 1 - backoffLimit: 0 - priorityClassName: - prod: "production-high" - restartPolicy: "Never" - startingDeadlineSeconds: 60 - verticalPodAutoscaler: - enabled: true - updateMode: "Off" - resourcePolicy: | - {} - - apps-secrets-defaultSecret: - _include: ["apps-defaults"] - - apps-ingresses-defaultIngress: - _include: ["apps-defaults"] - class: "nginx" - - apps-jobs-defaultJob: - _include: ["apps-default-library-app"] - backoffLimit: 0 - priorityClassName: - prod: "production-high" - restartPolicy: "Never" - verticalPodAutoscaler: - enabled: true - updateMode: "Off" - resourcePolicy: | - {} - - apps-stateful-defaultApp: - _include: ["apps-default-library-app"] - revisionHistoryLimit: 3 - terminationGracePeriodSeconds: - _default: 30 - prod: 60 - affinity: | - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - podAffinityTerm: - labelSelector: - matchLabels: {{ include "fl.generateSelectorLabels" (list $ . .name) | nindent 22 }} - topologyKey: kubernetes.io/hostname - weight: 10 - priorityClassName: - prod: "production-medium" - podDisruptionBudget: - enabled: true - maxUnavailable: "15%" - verticalPodAutoscaler: - enabled: true - updateMode: "Off" - service: - enabled: false - name: "{{ $.CurrentApp.name }}" - headless: true - - apps-stateless-defaultApp: - _include: ["apps-default-library-app"] - revisionHistoryLimit: 3 - strategy: - _default: | - rollingUpdate: - maxSurge: 20% - maxUnavailable: 50% - type: RollingUpdate - prod: | - rollingUpdate: - maxSurge: 20% - maxUnavailable: 25% - type: RollingUpdate - priorityClassName: - prod: "production-medium" - podDisruptionBudget: - enabled: true - maxUnavailable: "15%" - verticalPodAutoscaler: - enabled: true - updateMode: "Off" - resourcePolicy: | - {} - horizontalPodAutoscaler: - enabled: false - service: - enabled: false - name: "{{ $.CurrentApp.name }}" - - apps-configmaps-defaultConfigmap: - _include: ["apps-defaults"] + _include_from_file: helm-apps-defaults.yaml + test-include-from-file: + _include_from_file: config/test-include.yaml ## Имя чарта. Ниже перечисляются ConfigMaps для развертывания. @@ -2004,6 +1907,9 @@ test-env-yaml: _include: ["apps-stateless-defaultApp"] containers: test: + secretEnvVars: + TEST_APP_LEVEL1_TEST_ENV3: + _default: default value from envVars envVars: TESTAPP_LEVEL1_TESTENV3: _default: default value from envVars @@ -2031,3 +1937,22 @@ test-env-yaml: level2: test: _default: default value #ошибки нет, в описании перемнной обязательно должен быть ключ "_default" + configFiles: + conf: + mountPath: /config + content: | + test + configFilesYAML: + config: + mountPath: /config + content: + _include: ["test-include-from-file"] + overwrited: + _default: default value from values.yaml + testIncludeVar: + _default: default value from values.yaml + testIncludeVarMustOverwrited: + _default: default value from values.yaml + prod: prod value from values.yaml + + diff --git a/tests/test_render.yaml b/tests/test_render.yaml index 976760d..5f39014 100644 --- a/tests/test_render.yaml +++ b/tests/test_render.yaml @@ -248,6 +248,21 @@ metadata: chart: "tests" repo: "" --- +# Helm Apps Library: test-env-yaml.env-yaml-app.containers.test.secretEnvVars +apiVersion: v1 +kind: Secret +metadata: + name: "envs-containers-env-yaml-app-test" + annotations: + helm-apps/version: "TEST" + labels: + app: "env-yaml-app" + chart: "tests" + repo: "" +type: Opaque +data: + "TEST_APP_LEVEL1_TEST_ENV3": "ZGVmYXVsdCB2YWx1ZSBmcm9tIGVudlZhcnM=" +--- # Helm Apps Library: apps-stateless.app-1.initContainers.init-container-1.secret.conf apiVersion: v1 kind: Secret @@ -542,6 +557,38 @@ data: "secret.conf": "cGxhaW5UZXh0TGluZTEKcGxhaW5UZXh0TGluZTIK" "secret2.conf": "cGxhaW5UZXh0TGluZTEK" --- +# Helm Apps Library: test-env-yaml.env-yaml-app.containers.test.configFiles.conf +apiVersion: v1 +kind: ConfigMap +metadata: + name: "config-containers-env-yaml-app-test-conf" + annotations: + helm-apps/version: "TEST" + labels: + app: "env-yaml-app" + chart: "tests" + repo: "" +data: + "conf": | + test +--- +# Helm Apps Library: test-env-yaml.env-yaml-app.containers.test.configFilesYAML.config +apiVersion: v1 +kind: ConfigMap +metadata: + name: "config-yaml-containers-env-yaml-app-test-config" + annotations: + helm-apps/version: "TEST" + labels: + app: "env-yaml-app" + chart: "tests" + repo: "" +data: + "config": | + overwrited: default value from values.yaml + testIncludeVar: default value from values.yaml + testIncludeVarMustOverwrited: prod value from values.yaml +--- # Helm Apps Library: apps-stateless.app-1.initContainers.init-container-1.configFiles.nginx.conf apiVersion: v1 kind: ConfigMap @@ -1106,7 +1153,7 @@ kind: Deployment metadata: name: "env-yaml-app" annotations: - checksum/config: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + checksum/config: "bc777c0f0207633ea7a6b7865d12e535294e500d9d6247ed7ce1f43a286d6577" helm-apps/version: "TEST" labels: app: "env-yaml-app" @@ -1122,7 +1169,7 @@ spec: metadata: name: "env-yaml-app" annotations: - checksum/config: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + checksum/config: "bc777c0f0207633ea7a6b7865d12e535294e500d9d6247ed7ce1f43a286d6577" labels: app: "env-yaml-app" chart: "tests" @@ -1140,8 +1187,25 @@ spec: value: "from envYAML" - name: "TESTAPP_LEVEL1_TESTENV4" value: "prod value from envVars" + envFrom: + - secretRef: + name: "envs-containers-env-yaml-app-test" + volumeMounts: + - name: "config-containers-env-yaml-app-test-conf" + subPath: "conf" + mountPath: "/config" + - name: "config-yaml-containers-env-yaml-app-test-config" + subPath: "config" + mountPath: "/config" imagePullSecrets: - name: registrysecret + volumes: + - name: "config-containers-env-yaml-app-test-conf" + configMap: + name: "config-containers-env-yaml-app-test-conf" + - name: "config-yaml-containers-env-yaml-app-test-config" + configMap: + name: "config-yaml-containers-env-yaml-app-test-config" priorityClassName: "production-medium" selector: matchLabels: From e517a361c12524d7c136efc5d81fdc07f67ebcc6 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 17 Oct 2022 20:38:57 +0300 Subject: [PATCH 04/45] Feature/include from file (#4) * [feature] add includes from files * fix * fix * [fix] include from file Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-utils.tpl | 23 +++-- docs/example/.helm/helm-apps-defaults.yaml | 102 ++++++++++++++++++++ docs/example/.helm/values.yaml | 103 +-------------------- tests/.helm/config/test-include.yaml | 3 + tests/.helm/helm-apps-defaults.yaml | 3 +- tests/.helm/values.yaml | 3 - tests/test_render.yaml | 8 +- 8 files changed, 129 insertions(+), 118 deletions(-) create mode 100644 docs/example/.helm/helm-apps-defaults.yaml diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index f124a20..9f375e3 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.2.0 +version: 1.2.1 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index 10b3d2d..1d130b4 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -256,20 +256,25 @@ {{- define "apps-utils.includesFromFiles" }} {{- $_ := set $ "HelmAppsArgs" (dict "owner" . "current" .Values "currentName" "Values")}} -{{- include "apps-utils._includesFromFiles" . }} +{{- include "apps-utils._includesFromFiles" (list . . .Values "Values") }} {{- end }} {{- define "apps-utils._includesFromFiles" }} -{{- $ := . }} -{{- if kindIs "map" $.HelmAppsArgs.current }} -{{- if hasKey $.HelmAppsArgs.current "_include_from_file" }} -{{- $_ := set $.HelmAppsArgs.owner $.HelmAppsArgs.currentName (mergeOverwrite ($.Files.Get $.HelmAppsArgs.current._include_from_file | fromYaml) $.HelmAppsArgs.current) }} -{{- $_ = unset $.HelmAppsArgs.current "_include_from_file" }} +{{- $ := index . 0 }} +{{- $owner := index . 1 }} +{{- $current := index . 2 }} +{{- $currentName := index . 3 }} +{{- if kindIs "map" $current }} +{{- if hasKey $current "_include_from_file" }} +{{- $dict := $.Files.Get $current._include_from_file | fromYaml }} +{{- $currentDict := deepCopy $current}} +{{- $_ := mergeOverwrite $dict $currentDict }} +{{- $_ = mergeOverwrite $current $dict }} +{{- $_ = unset $current "_include_from_file"}} {{- end }} -{{- range $k, $v := $.HelmAppsArgs.current }} +{{- range $k, $v := $current }} {{- if kindIs "map" $v }} -{{- $_ := set $ "HelmAppsArgs" (dict "owner" $.HelmAppsArgs.current "current" $v "currentName" $k) }} -{{- include "apps-utils._includesFromFiles" $ }} +{{- include "apps-utils._includesFromFiles" (list $ $current $v $k) }} {{- end }} {{- end }} {{- end }} diff --git a/docs/example/.helm/helm-apps-defaults.yaml b/docs/example/.helm/helm-apps-defaults.yaml new file mode 100644 index 0000000..95649ed --- /dev/null +++ b/docs/example/.helm/helm-apps-defaults.yaml @@ -0,0 +1,102 @@ +apps-defaults: + enabled: false +apps-default-library-app: + _include: ["apps-defaults"] + # CLIENT: ask if this is ok for a defaul + imagePullSecrets: | + - name: registrysecret +## Конфигурация по умолчанию для CronJob в целом. +apps-cronjobs-defaultCronJob: + _include: ["apps-default-library-app"] + concurrencyPolicy: "Forbid" + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 1 + backoffLimit: 0 + priorityClassName: + prod: "production-high" + restartPolicy: "Never" + startingDeadlineSeconds: 60 + verticalPodAutoscaler: + enabled: true + updateMode: "Off" + resourcePolicy: | + {} + +apps-secrets-defaultSecret: + _include: ["apps-defaults"] + +apps-ingresses-defaultIngress: + _include: ["apps-defaults"] + class: "nginx" + +apps-jobs-defaultJob: + _include: ["apps-default-library-app"] + backoffLimit: 0 + priorityClassName: + prod: "production-high" + restartPolicy: "Never" + verticalPodAutoscaler: + enabled: true + updateMode: "Off" + resourcePolicy: | + {} + +apps-stateful-defaultApp: + _include: ["apps-default-library-app"] + revisionHistoryLimit: 3 + terminationGracePeriodSeconds: + _default: 30 + prod: 60 + affinity: | + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: {{ include "fl.generateSelectorLabels" (list $ . .name) | nindent 22 }} + topologyKey: kubernetes.io/hostname + weight: 10 + priorityClassName: + prod: "production-medium" + podDisruptionBudget: + enabled: true + maxUnavailable: "15%" + verticalPodAutoscaler: + enabled: true + updateMode: "Off" + service: + enabled: false + name: "{{ $.CurrentApp.name }}" + headless: true + +apps-stateless-defaultApp: + _include: ["apps-default-library-app"] + revisionHistoryLimit: 3 + strategy: + _default: | + rollingUpdate: + maxSurge: 20% + maxUnavailable: 50% + type: RollingUpdate + prod: | + rollingUpdate: + maxSurge: 20% + maxUnavailable: 25% + type: RollingUpdate + priorityClassName: + prod: "production-medium" + podDisruptionBudget: + enabled: true + maxUnavailable: "15%" + verticalPodAutoscaler: + enabled: true + updateMode: "Off" + resourcePolicy: | + {} + horizontalPodAutoscaler: + enabled: false + service: + enabled: false + name: "{{ $.CurrentApp.name }}" + +apps-configmaps-defaultConfigmap: + _include: ["apps-defaults"] diff --git a/docs/example/.helm/values.yaml b/docs/example/.helm/values.yaml index 2d1c6d2..e39ea98 100644 --- a/docs/example/.helm/values.yaml +++ b/docs/example/.helm/values.yaml @@ -18,106 +18,7 @@ global: # # Подробнее: https://github.com/flant/helm-charts/tree/master/.helm/charts/flant-lib#flexpandincludesinvalues-function _includes: - apps-defaults: - enabled: false - apps-default-library-app: - _include: ["apps-defaults"] - # CLIENT: ask if this is ok for a defaul - imagePullSecrets: | - - name: registrysecret - ## Конфигурация по умолчанию для CronJob в целом. - apps-cronjobs-defaultCronJob: - _include: ["apps-default-library-app"] - concurrencyPolicy: "Forbid" - successfulJobsHistoryLimit: 1 - failedJobsHistoryLimit: 1 - backoffLimit: 0 - priorityClassName: - prod: "production-high" - restartPolicy: "Never" - startingDeadlineSeconds: 60 - verticalPodAutoscaler: - enabled: true - updateMode: "Off" - resourcePolicy: | - {} + # _include_from_file добавляет(инклудит) YAML из файла, работает в любом месте values.yaml + _include_from_file: helm-apps-defaults.yaml - apps-secrets-defaultSecret: - _include: ["apps-defaults"] - apps-ingresses-defaultIngress: - _include: ["apps-defaults"] - class: "nginx" - - apps-jobs-defaultJob: - _include: ["apps-default-library-app"] - backoffLimit: 0 - priorityClassName: - prod: "production-high" - restartPolicy: "Never" - verticalPodAutoscaler: - enabled: true - updateMode: "Off" - resourcePolicy: | - {} - - apps-stateful-defaultApp: - _include: ["apps-default-library-app"] - revisionHistoryLimit: 3 - terminationGracePeriodSeconds: - _default: 30 - prod: 60 - affinity: | - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - podAffinityTerm: - labelSelector: - matchLabels: {{ include "fl.generateSelectorLabels" (list $ . .name) | nindent 22 }} - topologyKey: kubernetes.io/hostname - weight: 10 - priorityClassName: - prod: "production-medium" - podDisruptionBudget: - enabled: true - maxUnavailable: "15%" - verticalPodAutoscaler: - enabled: true - updateMode: "Off" - service: - enabled: false - name: "{{ $.CurrentApp.name }}" - headless: true - - apps-stateless-defaultApp: - _include: ["apps-default-library-app"] - revisionHistoryLimit: 3 - strategy: - _default: | - rollingUpdate: - maxSurge: 20% - maxUnavailable: 50% - type: RollingUpdate - prod: | - rollingUpdate: - maxSurge: 20% - maxUnavailable: 25% - type: RollingUpdate - priorityClassName: - prod: "production-medium" - podDisruptionBudget: - enabled: true - maxUnavailable: "15%" - verticalPodAutoscaler: - enabled: true - updateMode: "Off" - resourcePolicy: | - {} - horizontalPodAutoscaler: - enabled: false - service: - enabled: false - name: "{{ $.CurrentApp.name }}" - headless: true - - apps-configmaps-defaultConfigmap: - _include: ["apps-defaults"] diff --git a/tests/.helm/config/test-include.yaml b/tests/.helm/config/test-include.yaml index 831ddb0..87d267f 100644 --- a/tests/.helm/config/test-include.yaml +++ b/tests/.helm/config/test-include.yaml @@ -5,3 +5,6 @@ testIncludeVarMustOverwrited: _default: default from file prod: prod value from file overwrited: value from file +testValue: value from file +testValueWithDefault: + _default: default value from file diff --git a/tests/.helm/helm-apps-defaults.yaml b/tests/.helm/helm-apps-defaults.yaml index abcb8cb..95649ed 100644 --- a/tests/.helm/helm-apps-defaults.yaml +++ b/tests/.helm/helm-apps-defaults.yaml @@ -1,4 +1,5 @@ - +apps-defaults: + enabled: false apps-default-library-app: _include: ["apps-defaults"] # CLIENT: ask if this is ok for a defaul diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index 3521b0f..7e5bb97 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -19,13 +19,10 @@ global: # # Подробнее: https://github.com/flant/helm-charts/tree/master/.helm/charts/flant-lib#flexpandincludesinvalues-function _includes: - apps-defaults: - enabled: false _include_from_file: helm-apps-defaults.yaml test-include-from-file: _include_from_file: config/test-include.yaml - ## Имя чарта. Ниже перечисляются ConfigMaps для развертывания. # Указано в .helm/requirements.yaml в репозитории приложения в ключах dependencies.name или dependencies.alias. # https://helm.sh/docs/topics/charts/#managing-dependencies-with-the-dependencies-field diff --git a/tests/test_render.yaml b/tests/test_render.yaml index 5f39014..aa5e37c 100644 --- a/tests/test_render.yaml +++ b/tests/test_render.yaml @@ -586,8 +586,10 @@ metadata: data: "config": | overwrited: default value from values.yaml - testIncludeVar: default value from values.yaml + testIncludeVar: prod value from file testIncludeVarMustOverwrited: prod value from values.yaml + testValue: value from file + testValueWithDefault: default value from file --- # Helm Apps Library: apps-stateless.app-1.initContainers.init-container-1.configFiles.nginx.conf apiVersion: v1 @@ -1153,7 +1155,7 @@ kind: Deployment metadata: name: "env-yaml-app" annotations: - checksum/config: "bc777c0f0207633ea7a6b7865d12e535294e500d9d6247ed7ce1f43a286d6577" + checksum/config: "854d6e54107f849087f3bee5e705db1b4d8386420d8b18ab03e98402ec27aeb2" helm-apps/version: "TEST" labels: app: "env-yaml-app" @@ -1169,7 +1171,7 @@ spec: metadata: name: "env-yaml-app" annotations: - checksum/config: "bc777c0f0207633ea7a6b7865d12e535294e500d9d6247ed7ce1f43a286d6577" + checksum/config: "854d6e54107f849087f3bee5e705db1b4d8386420d8b18ab03e98402ec27aeb2" labels: app: "env-yaml-app" chart: "tests" From f508f66c6795f9fbfc57217de13a88d50f5aaf89 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 17 Oct 2022 23:24:26 +0300 Subject: [PATCH 05/45] [fix] includes function (#5) Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- .../templates/fl-functions/_expandIncludesInValues.tpl | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 9f375e3..09ef8db 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.2.1 +version: 1.2.2 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/fl-functions/_expandIncludesInValues.tpl b/charts/helm-apps/templates/fl-functions/_expandIncludesInValues.tpl index e2e6cd5..2d87f98 100644 --- a/charts/helm-apps/templates/fl-functions/_expandIncludesInValues.tpl +++ b/charts/helm-apps/templates/fl-functions/_expandIncludesInValues.tpl @@ -109,6 +109,9 @@ {{- define "fl._recursiveMapsMerge" }} {{- $ := index . 0 }} {{- $mapToMergeFrom := index . 1 }} + {{- if kindIs "map" $mapToMergeFrom }} + {{- $mapToMergeFrom = deepCopy $mapToMergeFrom }} + {{- end }} {{- $mapToMergeInto := index . 2 }} {{- range $keyToMergeFrom, $valToMergeFrom := $mapToMergeFrom }} From 265b5f62521863ecf8eba55e30c40215b8c3d645 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Wed, 19 Oct 2022 16:40:50 +0300 Subject: [PATCH 06/45] [fix] add spec to CM (#6) Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-configmaps.tpl | 16 ++++++++++++++-- tests/.helm/values.yaml | 2 ++ tests/test_render.yaml | 2 ++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 09ef8db..3819fc4 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.2.2 +version: 1.2.3 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-configmaps.tpl b/charts/helm-apps/templates/_apps-configmaps.tpl index 6ac6b36..c40d2e7 100644 --- a/charts/helm-apps/templates/_apps-configmaps.tpl +++ b/charts/helm-apps/templates/_apps-configmaps.tpl @@ -14,8 +14,20 @@ apiVersion: v1 kind: ConfigMap {{- include "apps-helpers.metadataGenerator" (list $ .) }} +{{- $data := "" }} +{{- with include "apps.generateConfigMapEnvVars" (list $ . .envVars "envVars") }} +{{- $data = printf "%s\n%s" $data . | trim }} +{{- end }} +{{- with include "fl.value" (list $ . .data) }} +{{- $data = printf "%s\n%s" $data . | trim }} +{{- end }} +{{ with $data }} data: -{{- include "apps.generateConfigMapEnvVars" (list $ . .envVars "envVars") | nindent 2 }} -{{- include "fl.value" (list $ . .data) | nindent 2 }} +{{- . | nindent 2 }} +{{- end }} +{{ with include "fl.value" (list $ . .binaryData) }} +binaryData: +{{- . | nindent 2 }} +{{- end }} {{- end }} {{- end }} diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index 7e5bb97..64c6d55 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -41,6 +41,8 @@ apps-configmaps: configline2 something.conf: | configline1 + binaryData: | + test.gz: jdbkjbkjsdbkjdbjdsbljl ## Содержание ConfigMap'а. Несекретные переменные окружения, пробрасываемые в контейнеры. # По итогу пробросится в ConfigMap.data. # https://github.com/flant/helm-charts/tree/master/.helm/charts/flant-lib#flgeneratecontainerenvvars-template diff --git a/tests/test_render.yaml b/tests/test_render.yaml index aa5e37c..f4da1af 100644 --- a/tests/test_render.yaml +++ b/tests/test_render.yaml @@ -770,6 +770,8 @@ data: configline2 something.conf: | configline1 +binaryData: + test.gz: jdbkjbkjsdbkjdbjdsbljl --- # Source: tests/templates/init-helm-apps-library.yaml # Helm Apps Library: apps-pvcs.test-pvc From 5f21e46a7a16e87b2c1bab5f3864c5d7da7e68ec Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Wed, 19 Oct 2022 23:24:47 +0300 Subject: [PATCH 07/45] Fix/config map data (#7) * [fix] secrets create * [fix] configMap data Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- .../helm-apps/templates/_apps-configmaps.tpl | 8 ++++++- .../helm-apps/templates/_apps-fl-wrappers.tpl | 9 +++++++- .../fl-snippets/_generateConfigMapData.tpl | 21 +++++++++++++++++++ .../fl-snippets/_generateConfigMapEnvVars.tpl | 21 ++++++------------- .../fl-snippets/_generateSecretData.tpl | 8 +++++-- .../fl-snippets/_generateSecretEnvVars.tpl | 5 +++++ tests/.helm/values.yaml | 6 ++++++ tests/test_render.yaml | 15 +++++++++++++ 9 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 charts/helm-apps/templates/fl-snippets/_generateConfigMapData.tpl diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 3819fc4..90c5d2c 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.2.3 +version: 1.2.5 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-configmaps.tpl b/charts/helm-apps/templates/_apps-configmaps.tpl index c40d2e7..ed2b880 100644 --- a/charts/helm-apps/templates/_apps-configmaps.tpl +++ b/charts/helm-apps/templates/_apps-configmaps.tpl @@ -15,12 +15,18 @@ apiVersion: v1 kind: ConfigMap {{- include "apps-helpers.metadataGenerator" (list $ .) }} {{- $data := "" }} -{{- with include "apps.generateConfigMapEnvVars" (list $ . .envVars "envVars") }} +{{- with include "apps.generateConfigMapEnvVars" (list $ . .envVars) }} {{- $data = printf "%s\n%s" $data . | trim }} {{- end }} +{{- if kindIs "map" .data }} +{{- with include "apps.generateConfigMapData" (list $ . .data) }} +{{- $data = printf "%s\n%s" $data . | trim }} +{{- end }} +{{- else }} {{- with include "fl.value" (list $ . .data) }} {{- $data = printf "%s\n%s" $data . | trim }} {{- end }} +{{- end }} {{ with $data }} data: {{- . | nindent 2 }} diff --git a/charts/helm-apps/templates/_apps-fl-wrappers.tpl b/charts/helm-apps/templates/_apps-fl-wrappers.tpl index a23b45a..17dc8ec 100644 --- a/charts/helm-apps/templates/_apps-fl-wrappers.tpl +++ b/charts/helm-apps/templates/_apps-fl-wrappers.tpl @@ -14,11 +14,18 @@ {{- define "apps.generateConfigMapEnvVars" }} {{- $ := index . 0 }} -{{- include "apps-utils.enterScope" (list $ "secretEnvVars") }} +{{- include "apps-utils.enterScope" (list $ "EnvVars") }} {{- include "fl.generateConfigMapEnvVars" . }} {{- include "apps-utils.leaveScope" $ }} {{- end }} +{{- define "apps.generateConfigMapData" }} +{{- $ := index . 0 }} +{{- include "apps-utils.enterScope" (list $ "data") }} +{{- include "fl.generateConfigMapData" . }} +{{- include "apps-utils.leaveScope" $ }} +{{- end }} + {{- define "apps.value" }} {{- $ := index . 0 }} {{- include "apps-utils.enterScope" (list $ (last .)) }} diff --git a/charts/helm-apps/templates/fl-snippets/_generateConfigMapData.tpl b/charts/helm-apps/templates/fl-snippets/_generateConfigMapData.tpl new file mode 100644 index 0000000..e433e58 --- /dev/null +++ b/charts/helm-apps/templates/fl-snippets/_generateConfigMapData.tpl @@ -0,0 +1,21 @@ +{{- define "fl.generateConfigMapData" }} + {{- $ := index . 0 }} + {{- $relativeScope := index . 1 }} + {{- $data := index . 2 }} + {{- $upper := false }} + {{- if gt (len .) 3 }} + {{- $upper = true }} + {{- end -}} + + {{- range $key, $value := $data }} + {{- if $upper }} + {{- $key = upper $key }} + {{- end }} + {{- $value = include "apps.value" (list $ $relativeScope $value $key) }} + {{- if eq $value "___FL_THIS_ENV_VAR_WILL_BE_DEFINED_BUT_EMPTY___" }} +{{ $key | quote }}: "" + {{- else if ne $value "" }} +{{ $key | quote }}: {{ $value | quote }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/helm-apps/templates/fl-snippets/_generateConfigMapEnvVars.tpl b/charts/helm-apps/templates/fl-snippets/_generateConfigMapEnvVars.tpl index 290f262..19bdfb3 100644 --- a/charts/helm-apps/templates/fl-snippets/_generateConfigMapEnvVars.tpl +++ b/charts/helm-apps/templates/fl-snippets/_generateConfigMapEnvVars.tpl @@ -1,17 +1,8 @@ {{- define "fl.generateConfigMapEnvVars" }} - {{- $ := index . 0 }} - {{- $relativeScope := index . 1 }} - {{- $envs := index . 2 }} - - {{- range $envVarName, $envVarVal := $envs }} - {{- if $.Values.global.configFlantLibVariableUppercaseEnvs }} - {{- $envVarName = upper $envVarName }} - {{- end }} - {{- $envVarVal = include "apps.value" (list $ $relativeScope $envVarVal $envVarName) }} - {{- if eq $envVarVal "___FL_THIS_ENV_VAR_WILL_BE_DEFINED_BUT_EMPTY___" }} -{{ $envVarName | quote }}: "" - {{- else if ne $envVarVal "" }} -{{ $envVarName | quote }}: {{ $envVarVal | quote }} - {{- end }} - {{- end }} +{{- $ := index . 0 }} +{{- if $.Values.global.configFlantLibVariableUppercaseEnvs }} +{{- include "fl.generateConfigMapData" (append . true) }} +{{- else }} +{{- include "fl.generateConfigMapData" . }} +{{- end }} {{- end }} diff --git a/charts/helm-apps/templates/fl-snippets/_generateSecretData.tpl b/charts/helm-apps/templates/fl-snippets/_generateSecretData.tpl index b1c3fbf..f561b90 100644 --- a/charts/helm-apps/templates/fl-snippets/_generateSecretData.tpl +++ b/charts/helm-apps/templates/fl-snippets/_generateSecretData.tpl @@ -2,9 +2,13 @@ {{- $ := index . 0 }} {{- $relativeScope := index . 1 }} {{- $data := index . 2 }} + {{- $upper := false }} + {{- if gt (len .) 3 }} + {{- $upper = true }} + {{- end -}} {{- range $key, $value := $data }} - {{- if $.Values.global.configFlantLibVariableUppercaseEnvs }} + {{- if $upper }} {{- $key = upper $key }} {{- end }} {{- $value = include "apps.value" (list $ $relativeScope $value $key) }} @@ -14,4 +18,4 @@ {{ $key | quote }}: {{ $value | b64enc | quote }} {{- end }} {{- end }} -{{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/helm-apps/templates/fl-snippets/_generateSecretEnvVars.tpl b/charts/helm-apps/templates/fl-snippets/_generateSecretEnvVars.tpl index 9b4399f..09bc409 100644 --- a/charts/helm-apps/templates/fl-snippets/_generateSecretEnvVars.tpl +++ b/charts/helm-apps/templates/fl-snippets/_generateSecretEnvVars.tpl @@ -1,3 +1,8 @@ {{- define "fl.generateSecretEnvVars" }} +{{- $ := index . 0 }} +{{- if $.Values.global.configFlantLibVariableUppercaseEnvs }} +{{- include "fl.generateSecretData" (append . true) }} +{{- else }} {{- include "fl.generateSecretData" . }} {{- end }} +{{- end }} diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index 64c6d55..8cf8a17 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -49,6 +49,12 @@ apps-configmaps: envVars: TEST1: "val1" TEST2: "val2" + configmap-2: + _include: ["apps-configmaps-defaultConfigmap"] + data: + nginx.conf: | + configline1 + configline2 ## Имя чарта. Ниже перечисляются CronJob'ы для развертывания. # Указано в .helm/requirements.yaml в репозитории приложения в ключах dependencies.name или dependencies.alias. diff --git a/tests/test_render.yaml b/tests/test_render.yaml index f4da1af..8c25b66 100644 --- a/tests/test_render.yaml +++ b/tests/test_render.yaml @@ -774,6 +774,21 @@ binaryData: test.gz: jdbkjbkjsdbkjdbjdsbljl --- # Source: tests/templates/init-helm-apps-library.yaml +# Helm Apps Library: apps-configmaps.configmap-2 +apiVersion: v1 +kind: ConfigMap +metadata: + name: "configmap-2" + annotations: + helm-apps/version: "TEST" + labels: + app: "configmap-2" + chart: "tests" + repo: "" +data: + "nginx.conf": "configline1\nconfigline2 \n" +--- +# Source: tests/templates/init-helm-apps-library.yaml # Helm Apps Library: apps-pvcs.test-pvc kind: PersistentVolumeClaim apiVersion: v1 From ca34d01b0d3070cb5d5f3763ff4c49b52b7481fd Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Sat, 22 Oct 2022 15:01:05 +0300 Subject: [PATCH 08/45] [fix] add _include_files (#8) Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-utils.tpl | 14 ++++++++++++++ .../{test-include.yaml => test-include-files.yaml} | 0 tests/.helm/values.yaml | 4 ++-- 4 files changed, 17 insertions(+), 3 deletions(-) rename tests/.helm/config/{test-include.yaml => test-include-files.yaml} (100%) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 90c5d2c..e7206df 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.2.5 +version: 1.2.6 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index 1d130b4..588a4fb 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -272,6 +272,20 @@ {{- $_ = mergeOverwrite $current $dict }} {{- $_ = unset $current "_include_from_file"}} {{- end }} +{{- if hasKey $current "_include_files" }} +{{- $newInclude := list }} +{{- range $_, $fileName := $current._include_files }} +{{- $includeContent := $.Files.Get $fileName | fromYaml }} +{{- $includeName := sha256sum $fileName }} +{{- $_ := set $.Values.global._includes $includeName $includeContent }} +{{- $newInclude = append $newInclude $includeName }} +{{- end }} +{{- if hasKey $current "_include" }} +{{- $newInclude = concat $newInclude $current._include }} +{{- end }} +{{- $_ := set $current "_include" $newInclude }} +{{- $_ = unset $current "_include_files"}} +{{- end }} {{- range $k, $v := $current }} {{- if kindIs "map" $v }} {{- include "apps-utils._includesFromFiles" (list $ $current $v $k) }} diff --git a/tests/.helm/config/test-include.yaml b/tests/.helm/config/test-include-files.yaml similarity index 100% rename from tests/.helm/config/test-include.yaml rename to tests/.helm/config/test-include-files.yaml diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index 8cf8a17..0f15b41 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -21,7 +21,7 @@ global: _includes: _include_from_file: helm-apps-defaults.yaml test-include-from-file: - _include_from_file: config/test-include.yaml + _include_from_file: config/test-include-from-file.yaml ## Имя чарта. Ниже перечисляются ConfigMaps для развертывания. # Указано в .helm/requirements.yaml в репозитории приложения в ключах dependencies.name или dependencies.alias. @@ -1951,7 +1951,7 @@ test-env-yaml: config: mountPath: /config content: - _include: ["test-include-from-file"] + _include_files: ["config/test-include-files.yaml"] overwrited: _default: default value from values.yaml testIncludeVar: From f70b294d2c991b38f8e7fe5b0eba361e6c6a710e Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Sun, 23 Oct 2022 19:21:33 +0300 Subject: [PATCH 09/45] [fix] merge with _default (#9) Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-helpers.tpl | 11 +- .../fl-functions/_expandIncludesInValues.tpl | 117 +++++++++--------- tests/.helm/config/test-include-files.yaml | 23 ++++ tests/.helm/values.yaml | 19 ++- tests/test_render.yaml | 18 ++- 6 files changed, 127 insertions(+), 63 deletions(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index e7206df..f0029f5 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.2.6 +version: 1.2.7 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-helpers.tpl b/charts/helm-apps/templates/_apps-helpers.tpl index fe70b7f..f647802 100644 --- a/charts/helm-apps/templates/_apps-helpers.tpl +++ b/charts/helm-apps/templates/_apps-helpers.tpl @@ -348,13 +348,20 @@ metadata: {{- include "apps-utils.enterScope" (list $ $CurrentKey) }} {{- if kindIs "map" $CurrentDict }} {{- if hasKey $CurrentDict "_default" }} + {{- $val := index $CurrentDict "_default" }} + {{- if hasKey $CurrentDict $.Values.global.env }} + {{- $val = index $CurrentDict $.Values.global.env }} + {{- end }} + {{- if kindIs "string" $val }} {{- $_ := set $content $CurrentKey (include "fl.value" (list $ . $CurrentDict))}} {{- else }} + {{- $_ := set $content $CurrentKey $val }} + {{- end }} + {{- else }} {{- $_ := set $.CurrentConfigYAML "content" $CurrentDict }} {{- include "apps-helpers.generateConfigYAML" $ }} {{- end }} - {{- else }} {{- end }} {{- include "apps-utils.leaveScope" $ }} {{- end }} -{{- end }} \ No newline at end of file + {{- end }} diff --git a/charts/helm-apps/templates/fl-functions/_expandIncludesInValues.tpl b/charts/helm-apps/templates/fl-functions/_expandIncludesInValues.tpl index 2d87f98..5679a51 100644 --- a/charts/helm-apps/templates/fl-functions/_expandIncludesInValues.tpl +++ b/charts/helm-apps/templates/fl-functions/_expandIncludesInValues.tpl @@ -12,63 +12,7 @@ {{- end }} {{- end }} -{{- define "_fl.make_includes_from" }} -{{- $ := index . 0 }} -{{- $prevContext := index . 1 }} -{{- $curContext := index . 2 }} -{{- $varName := index . 3 }} -{{- if kindIs "map" $curContext }} -{{- if hasKey $curContext "_include_from" }} -{{- $includeVar := "" }} -{{- $excludeParam := list }} -{{- $includeParams := $curContext._include_from }} -{{- if kindIs "map" $includeParams }} -{{- $excludeParam = $curContext._include_from.exclude }} -{{- $includeVar = $curContext._include_from.path }} -{{- else }} -{{- $includeVar = $curContext._include_from }} -{{- end }} -{{- $tmpMap := include "_getMapKeyValue" (list $.Values $includeVar) | fromJson }} -{{- if gt (len $excludeParam) 0 }} -{{- range $e := $excludeParam }} -{{- $_ := unset $tmpMap $e }} -{{- end }} -{{- end }} -{{- $curContext := mergeOverwrite $curContext $tmpMap }} -{{- $_ := set $prevContext $varName $curContext }} -{{- $_ = unset $curContext "_include_from" }} -{{- end }} -{{- range $key,$varsDict := $curContext -}} -{{- if kindIs "map" $varsDict -}} -{{- if gt (len $varsDict) 0 -}} -{{- include "_fl.make_includes_from" (list $ $curContext $varsDict $key) -}} -{{- end -}} -{{- end -}} -{{- end }} -{{- end }} -{{- end }} -{{- define "_getMapKeyValue" }} -{{- $map := index . 0 }} -{{- $path := index . 1 }} -{{- $tmpMap := $map }} -{{- if contains "." $path }} -{{- $keys := regexSplit "\\." $path -1 }} -{{- range $k := $keys }} -{{ if kindIs "map" $tmpMap }} -{{- $tmpValue := get $tmpMap $k }} -{{- if kindIs "map" $tmpValue }} -{{ $tmpMap = $tmpValue }} -{{- else }} -{{ fail $k }} -{{- end }} -{{- end }} -{{- end }} -{{- else }} -{{- $tmpMap = get $tmpMap $path }} -{{- end }} -{{- toJson $tmpMap }} -{{- end }} {{- define "fl._recursiveMergeAndExpandIncludes" }} {{- $ := index . 0 }} @@ -119,7 +63,9 @@ {{- if kindIs "map" $valToMergeFrom }} {{- if kindIs "map" $valToMergeInto }} + {{- if not (hasKey $mapToMergeFrom "_default") }} {{- include "fl._recursiveMapsMerge" (list $ $valToMergeFrom $valToMergeInto) }} + {{- end }} {{- else if not (hasKey $mapToMergeInto $keyToMergeFrom) }} {{- $_ := set $mapToMergeInto $keyToMergeFrom $valToMergeFrom }} {{- end }} @@ -155,3 +101,62 @@ {{- end }} {{- dict "wrapper" $result | toJson }} {{- end }} + + +{{- define "_fl.make_includes_from" }} +{{- $ := index . 0 }} +{{- $prevContext := index . 1 }} +{{- $curContext := index . 2 }} +{{- $varName := index . 3 }} +{{- if kindIs "map" $curContext }} +{{- if hasKey $curContext "_include_from" }} +{{- $includeVar := "" }} +{{- $excludeParam := list }} +{{- $includeParams := $curContext._include_from }} +{{- if kindIs "map" $includeParams }} +{{- $excludeParam = $curContext._include_from.exclude }} +{{- $includeVar = $curContext._include_from.path }} +{{- else }} +{{- $includeVar = $curContext._include_from }} +{{- end }} +{{- $tmpMap := include "_getMapKeyValue" (list $.Values $includeVar) | fromJson }} +{{- if gt (len $excludeParam) 0 }} +{{- range $e := $excludeParam }} +{{- $_ := unset $tmpMap $e }} +{{- end }} +{{- end }} +{{- $curContext := mergeOverwrite $curContext $tmpMap }} +{{- $_ := set $prevContext $varName $curContext }} +{{- $_ = unset $curContext "_include_from" }} +{{- end }} +{{- range $key,$varsDict := $curContext -}} +{{- if kindIs "map" $varsDict -}} +{{- if gt (len $varsDict) 0 -}} +{{- include "_fl.make_includes_from" (list $ $curContext $varsDict $key) -}} +{{- end -}} +{{- end -}} +{{- end }} +{{- end }} +{{- end }} + +{{- define "_getMapKeyValue" }} +{{- $map := index . 0 }} +{{- $path := index . 1 }} +{{- $tmpMap := $map }} +{{- if contains "." $path }} +{{- $keys := regexSplit "\\." $path -1 }} +{{- range $k := $keys }} +{{ if kindIs "map" $tmpMap }} +{{- $tmpValue := get $tmpMap $k }} +{{- if kindIs "map" $tmpValue }} +{{ $tmpMap = $tmpValue }} +{{- else }} +{{ fail $k }} +{{- end }} +{{- end }} +{{- end }} +{{- else }} +{{- $tmpMap = get $tmpMap $path }} +{{- end }} +{{- toJson $tmpMap }} +{{- end }} \ No newline at end of file diff --git a/tests/.helm/config/test-include-files.yaml b/tests/.helm/config/test-include-files.yaml index 87d267f..29c1842 100644 --- a/tests/.helm/config/test-include-files.yaml +++ b/tests/.helm/config/test-include-files.yaml @@ -8,3 +8,26 @@ overwrited: value from file testValue: value from file testValueWithDefault: _default: default value from file +level1: + testSlice: + _default: + - test1 + - test2 + prod: + - testProd + - testProd2 + testMap: + testProd1: 1 + mustDeleted: 1 + testMap2: + testNestedMap: + _default: + nestedMap: + test: 1 + prod: 1 + test: 1 + test: 1 + test2: 1 + test3: + _default: 1 + prod: 3 diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index 0f15b41..3f94b45 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -1959,5 +1959,20 @@ test-env-yaml: testIncludeVarMustOverwrited: _default: default value from values.yaml prod: prod value from values.yaml - - + level1: + testMap: #эта мапка перезапишется + _default: + test1: 2v + test2: 2v + prod: + testProd2: 2v + testMap2: #эта мапка смержится + testNestedMap: + nestedMap: + test: 3v + test: 2v + test2: + _default: 2v + prod: 3v + test3: + _default: 2v diff --git a/tests/test_render.yaml b/tests/test_render.yaml index 8c25b66..3992f37 100644 --- a/tests/test_render.yaml +++ b/tests/test_render.yaml @@ -585,6 +585,20 @@ metadata: repo: "" data: "config": | + level1: + testMap: + testProd2: 2v + testMap2: + test: 2v + test2: 3v + test3: 3 + testNestedMap: + nestedMap: + prod: 1 + test: 1 + testSlice: + - testProd + - testProd2 overwrited: default value from values.yaml testIncludeVar: prod value from file testIncludeVarMustOverwrited: prod value from values.yaml @@ -1172,7 +1186,7 @@ kind: Deployment metadata: name: "env-yaml-app" annotations: - checksum/config: "854d6e54107f849087f3bee5e705db1b4d8386420d8b18ab03e98402ec27aeb2" + checksum/config: "f0b89172b9c460ce9fe6c12abe9d5a02d3210d97827b134b7d04abf3c499c822" helm-apps/version: "TEST" labels: app: "env-yaml-app" @@ -1188,7 +1202,7 @@ spec: metadata: name: "env-yaml-app" annotations: - checksum/config: "854d6e54107f849087f3bee5e705db1b4d8386420d8b18ab03e98402ec27aeb2" + checksum/config: "f0b89172b9c460ce9fe6c12abe9d5a02d3210d97827b134b7d04abf3c499c822" labels: app: "env-yaml-app" chart: "tests" From e3dd0bdfa3f761554ac596af02f7d707f044b814 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Fri, 28 Oct 2022 01:16:53 +0300 Subject: [PATCH 10/45] [fix] add tpl to includes file names (#10) Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-utils.tpl | 10 ++++++++-- tests/.helm/values.yaml | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index f0029f5..6a3db5c 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.2.7 +version: 1.2.8 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index 588a4fb..f75b292 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -266,7 +266,7 @@ {{- $currentName := index . 3 }} {{- if kindIs "map" $current }} {{- if hasKey $current "_include_from_file" }} -{{- $dict := $.Files.Get $current._include_from_file | fromYaml }} +{{- $dict := $.Files.Get (include "apps-utils.tpl" (list $ $current._include_from_file)) | fromYaml }} {{- $currentDict := deepCopy $current}} {{- $_ := mergeOverwrite $dict $currentDict }} {{- $_ = mergeOverwrite $current $dict }} @@ -275,7 +275,7 @@ {{- if hasKey $current "_include_files" }} {{- $newInclude := list }} {{- range $_, $fileName := $current._include_files }} -{{- $includeContent := $.Files.Get $fileName | fromYaml }} +{{- $includeContent := $.Files.Get (include "apps-utils.tpl" (list $ $fileName)) | fromYaml }} {{- $includeName := sha256sum $fileName }} {{- $_ := set $.Values.global._includes $includeName $includeContent }} {{- $newInclude = append $newInclude $includeName }} @@ -293,3 +293,9 @@ {{- end }} {{- end }} {{- end }} + +{{- define "apps-utils.tpl" }} +{{- $ := index . 0 }} +{{- $value := index . 1 }} +{{- tpl $value $ }} +{{- end }} \ No newline at end of file diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index 3f94b45..f834775 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -1951,7 +1951,7 @@ test-env-yaml: config: mountPath: /config content: - _include_files: ["config/test-include-files.yaml"] + _include_files: ['config/test-include-{{ print "files" }}.yaml'] overwrited: _default: default value from values.yaml testIncludeVar: From 5cb9ceff4e649346a51a93c6ed28093a30214a9c Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 31 Oct 2022 23:24:45 +0300 Subject: [PATCH 11/45] [fix] add error processing for includes from files (#11) Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-utils.tpl | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 6a3db5c..8809406 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.2.8 +version: 1.2.9 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index f75b292..ff0effc 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -266,18 +266,22 @@ {{- $currentName := index . 3 }} {{- if kindIs "map" $current }} {{- if hasKey $current "_include_from_file" }} -{{- $dict := $.Files.Get (include "apps-utils.tpl" (list $ $current._include_from_file)) | fromYaml }} +{{- $fn := include "apps-utils.tpl" (list $ $current._include_from_file) }} +{{- $includeContent := $.Files.Get $fn | fromYaml }} +{{- $_ := required (printf "Including file %s in _include_from_file emtty or has errors!" $fn) $includeContent }} {{- $currentDict := deepCopy $current}} -{{- $_ := mergeOverwrite $dict $currentDict }} -{{- $_ = mergeOverwrite $current $dict }} +{{- $_ = mergeOverwrite $includeContent $currentDict }} +{{- $_ = mergeOverwrite $current $includeContent }} {{- $_ = unset $current "_include_from_file"}} {{- end }} {{- if hasKey $current "_include_files" }} {{- $newInclude := list }} {{- range $_, $fileName := $current._include_files }} -{{- $includeContent := $.Files.Get (include "apps-utils.tpl" (list $ $fileName)) | fromYaml }} +{{- $fn := include "apps-utils.tpl" (list $ $fileName) }} +{{- $includeContent := $.Files.Get $fn | fromYaml }} +{{- $_ := required (printf "Including file %s in _include_files emtty or has errors!" $fn) $includeContent }} {{- $includeName := sha256sum $fileName }} -{{- $_ := set $.Values.global._includes $includeName $includeContent }} +{{- $_ = set $.Values.global._includes $includeName $includeContent }} {{- $newInclude = append $newInclude $includeName }} {{- end }} {{- if hasKey $current "_include" }} From 37e9feefe1eecefa00eec273e9aeca39565d406c Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Tue, 8 Nov 2022 00:26:39 +0300 Subject: [PATCH 12/45] [feature] add helm3 compatibility (#12) Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-ingresses.tpl | 2 ++ charts/helm-apps/templates/_apps-system.tpl | 2 +- charts/helm-apps/templates/_apps-utils.tpl | 6 ++++-- .../templates/fl-snippets/_generateContainerImage.tpl | 4 +++- charts/helm-apps/templates/fl-snippets/_generateLabels.tpl | 4 +++- tests/.helm/Chart.lock | 6 +++--- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 8809406..90d3dd4 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.2.9 +version: 1.3.0 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-ingresses.tpl b/charts/helm-apps/templates/_apps-ingresses.tpl index 415b522..ad97750 100644 --- a/charts/helm-apps/templates/_apps-ingresses.tpl +++ b/charts/helm-apps/templates/_apps-ingresses.tpl @@ -22,9 +22,11 @@ metadata: {{- with .dexAuth }} {{- if (include "fl.isTrue" (list $ $.CurrentApp .enabled)) }} {{- include "apps-utils.enterScope" (list $ "dexAuth") }} + {{- if $.Values.werf }} nginx.ingress.kubernetes.io/auth-signin: https://$host/dex-authenticator/sign_in nginx.ingress.kubernetes.io/auth-url: https://{{ $.CurrentApp.name }}-dex-authenticator.{{ $.Values.werf.namespace }}.svc.{{ include "apps-utils.requiredValue" (list $ . "clusterDomain") }}/dex-authenticator/auth nginx.ingress.kubernetes.io/auth-response-headers: X-Auth-Request-User,X-Auth-Request-Email,Authorization + {{- end }} {{- include "apps-utils.leaveScope" $ }} {{- end }} {{- end }} diff --git a/charts/helm-apps/templates/_apps-system.tpl b/charts/helm-apps/templates/_apps-system.tpl index 181bb55..ef02132 100644 --- a/charts/helm-apps/templates/_apps-system.tpl +++ b/charts/helm-apps/templates/_apps-system.tpl @@ -32,7 +32,7 @@ roleRef: subjects: - kind: ServiceAccount name: {{ $serviceAccountName }} - namespace: {{ $.Values.werf.namespace }} + namespace: {{ $.Release.Namespace }} {{- include "apps-utils.leaveScope" $ }} {{- end }} {{- include "apps-utils.leaveScope" $ }} diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index ff0effc..cb9fd86 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -18,8 +18,10 @@ {{- $imageName := include "fl.value" (list $ . $imageConfig.name) }} {{- if include "fl.value" (list $ . $imageConfig.staticTag) }} {{- $imageName }}:{{ include "fl.value" (list $ . $imageConfig.staticTag) }} -{{- else -}} -{{- index $.Values.werf.image $imageName }} +{{- else -}} +{{- with $.Values.werf }} +{{- index .image $imageName }} +{{- end }} {{- end }} {{- end -}} diff --git a/charts/helm-apps/templates/fl-snippets/_generateContainerImage.tpl b/charts/helm-apps/templates/fl-snippets/_generateContainerImage.tpl index 9960f7d..265f724 100644 --- a/charts/helm-apps/templates/fl-snippets/_generateContainerImage.tpl +++ b/charts/helm-apps/templates/fl-snippets/_generateContainerImage.tpl @@ -7,6 +7,8 @@ {{- if include "fl.value" (list $ . $imageConfig.staticTag) }} {{- $imageName }}:{{ include "fl.value" (list $ . $imageConfig.staticTag) }} {{- else -}} - {{- index $.Values.werf.image $imageName }} + {{- with $.Values.werf }} + {{- index .image $imageName }} + {{- end }} {{- end }} {{- end }} diff --git a/charts/helm-apps/templates/fl-snippets/_generateLabels.tpl b/charts/helm-apps/templates/fl-snippets/_generateLabels.tpl index 4519395..50737e1 100644 --- a/charts/helm-apps/templates/fl-snippets/_generateLabels.tpl +++ b/charts/helm-apps/templates/fl-snippets/_generateLabels.tpl @@ -4,5 +4,7 @@ {{- $appName := index . 2 }} app: {{ $appName | quote }} chart: {{ $.Chart.Name | trunc 63 | quote }} -repo: {{ regexSplit "/" $.Values.werf.repo -1 | rest | join "-" | trunc 63 | quote }} +{{- with $.Values.werf }} +repo: {{ regexSplit "/" .repo -1 | rest | join "-" | trunc 63 | quote }} +{{- end }} {{- end }} diff --git a/tests/.helm/Chart.lock b/tests/.helm/Chart.lock index 49cf84f..efd09cf 100644 --- a/tests/.helm/Chart.lock +++ b/tests/.helm/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.0.6 -digest: sha256:cdc41e0cff7749746c9a970b09861d84841b61edfa353523e12b6a9fcb6a902e -generated: "2022-09-28T20:16:33.909062+03:00" + version: 1.3.0 +digest: sha256:689f6ce5efd9465a4e969cf2f7e12912df39a400f561244dee14646b481bc730 +generated: "2022-11-08T00:11:07.330588+03:00" From 8c5502fa371264547ab5885620f66f519d7333e5 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Wed, 9 Nov 2022 01:28:59 +0300 Subject: [PATCH 13/45] [fix] remove vars with null value from configmapYaml (#13) Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-components.tpl | 6 ++---- charts/helm-apps/templates/_apps-helpers.tpl | 10 ++++++---- tests/.helm/config/test-include-files.yaml | 3 +++ 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 90d3dd4..a4444c2 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.3.0 +version: 1.3.1 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-components.tpl b/charts/helm-apps/templates/_apps-components.tpl index 6609186..d9cbaf5 100644 --- a/charts/helm-apps/templates/_apps-components.tpl +++ b/charts/helm-apps/templates/_apps-components.tpl @@ -224,8 +224,7 @@ metadata: {{- end }} labels: {{ include "fl.generateLabels" (list $ . $.CurrentApp.name) | trim | nindent 4 }} data: -{{- $_ := set $ "CurrentConfigYAML" (dict "local" . "content" .) }} -{{- include "apps-helpers.generateConfigYAML" $ }} +{{- include "apps-helpers.generateConfigYAML" (list $ .content .content) }} {{ $configFileName | quote }}: | {{ toYaml .content | trim | nindent 4 }} {{- include "apps-utils.leaveScope" $ }} {{- end }} @@ -306,8 +305,7 @@ data: {{ include "fl.generateSecretEnvVars" (list $ . .secretEnvVars) | trim | n {{- print (include "fl.value" (list $ . $configFile.content)) }} {{- end }} {{- range $_, $configFile := $.CurrentContainer.configFilesYAML }} -{{- $_ := set $ "CurrentConfigYAML" (dict "local" $.CurrentApp "content" $configFile.content) }} -{{- include "apps-helpers.generateConfigYAML" $ }} +{{- include "apps-helpers.generateConfigYAML" (list $ $configFile.content $configFile.content) }} {{- $configFile.content | toYaml }} {{- end }} {{- end }} diff --git a/charts/helm-apps/templates/_apps-helpers.tpl b/charts/helm-apps/templates/_apps-helpers.tpl index f647802..5868285 100644 --- a/charts/helm-apps/templates/_apps-helpers.tpl +++ b/charts/helm-apps/templates/_apps-helpers.tpl @@ -342,8 +342,9 @@ metadata: {{- end }} {{- define "apps-helpers.generateConfigYAML" }} - {{- $ := . }} - {{- $content := $.CurrentConfigYAML.content }} + {{- $ := index . 0 }} + {{- $owner := index . 1 }} + {{- $content := index . 2 }} {{- range $CurrentKey, $CurrentDict := $content }} {{- include "apps-utils.enterScope" (list $ $CurrentKey) }} {{- if kindIs "map" $CurrentDict }} @@ -354,12 +355,13 @@ metadata: {{- end }} {{- if kindIs "string" $val }} {{- $_ := set $content $CurrentKey (include "fl.value" (list $ . $CurrentDict))}} + {{- else if kindIs "invalid" $val }} + {{- $_ := unset $content $CurrentKey}} {{- else }} {{- $_ := set $content $CurrentKey $val }} {{- end }} {{- else }} - {{- $_ := set $.CurrentConfigYAML "content" $CurrentDict }} - {{- include "apps-helpers.generateConfigYAML" $ }} + {{- include "apps-helpers.generateConfigYAML" (list $ $content $CurrentDict) }} {{- end }} {{- end }} {{- include "apps-utils.leaveScope" $ }} diff --git a/tests/.helm/config/test-include-files.yaml b/tests/.helm/config/test-include-files.yaml index 29c1842..4bc63b5 100644 --- a/tests/.helm/config/test-include-files.yaml +++ b/tests/.helm/config/test-include-files.yaml @@ -31,3 +31,6 @@ level1: test3: _default: 1 prod: 3 + deletedInProd: + _default: test + prod: null From cb76f51fc5c9fd8769c1e8f622b0a6c072f3c465 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Wed, 9 Nov 2022 01:37:25 +0300 Subject: [PATCH 14/45] Fix/remove null vars in cmyaml (#14) * [fix] remove vars with null value from configmapYaml * [fix] change ci Co-authored-by: Alexandr Vnukov --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59cc98c..542f331 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: - name: Install Dyff run: | - sudo snap install dyff + curl --silent --location https://git.io/JYfAY | bash - name: Render From 48c498d55a0b1e7206064a74ece399380cc8dfed Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Wed, 9 Nov 2022 02:47:16 +0300 Subject: [PATCH 15/45] Fix/remove null vars in cmyaml (#15) * [fix] remove vars with null value from configmapYaml * [fix] change ci * [fix] add recursive deleted empty map in configmapsYaml Co-authored-by: Alexandr Vnukov --- charts/helm-apps/Chart.yaml | 2 +- .../helm-apps/templates/_apps-components.tpl | 6 ++- charts/helm-apps/templates/_apps-helpers.tpl | 40 +++++++++++++++++-- tests/.helm/config/test-include-files.yaml | 8 ++++ 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index a4444c2..73dfccf 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.3.1 +version: 1.3.2 maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/charts/helm-apps/templates/_apps-components.tpl b/charts/helm-apps/templates/_apps-components.tpl index d9cbaf5..a129212 100644 --- a/charts/helm-apps/templates/_apps-components.tpl +++ b/charts/helm-apps/templates/_apps-components.tpl @@ -224,7 +224,8 @@ metadata: {{- end }} labels: {{ include "fl.generateLabels" (list $ . $.CurrentApp.name) | trim | nindent 4 }} data: -{{- include "apps-helpers.generateConfigYAML" (list $ .content .content) }} + +{{- include "apps-helpers.generateConfigYAML" (list $ .content .content "content") }} {{ $configFileName | quote }}: | {{ toYaml .content | trim | nindent 4 }} {{- include "apps-utils.leaveScope" $ }} {{- end }} @@ -305,7 +306,8 @@ data: {{ include "fl.generateSecretEnvVars" (list $ . .secretEnvVars) | trim | n {{- print (include "fl.value" (list $ . $configFile.content)) }} {{- end }} {{- range $_, $configFile := $.CurrentContainer.configFilesYAML }} -{{- include "apps-helpers.generateConfigYAML" (list $ $configFile.content $configFile.content) }} + +{{- include "apps-helpers.generateConfigYAML" (list $ $configFile.content $configFile.content "content") }} {{- $configFile.content | toYaml }} {{- end }} {{- end }} diff --git a/charts/helm-apps/templates/_apps-helpers.tpl b/charts/helm-apps/templates/_apps-helpers.tpl index 5868285..fc20228 100644 --- a/charts/helm-apps/templates/_apps-helpers.tpl +++ b/charts/helm-apps/templates/_apps-helpers.tpl @@ -342,9 +342,17 @@ metadata: {{- end }} {{- define "apps-helpers.generateConfigYAML" }} + + {{- include "apps-helpers._generateConfigYAML" . }} + {{- $content := index . 2 }} + {{- $indicatorMap := dict "indicator" false }} + {{- include "apps-helpers._generateConfigYAML.clean" (list $content $indicatorMap) }} +{{- end }} +{{- define "apps-helpers._generateConfigYAML" }} {{- $ := index . 0 }} {{- $owner := index . 1 }} {{- $content := index . 2 }} + {{- $contentName := index . 3 }} {{- range $CurrentKey, $CurrentDict := $content }} {{- include "apps-utils.enterScope" (list $ $CurrentKey) }} {{- if kindIs "map" $CurrentDict }} @@ -356,14 +364,40 @@ metadata: {{- if kindIs "string" $val }} {{- $_ := set $content $CurrentKey (include "fl.value" (list $ . $CurrentDict))}} {{- else if kindIs "invalid" $val }} - {{- $_ := unset $content $CurrentKey}} + {{- $_ := unset $content $CurrentKey }} + {{- if eq (len $owner) 0 }} + {{- $_ := unset $owner $contentName }} + {{- end }} {{- else }} {{- $_ := set $content $CurrentKey $val }} {{- end }} {{- else }} - {{- include "apps-helpers.generateConfigYAML" (list $ $content $CurrentDict) }} + {{- include "apps-helpers.generateConfigYAML" (list $ $content $CurrentDict $CurrentKey) }} {{- end }} {{- end }} {{- include "apps-utils.leaveScope" $ }} {{- end }} - {{- end }} +{{- end }} +{{- define "apps-helpers._generateConfigYAML.clean" }} +{{- $content := index . 0 }} +{{- $indicatorMap := index . 1 }} +{{- range $CurrentKey, $CurrentDict := $content }} +{{- if kindIs "map" $CurrentDict }} +{{- if eq (len $CurrentDict) 0 }} +{{- $_ := set $indicatorMap "indicator" true }} +{{- $_ = unset $content $CurrentKey }} +{{- else }} +{{- $i := dict "indicator" true }} +{{- range $_,$_ := until 10 }} +{{- if $i.indicator }} +{{- $_ := set $i "indicator" false }} +{{- include "apps-helpers._generateConfigYAML.clean" (list $CurrentDict $i) }} +{{- if $i.indicator }} +{{- $_ := set $indicatorMap.indicator true }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} diff --git a/tests/.helm/config/test-include-files.yaml b/tests/.helm/config/test-include-files.yaml index 4bc63b5..d4bc08d 100644 --- a/tests/.helm/config/test-include-files.yaml +++ b/tests/.helm/config/test-include-files.yaml @@ -34,3 +34,11 @@ level1: deletedInProd: _default: test prod: null + deletedInProdToo: + test: + test2: + _default: test + prod: null + test3: + _default: null + From f4de2f4c1aed68359f5287f06900a6a7e895b63d Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:53:43 +0300 Subject: [PATCH 16/45] add validation schema --- tests/.helm/Chart.lock | 6 +-- tests/.helm/values.yaml | 81 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/tests/.helm/Chart.lock b/tests/.helm/Chart.lock index efd09cf..74958b2 100644 --- a/tests/.helm/Chart.lock +++ b/tests/.helm/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.3.0 -digest: sha256:689f6ce5efd9465a4e969cf2f7e12912df39a400f561244dee14646b481bc730 -generated: "2022-11-08T00:11:07.330588+03:00" + version: 1.3.2 +digest: sha256:22f6f4667bf7cfbbbaa3c6996ab55fedbf3622821dadcc04fece2d76b5b05995 +generated: "2026-02-16T00:49:42.490482+03:00" diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index f834775..e4b0170 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -54,7 +54,7 @@ apps-configmaps: data: nginx.conf: | configline1 - configline2 + configline2 ## Имя чарта. Ниже перечисляются CronJob'ы для развертывания. # Указано в .helm/requirements.yaml в репозитории приложения в ключах dependencies.name или dependencies.alias. @@ -1904,7 +1904,7 @@ apps-services: port: 80 selector: | app: test-app - + test-env-yaml: __GroupVars__: type: apps-stateless @@ -1976,3 +1976,80 @@ test-env-yaml: prod: 3v test3: _default: 2v + +# Дополнительные примеры для покрытия кейсов, которые не были описаны выше. +# Все примеры ниже выключены (enabled: false), чтобы не влиять на текущий рендер тестов. + +apps-dex-authenticators: + dex-auth-example: + _include: ["apps-default-library-app"] + enabled: false + applicationDomain: "example.org" + applicationIngressClassName: "nginx" + applicationIngressCertificateSecretName: "example-tls" + allowedGroups: | + - team-admins + - team-devops + sendAuthorizationHeader: + _default: "false" + production: "true" + +apps-grafana-dashboards: + dashboard-example: + _include: ["apps-default-library-app"] + enabled: false + folder: "Custom" + +apps-infra: + node-users: + infra-user-example: + enabled: false + uid: 2001 + isSudoer: true + sshPublicKeys: | + - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDemoKey1 + - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDemoKey2 + extraGroups: | + - wheel + - docker + nodeGroups: | + - worker + annotations: | + owner: platform-team + labels: | + role: ops + node-groups: + infra-group-example: + enabled: false + +test-env-overrides: + __GroupVars__: + type: apps-stateless + env-overrides-app: + _include: ["apps-stateless-defaultApp"] + enabled: false + containers: + main: + image: + name: nginx + staticTag: "1.27" + envVars: + SIMPLE_VALUE: + _default: "from-default" + production: "from-production" + REGEX_ENV_VALUE: + _default: "fallback" + "^prod.*$": "from-regex-prod" + "^dev.*$": "from-regex-dev" + DELETE_IN_PROD: + _default: "value-exists" + production: "" + envYAML: + nested: + level1: + timeoutSeconds: + _default: 30 + production: 60 + retries: + _default: 3 + "^dev.*$": 1 From 0b37684bf30fde05190df9b24e945ef897d99522 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:20:39 +0300 Subject: [PATCH 17/45] docs: overhaul helm-apps user documentation and add schema validation in CI - Reworked README into a user-facing landing page with quick start, supported resources, and include/merge examples\n- Added a full documentation portal under docs/: handbook, values reference, cookbook, and operations playbook\n- Added docs index (docs/README.md) with role-based reading paths and onboarding flow\n- Added comprehensive JSON schema for tests/.helm/values.yaml and documented it as the validation source\n- Added CI step to validate values against schema via helm lint before render checks\n- Improved guidance around global._includes recursion, include precedence, and practical merge patterns --- .github/workflows/release.yml | 99 +++-- README.md | 557 +++++++++++------------- docs/README.md | 51 +++ docs/cookbook.md | 412 ++++++++++++++++++ docs/library-guide.md | 382 ++++++++++++++++ docs/operations.md | 277 ++++++++++++ docs/reference-values.md | 538 +++++++++++++++++++++++ tests/.helm/values.schema.json | 767 +++++++++++++++++++++++++++++++++ 8 files changed, 2738 insertions(+), 345 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/cookbook.md create mode 100644 docs/library-guide.md create mode 100644 docs/operations.md create mode 100644 docs/reference-values.md create mode 100644 tests/.helm/values.schema.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 542f331..ef9c84f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,64 +3,71 @@ name: Release Charts on: push: branches: - - "*" + - "*" jobs: release: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v2 - with: - fetch-depth: 0 + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 - - name: Configure Git - run: | - git config user.name "$GITHUB_ACTOR" - git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" - - name: Set lib version - run: | - LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) - sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl + - name: Set lib version + run: | + LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) + sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl - - name: Install werf CLI - with: - channel: ea - uses: werf/actions/install@v1.2 + - name: Install werf CLI + with: + channel: ea + uses: werf/actions/install@v1.2 - - name: Install Helm3 - run: | - curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + - name: Install Helm3 + run: | + curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - - name: Install Dyff - run: | - curl --silent --location https://git.io/JYfAY | bash - + - name: Install Dyff + run: | + curl --silent --location https://git.io/JYfAY | bash - - name: Render - run: | - set -e - source $(werf ci-env github --as-file) - cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod + - name: Update test chart dependencies + run: | + helm dependency update tests/.helm - - name: Test render - run: | - set -e - source $(werf ci-env github --as-file) - cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod | sed '/werf.io\//d' > test_render_check.yaml - dyff between test_render.yaml test_render_check.yaml | tee /tmp/test_render_check - check_tests=$(sed 1,7d /tmp/test_render_check | wc -l) - if [ $check_tests -gt "7" ]; then exit 1; fi + - name: Validate values schema + run: | + helm lint tests/.helm --values tests/.helm/values.yaml - - name: Run chart-releaser - if: ${{ github.ref == 'refs/heads/main' }} - uses: helm/chart-releaser-action@v1.4.0 - with: - charts_dir: charts - config: cr.yaml - env: - CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + - name: Render + run: | + set -e + source $(werf ci-env github --as-file) + cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod + + - name: Test render + run: | + set -e + source $(werf ci-env github --as-file) + cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod | sed '/werf.io\//d' > test_render_check.yaml + dyff between test_render.yaml test_render_check.yaml | tee /tmp/test_render_check + check_tests=$(sed 1,7d /tmp/test_render_check | wc -l) + if [ $check_tests -gt "7" ]; then exit 1; fi + + - name: Run chart-releaser + if: ${{ github.ref == 'refs/heads/main' }} + uses: helm/chart-releaser-action@v1.4.0 + with: + charts_dir: charts + config: cr.yaml + env: + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" # - name: Publish to CR # env: @@ -69,4 +76,4 @@ jobs: # echo $CR_PAT | helm registry login -u alvnukov --password-stdin ghcr.io # find .cr-release-packages -mindepth 1 -maxdepth 1 -type f -name '*.tgz' -exec sh -c 'basename "$0"' '{}' \; | while read PACKAGE; do # helm push .cr-release-packages/$PACKAGE oci://ghcr.io/${GITHUB_REPOSITORY} -# done \ No newline at end of file +# done diff --git a/README.md b/README.md index 2371249..17f5150 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,88 @@ -## Репозиторий библиотеки для развертывания приложений в Kubernetes. -1. Позволяет: - * упростить структуру описания приложения. - * переиспользовать шаблоны одного приложения для множества других -2. Ускоряет: - * процесс ревью изменений приложения за счет стандартизирования подхода и уменьшению количества кода. - * развертывание новых приложений за счет лаконичного синтаксиса, сокращения повторяемого кода - * редактирование и добавление новых ресурсов к приложению -3. Упрощает: - * работу с сущностями Kubernetes (не нужно описывать все поля приложения, не нужно думать как правильно выглядит конструкции сущностей). - * связывание сущностей Kubernetes за счет использования хелперов - -> :warning: **На данный момент корректная работа чартов гарантируется только с утилитой** [**Werf**](https://werf.io) - -## Для подключения библиотеки необходимо: -### Инструкция по использованию -#### Использовать пример: -* Скопировать содержимое папки [docs/example](/docs/example) в корень нового проекта -* настроить файлы под свой проект -#### Вручную: -* Добавить в .gitlab-ci.yml строку подключения библиотеки общих чартов - ```bash - werf helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps - ``` - + к примеру так: - ```yaml - before_script: - - type trdl && source $(trdl use werf ${WERF_VERSION:-1.2 ea}) - - type werf && source $(werf ci-env gitlab --as-file) - - werf helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps - ``` - у себя на компьютере добавляем репозиторий helm-apps: - ```yaml - werf helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps - ``` - и обновляем зависимости: - ```yaml - werf helm dependency update .helm - ``` -* Добавить в папку .helm/templates файл [init-helm-apps.yaml](tests/.helm/templates/init-helm-apps.yaml) для инициализаци библиотеки, содержимое файла: - ```yaml - {{- /* Подключаем библиотеку */}} - {{- include "apps-utils.init-library" $ }} - ``` -* В Chart.yaml в секцию **dependencies**: - ```yaml - apiVersion: v2 - name: test-app - version: 1.0.0 - dependencies: +# Helm Apps Library + +Библиотека Helm-шаблонов для стандартизированного деплоя приложений в Kubernetes. + +`helm-apps` позволяет описывать приложения через `values.yaml` без копирования шаблонов между сервисами. +Логика рендера централизована в библиотеке, а сервисные репозитории хранят только конфигурацию. + +> :warning: На текущий момент основной и проверенный сценарий использования — через [werf](https://werf.io). + +## Зачем использовать библиотеку + +- Единый стандарт деплоя для всех сервисов команды. +- Меньше копипаста и ручных Kubernetes-манифестов. +- Быстрее ревью: одинаковая структура конфигов между проектами. +- Переиспользование через `_include` и `global._includes`. +- Поддержка окружений (`_default`, env overrides, regex env keys). +- Поддержка связанных ресурсов (Service, Ingress, ConfigMap, Secret, HPA, VPA, PDB и др.) в одной модели. + +## Какие ресурсы поддерживаются + +- `apps-stateless` (`Deployment`) +- `apps-stateful` (`StatefulSet`) +- `apps-jobs` (`Job`) +- `apps-cronjobs` (`CronJob`) +- `apps-services` (`Service`) +- `apps-ingresses` (`Ingress`) +- `apps-configmaps` (`ConfigMap`) +- `apps-secrets` (`Secret`) +- `apps-pvcs` (`PersistentVolumeClaim`) +- `apps-limit-range` (`LimitRange`) +- `apps-certificates` (`Certificate`) +- `apps-dex-clients`, `apps-dex-authenticators` +- `apps-custom-prometheus-rules`, `apps-grafana-dashboards` +- `apps-kafka-strimzi` +- `apps-infra` + +## Быстрый старт + +### 1. Подключить dependency + +В `.helm/Chart.yaml`: + +```yaml +apiVersion: v2 +name: my-app +version: 1.0.0 +dependencies: - name: helm-apps version: ~1 repository: "@helm-apps" - ``` -* В [values.yaml](docs/example/.helm/values.yaml) отредактировать секцию global._includes с параметрами по умолчанию для хелперов. +``` + +### 2. Добавить инициализацию библиотеки -На данный момент актуальная документация находится в файле [tests/.helm/values.yaml](tests/.helm/values.yaml). Ведется дополнительная работа над созданием расширенной версии документации. +Создать `.helm/templates/init-helm-apps-library.yaml`: -[О хелперах]( docs/usage.md) +```yaml +{{- include "apps-utils.init-library" $ }} +``` -## Пример простейшего деплоймента Nginx на библиотеке: -
-values.yaml секция приложений +### 3. Обновить зависимости + +```bash +werf helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps +werf helm dependency update .helm +``` + +### 4. Описать приложение в values + +Минимальный пример: ```yaml global: - ci_url: example.com -# ... + ci_url: example.org + apps-stateless: - # Приложение из примера в документации - nginx: + api: _include: ["apps-stateless-defaultApp"] - replicas: 1 containers: - nginx: + main: image: name: nginx ports: | - name: http containerPort: 80 - configFiles: - default.conf: - mountPath: /etc/nginx/templates/default.conf.template - content: | - server { - listen 80 default_server; - listen [::]:80 default_server; - server_name {{ $.Values.global.ci_url }} {{ $.Values.global.ci_url }}; - root /var/www/{{ $.Values.global.ci_url }}; - index index.html; - try_files $uri /index.html; - location / { - proxy_set_header Authorization "Bearer ${SECRET_TOKEN}"; - proxy_pass_header Authorization; - proxy_pass https://backend:3000; - } - } - secretEnvVars: - SECRET_TOKEN: "!!!secret-token-for-backend!!!" service: enabled: true ports: | @@ -104,240 +90,213 @@ apps-stateless: port: 80 apps-ingresses: - nginx: + api: _include: ["apps-ingresses-defaultIngress"] - host: '{{ $.Values.global.ci_url }}' + host: "{{ $.Values.global.ci_url }}" paths: | - path: / pathType: Prefix backend: service: - name: nginx + name: api port: number: 80 tls: enabled: true ``` -
-
-Сгенерирует следующее... + +## Ключевая механика: `global._includes` и рекурсивный merge + +`global._includes` — это библиотека переиспользуемых конфигурационных блоков. +Приложение подключает их через `_include`, после чего библиотека делает рекурсивный merge. + +Базовый пример: ```yaml -# Helm Apps Library: apps-stateless.nginx.podDisruptionBudget -apiVersion: policy/v1beta1 -kind: PodDisruptionBudget -metadata: - name: "nginx" - labels: - app: "nginx" - chart: "tests" - repo: "" - annotations: - project.werf.io/env: "" - project.werf.io/name: test - werf.io/version: v1.2.162 -spec: - selector: - matchLabels: - app: "nginx" - maxUnavailable: "15%" ---- -# Helm Apps Library: apps-stateless.nginx.containers.nginx.secretEnvVars -apiVersion: v1 -kind: Secret -metadata: - name: "envs-containers-nginx-nginx" - labels: - app: "nginx" - chart: "tests" - repo: "" - annotations: - project.werf.io/env: "" - project.werf.io/name: test - werf.io/version: v1.2.162 -type: Opaque -data: - "SECRET_TOKEN": "ISEhc2VjcmV0LXRva2VuLWZvci1iYWNrZW5kISEh" ---- -# Helm Apps Library: apps-stateless.nginx.containers.nginx.configFiles.default.conf -apiVersion: v1 -kind: ConfigMap -metadata: - name: "config-containers-nginx-nginx-default-conf" - labels: - app: "nginx" - chart: "tests" - repo: "" - annotations: - project.werf.io/env: "" - project.werf.io/name: test - werf.io/version: v1.2.162 -data: - "default.conf": | - server { - listen 80 default_server; - listen [::]:80 default_server; - server_name example.com example.com; - root /var/www/example.com; - index index.html; - try_files $uri /index.html; - location / { - proxy_set_header Authorization "Bearer ${SECRET_TOKEN}"; - proxy_pass_header Authorization; - proxy_pass https://backend:3000; - } - } ---- -# Helm Apps Library: apps-stateless.nginx.service -apiVersion: v1 -kind: Service -metadata: - name: "nginx" - labels: - app: "nginx" - chart: "tests" - repo: "" - annotations: - project.werf.io/env: "" - project.werf.io/name: test - werf.io/version: v1.2.162 -spec: - selector: - app: "nginx" - ports: - - name: http - port: 80 ---- -# Source: tests/templates/init-flant-apps-library.yaml -# Helm Apps Library: apps-stateless.nginx -apiVersion: apps/v1 -kind: Deployment -metadata: - name: "nginx" - annotations: - checksum/config: "19812d5210967fd69097dc991263af171c4071ebb455357bd49be2a0ca05acdd" - project.werf.io/env: "" - project.werf.io/name: test - werf.io/version: v1.2.162 - labels: - app: "nginx" - chart: "tests" - repo: "" -spec: - strategy: - rollingUpdate: - maxSurge: 20% - maxUnavailable: 50% - type: RollingUpdate - template: - metadata: - name: "nginx" - annotations: - checksum/config: "19812d5210967fd69097dc991263af171c4071ebb455357bd49be2a0ca05acdd" - labels: - app: "nginx" - chart: "tests" - repo: "" - spec: +global: + _includes: + profile-base: + replicas: 2 + service: + enabled: true + ports: | + - name: http + port: 80 + containers: + main: + resources: + requests: + mcpu: 100 + memoryMb: 128 + profile-prod: + replicas: 4 containers: - - name: "nginx" - image: REPO:TAG - envFrom: - - secretRef: - name: "envs-containers-nginx-nginx" + main: resources: - volumeMounts: - - name: "config-containers-nginx-nginx-default-conf" - subPath: "default.conf" - mountPath: "/etc/nginx/templates/default.conf.template" - ports: - - name: http - containerPort: 80 - imagePullSecrets: - - name: registrysecret - volumes: - - name: "config-containers-nginx-nginx-default-conf" - configMap: - name: "config-containers-nginx-nginx-default-conf" - selector: - matchLabels: - app: "nginx" - revisionHistoryLimit: 3 - replicas: 1 ---- -# Source: tests/templates/init-flant-apps-library.yaml -# Helm Apps Library: apps-ingresses.nginx -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: "nginx" - annotations: - kubernetes.io/ingress.class: "nginx" - project.werf.io/env: "" - project.werf.io/name: test - werf.io/version: v1.2.162 - labels: - app: "nginx" - chart: "tests" - repo: "" -spec: - tls: - - secretName: nginx - rules: - - host: "example.com" - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: nginx - port: - number: 80 ---- -# Helm Apps Library: apps-ingresses.nginx.tls -apiVersion: cert-manager.io/v1 -kind: Certificate -metadata: - name: nginx - annotations: - project.werf.io/env: "" - project.werf.io/name: test - werf.io/version: v1.2.162 -spec: - secretName: nginx - issuerRef: - kind: ClusterIssuer - name: letsencrypt - dnsNames: - - "example.com" ---- -# Helm Apps Library: apps-stateless.nginx.verticalPodAutoscaler -apiVersion: autoscaling.k8s.io/v1 -kind: VerticalPodAutoscaler -metadata: - name: "nginx" - labels: - app: "nginx" - chart: "tests" - repo: "" - annotations: - project.werf.io/env: "" - project.werf.io/name: test - werf.io/version: v1.2.162 -spec: - targetRef: - apiVersion: "apps/v1" - kind: Deployment - name: "nginx" - updatePolicy: - updateMode: "Off" - resourcePolicy: {} + limits: + memoryMb: 512 + +apps-stateless: + api: + _include: ["profile-base", "profile-prod"] + containers: + main: + image: + name: nginx +``` + +Что важно: + +1. Merge рекурсивный: вложенные map-структуры не заменяются целиком, а объединяются по ключам. +2. Порядок `_include` важен: каждый следующий профиль может переопределять предыдущий. +3. Локальные поля приложения имеют приоритет над значениями из include-блоков. +4. Это главный механизм DRY в библиотеке: стандартные профили задаются один раз и переиспользуются во всех сервисах. + +### Примеры merge-поведения + +#### Пример 1: Рекурсивный merge map + +```yaml +global: + _includes: + base: + service: + enabled: true + headless: false + net: + service: + ports: | + - name: http + port: 80 + +apps-stateless: + api: + _include: ["base", "net"] +``` + +Итог для `api.service`: +- `enabled: true` +- `headless: false` +- `ports: ...` + +#### Пример 2: Порядок include (последний имеет приоритет) + +```yaml +global: + _includes: + base: + replicas: 2 + prod: + replicas: 5 + +apps-stateless: + api: + _include: ["base", "prod"] +``` + +Итог: `replicas: 5`. + +#### Пример 3: Локальный override сильнее include + +```yaml +global: + _includes: + base: + replicas: 2 + +apps-stateless: + api: + _include: ["base"] + replicas: 3 +``` + +Итог: `replicas: 3`. + +#### Пример 4: Env-map (`_default`) как атомарный блок + +Для значений окружений используйте один полный блок в более приоритетном месте: + +```yaml +global: + _includes: + base: + replicas: + _default: 2 + production: 4 + canary: + replicas: + _default: 1 + production: 2 + +apps-stateless: + api: + _include: ["base", "canary"] ``` -
-Самостоятельно можно отрендерить следующей командой: +Итог: берется блок `canary.replicas`, то есть: +- `_default: 1` +- `production: 2` + +Практика: для env-map не “достраивайте” куски из нескольких include, задавайте целиком в одном профиле. + +#### Пример 5: `_include`-списки конкатенируются + +Если include-профиль сам содержит `_include`, итоговый список объединяется. + +```yaml +global: + _includes: + profile-a: + _include: ["base-a"] + replicas: 2 + profile-b: + _include: ["base-b"] + service: + enabled: true + +apps-stateless: + api: + _include: ["profile-a", "profile-b"] +``` + +Итоговый include-chain для `api` объединяет оба списка (`base-a` + `base-b`) и затем применяет локальные поля. + +#### Пример 6: Осторожно со списками в include (кроме `_include`) + +Для обычных YAML-массивов (не `_include`) merge может быть неочевидным. +Рекомендация: +- задавайте такие поля финально в более приоритетном include или локально в app; +- для сложных структур используйте проверку через `werf render`. + +### 5. Проверить рендер ```bash -$ cd tests && werf render --dev --set "apps-ingresses.nginx.enabled=true" --set "apps-stateless.nginx.enabled=true" +helm lint .helm +werf render --env=prod --dev ``` + +## Маршрут по документации + +Стартовая точка: +- [docs/README.md](docs/README.md) + +Подробные документы: +- Концепция и архитектура: [docs/library-guide.md](docs/library-guide.md) +- Полный справочник полей: [docs/reference-values.md](docs/reference-values.md) +- Готовые шаблоны для типовых сценариев: [docs/cookbook.md](docs/cookbook.md) +- Эксплуатация, triage, rollback: [docs/operations.md](docs/operations.md) +- Краткие правила helper-паттернов: [docs/usage.md](docs/usage.md) + +Практические артефакты: +- Полный рабочий пример values: [tests/.helm/values.yaml](tests/.helm/values.yaml) +- JSON Schema валидации values: [tests/.helm/values.schema.json](tests/.helm/values.schema.json) +- Готовый пример проекта: [docs/example](docs/example) + +## Для контрибьюторов библиотеки + +При изменении возможностей библиотеки обновляйте синхронно: + +1. шаблоны в `charts/helm-apps/templates`; +2. примеры в `tests/.helm/values.yaml`; +3. схему в `tests/.helm/values.schema.json`; +4. документацию в `docs/reference-values.md` и `docs/cookbook.md`. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..36309b2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,51 @@ +# Документация Helm Apps Library + +Этот файл — точка входа в документацию. +Если открываете docs впервые, начните отсюда. + +## Быстрый маршрут (15 минут) + +1. Прочитать концепцию и зачем библиотека нужна: `docs/library-guide.md` +2. Взять готовый шаблон под свой сценарий: `docs/cookbook.md` +3. Сверить поля и типы перед merge: `docs/reference-values.md` +4. Проверить values по schema: `tests/.helm/values.schema.json` +5. Сравнить с рабочими примерами: `tests/.helm/values.yaml` + +## Как читать документацию по роли + +### Разработчик сервиса + +1. `docs/cookbook.md` +2. `docs/reference-values.md` +3. `docs/operations.md` (разделы triage и частые ошибки) + +### DevOps / Platform Engineer + +1. `docs/library-guide.md` +2. `docs/reference-values.md` +3. `docs/operations.md` + +### Ревьюер MR с изменениями `.helm/values.yaml` + +1. `docs/reference-values.md` +2. `docs/operations.md` (чеклисты merge/release) +3. `tests/.helm/values.schema.json` + +## Карта документов + +- Архитектура и принципы: `docs/library-guide.md` +- Полный справочник полей: `docs/reference-values.md` +- Готовые практические рецепты: `docs/cookbook.md` +- Эксплуатация, triage, rollback: `docs/operations.md` +- Краткие правила по helper-паттернам: `docs/usage.md` +- Полный рабочий пример values: `tests/.helm/values.yaml` +- Schema валидации values: `tests/.helm/values.schema.json` + +## Минимальный командный чеклист + +```bash +werf helm dependency update .helm +helm lint .helm +werf render --env=prod --dev +``` + diff --git a/docs/cookbook.md b/docs/cookbook.md new file mode 100644 index 0000000..9545681 --- /dev/null +++ b/docs/cookbook.md @@ -0,0 +1,412 @@ +# Helm Apps Library Cookbook + +Готовые рецепты для типовых сценариев. +Все примеры можно адаптировать под ваш `global._includes`. + +## 1. Базовый HTTP API (stateless) + +```yaml +apps-stateless: + api: + _include: ["apps-stateless-defaultApp"] + replicas: + _default: 2 + production: 4 + containers: + main: + image: + name: api + staticTag: "1.0.0" + ports: | + - name: http + containerPort: 8080 + envVars: + APP_ENV: + _default: dev + production: production + resources: + requests: + mcpu: 200 + memoryMb: 256 + limits: + mcpu: 1000 + memoryMb: 1024 + service: + enabled: true + ports: | + - name: http + port: 80 + targetPort: 8080 +``` + +## 2. API + Ingress + TLS + +```yaml +apps-ingresses: + api: + _include: ["apps-ingresses-defaultIngress"] + ingressClassName: nginx + host: api.example.org + paths: | + - path: / + pathType: Prefix + backend: + service: + name: api + port: + number: 80 + tls: + enabled: true +``` + +## 3. Worker без Service + +```yaml +apps-stateless: + worker: + _include: ["apps-stateless-defaultApp"] + service: + enabled: false + containers: + main: + image: + name: worker + staticTag: "1.0.0" + command: | + - /app/worker + envVars: + QUEUE: default +``` + +## 4. CronJob + +```yaml +apps-cronjobs: + sync-every-5m: + _include: ["apps-cronjobs-defaultCronJob"] + schedule: "*/5 * * * *" + containers: + main: + image: + name: sync + staticTag: "2.1.0" + command: | + - /app/sync + envVars: + LOG_LEVEL: info +``` + +## 5. One-shot Job (migration) + +```yaml +apps-jobs: + db-migrate: + _include: ["apps-jobs-defaultJob"] + backoffLimit: 1 + containers: + main: + image: + name: migrate + staticTag: "3.0.0" + command: | + - /app/migrate +``` + +## 6. Секреты через `secretEnvVars` + +```yaml +apps-stateless: + api: + _include: ["apps-stateless-defaultApp"] + containers: + main: + image: + name: api + staticTag: "1.0.0" + secretEnvVars: + DB_PASSWORD: very-secret + JWT_SECRET: + _default: dev-secret + production: prod-secret +``` + +## 7. Из внешнего Secret через `fromSecretsEnvVars` + +```yaml +apps-stateless: + api: + _include: ["apps-stateless-defaultApp"] + containers: + main: + image: + name: api + staticTag: "1.0.0" + fromSecretsEnvVars: + external-secret: + APP_DB_PASSWORD: db_password + APP_API_TOKEN: api_token +``` + +## 8. Файлы конфигурации (ConfigMap mount) + +```yaml +apps-stateless: + nginx: + _include: ["apps-stateless-defaultApp"] + containers: + main: + image: + name: nginx + staticTag: "1.27" + configFiles: + nginx.conf: + mountPath: /etc/nginx/nginx.conf + content: | + worker_processes auto; + events { worker_connections 1024; } +``` + +## 9. YAML-конфиг с env override (`configFilesYAML`) + +```yaml +apps-stateless: + app: + _include: ["apps-stateless-defaultApp"] + containers: + main: + image: + name: app + staticTag: "1.0.0" + configFilesYAML: + app.yaml: + mountPath: /etc/app/app.yaml + content: + db: + host: + _default: db.dev + production: db.prod + cache: + ttlSeconds: + _default: 30 + production: 300 +``` + +## 10. HPA для API + +```yaml +apps-stateless: + api: + _include: ["apps-stateless-defaultApp"] + containers: + main: + image: + name: api + staticTag: "1.0.0" + horizontalPodAutoscaler: + enabled: true + minReplicas: 2 + maxReplicas: 10 + behavior: | + scaleDown: + policies: + - type: Percent + value: 10 + periodSeconds: 60 + metrics: + cpu: + enabled: true + averageUtilization: 70 + memory: + enabled: true + averageUtilization: 80 +``` + +## 11. ServiceAccount + ClusterRole + +```yaml +apps-stateless: + metrics-client: + _include: ["apps-stateless-defaultApp"] + containers: + main: + image: + name: client + staticTag: "1.0.0" + serviceAccount: + enabled: true + name: metrics-client + clusterRole: + name: metrics-client:reader + rules: | + - apiGroups: ["monitoring.coreos.com"] + resources: ["prometheuses/http"] + resourceNames: ["main", "longterm"] + verbs: ["get"] +``` + +## 12. Stateful сервис с PVC + +```yaml +apps-stateful: + redis: + _include: ["apps-stateful-defaultApp"] + replicas: 1 + containers: + main: + image: + name: redis + staticTag: "7.2" + ports: | + - name: redis + containerPort: 6379 + persistantVolumes: + data: + mountPath: /data + size: + _default: 1Gi + production: 20Gi + storageClass: fast-ssd +``` + +## 13. Dedicated ConfigMap/Secret resources + +```yaml +apps-configmaps: + shared-env: + _include: ["apps-configmaps-defaultConfigmap"] + envVars: + FEATURE_FLAG_X: "true" + REQUEST_TIMEOUT_MS: + _default: "1000" + production: "5000" + +apps-secrets: + shared-secret: + _include: ["apps-secrets-defaultSecret"] + envVars: + API_KEY: secret +``` + +## 14. Внешний Service через `apps-services` + +```yaml +apps-services: + api-internal: + _include: ["apps-defaults"] + ports: | + - name: http + port: 80 + targetPort: 8080 + selector: | + app: api +``` + +## 15. Пользовательская группа и mix app types + +```yaml +payment: + __GroupVars__: + type: apps-stateless + api: + _include: ["apps-stateless-defaultApp"] + containers: + main: + image: + name: payment-api + staticTag: "1.0.0" + ingress: + __AppType__: apps-ingresses + _include: ["apps-ingresses-defaultIngress"] + host: pay.example.org + paths: | + - path: / + pathType: Prefix + backend: + service: + name: api + port: + number: 80 +``` + +## 16. Рецепт с `_default` + regex env + +```yaml +apps-stateless: + env-aware: + _include: ["apps-stateless-defaultApp"] + containers: + main: + image: + name: app + staticTag: "1.0.0" + envVars: + LOG_LEVEL: + _default: info + production: warning + "^prod-.*$": error + FEATURE_ALPHA: + _default: "false" + "^dev-.*$": "true" +``` + +## 17. apps-infra: NodeUser + +```yaml +apps-infra: + node-users: + platform-admin: + enabled: true + uid: 2001 + isSudoer: true + sshPublicKeys: | + - ssh-rsa AAAAB3Nza... + extraGroups: | + - wheel + nodeGroups: | + - worker +``` + +## 18. apps-dex-authenticators + +```yaml +apps-dex-authenticators: + auth-api: + enabled: true + applicationDomain: api.example.org + applicationIngressClassName: nginx + applicationIngressCertificateSecretName: api-example-org-tls + allowedGroups: | + - platform-admins + - backend-team +``` + +## 19. apps-custom-prometheus-rules + +```yaml +apps-custom-prometheus-rules: + api-rules: + groups: + api-group: + alerts: + high-error-rate: + isTemplate: false + content: | + expr: sum(rate(http_requests_total{status=~"5.."}[5m])) > 10 + for: 10m + labels: + severity_level: "3" +``` + +## 20. Как использовать cookbook + +1. Выберите сценарий, близкий вашему сервису. +2. Скопируйте блок в `values.yaml`. +3. Подключите ваш include-профиль. +4. Добавьте env-overrides. +5. Прогоните `werf render`. + +Связанные документы: +- `docs/library-guide.md` +- `docs/reference-values.md` +- `tests/.helm/values.yaml` + diff --git a/docs/library-guide.md b/docs/library-guide.md new file mode 100644 index 0000000..2951636 --- /dev/null +++ b/docs/library-guide.md @@ -0,0 +1,382 @@ +# Helm Apps Library Handbook + +## 1. Для кого этот документ + +Документ предназначен для: +- разработчиков, которые деплоят приложения в Kubernetes через Helm/werf; +- DevOps/SRE, которые поддерживают единый стандарт деплоя сервисов; +- ревьюеров конфигураций `.helm/values.yaml`. + +Если нужен быстрый старт, сначала прочитайте раздел `3`. +Если нужен маршрут по документам, откройте `docs/README.md`. +Если нужен полный справочник полей, смотрите `docs/reference-values.md`. +Если нужны готовые шаблоны под типовые сценарии, смотрите `docs/cookbook.md`. + +## 2. Что такое helm-apps и зачем его использовать + +`helm-apps` это библиотечный Helm chart (`type: library`), который рендерит Kubernetes-ресурсы на основе унифицированной структуры `values.yaml`. + +Ключевая идея: +- логика рендера ресурсов живет в одной библиотеке; +- сервисные репозитории описывают только конфигурацию; +- дефолты и переиспользование реализуются через `_include` и `global._includes`. + +Почему это выгодно команде: +- меньше ручных манифестов и копипаста; +- единый формат деплоя во всех сервисах; +- проще ревью и онбординг; +- меньше расхождений между сервисами в runtime-поведении; +- быстрее массовые изменения платформенных практик. + +Когда библиотека особенно полезна: +- десятки+ сервисов с похожими паттернами деплоя; +- необходимость стандартизировать логику HPA/VPA/PDB/Ingress/ServiceAccount; +- мультиокружения с разными параметрами в одном `values.yaml`. + +## 3. Quick Start + +1. Подключить библиотеку в `.helm/Chart.yaml` как dependency. +2. Создать шаблон инициализации: + +```yaml +{{- include "apps-utils.init-library" $ }} +``` + +3. Задать в `global._includes` дефолтные профили. +4. Описать приложения в секциях `apps-*` или custom-группах. +5. Проверить рендер: + +```bash +werf helm dependency update .helm +werf render --env=prod --dev +``` + +## 4. Базовая модель конфигурации + +Верхний уровень `values.yaml`: +- `global` — общие переменные и include-блоки; +- `apps-*` — встроенные группы ресурсов; +- произвольные группы через `__GroupVars__`. + +### 4.1 Встроенные группы `apps-*` + +Библиотека поддерживает: +- `apps-stateless` (`Deployment`); +- `apps-stateful` (`StatefulSet`); +- `apps-jobs` (`Job`); +- `apps-cronjobs` (`CronJob`); +- `apps-ingresses` (`Ingress`, optional `Certificate`, optional `DexAuthenticator`); +- `apps-services` (`Service`); +- `apps-configmaps` (`ConfigMap`); +- `apps-secrets` (`Secret`); +- `apps-pvcs` (`PersistentVolumeClaim`); +- `apps-certificates` (`Certificate`); +- `apps-limit-range` (`LimitRange`); +- `apps-dex-clients` (`DexClient`); +- `apps-dex-authenticators` (`DexAuthenticator`); +- `apps-custom-prometheus-rules` (`CustomPrometheusRules`); +- `apps-grafana-dashboards` (`GrafanaDashboardDefinition`); +- `apps-kafka-strimzi` (Kafka + KafkaTopic + VPA под Strimzi); +- `apps-infra` (`NodeUser` и `NodeGroup` Deckhouse). + +### 4.2 Произвольные группы через `__GroupVars__` + +Позволяют описывать “логические” группы приложений: + +```yaml +payment-group: + __GroupVars__: + type: apps-stateless + api: + _include: ["apps-stateless-defaultApp"] + worker: + _include: ["apps-stateless-defaultApp"] +``` + +Для отдельного приложения можно переопределить тип: + +```yaml +payment-group: + __GroupVars__: + type: apps-stateless + edge: + __AppType__: apps-ingresses +``` + +## 5. Переиспользование конфигурации + +### 5.1 `global._includes` + `_include` + +`global._includes` хранит шаблонные блоки: + +```yaml +global: + _includes: + apps-stateless-defaultApp: + replicas: 2 + service: + enabled: false +``` + +Подключение в приложении: + +```yaml +apps-stateless: + billing-api: + _include: ["apps-stateless-defaultApp"] +``` + +Несколько include: + +```yaml +_include: ["profile-base", "profile-prod", "profile-api"] +``` + +Практика: +- делите include на небольшие “профили”; +- используйте явные имена (`apps-stateless-defaultApp`, `profile-worker`); +- локальные overrides держите прямо в приложении. + +### 5.2 `_include_from_file` + +Поддерживается загрузка include-блоков из файлов через `global._includes`. +Используйте это для больших наборов дефолтов, чтобы не раздувать основной `values.yaml`. + +## 6. Окружения: `_default`, env-override, regex + +Любое значение может задаваться: +- как обычный скаляр; +- как map по окружениям. + +Пример: + +```yaml +replicas: + _default: 2 + production: 5 + "^prod-.*$": 4 +``` + +Алгоритм выбора значения: +1. точное совпадение `global.env`; +2. regex-совпадение по ключам; +3. `_default`. + +Важно: +- несколько regex-совпадений для одного поля вызывают ошибку; +- для вложенных env-структур (`envYAML`, `configFilesYAML`) ожидается `_default`, иначе узел может быть проигнорирован логикой рендера. + +## 7. Типы полей: “строка YAML” vs map/list + +В библиотеке есть поля, которые вставляются в манифест как raw YAML. +Часто это делается через block string: + +```yaml +annotations: | + key: value +ports: | + - name: http + port: 80 +``` + +Преимущество: +- 1:1 перенос kubernetes-структуры без дополнительной обвязки. + +Риск: +- если передать не тот тип, можно получить неочевидный runtime-результат. + +Рекомендация: +- держите schema-валидацию включенной; +- используйте рабочие шаблоны из `docs/cookbook.md`. + +## 8. Контейнерный слой + +`containers` и `initContainers` поддерживают: +- image: `name`, `staticTag`, `generateSignatureBasedTag`; +- process: `command`, `args`, `workingDir`; +- env: `envVars`, `secretEnvVars`, `envFrom`, `envYAML`, `fromSecretsEnvVars`; +- resources: `requests/limits` (`mcpu`, `memoryMb`, `ephemeralStorageMb`); +- configs: `configFiles`, `configFilesYAML`, `secretConfigFiles`; +- probes/lifecycle/security: `livenessProbe`, `readinessProbe`, `startupProbe`, `lifecycle`, `securityContext`; +- volumes: `volumeMounts`, `persistantVolumes`. + +Особенности: +- `secretEnvVars` автоматически создают Secret и подключают его в `envFrom`; +- `configFiles*` автоматически создают ConfigMap/Secret и монтируются в контейнер; +- `alwaysRestart` добавляет псевдослучайный env `FL_APP_ALWAYS_RESTART`. + +## 9. Слой Pod/Workload + +Для `apps-stateless` и `apps-stateful` доступны: +- `replicas`; +- `affinity`, `tolerations`, `nodeSelector`, `topologySpreadConstraints`; +- `imagePullSecrets`, `volumes`; +- `serviceAccount` с optional `clusterRole`; +- `podDisruptionBudget`; +- `verticalPodAutoscaler`; +- `horizontalPodAutoscaler` (для stateless); +- `service`. + +Для `apps-jobs`/`apps-cronjobs`: +- `backoffLimit`, `activeDeadlineSeconds`, `restartPolicy`; +- для cron: `schedule`, `concurrencyPolicy`, `startingDeadlineSeconds`, `successfulJobsHistoryLimit`, `failedJobsHistoryLimit`. + +## 10. Сетевой слой + +### 10.1 Service + +`apps-services` или вложенный `service` у workload: +- `ports`; +- `selector`; +- `type`, `clusterIP`, `sessionAffinity`, и другие параметры Service API. + +### 10.2 Ingress + +`apps-ingresses`: +- `host`, `paths`; +- `class` и/или `ingressClassName`; +- `tls.enabled`; +- optional `tls.secret_name`. + +Если `tls.enabled=true` и `secret_name` не задан: +- библиотека генерирует `Certificate` автоматически. + +`dexAuth` в ingress: +- включает генерацию связанного `DexAuthenticator` для защиты приложения. + +## 11. Безопасность и доступы + +### 11.1 Secrets + +Сценарии: +- `apps-secrets` для отдельного Secret ресурса; +- `secretEnvVars` в контейнере для привязки секретов к pod; +- `secretConfigFiles` для файловых секретов. + +### 11.2 ServiceAccount и RBAC + +В приложении: + +```yaml +serviceAccount: + enabled: true + name: app-sa + clusterRole: + name: app-sa:read + rules: | + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +``` + +Библиотека создаст: +- `ServiceAccount`; +- `ClusterRole`; +- `ClusterRoleBinding`. + +## 12. Масштабирование и SLO + +### 12.1 VerticalPodAutoscaler + +Поддерживается для workload-ресурсов и части специализированных групп. +Используйте: +- `updateMode: Off` для сбора метрик; +- `updateMode: Initial/Auto` по стратегии команды. + +### 12.2 HorizontalPodAutoscaler + +Поддержка: +- `cpu`, `memory`; +- object/custom metrics; +- optional генерация Deckhouse metric ресурсов (`customMetricResources`). + +## 13. Observability + +Поддерживаются: +- `apps-custom-prometheus-rules`; +- `apps-grafana-dashboards`; +- `deckhouseMetrics` в приложении. + +Это позволяет держать метрики/правила алертинга рядом с конфигурацией деплоя сервиса. + +## 14. Специализированные группы + +### 14.1 `apps-kafka-strimzi` + +Шаблоны для Strimzi: +- `Kafka` cluster; +- `KafkaTopic`; +- сопутствующие VPA. + +Типичный сценарий: +- единый блок конфигурации Kafka в values; +- env-override для `prod/non-prod`. + +### 14.2 `apps-infra` + +Deckhouse-инфраструктурные сущности: +- `node-users`; +- `node-groups`. + +Используйте для инфраструктурных репозиториев или platform-слоя. + +## 15. Хуки и расширяемость + +Поддерживаются pre-render hooks: +- group-level: `__GroupVars__._preRenderGroupHook`; +- app-level default: `__GroupVars__._preRenderAppHook`; +- app-level explicit: `_preRenderHook`. + +Практические применения: +- массовая модификация группы перед рендером; +- автоматическое включение/клонирование приложений; +- вычисление derived-конфигурации. + +## 16. Рекомендуемые практики команды + +1. Выделяйте платформенные include-профили в `global._includes`. +2. Делайте значения окружений через `_default` + target-env overrides. +3. Не храните business-логику в Helm-шаблонах сервисов. +4. Для сложных структур используйте raw YAML блоки и шаблоны из cookbook. +5. Прогоняйте `werf render` в CI на каждом merge request. +6. Проверяйте значения schema-валидатором. + +## 17. Антипаттерны + +1. Копировать готовые Deployment/Service шаблоны между сервисами. +2. Размазывать дефолты по множеству несвязанных include-блоков. +3. Использовать “неявные” regex для env, создающие неоднозначности. +4. Смешивать в одном приложении слишком много unrelated-ролей (api+worker+cron). +5. Отключать schema-валидацию в CI. + +## 18. Валидация + +Основные артефакты: +- примеры: `tests/.helm/values.yaml`; +- схема: `tests/.helm/values.schema.json`. + +Рекомендуемые проверки: + +```bash +helm lint .helm +werf render --env=prod --dev +``` + +## 19. Миграция на библиотеку (пошагово) + +1. Подключите библиотеку и инициализатор. +2. Перенесите один сервис в `apps-stateless`. +3. Вынесите общие настройки в include-профили. +4. Добавьте service/ingress/hpa/vpa/pdb. +5. Перенесите jobs/cronjobs. +6. Включите CI-проверки schema + render. +7. Только после стабилизации удаляйте legacy шаблоны. + +## 20. Навигация по документации + +- Концепция и архитектура: `docs/library-guide.md` +- Полный справочник полей: `docs/reference-values.md` +- Готовые рецепты: `docs/cookbook.md` +- Эксплуатация и troubleshooting: `docs/operations.md` +- Полные рабочие примеры: `tests/.helm/values.yaml` +- Схема валидации: `tests/.helm/values.schema.json` diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..0555b0f --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,277 @@ +# Helm Apps Library Operations Playbook + +Документ для эксплуатации и поддержки деплоев на `helm-apps`: +- как быстро диагностировать проблемы; +- как локализовать источник ошибки; +- какие команды и чеклисты использовать в CI/CD и при релизах; +- как откатываться безопасно. + +## 1. Operational Mindset + +При инцидентах действуйте в порядке: +1. Подтвердить симптом (что именно сломано). +2. Локализовать слой (schema -> render -> apply -> runtime). +3. Найти минимальный diff, который вызвал проблему. +4. Восстановить сервис (rollback/hotfix). +5. Зафиксировать постоянное исправление (include/profile/schema/tests). + +## 2. Быстрый triage по слоям + +### 2.1 Layer 1: Values/Schema + +Признаки: +- ошибки валидации `values`; +- не тот тип поля; +- пропущены обязательные ключи. + +Проверки: + +```bash +helm lint .helm +``` + +Для репозитория библиотеки: + +```bash +helm lint tests/.helm --values tests/.helm/values.yaml +``` + +### 2.2 Layer 2: Render + +Признаки: +- шаблоны не рендерятся; +- ошибки `include`/`tpl`/`required`/`fail`; +- неоднозначный env regex. + +Проверки: + +```bash +werf render --env=prod --dev +``` + +Если рендер падает: +- ищите в тексте ошибки полный `CurrentPath` (путь до проблемного блока); +- сверяйте тип/структуру поля с `docs/reference-values.md`; +- проверяйте merge include-блоков. + +### 2.3 Layer 3: Apply/Release + +Признаки: +- рендер успешен, но релиз не применился; +- ошибки Kubernetes API validation; +- forbidden/unauthorized по RBAC. + +Проверки: +- события namespace; +- статус rollout; +- актуальность CRD (для cert-manager/Deckhouse/Strimzi). + +### 2.4 Layer 4: Runtime + +Признаки: +- pod crashloop; +- readiness/liveness failures; +- нет трафика через ingress/service; +- HPA/VPA не работают как ожидается. + +Проверки: +- pod logs/describe; +- service endpoints; +- ingress controller events; +- метрики HPA/VPA. + +## 3. Стандартные команды диагностики + +### 3.1 Helm/Werf + +```bash +werf helm dependency update .helm +helm lint .helm +werf render --env=prod --dev +``` + +### 3.2 Kubernetes runtime + +```bash +kubectl -n get deploy,sts,job,cronjob,svc,ing,pdb,hpa,vpa +kubectl -n get pods +kubectl -n describe pod +kubectl -n logs -c +kubectl -n get events --sort-by=.metadata.creationTimestamp +``` + +### 3.3 Service/Ingress debug + +```bash +kubectl -n get endpoints +kubectl -n describe ingress +``` + +## 4. Частые ошибки и что делать + +## 4.1 Ошибка schema: `Invalid type` + +Причина: +- передан map/list вместо строки YAML (или наоборот); +- env-map там, где ожидался plain scalar. + +Действия: +1. Проверить поле в `docs/reference-values.md`. +2. Сверить пример в `docs/cookbook.md`. +3. Повторно запустить `helm lint`. + +## 4.2 Ошибка рендера: `__GroupVars__ is required` + +Причина: +- top-level custom group без `__GroupVars__`; +- schema трактует ключ как custom group. + +Действия: +1. Если это custom group, добавить: +```yaml +__GroupVars__: + type: apps-stateless +``` +2. Если это служебный ключ/секция, убедиться, что он описан в schema. + +## 4.3 Ошибка рендера: ambiguous regex env + +Причина: +- несколько regex-ключей окружений совпали одновременно. + +Действия: +1. Убрать пересечение regex. +2. Оставить один явный env-override и `_default`. + +## 4.4 Включен app, но не заданы контейнеры + +Признак: +- `fail` из шаблонов `apps-stateless`/`apps-stateful`/`apps-jobs`/`apps-cronjobs`. + +Действия: +1. Добавить `containers`. +2. Либо временно выключить ресурс `enabled: false`. + +## 4.5 Service есть, но трафика нет + +Причины: +- selector не совпадает с labels pod; +- нет endpoints; +- порт не совпадает (`targetPort` vs container port). + +Действия: +1. `kubectl get endpoints`. +2. Сверить selector и labels. +3. Проверить контейнерные порты. + +## 4.6 Ingress есть, но 404/502 + +Причины: +- неверный backend service/port; +- ingress class mismatch; +- TLS secret отсутствует. + +Действия: +1. `kubectl describe ingress`. +2. Проверить `ingressClassName`/`class`. +3. Проверить наличие секрета и сертификата. + +## 4.7 HPA не скейлит + +Причины: +- невалидные metrics; +- отсутствуют источники метрик; +- min/max реплики блокируют ожидаемое поведение. + +Действия: +1. Проверить объект HPA и его conditions. +2. Сверить `metrics` и `customMetricResources`. +3. Проверить доступность metrics API. + +## 4.8 VPA не влияет на pods + +Причины: +- `updateMode: Off`; +- конфликт ожиданий между HPA и VPA; +- ресурс применен, но policy не задает нужное поведение. + +Действия: +1. Проверить `updateMode`. +2. Проверить policy. +3. Согласовать autoscaling стратегию. + +## 5. Чеклист изменения values перед merge + +1. Изменения проходят schema (`helm lint`). +2. Изменения рендерятся в target env (`werf render --env= --dev`). +3. Проверены include-конфликты и приоритет override. +4. Для env-ключей нет неоднозначных regex. +5. Для ingress/service проверены имена backend и порты. +6. Для секретов исключены plaintext утечки в git (используйте `secret-values` или внешние хранилища). +7. Для HPA/VPA согласованы min/max/updateMode и metrics. + +## 6. Чеклист релиза + +1. Подтянуты зависимости чарта. +2. Отрендерен итоговый манифест для target env. +3. Нет неожиданных изменений в критичных ресурсах: +- Service selectors; +- Ingress host/path/tls; +- Stateful PVC/retention settings; +- ServiceAccount/RBAC. +4. Подготовлен rollback-план. + +## 7. Rollback стратегия + +При регрессии: +1. Откатить `values` к последнему рабочему коммиту. +2. Повторить рендер и деплой. +3. Если проблема в include-profile, зафиксировать hotfix в профиле. + +Рекомендации: +- держите small-batch изменения в values; +- не смешивайте в одном MR массовый refactor и функциональные изменения. + +## 8. Incident response шаблон + +Минимальный протокол: +1. Time started. +2. Затронутые сервисы/окружения. +3. Последний измененный commit в values/include. +4. Симптом/алерт. +5. Layer диагностики (schema/render/apply/runtime). +6. Временное восстановление (rollback/hotfix). +7. Root cause. +8. Permanent fix. +9. Action items. + +## 9. Hardening practices + +1. Обязательный `helm lint` + `werf render` в CI. +2. Обязательный code-review для include-профилей. +3. Запрет на “широкие” regex для env без необходимости. +4. Разделение common include-профилей по доменам: +- compute; +- networking; +- security; +- autoscaling. +5. Документирование нестандартных hooks рядом с группой. + +## 10. Сопровождение schema + +При добавлении нового поля/ресурса в библиотеку: +1. Обновить `tests/.helm/values.schema.json`. +2. Добавить пример в `tests/.helm/values.yaml`. +3. Обновить `docs/reference-values.md`. +4. При необходимости добавить рецепт в `docs/cookbook.md`. + +Это защищает от дрейфа между кодом библиотеки, примерами и документацией. + +## 11. Полезные артефакты в репозитории + +- Полные примеры: `tests/.helm/values.yaml` +- Schema: `tests/.helm/values.schema.json` +- Концепция: `docs/library-guide.md` +- Reference: `docs/reference-values.md` +- Cookbook: `docs/cookbook.md` + diff --git a/docs/reference-values.md b/docs/reference-values.md new file mode 100644 index 0000000..5ea3c82 --- /dev/null +++ b/docs/reference-values.md @@ -0,0 +1,538 @@ +# Helm Apps Library: Reference по values + +Документ описывает практический референс структуры `values.yaml`. +Он дополняет `docs/library-guide.md` и должен читаться вместе с ним. + +## 1. Top-level ключи + +Поддерживаемые секции: +- `global` +- `apps-stateless` +- `apps-stateful` +- `apps-jobs` +- `apps-cronjobs` +- `apps-services` +- `apps-ingresses` +- `apps-configmaps` +- `apps-secrets` +- `apps-pvcs` +- `apps-limit-range` +- `apps-certificates` +- `apps-dex-clients` +- `apps-dex-authenticators` +- `apps-custom-prometheus-rules` +- `apps-grafana-dashboards` +- `apps-kafka-strimzi` +- `apps-infra` +- произвольные custom-группы с `__GroupVars__` + +Служебные ключи, которые могут появляться в merged values: +- `werf` +- `helm-apps` + +## 2. `global` + +Типичные поля: +- `env`: текущее окружение (`dev`, `prod`, `production`, etc.); +- `_includes`: библиотека include-блоков; +- произвольные project-level переменные (`ci_url`, `baseUrl` и т.д.). + +Пример: + +```yaml +global: + env: production + ci_url: example.org + _includes: + apps-stateless-defaultApp: + replicas: + _default: 2 + production: 4 +``` + +### 2.1 `global._includes` + `_include`: примеры merge + +Ниже примеры, как библиотека объединяет include-профили. + +#### Пример A: Рекурсивный merge вложенных map + +```yaml +global: + _includes: + base: + service: + enabled: true + headless: false + net: + service: + ports: | + - name: http + port: 80 + +apps-stateless: + api: + _include: ["base", "net"] +``` + +Итог: +- `service.enabled=true` +- `service.headless=false` +- `service.ports` добавлен из `net` + +#### Пример B: Приоритет include по порядку + +```yaml +global: + _includes: + base: + replicas: 2 + prod: + replicas: 5 + +apps-stateless: + api: + _include: ["base", "prod"] +``` + +Итог: `replicas=5`. + +#### Пример C: Локальный override сильнее include + +```yaml +global: + _includes: + base: + replicas: 2 + +apps-stateless: + api: + _include: ["base"] + replicas: 3 +``` + +Итог: `replicas=3`. + +#### Пример D: Env-map (`_default`) задавайте целиком в одном профиле + +```yaml +global: + _includes: + base: + replicas: + _default: 2 + production: 4 + canary: + replicas: + _default: 1 + production: 2 + +apps-stateless: + api: + _include: ["base", "canary"] +``` + +Итоговый блок берется из более приоритетного include (`canary`). + +#### Пример E: `_include`-списки конкатенируются + +```yaml +global: + _includes: + profile-a: + _include: ["base-a"] + replicas: 2 + profile-b: + _include: ["base-b"] + +apps-stateless: + api: + _include: ["profile-a", "profile-b"] +``` + +Итоговый include-chain для приложения объединяет `base-a` и `base-b`. + +## 3. Общая форма приложения в `apps-*` + +```yaml +apps-stateless: + app-name: + _include: ["profile-name"] + enabled: true + name: "custom-name" + werfWeight: -10 + annotations: | + key: value + labels: | + tier: backend +``` + +Общие поля, которые могут встречаться в большинстве app-типов: +- `_include` +- `enabled` +- `name` +- `werfWeight` +- `annotations` +- `labels` + +## 4. Workload app-поля + +Актуально для: +- `apps-stateless` +- `apps-stateful` +- `apps-jobs` +- `apps-cronjobs` + +### 4.1 Pod/workload common + +- `containers` +- `initContainers` +- `imagePullSecrets` +- `affinity` +- `tolerations` +- `nodeSelector` +- `volumes` +- `serviceAccount` +- `verticalPodAutoscaler` + +### 4.2 Stateless/Stateful + +Дополнительно: +- `replicas` +- `podDisruptionBudget` +- `service` +- `selector` +- `horizontalPodAutoscaler` (в основном для stateless) + +Stateful-specific: +- `service.name` (для headless service), +- `updateStrategy`, +- `persistentVolumeClaimRetentionPolicy`, +- `volumeClaimTemplates`. + +### 4.3 Jobs/CronJobs + +Общие job-поля: +- `backoffLimit` +- `activeDeadlineSeconds` +- `restartPolicy` +- `ttlSecondsAfterFinished` (в соответствующем API-блоке) + +Только cron: +- `schedule` +- `concurrencyPolicy` +- `startingDeadlineSeconds` +- `successfulJobsHistoryLimit` +- `failedJobsHistoryLimit` + +## 5. `containers` / `initContainers` + +Форма: + +```yaml +containers: + main: + enabled: true + image: + name: app + staticTag: "1.0.0" + command: | + - /bin/app + args: | + - --serve +``` + +Поддерживаемые поля контейнера: +- `enabled` +- `name` +- `image.name` +- `image.staticTag` +- `image.generateSignatureBasedTag` +- `command` +- `args` +- `envVars` +- `envYAML` +- `env` +- `envFrom` +- `secretEnvVars` +- `fromSecretsEnvVars` +- `resources` +- `lifecycle` +- `livenessProbe` +- `readinessProbe` +- `startupProbe` +- `securityContext` +- `volumeMounts` +- `volumes` +- `ports` +- `configFiles` +- `configFilesYAML` +- `secretConfigFiles` +- `persistantVolumes` + +## 6. Env-паттерн + +Любое поле, поддерживающее env-map: + +```yaml +field: + _default: value + production: value2 + "^prod-.*$": value3 +``` + +Используйте: +- `_default` для базового значения; +- явный env-ключ для таргет окружения; +- regex только когда реально нужен паттерн. + +## 7. Ресурсы контейнера + +Форма: + +```yaml +resources: + requests: + mcpu: 100 + memoryMb: 256 + ephemeralStorageMb: 100 + limits: + mcpu: 500 + memoryMb: 512 +``` + +Поддержка env-map также применима к этим полям. + +## 8. Config files + +### 8.1 `configFiles` + +```yaml +configFiles: + app.yaml: + mountPath: /etc/app/app.yaml + content: | + key: value +``` + +### 8.2 `configFilesYAML` + +```yaml +configFilesYAML: + app.yaml: + mountPath: /etc/app/app.yaml + content: + key: + _default: value + production: prod-value +``` + +### 8.3 `secretConfigFiles` + +```yaml +secretConfigFiles: + token.txt: + mountPath: /etc/secret/token.txt + content: super-secret +``` + +## 9. Service block + +Используется: +- как nested `service` у workload; +- как отдельный объект в `apps-services`. + +Типовые поля: +- `enabled` +- `name` +- `ports` +- `selector` +- `type` +- `clusterIP` +- `sessionAffinity` +- `annotations` + +## 10. Ingress block + +`apps-ingresses.`: +- `class` +- `ingressClassName` +- `host` +- `paths` +- `annotations` +- `tls.enabled` +- `tls.secret_name` +- `dexAuth` + +`dexAuth` поля: +- `enabled` +- `clusterDomain` + +## 11. Autoscaling blocks + +### 11.1 `verticalPodAutoscaler` + +- `enabled` +- `updateMode` +- `resourcePolicy` + +### 11.2 `horizontalPodAutoscaler` + +- `enabled` +- `minReplicas` +- `maxReplicas` +- `behavior` +- `metrics` +- `customMetricResources` + +`customMetricResources.`: +- `enabled` +- `kind` +- `name` (optional) +- `query` + +## 12. `podDisruptionBudget` + +Поля: +- `enabled` +- `maxUnavailable` +- `minAvailable` + +## 13. `serviceAccount` + +Поля: +- `enabled` +- `name` +- `clusterRole` + +`clusterRole`: +- `name` +- `rules` + +## 14. Прочие `apps-*` секции + +### 14.1 `apps-configmaps` + +Поля app: +- `data` +- `binaryData` +- `envVars` + +### 14.2 `apps-secrets` + +Поля app: +- `type` +- `data` +- `envVars` + +### 14.3 `apps-pvcs` + +Поля app: +- `storageClassName` +- `accessModes` +- `resources` + +### 14.4 `apps-limit-range` + +Поля app: +- `limits` + +### 14.5 `apps-certificates` + +Поля app: +- `name` (optional override) +- `clusterIssuer` +- `host` +- `hosts` + +### 14.6 `apps-dex-clients` + +Поля app: +- `redirectURIs` (required для включенного ресурса) + +### 14.7 `apps-dex-authenticators` + +Поля app: +- `applicationDomain` +- `applicationIngressClassName` +- `applicationIngressCertificateSecretName` +- `allowedGroups` +- `sendAuthorizationHeader` +- `whitelistSourceRanges` +- `nodeSelector` +- `tolerations` + +### 14.8 `apps-custom-prometheus-rules` + +Поля app: +- `groups` + +Глубже: +- `groups..alerts..isTemplate` +- `groups..alerts..content` + +### 14.9 `apps-grafana-dashboards` + +Поля app: +- `folder` + +Dashboard definition читается из `dashboards/.json`. + +### 14.10 `apps-kafka-strimzi` + +Поля app (основные): +- `kafka` +- `zookeeper` +- `entityOperator` +- `exporter` +- `topics` + +Эта секция специализирована под Strimzi и обычно выносится в отдельный infra/service chart. + +### 14.11 `apps-infra` + +Содержит: +- `node-users` +- `node-groups` + +`node-users.`: +- `enabled` +- `uid` (required) +- `passwordHash` +- `sshPublicKey` +- `sshPublicKeys` +- `extraGroups` +- `nodeGroups` +- `isSudoer` +- `annotations` +- `labels` + +## 15. Custom-группы + +Форма: + +```yaml +group-name: + __GroupVars__: + type: apps-stateless + enabled: true + _preRenderGroupHook: | + {{/* hook */}} + _preRenderAppHook: | + {{/* hook */}} + app-a: + _include: ["apps-stateless-defaultApp"] +``` + +Важные поля `__GroupVars__`: +- `type` (required) +- `enabled` +- `_include` +- `_preRenderGroupHook` +- `_preRenderAppHook` + +## 16. Полезные ссылки + +- Общая концепция: `docs/library-guide.md` +- Практические рецепты: `docs/cookbook.md` +- Рабочие примеры: `tests/.helm/values.yaml` +- JSON Schema: `tests/.helm/values.schema.json` diff --git a/tests/.helm/values.schema.json b/tests/.helm/values.schema.json new file mode 100644 index 0000000..e904ada --- /dev/null +++ b/tests/.helm/values.schema.json @@ -0,0 +1,767 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://helm-apps.local/tests/.helm/values.schema.json", + "title": "helm-apps values", + "type": "object", + "properties": { + "global": { + "$ref": "#/$defs/global" + }, + "apps-configmaps": { + "$ref": "#/$defs/appMap" + }, + "apps-cronjobs": { + "$ref": "#/$defs/appMap" + }, + "apps-ingresses": { + "$ref": "#/$defs/appMap" + }, + "apps-jobs": { + "$ref": "#/$defs/appMap" + }, + "apps-secrets": { + "$ref": "#/$defs/appMap" + }, + "apps-stateful": { + "$ref": "#/$defs/appMap" + }, + "apps-stateless": { + "$ref": "#/$defs/appMap" + }, + "apps-custom-prometheus-rules": { + "$ref": "#/$defs/appMap" + }, + "apps-limit-range": { + "$ref": "#/$defs/appMap" + }, + "apps-pvcs": { + "$ref": "#/$defs/appMap" + }, + "apps-certificates": { + "$ref": "#/$defs/appMap" + }, + "apps-kafka-strimzi": { + "$ref": "#/$defs/appMap" + }, + "apps-dex-authenticators": { + "$ref": "#/$defs/appMap" + }, + "apps-dex-clients": { + "$ref": "#/$defs/appMap" + }, + "apps-grafana-dashboards": { + "$ref": "#/$defs/appMap" + }, + "apps-services": { + "$ref": "#/$defs/appMap" + }, + "apps-infra": { + "$ref": "#/$defs/appsInfra" + }, + "werf": { + "type": "object", + "additionalProperties": true + }, + "helm-apps": { + "type": "object", + "additionalProperties": true + } + }, + "$defs": { + "scalar": { + "type": ["string", "number", "integer", "boolean", "null"] + }, + "yamlScalar": { + "oneOf": [ + { + "$ref": "#/$defs/scalar" + }, + { + "type": "array" + }, + { + "type": "object" + } + ] + }, + "envValue": { + "description": "Обычное значение или map вида {_default: ..., prod: ..., ^prod.*: ...}.", + "oneOf": [ + { + "$ref": "#/$defs/scalar" + }, + { + "type": "array" + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/yamlScalar" + } + } + ] + }, + "envMap": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/envValue" + } + }, + "resources": { + "type": "object", + "properties": { + "requests": { + "$ref": "#/$defs/resourcesGroup" + }, + "limits": { + "$ref": "#/$defs/resourcesGroup" + } + }, + "additionalProperties": false + }, + "resourcesGroup": { + "type": "object", + "properties": { + "mcpu": { + "$ref": "#/$defs/envValue" + }, + "memoryMb": { + "$ref": "#/$defs/envValue" + }, + "ephemeralStorageMb": { + "$ref": "#/$defs/envValue" + } + }, + "additionalProperties": true + }, + "image": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/envValue" + }, + "staticTag": { + "$ref": "#/$defs/envValue" + }, + "generateSignatureBasedTag": { + "$ref": "#/$defs/envValue" + } + }, + "required": ["name"], + "additionalProperties": true + }, + "configFile": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/envValue" + }, + "mountPath": { + "$ref": "#/$defs/envValue" + }, + "defaultMode": { + "$ref": "#/$defs/envValue" + }, + "content": { + "$ref": "#/$defs/yamlScalar" + } + }, + "required": ["mountPath"], + "additionalProperties": true + }, + "container": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "name": { + "$ref": "#/$defs/envValue" + }, + "image": { + "$ref": "#/$defs/image" + }, + "command": { + "$ref": "#/$defs/yamlScalar" + }, + "args": { + "$ref": "#/$defs/yamlScalar" + }, + "envVars": { + "$ref": "#/$defs/envMap" + }, + "envYAML": { + "type": "object", + "additionalProperties": true + }, + "secretEnvVars": { + "$ref": "#/$defs/envMap" + }, + "fromSecretsEnvVars": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/envValue" + } + } + }, + "env": { + "$ref": "#/$defs/yamlScalar" + }, + "envFrom": { + "$ref": "#/$defs/yamlScalar" + }, + "resources": { + "$ref": "#/$defs/resources" + }, + "lifecycle": { + "$ref": "#/$defs/yamlScalar" + }, + "livenessProbe": { + "$ref": "#/$defs/yamlScalar" + }, + "readinessProbe": { + "$ref": "#/$defs/yamlScalar" + }, + "startupProbe": { + "$ref": "#/$defs/yamlScalar" + }, + "securityContext": { + "$ref": "#/$defs/yamlScalar" + }, + "volumeMounts": { + "$ref": "#/$defs/yamlScalar" + }, + "volumes": { + "$ref": "#/$defs/yamlScalar" + }, + "ports": { + "$ref": "#/$defs/yamlScalar" + }, + "configFiles": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configFile" + } + }, + "configFilesYAML": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configFile" + } + }, + "secretConfigFiles": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configFile" + } + }, + "persistantVolumes": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "mountPath": { + "$ref": "#/$defs/envValue" + }, + "size": { + "$ref": "#/$defs/envValue" + }, + "storageClass": { + "$ref": "#/$defs/envValue" + }, + "accessModes": { + "$ref": "#/$defs/yamlScalar" + } + }, + "required": ["mountPath", "size", "storageClass"], + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "vpa": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "updateMode": { + "$ref": "#/$defs/envValue" + }, + "resourcePolicy": { + "$ref": "#/$defs/yamlScalar" + } + }, + "additionalProperties": true + }, + "hpa": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "minReplicas": { + "$ref": "#/$defs/envValue" + }, + "maxReplicas": { + "$ref": "#/$defs/envValue" + }, + "behavior": { + "$ref": "#/$defs/yamlScalar" + }, + "metrics": { + "$ref": "#/$defs/yamlScalar" + }, + "customMetricResources": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "kind": { + "$ref": "#/$defs/envValue" + }, + "name": { + "$ref": "#/$defs/envValue" + }, + "query": { + "$ref": "#/$defs/envValue" + } + }, + "required": ["kind", "query"], + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "pdb": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "maxUnavailable": { + "$ref": "#/$defs/envValue" + }, + "minAvailable": { + "$ref": "#/$defs/envValue" + } + }, + "additionalProperties": true + }, + "service": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "name": { + "$ref": "#/$defs/envValue" + }, + "headless": { + "$ref": "#/$defs/envValue" + }, + "ports": { + "$ref": "#/$defs/yamlScalar" + }, + "selector": { + "$ref": "#/$defs/yamlScalar" + }, + "annotations": { + "$ref": "#/$defs/yamlScalar" + } + }, + "additionalProperties": true + }, + "serviceAccount": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "name": { + "$ref": "#/$defs/envValue" + }, + "clusterRole": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/envValue" + }, + "rules": { + "$ref": "#/$defs/yamlScalar" + } + }, + "required": ["name", "rules"], + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "tls": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "secret_name": { + "$ref": "#/$defs/envValue" + } + }, + "additionalProperties": true + }, + "app": { + "type": "object", + "properties": { + "_include": { + "type": "array", + "items": { + "type": "string" + } + }, + "__AppType__": { + "type": "string" + }, + "enabled": { + "$ref": "#/$defs/envValue" + }, + "name": { + "$ref": "#/$defs/envValue" + }, + "randomName": { + "$ref": "#/$defs/envValue" + }, + "alwaysRestart": { + "$ref": "#/$defs/envValue" + }, + "werfWeight": { + "$ref": "#/$defs/envValue" + }, + "annotations": { + "$ref": "#/$defs/yamlScalar" + }, + "labels": { + "$ref": "#/$defs/yamlScalar" + }, + "selector": { + "$ref": "#/$defs/yamlScalar" + }, + "containers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/container" + } + }, + "initContainers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/container" + } + }, + "verticalPodAutoscaler": { + "$ref": "#/$defs/vpa" + }, + "horizontalPodAutoscaler": { + "$ref": "#/$defs/hpa" + }, + "podDisruptionBudget": { + "$ref": "#/$defs/pdb" + }, + "service": { + "$ref": "#/$defs/service" + }, + "serviceAccount": { + "$ref": "#/$defs/serviceAccount" + }, + "schedule": { + "$ref": "#/$defs/envValue" + }, + "concurrencyPolicy": { + "$ref": "#/$defs/envValue" + }, + "successfulJobsHistoryLimit": { + "$ref": "#/$defs/envValue" + }, + "failedJobsHistoryLimit": { + "$ref": "#/$defs/envValue" + }, + "startingDeadlineSeconds": { + "$ref": "#/$defs/envValue" + }, + "backoffLimit": { + "$ref": "#/$defs/envValue" + }, + "activeDeadlineSeconds": { + "$ref": "#/$defs/envValue" + }, + "restartPolicy": { + "$ref": "#/$defs/envValue" + }, + "priorityClassName": { + "$ref": "#/$defs/envValue" + }, + "affinity": { + "$ref": "#/$defs/yamlScalar" + }, + "tolerations": { + "$ref": "#/$defs/yamlScalar" + }, + "nodeSelector": { + "$ref": "#/$defs/yamlScalar" + }, + "topologySpreadConstraints": { + "$ref": "#/$defs/yamlScalar" + }, + "volumes": { + "$ref": "#/$defs/yamlScalar" + }, + "imagePullSecrets": { + "$ref": "#/$defs/yamlScalar" + }, + "ingressClassName": { + "$ref": "#/$defs/envValue" + }, + "class": { + "$ref": "#/$defs/envValue" + }, + "host": { + "$ref": "#/$defs/envValue" + }, + "hosts": { + "$ref": "#/$defs/yamlScalar" + }, + "paths": { + "$ref": "#/$defs/yamlScalar" + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "dexAuth": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "clusterDomain": { + "$ref": "#/$defs/envValue" + } + }, + "additionalProperties": true + }, + "type": { + "$ref": "#/$defs/envValue" + }, + "data": { + "$ref": "#/$defs/yamlScalar" + }, + "binaryData": { + "$ref": "#/$defs/yamlScalar" + }, + "envVars": { + "$ref": "#/$defs/envMap" + }, + "limits": { + "$ref": "#/$defs/yamlScalar" + }, + "storageClassName": { + "$ref": "#/$defs/envValue" + }, + "accessModes": { + "$ref": "#/$defs/yamlScalar" + }, + "resources": { + "$ref": "#/$defs/yamlScalar" + }, + "clusterIssuer": { + "$ref": "#/$defs/envValue" + }, + "groups": { + "type": "object", + "additionalProperties": true + }, + "folder": { + "$ref": "#/$defs/envValue" + }, + "redirectURIs": { + "$ref": "#/$defs/yamlScalar" + }, + "applicationDomain": { + "$ref": "#/$defs/envValue" + }, + "applicationIngressCertificateSecretName": { + "$ref": "#/$defs/envValue" + }, + "applicationIngressClassName": { + "$ref": "#/$defs/envValue" + }, + "allowedGroups": { + "$ref": "#/$defs/yamlScalar" + }, + "sendAuthorizationHeader": { + "$ref": "#/$defs/envValue" + }, + "kafka": { + "type": "object", + "additionalProperties": true + }, + "zookeeper": { + "type": "object", + "additionalProperties": true + }, + "topics": { + "type": "object", + "additionalProperties": true + }, + "entityOperator": { + "type": "object", + "additionalProperties": true + }, + "exporter": { + "type": "object", + "additionalProperties": true + }, + "deckhouseMetrics": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "appMap": { + "type": "object", + "patternProperties": { + "^[A-Za-z0-9][A-Za-z0-9_.-]*$": { + "$ref": "#/$defs/app" + } + }, + "additionalProperties": false + }, + "customGroupVars": { + "type": "object", + "properties": { + "_include": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "$ref": "#/$defs/envValue" + }, + "type": { + "type": "string" + }, + "_preRenderGroupHook": { + "$ref": "#/$defs/yamlScalar" + }, + "_preRenderAppHook": { + "$ref": "#/$defs/yamlScalar" + } + }, + "required": ["type"], + "additionalProperties": true + }, + "customGroup": { + "type": "object", + "properties": { + "__GroupVars__": { + "$ref": "#/$defs/customGroupVars" + } + }, + "required": ["__GroupVars__"], + "patternProperties": { + "^[A-Za-z0-9][A-Za-z0-9_.-]*$": { + "$ref": "#/$defs/app" + } + }, + "additionalProperties": false + }, + "appsInfraNodeUser": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "uid": { + "$ref": "#/$defs/envValue" + }, + "passwordHash": { + "$ref": "#/$defs/envValue" + }, + "sshPublicKey": { + "$ref": "#/$defs/envValue" + }, + "sshPublicKeys": { + "$ref": "#/$defs/yamlScalar" + }, + "extraGroups": { + "$ref": "#/$defs/yamlScalar" + }, + "nodeGroups": { + "$ref": "#/$defs/yamlScalar" + }, + "isSudoer": { + "$ref": "#/$defs/envValue" + }, + "labels": { + "$ref": "#/$defs/yamlScalar" + }, + "annotations": { + "$ref": "#/$defs/yamlScalar" + } + }, + "required": ["uid"], + "additionalProperties": true + }, + "appsInfra": { + "type": "object", + "properties": { + "node-users": { + "type": "object", + "patternProperties": { + "^[A-Za-z0-9][A-Za-z0-9_.-]*$": { + "$ref": "#/$defs/appsInfraNodeUser" + } + }, + "additionalProperties": false + }, + "node-groups": { + "type": "object", + "patternProperties": { + "^[A-Za-z0-9][A-Za-z0-9_.-]*$": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false + } + }, + "additionalProperties": true + }, + "global": { + "type": "object", + "properties": { + "ci_url": { + "$ref": "#/$defs/envValue" + }, + "env": { + "$ref": "#/$defs/envValue" + }, + "_includes": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "additionalProperties": { + "$ref": "#/$defs/customGroup" + } +} From 801e1bac49a9f2c0a48c5300f9f81c9e3228fd21 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:37:07 +0300 Subject: [PATCH 18/45] ci: split validation workflow and add include-merge contract tests - Added dedicated CI workflow for PR/branch validation with schema lint, render checks, and snapshot diff\n- Restricted release workflow to main/manual triggers and kept publishing flow there\n- Added contract test chart to verify global._includes behavior: recursive map merge, include order precedence, local overrides, _include concatenation, and env-map resolution\n- Updated README and values reference docs to reflect actual env-map merge behavior\n- Ignored local IDE metadata directory (.idea/) --- .github/workflows/ci.yml | 84 +++++++++++++++++++ .github/workflows/release.yml | 3 +- .gitignore | 1 + README.md | 14 ++-- docs/reference-values.md | 8 +- tests/contracts/Chart.lock | 6 ++ tests/contracts/Chart.yaml | 7 ++ .../templates/init-helm-apps-library.yaml | 1 + tests/contracts/values.yaml | 39 +++++++++ 9 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/contracts/Chart.lock create mode 100644 tests/contracts/Chart.yaml create mode 100644 tests/contracts/templates/init-helm-apps-library.yaml create mode 100644 tests/contracts/values.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f85efa3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + pull_request: + push: + branches-ignore: + - main + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set lib version + run: | + LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) + sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl + + - name: Install werf CLI + with: + channel: ea + uses: werf/actions/install@v1.2 + + - name: Install Helm3 + run: | + curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + + - name: Install Dyff + run: | + curl --silent --location https://git.io/JYfAY | bash + + - name: Update test chart dependencies + run: | + helm dependency update tests/.helm + + - name: Validate values schema + run: | + helm lint tests/.helm --values tests/.helm/values.yaml + + - name: Render + run: | + set -e + source $(werf ci-env github --as-file) + cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod + + - name: Test render snapshot + run: | + set -e + source $(werf ci-env github --as-file) + cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod | sed '/werf.io\//d' > test_render_check.yaml + dyff between test_render.yaml test_render_check.yaml | tee /tmp/test_render_check + check_tests=$(sed 1,7d /tmp/test_render_check | wc -l) + if [ $check_tests -gt "7" ]; then exit 1; fi + + - name: Update contract chart dependencies + run: | + helm dependency update tests/contracts + + - name: Contract test for include merge behavior + run: | + set -euo pipefail + helm template contracts tests/contracts > /tmp/contracts_render.yaml + + # Include order override and local override precedence. + grep -q '"A": "2"' /tmp/contracts_render.yaml + grep -q '"LOCAL": "ok"' /tmp/contracts_render.yaml + grep -q '"key2": "local-value-2"' /tmp/contracts_render.yaml + + # Recursive merge and _include list concatenation from base-a/base-b. + grep -q '"key1": "value-1"' /tmp/contracts_render.yaml + grep -q '"fromBaseA": "A"' /tmp/contracts_render.yaml + grep -q '"fromBaseB": "B"' /tmp/contracts_render.yaml + + # Env-map behavior: + # production resolves explicit env key from base profile. + grep -q '"ENV_SWITCH": "base-production"' /tmp/contracts_render.yaml + + # non-production resolves _default from higher-priority include. + helm template contracts tests/contracts --set global.env=dev > /tmp/contracts_render_dev.yaml + grep -q '"ENV_SWITCH": "override-default"' /tmp/contracts_render_dev.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef9c84f..d1a4e11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,8 @@ name: Release Charts on: push: branches: - - "*" + - main + workflow_dispatch: jobs: release: diff --git a/.gitignore b/.gitignore index 999b624..cc498fd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ **.tgz /.packages/ tests/test_render_check.yaml +.idea/ diff --git a/README.md b/README.md index 17f5150..faefed7 100644 --- a/README.md +++ b/README.md @@ -212,9 +212,9 @@ apps-stateless: Итог: `replicas: 3`. -#### Пример 4: Env-map (`_default`) как атомарный блок +#### Пример 4: Env-map merge с `_default` и конкретным env -Для значений окружений используйте один полный блок в более приоритетном месте: +Пример: ```yaml global: @@ -233,11 +233,13 @@ apps-stateless: _include: ["base", "canary"] ``` -Итог: берется блок `canary.replicas`, то есть: -- `_default: 1` -- `production: 2` +Итоговое поведение: +- для `production` будет использовано значение `4` (из `base.production`); +- для env без явного ключа будет использовано `_default: 1` (из `canary._default`). -Практика: для env-map не “достраивайте” куски из нескольких include, задавайте целиком в одном профиле. +Практика: +- всегда проверяйте итоговый рендер в целевом env (`werf render --env=`); +- для критичных env-map лучше держать все нужные env-ключи явно в финальном профиле. #### Пример 5: `_include`-списки конкатенируются diff --git a/docs/reference-values.md b/docs/reference-values.md index 5ea3c82..b6abade 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -112,7 +112,7 @@ apps-stateless: Итог: `replicas=3`. -#### Пример D: Env-map (`_default`) задавайте целиком в одном профиле +#### Пример D: Env-map поведение при merge include ```yaml global: @@ -131,7 +131,11 @@ apps-stateless: _include: ["base", "canary"] ``` -Итоговый блок берется из более приоритетного include (`canary`). +Поведение в результате merge: +- ключ `production` будет взят из `base` (значение `4`); +- `_default` будет взят из `canary` (значение `1`). + +Вывод: для env-map обязательно проверяйте финальный рендер в нужном окружении. #### Пример E: `_include`-списки конкатенируются diff --git a/tests/contracts/Chart.lock b/tests/contracts/Chart.lock new file mode 100644 index 0000000..9efd48a --- /dev/null +++ b/tests/contracts/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: helm-apps + repository: file://../../charts/helm-apps/ + version: 1.3.2 +digest: sha256:22f6f4667bf7cfbbbaa3c6996ab55fedbf3622821dadcc04fece2d76b5b05995 +generated: "2026-02-16T01:34:38.607167+03:00" diff --git a/tests/contracts/Chart.yaml b/tests/contracts/Chart.yaml new file mode 100644 index 0000000..7f49383 --- /dev/null +++ b/tests/contracts/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: contracts +version: 1.0.0 +dependencies: + - name: helm-apps + repository: "file://../../charts/helm-apps/" + version: ~1 diff --git a/tests/contracts/templates/init-helm-apps-library.yaml b/tests/contracts/templates/init-helm-apps-library.yaml new file mode 100644 index 0000000..4e64ccc --- /dev/null +++ b/tests/contracts/templates/init-helm-apps-library.yaml @@ -0,0 +1 @@ +{{- include "apps-utils.init-library" $ }} diff --git a/tests/contracts/values.yaml b/tests/contracts/values.yaml new file mode 100644 index 0000000..53fc3cb --- /dev/null +++ b/tests/contracts/values.yaml @@ -0,0 +1,39 @@ +global: + env: production + _includes: + base-a: + data: + fromBaseA: "A" + base-b: + data: + fromBaseB: "B" + profile-base: + enabled: true + _include: ["base-a"] + envVars: + A: "1" + ENV_SWITCH: + _default: "base-default" + production: "base-production" + data: + key1: "value-1" + key2: "base-value-2" + profile-override: + _include: ["base-b"] + envVars: + A: "2" + ENV_SWITCH: + _default: "override-default" + data: + key2: "override-value-2" + key4: "value-4" + +apps-configmaps: + merge-contract: + _include: ["profile-base", "profile-override"] + enabled: true + envVars: + LOCAL: "ok" + data: + key2: "local-value-2" + key3: "value-3" From 6c18bf83b7c5152128f14b763a133900e024b99c Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:43:59 +0300 Subject: [PATCH 19/45] docs: clarify Helm-first guidance with full Helm support and werf compatibility - Clarified positioning: Helm is fully supported, while werf is compatible and often convenient in team workflows\n- Added explicit guidance that environments are selected via global.env\n- Replaced remaining werf-centric command examples with Helm-based commands in user docs\n- Updated CI and release workflows to use Helm template/lint flow for render validation --- .github/workflows/ci.yml | 17 +++++++---------- .github/workflows/release.yml | 17 +++++++---------- README.md | 16 ++++++++++------ docs/README.md | 9 ++++++--- docs/cookbook.md | 3 +-- docs/library-guide.md | 14 +++++++++----- docs/operations.md | 13 ++++++------- docs/reference-values.md | 1 - 8 files changed, 46 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f85efa3..df61e0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,11 +20,6 @@ jobs: LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl - - name: Install werf CLI - with: - channel: ea - uses: werf/actions/install@v1.2 - - name: Install Helm3 run: | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash @@ -44,15 +39,17 @@ jobs: - name: Render run: | set -e - source $(werf ci-env github --as-file) - cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod + helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" > /tmp/tests_render.yaml - name: Test render snapshot run: | set -e - source $(werf ci-env github --as-file) - cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod | sed '/werf.io\//d' > test_render_check.yaml - dyff between test_render.yaml test_render_check.yaml | tee /tmp/test_render_check + helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" > /tmp/test_render_check.yaml + dyff between tests/test_render.yaml /tmp/test_render_check.yaml | tee /tmp/test_render_check check_tests=$(sed 1,7d /tmp/test_render_check | wc -l) if [ $check_tests -gt "7" ]; then exit 1; fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1a4e11..e88b8d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,11 +25,6 @@ jobs: LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl - - name: Install werf CLI - with: - channel: ea - uses: werf/actions/install@v1.2 - - name: Install Helm3 run: | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash @@ -49,15 +44,17 @@ jobs: - name: Render run: | set -e - source $(werf ci-env github --as-file) - cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod + helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" > /tmp/tests_render.yaml - name: Test render run: | set -e - source $(werf ci-env github --as-file) - cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod | sed '/werf.io\//d' > test_render_check.yaml - dyff between test_render.yaml test_render_check.yaml | tee /tmp/test_render_check + helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" > /tmp/test_render_check.yaml + dyff between tests/test_render.yaml /tmp/test_render_check.yaml | tee /tmp/test_render_check check_tests=$(sed 1,7d /tmp/test_render_check | wc -l) if [ $check_tests -gt "7" ]; then exit 1; fi diff --git a/README.md b/README.md index faefed7..43df317 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ `helm-apps` позволяет описывать приложения через `values.yaml` без копирования шаблонов между сервисами. Логика рендера централизована в библиотеке, а сервисные репозитории хранят только конфигурацию. -> :warning: На текущий момент основной и проверенный сценарий использования — через [werf](https://werf.io). +> Библиотека полностью поддерживает Helm и совместима с werf. +> Практически, для командного daily workflow werf часто удобнее: он объединяет рендер и процесс поставки в единый поток, снижая количество ручных шагов в CI/CD. +> При этом весь функционал библиотеки доступен и через чистый Helm. ## Зачем использовать библиотеку @@ -61,8 +63,8 @@ dependencies: ### 3. Обновить зависимости ```bash -werf helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps -werf helm dependency update .helm +helm repo add --force-update helm-apps https://alvnukov.github.io/helm-apps +helm dependency update .helm ``` ### 4. Описать приложение в values @@ -71,6 +73,7 @@ werf helm dependency update .helm ```yaml global: + env: prod ci_url: example.org apps-stateless: @@ -238,7 +241,8 @@ apps-stateless: - для env без явного ключа будет использовано `_default: 1` (из `canary._default`). Практика: -- всегда проверяйте итоговый рендер в целевом env (`werf render --env=`); +- окружение передавайте через `global.env`; +- всегда проверяйте итоговый рендер в целевом env (`helm template ... --set global.env=`); - для критичных env-map лучше держать все нужные env-ключи явно в финальном профиле. #### Пример 5: `_include`-списки конкатенируются @@ -268,13 +272,13 @@ apps-stateless: Для обычных YAML-массивов (не `_include`) merge может быть неочевидным. Рекомендация: - задавайте такие поля финально в более приоритетном include или локально в app; -- для сложных структур используйте проверку через `werf render`. +- для сложных структур используйте проверку через `helm template`. ### 5. Проверить рендер ```bash helm lint .helm -werf render --env=prod --dev +helm template my-app .helm --set global.env=prod ``` ## Маршрут по документации diff --git a/docs/README.md b/docs/README.md index 36309b2..3433830 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,10 @@ Этот файл — точка входа в документацию. Если открываете docs впервые, начните отсюда. +Примечание: библиотека полностью поддерживает `Helm` и совместима с `werf`. +На практике `werf` часто удобнее для продуктовых команд, потому что он объединяет рендер и delivery-процесс в один workflow. +При этом все сценарии библиотеки доступны через чистый `Helm`. + ## Быстрый маршрут (15 минут) 1. Прочитать концепцию и зачем библиотека нужна: `docs/library-guide.md` @@ -44,8 +48,7 @@ ## Минимальный командный чеклист ```bash -werf helm dependency update .helm +helm dependency update .helm helm lint .helm -werf render --env=prod --dev +helm template my-app .helm --set global.env=prod ``` - diff --git a/docs/cookbook.md b/docs/cookbook.md index 9545681..c084129 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -403,10 +403,9 @@ apps-custom-prometheus-rules: 2. Скопируйте блок в `values.yaml`. 3. Подключите ваш include-профиль. 4. Добавьте env-overrides. -5. Прогоните `werf render`. +5. Прогоните `helm template` с нужным окружением через `global.env`. Связанные документы: - `docs/library-guide.md` - `docs/reference-values.md` - `tests/.helm/values.yaml` - diff --git a/docs/library-guide.md b/docs/library-guide.md index 2951636..37b935d 100644 --- a/docs/library-guide.md +++ b/docs/library-guide.md @@ -3,10 +3,14 @@ ## 1. Для кого этот документ Документ предназначен для: -- разработчиков, которые деплоят приложения в Kubernetes через Helm/werf; +- разработчиков, которые деплоят приложения в Kubernetes через Helm; - DevOps/SRE, которые поддерживают единый стандарт деплоя сервисов; - ревьюеров конфигураций `.helm/values.yaml`. +Примечание: библиотека полностью поддерживает `Helm` и совместима с `werf`. +Практически `werf` нередко удобнее в командной эксплуатации: меньше ручной склейки шагов между рендером и поставкой. +Но все возможности библиотеки остаются полностью доступными через `Helm`. + Если нужен быстрый старт, сначала прочитайте раздел `3`. Если нужен маршрут по документам, откройте `docs/README.md`. Если нужен полный справочник полей, смотрите `docs/reference-values.md`. @@ -47,8 +51,8 @@ 5. Проверить рендер: ```bash -werf helm dependency update .helm -werf render --env=prod --dev +helm dependency update .helm +helm template my-app .helm --set global.env=prod ``` ## 4. Базовая модель конфигурации @@ -338,7 +342,7 @@ Deckhouse-инфраструктурные сущности: 2. Делайте значения окружений через `_default` + target-env overrides. 3. Не храните business-логику в Helm-шаблонах сервисов. 4. Для сложных структур используйте raw YAML блоки и шаблоны из cookbook. -5. Прогоняйте `werf render` в CI на каждом merge request. +5. Прогоняйте `helm template` в CI на каждом merge request. 6. Проверяйте значения schema-валидатором. ## 17. Антипаттерны @@ -359,7 +363,7 @@ Deckhouse-инфраструктурные сущности: ```bash helm lint .helm -werf render --env=prod --dev +helm template my-app .helm --set global.env=prod ``` ## 19. Миграция на библиотеку (пошагово) diff --git a/docs/operations.md b/docs/operations.md index 0555b0f..2265042 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -46,7 +46,7 @@ helm lint tests/.helm --values tests/.helm/values.yaml Проверки: ```bash -werf render --env=prod --dev +helm template my-app .helm --set global.env=prod ``` Если рендер падает: @@ -82,12 +82,12 @@ werf render --env=prod --dev ## 3. Стандартные команды диагностики -### 3.1 Helm/Werf +### 3.1 Helm ```bash -werf helm dependency update .helm +helm dependency update .helm helm lint .helm -werf render --env=prod --dev +helm template my-app .helm --set global.env=prod ``` ### 3.2 Kubernetes runtime @@ -203,7 +203,7 @@ __GroupVars__: ## 5. Чеклист изменения values перед merge 1. Изменения проходят schema (`helm lint`). -2. Изменения рендерятся в target env (`werf render --env= --dev`). +2. Изменения рендерятся в target env (`helm template ... --set global.env=`). 3. Проверены include-конфликты и приоритет override. 4. Для env-ключей нет неоднозначных regex. 5. Для ingress/service проверены имена backend и порты. @@ -247,7 +247,7 @@ __GroupVars__: ## 9. Hardening practices -1. Обязательный `helm lint` + `werf render` в CI. +1. Обязательный `helm lint` + `helm template` в CI. 2. Обязательный code-review для include-профилей. 3. Запрет на “широкие” regex для env без необходимости. 4. Разделение common include-профилей по доменам: @@ -274,4 +274,3 @@ __GroupVars__: - Концепция: `docs/library-guide.md` - Reference: `docs/reference-values.md` - Cookbook: `docs/cookbook.md` - diff --git a/docs/reference-values.md b/docs/reference-values.md index b6abade..54e5189 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -27,7 +27,6 @@ - произвольные custom-группы с `__GroupVars__` Служебные ключи, которые могут появляться в merged values: -- `werf` - `helm-apps` ## 2. `global` From 01dda4331b3e6d54d9855c8bbe3209e61ad1c3ac Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 02:11:37 +0300 Subject: [PATCH 20/45] docs: add deep navigation map and tighten schema typing - Added parameter index with direct links from parameter names to reference sections and cookbook examples\n- Added use-case map (task -> parameter -> example -> checks) for faster onboarding and troubleshooting\n- Added anchors and cross-links across docs (top links, section navigation, back-to-index links)\n- Updated README/docs entry points to emphasize discoverability and fast navigation\n- Tightened values schema typing for string-based raw YAML fields and global.env semantics\n- Clarified merge/list behavior in docs to match actual library behavior --- README.md | 28 ++++++--- docs/README.md | 42 ++++++++----- docs/cookbook.md | 66 +++++++++++++++++++- docs/library-guide.md | 8 +++ docs/operations.md | 28 +++++++++ docs/parameter-index.md | 47 ++++++++++++++ docs/reference-values.md | 85 +++++++++++++++++++++++-- docs/use-case-map.md | 92 ++++++++++++++++++++++++++++ tests/.helm/values.schema.json | 109 ++++++++++++++++++--------------- 9 files changed, 426 insertions(+), 79 deletions(-) create mode 100644 docs/parameter-index.md create mode 100644 docs/use-case-map.md diff --git a/README.md b/README.md index 43df317..e1cce2a 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ - Единый стандарт деплоя для всех сервисов команды. - Меньше копипаста и ручных Kubernetes-манифестов. - Быстрее ревью: одинаковая структура конфигов между проектами. -- Переиспользование через `_include` и `global._includes`. -- Поддержка окружений (`_default`, env overrides, regex env keys). +- Переиспользование через [`_include`](docs/parameter-index.md#core) и [`global._includes`](docs/parameter-index.md#core). +- Поддержка окружений через [`global.env`](docs/parameter-index.md#core) (`_default`, env overrides, regex env keys). - Поддержка связанных ресурсов (Service, Ingress, ConfigMap, Secret, HPA, VPA, PDB и др.) в одной модели. ## Какие ресурсы поддерживаются @@ -108,6 +108,7 @@ apps-ingresses: enabled: true ``` + ## Ключевая механика: `global._includes` и рекурсивный merge `global._includes` — это библиотека переиспользуемых конфигурационных блоков. @@ -245,6 +246,7 @@ apps-stateless: - всегда проверяйте итоговый рендер в целевом env (`helm template ... --set global.env=`); - для критичных env-map лучше держать все нужные env-ключи явно в финальном профиле. + #### Пример 5: `_include`-списки конкатенируются Если include-профиль сам содержит `_include`, итоговый список объединяется. @@ -267,12 +269,16 @@ apps-stateless: Итоговый include-chain для `api` объединяет оба списка (`base-a` + `base-b`) и затем применяет локальные поля. -#### Пример 6: Осторожно со списками в include (кроме `_include`) +#### Пример 6: Что со списками -Для обычных YAML-массивов (не `_include`) merge может быть неочевидным. -Рекомендация: -- задавайте такие поля финально в более приоритетном include или локально в app; -- для сложных структур используйте проверку через `helm template`. +Важный нюанс библиотеки: +- специальные списки `_include` конкатенируются; +- обычные “списковые” параметры в большинстве случаев задаются как YAML-строки (`|`), а не как native list. + +Поэтому merge для обычных списков как list-поведение обычно не используется. +Практика: +- задавайте списковые Kubernetes-блоки строкой YAML; +- итог проверяйте через `helm template`. ### 5. Проверить рендер @@ -289,6 +295,8 @@ helm template my-app .helm --set global.env=prod Подробные документы: - Концепция и архитектура: [docs/library-guide.md](docs/library-guide.md) - Полный справочник полей: [docs/reference-values.md](docs/reference-values.md) +- Быстрый индекс параметров (описание + примеры): [docs/parameter-index.md](docs/parameter-index.md) +- Use-case карта (задача -> параметр -> пример -> проверка): [docs/use-case-map.md](docs/use-case-map.md) - Готовые шаблоны для типовых сценариев: [docs/cookbook.md](docs/cookbook.md) - Эксплуатация, triage, rollback: [docs/operations.md](docs/operations.md) - Краткие правила helper-паттернов: [docs/usage.md](docs/usage.md) @@ -298,6 +306,12 @@ helm template my-app .helm --set global.env=prod - JSON Schema валидации values: [tests/.helm/values.schema.json](tests/.helm/values.schema.json) - Готовый пример проекта: [docs/example](docs/example) +Быстрые ссылки на параметры: +- Индекс параметров: [docs/parameter-index.md](docs/parameter-index.md) +- `global.env`: [описание + пример](docs/parameter-index.md#core) +- `_include` / `global._includes`: [описание + примеры merge](docs/parameter-index.md#core) +- `containers` / `envVars` / `secretEnvVars`: [описание + примеры](docs/parameter-index.md#containers-envconfig) + ## Для контрибьюторов библиотеки При изменении возможностей библиотеки обновляйте синхронно: diff --git a/docs/README.md b/docs/README.md index 3433830..d90facf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,11 +9,13 @@ ## Быстрый маршрут (15 минут) -1. Прочитать концепцию и зачем библиотека нужна: `docs/library-guide.md` -2. Взять готовый шаблон под свой сценарий: `docs/cookbook.md` -3. Сверить поля и типы перед merge: `docs/reference-values.md` -4. Проверить values по schema: `tests/.helm/values.schema.json` -5. Сравнить с рабочими примерами: `tests/.helm/values.yaml` +1. Прочитать концепцию и зачем библиотека нужна: [docs/library-guide.md](library-guide.md) +2. Взять готовый шаблон под свой сценарий: [docs/cookbook.md](cookbook.md) +3. Сверить поля и типы перед merge: [docs/reference-values.md](reference-values.md) +4. Быстро найти нужный параметр и пример: [docs/parameter-index.md](parameter-index.md) +5. Найти решение по задаче: [docs/use-case-map.md](use-case-map.md) +6. Проверить values по schema: [tests/.helm/values.schema.json](../tests/.helm/values.schema.json) +7. Сравнить с рабочими примерами: [tests/.helm/values.yaml](../tests/.helm/values.yaml) ## Как читать документацию по роли @@ -21,29 +23,37 @@ 1. `docs/cookbook.md` 2. `docs/reference-values.md` -3. `docs/operations.md` (разделы triage и частые ошибки) +3. `docs/parameter-index.md` (быстрый переход по параметрам) +4. `docs/use-case-map.md` (карта решений по задачам) +5. `docs/operations.md` (разделы triage и частые ошибки) ### DevOps / Platform Engineer 1. `docs/library-guide.md` 2. `docs/reference-values.md` -3. `docs/operations.md` +3. `docs/parameter-index.md` +4. `docs/use-case-map.md` +5. `docs/operations.md` ### Ревьюер MR с изменениями `.helm/values.yaml` 1. `docs/reference-values.md` -2. `docs/operations.md` (чеклисты merge/release) -3. `tests/.helm/values.schema.json` +2. `docs/parameter-index.md` +3. `docs/use-case-map.md` +4. `docs/operations.md` (чеклисты merge/release) +5. `tests/.helm/values.schema.json` ## Карта документов -- Архитектура и принципы: `docs/library-guide.md` -- Полный справочник полей: `docs/reference-values.md` -- Готовые практические рецепты: `docs/cookbook.md` -- Эксплуатация, triage, rollback: `docs/operations.md` -- Краткие правила по helper-паттернам: `docs/usage.md` -- Полный рабочий пример values: `tests/.helm/values.yaml` -- Schema валидации values: `tests/.helm/values.schema.json` +- Архитектура и принципы: [docs/library-guide.md](library-guide.md) +- Полный справочник полей: [docs/reference-values.md](reference-values.md) +- Индекс параметров с примерами: [docs/parameter-index.md](parameter-index.md) +- Карта use-cases: [docs/use-case-map.md](use-case-map.md) +- Готовые практические рецепты: [docs/cookbook.md](cookbook.md) +- Эксплуатация, triage, rollback: [docs/operations.md](operations.md) +- Краткие правила по helper-паттернам: [docs/usage.md](usage.md) +- Полный рабочий пример values: [tests/.helm/values.yaml](../tests/.helm/values.yaml) +- Schema валидации values: [tests/.helm/values.schema.json](../tests/.helm/values.schema.json) ## Минимальный командный чеклист diff --git a/docs/cookbook.md b/docs/cookbook.md index c084129..57d79ff 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1,9 +1,26 @@ # Helm Apps Library Cookbook + Готовые рецепты для типовых сценариев. Все примеры можно адаптировать под ваш `global._includes`. +Быстрая навигация: +- [Старт docs](README.md) +- [Parameter Index](parameter-index.md) +- [Reference](reference-values.md) + +Оглавление (часто используемое): +- [1. Базовый HTTP API](#1-базовый-http-api-stateless) +- [2. API + Ingress + TLS](#2-api--ingress--tls) +- [4. CronJob](#4-cronjob) +- [6. Секреты через secretEnvVars](#6-секреты-через-secretenvvars) +- [9. configFilesYAML](#9-yaml-конфиг-с-env-override-configfilesyaml) +- [10. HPA](#10-hpa-для-api) +- [11. ServiceAccount + ClusterRole](#11-serviceaccount--clusterrole) +- [20. Как использовать cookbook](#20-как-использовать-cookbook) + ## 1. Базовый HTTP API (stateless) + ```yaml apps-stateless: @@ -39,7 +56,11 @@ apps-stateless: targetPort: 8080 ``` +Параметры: [containers](reference-values.md#param-containers), [service](reference-values.md#param-service), [envVars](reference-values.md#param-envvars) +Навигация: [Parameter Index](parameter-index.md#workload) | [Наверх](#top) + ## 2. API + Ingress + TLS + ```yaml apps-ingresses: @@ -59,6 +80,9 @@ apps-ingresses: enabled: true ``` +Параметры: [ingress](reference-values.md#param-ingress), [global.env](reference-values.md#param-global-env) +Навигация: [Parameter Index](parameter-index.md#networking-and-scaling) | [Наверх](#top) + ## 3. Worker без Service ```yaml @@ -79,6 +103,7 @@ apps-stateless: ``` ## 4. CronJob + ```yaml apps-cronjobs: @@ -96,6 +121,9 @@ apps-cronjobs: LOG_LEVEL: info ``` +Параметры: [containers](reference-values.md#param-containers), [global._includes/_include](reference-values.md#param-global-includes) +Навигация: [Parameter Index](parameter-index.md#core) | [Наверх](#top) + ## 5. One-shot Job (migration) ```yaml @@ -113,6 +141,7 @@ apps-jobs: ``` ## 6. Секреты через `secretEnvVars` + ```yaml apps-stateless: @@ -130,7 +159,11 @@ apps-stateless: production: prod-secret ``` +Параметры: [secretEnvVars](reference-values.md#param-secretenvvars) +Навигация: [Parameter Index](parameter-index.md#containers-envconfig) | [Наверх](#top) + ## 7. Из внешнего Secret через `fromSecretsEnvVars` + ```yaml apps-stateless: @@ -147,7 +180,11 @@ apps-stateless: APP_API_TOKEN: api_token ``` +Параметры: [fromSecretsEnvVars](reference-values.md#param-fromsecretsenvvars) +Навигация: [Parameter Index](parameter-index.md#containers-envconfig) | [Наверх](#top) + ## 8. Файлы конфигурации (ConfigMap mount) + ```yaml apps-stateless: @@ -166,7 +203,11 @@ apps-stateless: events { worker_connections 1024; } ``` +Параметры: [configFiles](reference-values.md#param-configfiles) +Навигация: [Parameter Index](parameter-index.md#containers-envconfig) | [Наверх](#top) + ## 9. YAML-конфиг с env override (`configFilesYAML`) + ```yaml apps-stateless: @@ -191,7 +232,11 @@ apps-stateless: production: 300 ``` +Параметры: [configFilesYAML](reference-values.md#param-configfilesyaml), [global.env](reference-values.md#param-global-env) +Навигация: [Parameter Index](parameter-index.md#containers-envconfig) | [Наверх](#top) + ## 10. HPA для API + ```yaml apps-stateless: @@ -221,7 +266,11 @@ apps-stateless: averageUtilization: 80 ``` +Параметры: [horizontalPodAutoscaler](reference-values.md#param-hpa), [hpa.metrics](reference-values.md#param-hpa-metrics) +Навигация: [Parameter Index](parameter-index.md#networking-and-scaling) | [Наверх](#top) + ## 11. ServiceAccount + ClusterRole + ```yaml apps-stateless: @@ -244,7 +293,11 @@ apps-stateless: verbs: ["get"] ``` +Параметры: [serviceAccount](reference-values.md#param-serviceaccount) +Навигация: [Parameter Index](parameter-index.md#workload) | [Наверх](#top) + ## 12. Stateful сервис с PVC + ```yaml apps-stateful: @@ -329,6 +382,7 @@ payment: ``` ## 16. Рецепт с `_default` + regex env + ```yaml apps-stateless: @@ -349,6 +403,9 @@ apps-stateless: "^dev-.*$": "true" ``` +Параметры: [global.env](reference-values.md#param-global-env), [envVars](reference-values.md#param-envvars) +Навигация: [Parameter Index](parameter-index.md#core) | [Наверх](#top) + ## 17. apps-infra: NodeUser ```yaml @@ -406,6 +463,9 @@ apps-custom-prometheus-rules: 5. Прогоните `helm template` с нужным окружением через `global.env`. Связанные документы: -- `docs/library-guide.md` -- `docs/reference-values.md` -- `tests/.helm/values.yaml` +- [docs/library-guide.md](library-guide.md) +- [docs/reference-values.md](reference-values.md) +- [docs/parameter-index.md](parameter-index.md) +- [tests/.helm/values.yaml](../tests/.helm/values.yaml) + +Навигация: [Наверх](#top) diff --git a/docs/library-guide.md b/docs/library-guide.md index 37b935d..99b19ca 100644 --- a/docs/library-guide.md +++ b/docs/library-guide.md @@ -1,4 +1,12 @@ # Helm Apps Library Handbook + + +Быстрая навигация: +- [Старт docs](README.md) +- [Parameter Index](parameter-index.md) +- [Reference](reference-values.md) +- [Cookbook](cookbook.md) +- [Operations](operations.md) ## 1. Для кого этот документ diff --git a/docs/operations.md b/docs/operations.md index 2265042..14f3dac 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,4 +1,5 @@ # Helm Apps Library Operations Playbook + Документ для эксплуатации и поддержки деплоев на `helm-apps`: - как быстро диагностировать проблемы; @@ -6,6 +7,19 @@ - какие команды и чеклисты использовать в CI/CD и при релизах; - как откатываться безопасно. +Быстрая навигация: +- [Старт docs](README.md) +- [Parameter Index](parameter-index.md) +- [Reference](reference-values.md) + +Оглавление: +- [2. Быстрый triage](#2-быстрый-triage-по-слоям) +- [3. Команды диагностики](#3-стандартные-команды-диагностики) +- [4. Частые ошибки](#4-частые-ошибки-и-что-делать) +- [5. Чеклист перед merge](#5-чеклист-изменения-values-перед-merge) +- [6. Чеклист релиза](#6-чеклист-релиза) +- [7. Rollback стратегия](#7-rollback-стратегия) + ## 1. Operational Mindset При инцидентах действуйте в порядке: @@ -80,6 +94,8 @@ helm template my-app .helm --set global.env=prod - ingress controller events; - метрики HPA/VPA. +Навигация: [Наверх](#top) + ## 3. Стандартные команды диагностики ### 3.1 Helm @@ -107,6 +123,8 @@ kubectl -n get endpoints kubectl -n describe ingress ``` +Навигация: [Наверх](#top) + ## 4. Частые ошибки и что делать ## 4.1 Ошибка schema: `Invalid type` @@ -200,6 +218,8 @@ __GroupVars__: 2. Проверить policy. 3. Согласовать autoscaling стратегию. +Навигация: [Reference](reference-values.md) | [Parameter Index](parameter-index.md) | [Наверх](#top) + ## 5. Чеклист изменения values перед merge 1. Изменения проходят schema (`helm lint`). @@ -210,6 +230,8 @@ __GroupVars__: 6. Для секретов исключены plaintext утечки в git (используйте `secret-values` или внешние хранилища). 7. Для HPA/VPA согласованы min/max/updateMode и metrics. +Навигация: [Наверх](#top) + ## 6. Чеклист релиза 1. Подтянуты зависимости чарта. @@ -221,6 +243,8 @@ __GroupVars__: - ServiceAccount/RBAC. 4. Подготовлен rollback-план. +Навигация: [Наверх](#top) + ## 7. Rollback стратегия При регрессии: @@ -232,6 +256,8 @@ __GroupVars__: - держите small-batch изменения в values; - не смешивайте в одном MR массовый refactor и функциональные изменения. +Навигация: [Наверх](#top) + ## 8. Incident response шаблон Минимальный протокол: @@ -274,3 +300,5 @@ __GroupVars__: - Концепция: `docs/library-guide.md` - Reference: `docs/reference-values.md` - Cookbook: `docs/cookbook.md` + +Навигация: [Наверх](#top) diff --git a/docs/parameter-index.md b/docs/parameter-index.md new file mode 100644 index 0000000..a32dd63 --- /dev/null +++ b/docs/parameter-index.md @@ -0,0 +1,47 @@ +# Helm Apps: Parameter Index + +Быстрые переходы по параметрам: описание + рабочий пример. + +## Core + +| Параметр | Описание | Пример | +|---|---|---| +| `global.env` | [Описание](reference-values.md#param-global-env) | [Пример](cookbook.md#example-global-env) | +| `global._includes` | [Описание](reference-values.md#param-global-includes) | [Пример](../README.md#example-global-includes-merge) | +| `_include` | [Описание](reference-values.md#param-include) | [Пример](../README.md#example-include-concat) | + +## Workload + +| Параметр | Описание | Пример | +|---|---|---| +| `containers` | [Описание](reference-values.md#param-containers) | [Пример](cookbook.md#example-basic-api) | +| `service` | [Описание](reference-values.md#param-service) | [Пример](cookbook.md#example-basic-api) | +| `podDisruptionBudget` | [Описание](reference-values.md#param-pdb) | [Пример](../tests/.helm/values.yaml) | +| `serviceAccount` | [Описание](reference-values.md#param-serviceaccount) | [Пример](cookbook.md#example-serviceaccount) | + +## Containers Env/Config + +| Параметр | Описание | Пример | +|---|---|---| +| `envVars` | [Описание](reference-values.md#param-envvars) | [Пример](cookbook.md#example-basic-api) | +| `secretEnvVars` | [Описание](reference-values.md#param-secretenvvars) | [Пример](cookbook.md#example-secretenvvars) | +| `fromSecretsEnvVars` | [Описание](reference-values.md#param-fromsecretsenvvars) | [Пример](cookbook.md#example-fromsecretsenvvars) | +| `envYAML` | [Описание](reference-values.md#param-envyaml) | [Пример](../tests/.helm/values.yaml) | +| `configFiles` | [Описание](reference-values.md#param-configfiles) | [Пример](cookbook.md#example-configfiles) | +| `configFilesYAML` | [Описание](reference-values.md#param-configfilesyaml) | [Пример](cookbook.md#example-configfilesyaml) | + +## Networking and Scaling + +| Параметр | Описание | Пример | +|---|---|---| +| `ingress` (`host/paths/tls`) | [Описание](reference-values.md#param-ingress) | [Пример](cookbook.md#example-ingress-tls) | +| `verticalPodAutoscaler` | [Описание](reference-values.md#param-vpa) | [Пример](../tests/.helm/values.yaml) | +| `horizontalPodAutoscaler` | [Описание](reference-values.md#param-hpa) | [Пример](cookbook.md#example-hpa) | +| `horizontalPodAutoscaler.metrics` | [Описание](reference-values.md#param-hpa-metrics) | [Пример](cookbook.md#example-hpa) | + +## Related Docs + +- Общая концепция: [library-guide.md](library-guide.md) +- Полный референс: [reference-values.md](reference-values.md) +- Практические рецепты: [cookbook.md](cookbook.md) +- Операционная эксплуатация: [operations.md](operations.md) diff --git a/docs/reference-values.md b/docs/reference-values.md index 54e5189..0c8b075 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -1,8 +1,25 @@ # Helm Apps Library: Reference по values + Документ описывает практический референс структуры `values.yaml`. Он дополняет `docs/library-guide.md` и должен читаться вместе с ним. +Быстрая навигация: +- [Старт docs](README.md) +- [Handbook](library-guide.md) +- [Cookbook](cookbook.md) +- [Parameter Index](parameter-index.md) + +Оглавление: +- [1. Top-level ключи](#1-top-level-ключи) +- [2. global](#2-global) +- [5. containers / initContainers](#5-containers--initcontainers) +- [8. Config files](#8-config-files) +- [9. Service block](#9-service-block) +- [10. Ingress block](#10-ingress-block) +- [11. Autoscaling blocks](#11-autoscaling-blocks) +- [17. Cheat sheet](#17-тип-поля---поведение-рендера-cheat-sheet) + ## 1. Top-level ключи Поддерживаемые секции: @@ -30,6 +47,7 @@ - `helm-apps` ## 2. `global` + Типичные поля: - `env`: текущее окружение (`dev`, `prod`, `production`, etc.); @@ -50,6 +68,8 @@ global: ``` ### 2.1 `global._includes` + `_include`: примеры merge + + Ниже примеры, как библиотека объединяет include-профили. @@ -136,6 +156,8 @@ apps-stateless: Вывод: для env-map обязательно проверяйте финальный рендер в нужном окружении. +Навигация: [Parameter Index](parameter-index.md#core) | [Наверх](#top) + #### Пример E: `_include`-списки конкатенируются ```yaml @@ -154,6 +176,10 @@ apps-stateless: Итоговый include-chain для приложения объединяет `base-a` и `base-b`. +Важно: +- это поведение относится к служебному ключу `_include`; +- обычные списковые параметры библиотеки, как правило, задаются строковым YAML-блоком (`|`), поэтому их merge как native list обычно не применяется. + ## 3. Общая форма приложения в `apps-*` ```yaml @@ -228,6 +254,7 @@ Stateful-specific: - `failedJobsHistoryLimit` ## 5. `containers` / `initContainers` + Форма: @@ -245,6 +272,10 @@ containers: ``` Поддерживаемые поля контейнера: + + + + - `enabled` - `name` - `image.name` @@ -272,7 +303,10 @@ containers: - `secretConfigFiles` - `persistantVolumes` +Навигация: [Parameter Index](parameter-index.md#containers-envconfig) | [Наверх](#top) + ## 6. Env-паттерн + Любое поле, поддерживающее env-map: @@ -306,6 +340,8 @@ resources: Поддержка env-map также применима к этим полям. ## 8. Config files + + ### 8.1 `configFiles` @@ -338,7 +374,10 @@ secretConfigFiles: content: super-secret ``` +Навигация: [Parameter Index](parameter-index.md#containers-envconfig) | [Наверх](#top) + ## 9. Service block + Используется: - как nested `service` у workload; @@ -354,7 +393,10 @@ secretConfigFiles: - `sessionAffinity` - `annotations` +Навигация: [Parameter Index](parameter-index.md#workload) | [Наверх](#top) + ## 10. Ingress block + `apps-ingresses.`: - `class` @@ -370,7 +412,11 @@ secretConfigFiles: - `enabled` - `clusterDomain` +Навигация: [Parameter Index](parameter-index.md#networking-and-scaling) | [Наверх](#top) + ## 11. Autoscaling blocks + + ### 11.1 `verticalPodAutoscaler` @@ -386,6 +432,7 @@ secretConfigFiles: - `behavior` - `metrics` - `customMetricResources` + `customMetricResources.`: - `enabled` @@ -393,7 +440,10 @@ secretConfigFiles: - `name` (optional) - `query` +Навигация: [Parameter Index](parameter-index.md#networking-and-scaling) | [Наверх](#top) + ## 12. `podDisruptionBudget` + Поля: - `enabled` @@ -401,6 +451,7 @@ secretConfigFiles: - `minAvailable` ## 13. `serviceAccount` + Поля: - `enabled` @@ -411,6 +462,8 @@ secretConfigFiles: - `name` - `rules` +Навигация: [Parameter Index](parameter-index.md#workload) | [Наверх](#top) + ## 14. Прочие `apps-*` секции ### 14.1 `apps-configmaps` @@ -535,7 +588,31 @@ group-name: ## 16. Полезные ссылки -- Общая концепция: `docs/library-guide.md` -- Практические рецепты: `docs/cookbook.md` -- Рабочие примеры: `tests/.helm/values.yaml` -- JSON Schema: `tests/.helm/values.schema.json` +- Общая концепция: [docs/library-guide.md](library-guide.md) +- Практические рецепты: [docs/cookbook.md](cookbook.md) +- Индекс параметров: [docs/parameter-index.md](parameter-index.md) +- Рабочие примеры: [tests/.helm/values.yaml](../tests/.helm/values.yaml) +- JSON Schema: [tests/.helm/values.schema.json](../tests/.helm/values.schema.json) + +## 17. Тип поля -> поведение рендера (cheat sheet) + +Ниже быстрый справочник по самым частым типам полей. + +| Поле/группа | Ожидаемый тип в values | Как используется при рендере | +|---|---|---| +| `_include` | `array[string]` | Конкатенируется между include-профилями, затем применяется merge. +| `global.env` | `string` | Выбирает env-значение из map (`_default`, `production`, regex). +| `replicas`, `enabled`, `werfWeight`, `priorityClassName` | scalar или env-map scalar | Резолвится через `fl.value` как скаляр. +| `envVars.` / `secretEnvVars.` | scalar или env-map scalar | Рендерится как env var value. +| `command`, `args`, `ports`, `envFrom`, `affinity`, `tolerations`, `nodeSelector`, `volumes`, `paths`, `rules`, `resourcePolicy` | string или env-map string | Обычно передаются как YAML block string (`|`) и вставляются в манифест. +| `horizontalPodAutoscaler.metrics` | string или object | Поддерживает 2 режима: raw YAML строка или map-конфиг метрик. +| `configFiles..content` | string (обычно) | Контент ConfigMap/файла. +| `configFilesYAML..content` | object | Рекурсивно обрабатывается как YAML-дерево (с `_default` в узлах). +| `apps-*..data` / `binaryData` (ConfigMap/Secret) | string или object | Для ConfigMap/Secret может быть raw YAML string или map. + +Практика: +- если поле описано как Kubernetes-блок, используйте YAML строку (`|`); +- для env-значений используйте scalar/env-map; +- итог всегда проверяйте через `helm template ... --set global.env=`. + +Навигация: [Parameter Index](parameter-index.md) | [Наверх](#top) diff --git a/docs/use-case-map.md b/docs/use-case-map.md new file mode 100644 index 0000000..86e3892 --- /dev/null +++ b/docs/use-case-map.md @@ -0,0 +1,92 @@ +# Helm Apps: Use-Case Map + + +Карта для быстрого выбора решения: +- что нужно сделать; +- какие параметры использовать; +- где взять рабочий пример; +- что проверить перед merge/release. + +## Быстрая навигация + +- [Старт docs](README.md) +- [Parameter Index](parameter-index.md) +- [Reference](reference-values.md) +- [Cookbook](cookbook.md) +- [Operations](operations.md) + +## 1. Нужен обычный HTTP/API сервис + +- Параметры: [containers](reference-values.md#param-containers), [service](reference-values.md#param-service) +- Пример: [Базовый HTTP API](cookbook.md#example-basic-api) +- Проверки: `helm lint`, `helm template ... --set global.env=` + +## 2. Нужен внешний доступ через Ingress + TLS + +- Параметры: [ingress](reference-values.md#param-ingress), [global.env](reference-values.md#param-global-env) +- Пример: [API + Ingress + TLS](cookbook.md#example-ingress-tls) +- Проверки: backend service/port, ingress class, tls secret/certificate +- Ops: [Ingress 404/502](operations.md#46-ingress-есть-но-404502) + +## 3. Нужен CronJob или Job + +- Параметры: [containers](reference-values.md#param-containers), [global._includes/_include](reference-values.md#param-global-includes) +- Пример: [CronJob](cookbook.md#example-cronjob) +- Проверки: schedule, backoffLimit, restartPolicy, image tag + +## 4. Нужны секреты в env + +- Параметры: [secretEnvVars](reference-values.md#param-secretenvvars), [fromSecretsEnvVars](reference-values.md#param-fromsecretsenvvars) +- Примеры: + - [secretEnvVars](cookbook.md#example-secretenvvars) + - [fromSecretsEnvVars](cookbook.md#example-fromsecretsenvvars) +- Проверки: отсутствие plaintext в git, корректность ключей в Secret + +## 5. Нужны файловые конфиги в контейнере + +- Параметры: [configFiles](reference-values.md#param-configfiles), [configFilesYAML](reference-values.md#param-configfilesyaml) +- Примеры: + - [configFiles](cookbook.md#example-configfiles) + - [configFilesYAML](cookbook.md#example-configfilesyaml) +- Проверки: mountPath, формат content, итог рендера ConfigMap/Secret + +## 6. Нужен HPA/VPA + +- Параметры: [horizontalPodAutoscaler](reference-values.md#param-hpa), [hpa.metrics](reference-values.md#param-hpa-metrics), [verticalPodAutoscaler](reference-values.md#param-vpa) +- Пример: [HPA для API](cookbook.md#example-hpa) +- Проверки: min/max, metrics, updateMode, conflicts HPA vs VPA +- Ops: [HPA не скейлит](operations.md#47-hpa-не-скейлит), [VPA не влияет](operations.md#48-vpa-не-влияет-на-pods) + +## 7. Нужен ServiceAccount и RBAC + +- Параметры: [serviceAccount](reference-values.md#param-serviceaccount) +- Пример: [ServiceAccount + ClusterRole](cookbook.md#example-serviceaccount) +- Проверки: role rules, binding namespace, права на нужные API + +## 8. Нужны разные значения для разных окружений + +- Параметры: [global.env](reference-values.md#param-global-env), [_include](reference-values.md#param-include), [global._includes](reference-values.md#param-global-includes) +- Пример: [env recipe](cookbook.md#example-global-env) +- Проверки: + - env задается через `global.env`; + - нет конфликтных regex ключей; + - финальный рендер проверен в каждом target env. + +## 9. Нужно переиспользование и минимум дублирования + +- Параметры: [global._includes](reference-values.md#param-global-includes), [_include](reference-values.md#param-include) +- Пример merge: [README merge section](../README.md#example-global-includes-merge) +- Проверки: + - порядок include осознанный; + - локальные overrides минимальны и понятны; + - финальный рендер совпадает с ожиданием. + +## 10. Быстрый pre-merge чеклист + +1. Сверить параметры в [Parameter Index](parameter-index.md). +2. Прогнать `helm lint`. +3. Прогнать `helm template ... --set global.env=`. +4. Проверить соответствующий раздел в [Operations](operations.md). + +Навигация: [Наверх](#top) + diff --git a/tests/.helm/values.schema.json b/tests/.helm/values.schema.json index e904ada..9cc24c2 100644 --- a/tests/.helm/values.schema.json +++ b/tests/.helm/values.schema.json @@ -71,6 +71,20 @@ "scalar": { "type": ["string", "number", "integer", "boolean", "null"] }, + "envStringValue": { + "description": "Строка или map по окружениям, где значения строковые.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ] + }, "yamlScalar": { "oneOf": [ { @@ -90,13 +104,10 @@ { "$ref": "#/$defs/scalar" }, - { - "type": "array" - }, { "type": "object", "additionalProperties": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/scalar" } } ] @@ -182,10 +193,10 @@ "$ref": "#/$defs/image" }, "command": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "args": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "envVars": { "$ref": "#/$defs/envMap" @@ -207,37 +218,37 @@ } }, "env": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "envFrom": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "resources": { "$ref": "#/$defs/resources" }, "lifecycle": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "livenessProbe": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "readinessProbe": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "startupProbe": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "securityContext": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "volumeMounts": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "volumes": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "ports": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "configFiles": { "type": "object", @@ -272,7 +283,7 @@ "$ref": "#/$defs/envValue" }, "accessModes": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" } }, "required": ["mountPath", "size", "storageClass"], @@ -292,7 +303,7 @@ "$ref": "#/$defs/envValue" }, "resourcePolicy": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" } }, "additionalProperties": true @@ -310,7 +321,7 @@ "$ref": "#/$defs/envValue" }, "behavior": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "metrics": { "$ref": "#/$defs/yamlScalar" @@ -368,13 +379,13 @@ "$ref": "#/$defs/envValue" }, "ports": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "selector": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "annotations": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" } }, "additionalProperties": true @@ -395,7 +406,7 @@ "$ref": "#/$defs/envValue" }, "rules": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" } }, "required": ["name", "rules"], @@ -444,13 +455,13 @@ "$ref": "#/$defs/envValue" }, "annotations": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "labels": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "selector": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "containers": { "type": "object", @@ -507,22 +518,22 @@ "$ref": "#/$defs/envValue" }, "affinity": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "tolerations": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "nodeSelector": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "topologySpreadConstraints": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "volumes": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "imagePullSecrets": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "ingressClassName": { "$ref": "#/$defs/envValue" @@ -534,10 +545,10 @@ "$ref": "#/$defs/envValue" }, "hosts": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "paths": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "tls": { "$ref": "#/$defs/tls" @@ -558,25 +569,25 @@ "$ref": "#/$defs/envValue" }, "data": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "binaryData": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "envVars": { "$ref": "#/$defs/envMap" }, "limits": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "storageClassName": { "$ref": "#/$defs/envValue" }, "accessModes": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "resources": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "clusterIssuer": { "$ref": "#/$defs/envValue" @@ -589,7 +600,7 @@ "$ref": "#/$defs/envValue" }, "redirectURIs": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "applicationDomain": { "$ref": "#/$defs/envValue" @@ -601,7 +612,7 @@ "$ref": "#/$defs/envValue" }, "allowedGroups": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "sendAuthorizationHeader": { "$ref": "#/$defs/envValue" @@ -658,10 +669,10 @@ "type": "string" }, "_preRenderGroupHook": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "_preRenderAppHook": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" } }, "required": ["type"], @@ -698,22 +709,22 @@ "$ref": "#/$defs/envValue" }, "sshPublicKeys": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "extraGroups": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "nodeGroups": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "isSudoer": { "$ref": "#/$defs/envValue" }, "labels": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" }, "annotations": { - "$ref": "#/$defs/yamlScalar" + "$ref": "#/$defs/envStringValue" } }, "required": ["uid"], @@ -751,7 +762,7 @@ "$ref": "#/$defs/envValue" }, "env": { - "$ref": "#/$defs/envValue" + "type": "string" }, "_includes": { "type": "object", From 32e52402115f511bf4043d022c521559d0db021a Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 03:06:35 +0300 Subject: [PATCH 21/45] fix(schema): allow null env values and add chart icon assets --- charts/helm-apps/Chart.yaml | 1 + docs/assets/icon.png | Bin 0 -> 236726 bytes docs/assets/icon.svg | 51 +++++++++++++++++++++++++++++++++ tests/.helm/values.schema.json | 5 +++- 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 docs/assets/icon.png create mode 100644 docs/assets/icon.svg diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 73dfccf..6bce3e1 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -3,6 +3,7 @@ name: helm-apps description: A Helm applications library type: library version: 1.3.2 +icon: https://raw.githubusercontent.com/alvnukov/helm-apps/main/docs/assets/icon.png maintainers: - name: alvnukov url: https://github.com/alvnukov diff --git a/docs/assets/icon.png b/docs/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7d0d73b2c22e6256b717c17a4f1fc59b57295cf9 GIT binary patch literal 236726 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�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 vxVA(4 zijOrL(dUP){WFeHUY19Mrax$3IexOdL;q&?b^0fFji){1X~)a)eFT0O|BUeNhdnG( zlD;yBh$*f;K7&twzw_P?9dG^cM~^Sx|26BaTwd#PffBXH#h(O9OrNU~Vy=vFdLDQR zV=Q96Kl;p&UY}gpYdpumOa}fK-sj`T@&42^zwLPRL;Z)K!G3k=`d#w_U;5JVhrj<; zeO3Sa-}?b7Sg#ekJ;kUMx`t*p%lTva-o%UY5VF-Dy?dv&g3 z#8o_GD#xWT@fX;BCM-vN!O3KjHT(__C0T(KJ&%4(dBY3qBS)T0bJe%R}s2;KY{GwbxJ*4m|Hu>UvL1wNHz94VJU?>qfJn z_EO)J#vs=9hHm&ihZ0~(OMo827AHmOMh$*I7q;3@0exI`u-;R_pz?OzKjj##5sle% zKF7}ciMi?@o}!<3ePYO^5s$(~GO)~#-1MgNt8#H9kb8ywQ@-sf$II}Y1)qm+EOfX2 zD|i;T^?vnTS%>HB=keFyzk~lZ@OvNp$nhoobGMZ*Z`(aZHX0z_-dSNk%8jPVv!1M| zo9AOnVOZ)Q2ixYu)zG`TfW zSGk0*<|amKpd>N|#>~Xb1xjMV@|^&E(^?MGRp@1`H(3#jn-CtRlKa%*N=HkT7Z}~6 zd6jF`4^!pY!*5bZryo{%<7%BuYq;_B*X!5?q+_g!scklob88*QH3OX^=PD7DJo@Cq z9J^PZJI5V}F3--+H6t9sbt49cfX*YPv$6o9G&;r|>oTG%Gve#417Z*#vrb#T@d-8a zwT^#koAr!G@0{1kkf3(uapL>}6TrM!CQps+b?9wRQzx5bjCf;{<2>>!T;dRT;**|u zy!01;;duT}{lsz4KX^DIqq&M#)zww;e*xdW{>}K#g8$_MA3DCs?`5Ai4iwSj;>C{J z9+exxpy}t@xEdc&pXqTt?4En@iT^3b|9bCJjz`?)UqqxQ`(-WN*xdicFCKsJJHLDU z=CA&n<0JT<{eGWYDBbb8e_2PjwAPy;rwm#PkDd!8uHC&w1_h-cNqjt@}S? zIX4LtVJ?LqK9;XK2SL1!PwrY^{34X@E^*?#W+_Mfo7u5>o$+}V=`!<))xm=%uV*rj zbqpY?{!v{1v-NEKWN5Eg#^a0$Gjj}-z?=P7!J4$ny_){k8f+#EJ!&{gm3$Z-9-fK4 z5=NZIh5t@qQ9T;J-+&Nod<>R~%2$B9s%L)x8-T8$XpRIhXGyEMK?e0Te#oz(8Xtg4 zD&FNzjhn*)Zjr$6u2FkGIF8pE*!9VRm81TgD>AnW{feUXRLD? z_3OvSKK5~>g31BHUEK@y*mMVj6RxwAHv${yoN3p8_46JDl3JFj`%Sru6L&dsGyMu& z%79EG6(z&XTFWCtcjRBd#S8xKel~!N+RqZMG15J?j9n=wgEGton|0>dB(cnQ;$IjE zU=>Hyaf!9UxLm9qYh4>|<+iZ+_e1E4lPx`T96nFUo)OqYM zrA;(^wq|m~pDnQ^UbG_jO_*`vOIRGNqi~h$y`Bb|Hx1Q^{edOfD5>_d+!za3>+ENF zagokTua^Jya1r1)0(!Q9)`|V}!nNCtdiAYNbptLQBGcl&RDJx4{RaP4qn2}R0n$-& z`rN2r8f{vBmVT^GagiW)%|A))%5UK8)&8MBUM!~s)^w--DTl1O|613jbZV!yl~995 z^|{!W;zD6^9i2SpTOND7 zNxQuN3>TFNOJ6zJayvFv6Nxp2DO7RogJCn~>wd7K?i22a!sj^QeEtRG#e2@$b$O{i zPEp|x002M$NklGIdJccp( z%oE2LfBVJdb`{;UhL|YlEBJ|bzmKmEc+-16c<>7cIi^f~vg{Y1(mp$#4UzlkPp&y< zeByuf!ycmlzyJKFJ^6UZ4L=|$J;TW3;>bOrs4so?v&WnM!+$#dRzsgj#WPF+%rZBs9fxD=E%LecCYuCX@nhqb)@O#fK6B38SjRw}u^-Ya z`Npv^@+C^hI`C2D8{g18^VGD~eP+ZlHOA&2@yVUFYqDeUTrV=qb8fDMR~XS2Q)}NW z$8!U&#Ae62)`Q=Y$}{)jDtW2l%wc_feC;SWVzO3E?u8PFlt%I>1hhV<70&#FLCf;Y zf)-E=;m<76>ciNrMsooaXkt~z@czF5lrU=cWVrXNlD^4yssZK<%bWN)K3cj4RQ6`% z>XOQeS7JulRR>O{Byy^~SI32u9Xupmb0Q`@bP>b(8gF_CzkfozGynWV1@cq9w6SG+ zO1%QoZO`1_f&bFj=OotUU!PF(aLK1=NxqMnsDDs4vX7jAz)UBYu^)Yi{tL=A6Y1 z(#e}RcoZJ^>@yDmZ6G<$B(_SoSbmJ6evWH3s&6la5)f4tpZ?h=HJ9%{Rikd>LjUCA zxUyC|XLZd!bNu^%0kl}8g;+u@SY2>gdHt!p!nZA_+Zw_81qbVDKW}7XhhKVs-^%WO!L=AtrQP=&8hGch8?tT!Zkyh;=QF`fzpx21uaD2{H zykU8x$`?q5E&W|r_3YjJ!BU6`bguhDgap#ziFV;xR`)`Ctff&mvb@xqxYj>wL_hSG zXY#GH$eDOnubJ0DetrJMK7En2bV;%$jo9fE1Mz47jQOyfn}9lhp8Xmouun$o9#a5n zzv-JEalH6tFF9WL@|PZu_~ysx{8p`3Im*epV;;~q5&CcU{mt=)cfbGOHy4^C&&sB& z`V*ID<*^UvC;n#~Kk}5vANSnwiGONY$5&Bt9>?cD{i)*(ul>#A_4xSQ=f0p0erDXQ zpC9fn@;KJpXK)oKo87e8l9ly5>Kz z(rYdf)LAjk(H^aD#`>9N@yY+ZS#II_`16gL{y|YK$(H5me*v^iVvM3d!o`dUb3%46 zq|*|bx*e->X&eV!`y43^k5*VNEEB#lC1l%MLvEZ%O5Cb324#JgTFB7SEL1RaW^WYYJVNui>xm z`CSFyh`$l|o6mg_^($vW>~Uauv*G-B{40f@{*I^N+x9*I-^^zokUPz5y3ScD(f4zo z{M7OKfBPH9f5w0He*s_N&v-t8$8io&w^1)Fve(UWQ2XRvABg!_!7=ZD?rnT2k4rIo zwsod7;vsv^m$5Jem3`*KInP+FhbOUXA1J$V4eT=DJ>#WU$0WKsPnIw}%SViK9P!d~ z@j2@}*9BKN*kG#D$frZ#IfuS41}f)SG_u(#Ln1R(cH5&$4%;qWR!Lb-?`^hJ{q_g*dQXk(W0Lwr$znMLiSG>py(}#|I zf6{D^i{Uay9*kf$8=re50Vm9WRr%5#!G7Zh*J|JEZ`?G84-I_oE73sI%JAUvU}1&31mHex~B!$SM})g9Q$~Zp;Jxgs{gjPAvi2g*S`p`Y@;$S#Pk+t zF?I}lhB$OTxKPZ-dtEnMc32v1ohA%s}Hw6aOSg~bNXAoF^tUzL`~@>@f!rV#+q;T z4-!&c^}#qnhPDBc(0`}9BiiI3BsxLXY?BG>SMPyCM%zkX4ad&Meaob!II zxz8YO)+Nq)9rPt9-}G;&Nsr$`%TH4 z5O2Lr4dSXRN6xpH9Y=0GKFF)D$^1k`2q9T>=9oBaZ}eYS`BxFCmKV}+xVzn~{5Ag; z5dU`1{uSp7B>d%BQftjl{M`@O5+EI$FX0?^jfCzv?&>Vk*MR*xb8{UOWBi>@+}Hdi z$C_(-#pxejh1G1dV>~DBm;JgWJ`*KyiSNveXI=I2%#?>5NLM1Oh^uukS3IQ7vxigvxw`boqeIKKZ0-*PpdU>*%( zt(lpM;)@PH@!=02zx8YX=6LfP-h`jfd%w=s`opmNEzis)o*9w4F`NIokE-Z3PNmK% z_mrAeJ-HBp%*?J|G44CBjj_0biLW0ra_8Z7q7}TDkQb!RNBL26O-y$J7>pyUpR5$lJnQW#@Eo_0+_P_c{W}I6^^hm7r&m3 z8w||w*1iNv3fCBFKjy@!kv*>Gr{}fq^C9P#Y-exsv) zu}+UdE4=g*#&Pns`8Q(KWWUX`5!URFY3g3^U^4@ULz85f@Y=%GI#@7bDSf9J@-zNm zeB+(x|HKQ9m;9?=IG*s-r{X8?jIYj*-zDbjUXfy{S;t~(T=9wjZ|=KK->&!f-t=bt z>jQo>qB+jhxAUJ`cfRz`dSlkP3@lRWh*0Ay#wsJZT5H|D;&kSA*LWf>uTL%!_l;cy z9a}ZGzW<^up4|VUCTsbkk6x>1VG&-P)|xSnoBXqg&#w_XPKEQ*o)E;^uOF}NfARF0 zoN7dugRe8|b5VI2CGaF#R24U^v7@W#c}XmDVw^+EnS=iI{(^OKj3!2m;^iglJNI7< ztk)6@Sa0>tOaY1XeD=b6^2~U^iDhOyKIi(b$36Wy@M)rL7OnUt)Hjr+!HUt%6B;v- zOsXmPb#{yeAw)AFP}uzukj+V|GEL`rRvhyn$G^u zc^2iYL{Jy`_5&@S19Rbj)?q#MMw?w4xcg!(XW=~iT|7U0-XsK=k*9X`;91O3dShQ6 znJ-w!85`cXT|oexI!}n&bmbmw)7=UvXC3n`9vOAbjF~-aq_@$4l_@2A=%% zZ^PdR-1-~y?EUX-xAXvy?>F$__kZB{tyll*@%wLi3$|2}Lz%O=bN_rW%yaNyjctEe z%=;)9aLHeL17VJ1)@}WeS0_FX1>WwZ%TW&;fITB#)LNO};5e+(LOQDZ*xm{e;w>&! zuC+kvW1kkH@>}>1J?2$rpWMfQ=_}5fv{;ZE@1D#qWH|?#zD-u$IHewS02Vp?v#tY#glv$)9Iumw)7`UG-Y00p@s` zGrPAyRI!F581H~L!sb$dlAH4+z{7f{V0&bZp0(8iE##l+pam{EFk4;p5kT|Fa?feec1}9)$Uj9kK@``##dC1Z#p|p{ z%ri4SgPZ*8`G^Uib*3ocF)(~v|AY~bFAjX(^PhLT^p(GOJoVmt`6mNpzP@3l;6j<# zBZ9x$|KNMxd;I2We&u-U@BIP3BOt%5a3Ryl`dP;u$Kuefm0{P#>m>%&RnGgzvF^Wf zx~~N)h+3wrif~D;`N&mqb4Fh(tgTQ|Z_Y5s8)h3yq36UjGgSO7VAaXLAccXllto1h z+|qyApeQOC;>d9)&Q9U>GwI%m&n&eMW#Y4+Vu;vAksP^@skj(!>7Udtx-2g;(5fr8 z!elO0nMzro1FJb0=P0&xQ)I)0yiRH5EFkYEJQ>{+0fGYEG@yo+V?6ux=$I0&HELbY zH!vB{rd2|mr~jeqx_=P(YXHKO>hm!no)=O&sAPEKQswCyF3fz;^e7>hIzRjfD7L^G zt~g_h)Zxm*n;23x{LpT=&`)3SYyta|(Kt{_ztbI-=5;2aQX2~5NKe(D{3|_U3vBgb z&_zBg&0PDcgOvZ1fSgJ2(V~6T<>~2ITHZ!~xYDxEkrtDx9rK4v(>!%mcjQK30E&k0P z|LqF~^2u+;R>!-JAO6uF!XE@)c|83)o_XALoA1%jy0R&XbT=P%_*TR3`ycPZkK}#z z@kjsl57pbb@~=(SnT^PGdewoVHbwZc8W{p_PXaCW5NB$vWak{UyN!e8C$Gr}%d$4|s z726gIBXZ|v!-ty@P{RhWS)8kUotyl#BxkWIE^eS-Uq07xWhMpJ9`!oMCVAyfzH|;V zR_ol;?VQLIvU%Un^^p+&gL9R~KPVBDqnzc?tOSc{+{(ZH2Ee^acDiBCvI*0_pyHn~ z1R~7+%@DZfdSfne9g`9EOjk1)HW<>N>U9i=`A=pHj=E4KJZQSj%w^4aj#1|akAWIk z`o+kFy9b_~_wl`EYM(hWUh}U{QW1RepHE=9o$!b@o4A^Zf~o_g~e+-w*!KbB>q2@)gIofA@Fd4+0M++tm?` zPJ)13_%Hr<{ncL|ulwa!AAj;kZyR+v;1TLWXDoR*DXMt`;@>#p^U2IArcY)$;`co7 zxl8{lW<5Tr>e{F(zkghS;$>8}sgdg#aTU)Elr3Be6Muo7{TpXE*-qt2&+<^KbyRWA zu*aN35Y9T6_oP&0p4eKq*OBWO>jo|~=NlJ6%pRTnb9gYk8#6P(TT%4C0V(uW9eO+)TEZsY&LfUB-vOU0H`?9@|Q*t(FZ+q5FL z{ue+Y+TUo=v1^!9EDdsAq)ALX+~b#y73$8h-9s(BBzHW``My%_F^kw?aCqoLdq$NNPVT)VL)X;xG zAZ1c>o!EIkV^qHGpSXyr5v|#CKF7}ciMi?@o}xegxldg!j1e{#{fW2z>ui87k2*iD zGZM(X@&P+L*C9xqW|Q8^Tj0CLy>8FBBC&9L&s_QA5;VF0Q$ABa@Pp4jUW%^~_^$8& zzT+x0$7!gx>qw5{ns&j-5E=s zj_Lm869zN2CWbrMC`ejq-g3cW=0Omj;Zv|4#@4C$d2eVpNhRm1yO!&iG3`g?#i~@i zrE@7lPS3k|tl5u0@{FZ>ZIu(B6GiQ_36@0|uJ*dR!8f0)+xs`(wO^0_b(2_I3QFWU zo29_V7jw4E-hIQ1rSCs;V=Sh4s`oEoE^=t*_ZnkK6HSe^pz(YNj~ zy$tqj##*2Cqf(i)#oUi27JMV(xTgPFOyIhZ=X<~JdybdB>J`WLKKlobyB_ioooQ?P z@>h;`{Mp-&*ZtC~kH2`wUsP3uOr{xw)6&R0xQ2B)XRpo&?^xMUna)WCP~`H8dq4fn zb$VWlCC54EcwWbURL+{@&ar+iN2sia0rT*&d{th@=k;EGszXyjGnWCE8#|7vpNZwjKN;}3P}vX=k26LHN9VXHfj9fFf;DLst<&FH zgUy7YM-4}HmJg%D!!xm0!ie*@@ZSk6sz>98|G@6lzwlL`?o>DplzOyX_e~hUl4A+; z>u&%UC`R&Y2<-#tvnbrw)DRJ}j)%qHT?3woaooa)P28=0RsA_vWNu~La@_(*=`b6C z-8W7GDz5P5dO}#r!8T1@nrpB8=|V3Y@qe`*`FJKaYt>f2oBf+ReOT|&B6}O(nOh4H z@io_oMTGe|`R0yBp~4X9Mv+OSr~J z_tY|WrJM}PFc)mrneEXGiDkYM|H7!8RUA>rCDsb#aRJ}G>ejJ4!3E2!i=~u*%zE!`9y7%WAK5mULIP@aheL3yi5hYsP&Z zoVh#K>~Z!IPJM({y$b8XqYalsB!1v{#=Z9{{+@Tgr{O!c1Z)RmU&N8a!+f>D}>K!YM5Rf?8AY=$eZf-HFtUSk?*`0(;~9j+E)Yhvi|t3s_Qt4s+#!oxgZ$9 zvk${6=;OQYA0Vp_c+quzbNO}3{_I`qQNO=_a~{%#TD@jTUv)F?u=%(qr}?>H1cNuQ z{bN7l%+DMdYEM`+3Re3rXAt9NoHOBw&*|TP0|1<$L{7F^$G4gxwjRhjP$p2wBfTh> zpE}^NCT=zJu8t4!e#mE^Fk`>>E~1jobw6<)OMm{pojgo?L9nk(hT-b?K{8nv&1VVAh=Z+b=G! zbM6=Z@c`YA8C5OG(B=7>|8$(f&yFT{ z)|@x3Q}-Y(##A-z&#>-8HSEZ~1rln%Ao_z;7IG=4at-u!ns8+X`)M{5p(-_)wI;k`>0jm6gK{zO!Ww~H%QY2-M{Dxc*wwq%_2ng8#gqQ6 zIQ~n3YN-`NpD;?d*2I)X0eDF)YvQZEXXYxF{;ETm`u&?k7{JB9xAw0bF07yV<|_bm z`Q)E9`MK6v`G%26y||SIMR?8<;PiP-iTqfT_LCBA*0{1xuYlIRh>@UH4>>4(j@hr* zym~Ja+~ET?^{}WKAnMDR2(NPYlm9XhT-t|+T13eUy#w>|rPyLK#*bjGRV2)b#2Y@6+!h9+*O9*Be+v0T~!l0;fYFuldiG{F!l4D1XeQ8W*BFnr2J5k4Y92UqKlCRT{i^o^C<5L7fH<=@gZ+~$E!yHT zy#>zMag(SgVxk>hgwsC$fhc16ubk_h!(#GOpSEv%v8Z11H(Levixkz;yjE5I?(r_hI*&{jc*P@6=Bf({t`IalHme zYAtdcDp44UpZues{R&k6$t6$g&I(rsW{uX^#bs zPu2T~IzMY;AN|s|0w$qAxNy|Tw3ljT-8?McnC$CF+$31hEG$V~SLEAX zU7NQBT){+JD*Av!%S6_5W<2-9Cl%SuxWs4UjiR^|c2U_!(&I6be#RM#*UXG(eV&=) zaGWc4^9a@g5sMI|%TQrK;1SH6nJF)Yy8V9CoIQo(TKpX2-caFZarf$ z{U%NY8JDrr7C^N2+zBiuw${Uw9w6-lWjC&6cApt9y>%oy8=WUhn4aY$7LW1LbMXPH zQRU*AF>bzk$L;(ZwXQkOM5frpYA!`l*4bPYXig0R)sGUM;s(xD{!X?WX z<$0M~u1hadqU42fd^8j>Vix9W==wT3vi7Jy6svJ!(3sdKl_E!D&A_-I=N5(V4^*@Dl zmbl0_YTCb$wWl}#Mw2eC-Zc@!QL}kP?6{M=qb@Xg$~qqKU`US6=~_HZU3s3ukOF&QS@0oD0ht#r);lkPg5p%9$A!nYhZaWzHz3 zBvK*c1TReIl&2vT)@(dIDs57)E3o-jxpfn&KDhWm&1FZpR{zd*hv6SF{_+E54cz;; z1JW46ew{Cn7QIcD7uqnMj9;;VR!&B ze{m$2Ub2dfI~r?`L{}|J$wAD@F>eRqy714I@lc@LS|;Y2Y~e94n=;BV*Xfz0l}C$t z$7=$$)0a5{a~6o&D#Z#md=M2>Kd=&D?I^G^EP zF&L@0Fv&5dJ~n{DdLOd^^;QG!DT*^k;cKT*ya|Iu`>$G;yTbj8KK@BdCP0W<9=(@( zBpCPWwZNPZi8t1Atsdx|z5JK{YySOgUixQ!JQ>4wP?57>o+JD2;c(*3gD}fE6vLWZ ztt-g&^5~rWGmnv6&Rl!+4G-h--#FMue}Kw3XLa-b4?a}+MI{1pk;gP{D*xmDHJ&{P6C9z+UZ4FNLoDkTw{hGj z#+f*dj*YeIoweLr+hO`L7|&UAF98fJa^Ez^Se~Yih$j9iO#XpscGR?gl_L`aye~yb z4C&AQ)snr6knI`MNDP}=BFFa+jzqYM9>2GwzH!ovUG%e-ThdS~n`(`V)?sj1u@ZLS zUnS(TKViWs#^U8v(cQAsz*u?;T0=6;O$V}85;g-MmHSVVk${O ztQ9E%tP(@9W7-XPzp^XOSd3=Pkk-7o9cRU=jn>R1PCyOjsE#x9?Vo)r=I2jI`HXsh z5nxb=$_J?EnT+yYdEHnA*$4-jj@kvwAP?9#IJZy25L;v12!)BPrqXS!V;0~C%WFeR zKxj*B>uP=ODo-UqCF)}@;zN)j9%ZoYAMy6jHGcJ>s<>jspgegh$F{EL3PZh^_nJ6i zEugW}^QuP`>)R+vFi2LOdaVY|t$IU+6;*+1l7A5p7Xq*Sm|d>j-uINcST}i;-wxB~ zEG4Dk!auykKUGA36uA$XF$BOo{U@HeZ)ydIt=^1yP<15~AhAi=zv;!5c$JoC-WF@+ zb9AmTmgV#Q(YvtKK=sX2_SDmBvbaz6rVmQhV4+tl#(V>iylD;h+jB*nMa_Dz6K5~t z;1#tO{spr?A~`5!>w`Tx)jIi_i6?!_XDbZ%@tUE|LvKNPCQ8rOx`VwMg+M{*P5`XxI&e;e{2e;KD|-l zs@IB8gLmJ#rz*%RaS%+d(}j2p+91(*J*pS`W$nx=nM6%R4QW{AHwj7L(m1>189HJ|)< zj`xA1gkg5eVh>rGF|Qa=StB|9Gr##)0~=2pVR%BM$^9pz&qnQ#RKCltm9Z*Uo~=|@ zXy(12_3`E6<^_>@@mjiZ>cvovFmbr1aKw(9_;+LED2f-jD0o^!v*m_FI$;`PJUX{R}rRLOHv6*sOW1|L4Y6fTV8f;X@E z+1qhn*XvT%TaKIP@Vsi78~2a9l@v9%(P94~FaOFHuj*NrQ%qmS#8dn_JJgcx>|eoC zJhD88-&**bCBAw)=R|byb%s$X=dg)E#=8-LYs+=~o5TLemFHrWmpcsWovQ8NkF7 zt7&=C{daiP4~#f{*vc2QpC9oSK0T!sn|^-kUeOaDH{x>tqS?=u*_rcw^UNOYzdSg$ z^5hJc$b+MfD`3UYE;Bn*LtYnTOV7m2@1BW0zy}IQ=J>mP6_+{5UiXwsjK+;V&N#RE zFFa#wK;8MAC;)x@5oBWAXFYuq?-BpjW^7_a#EZ4LF8;|K&Fr7L)Ff7|+P{m)oeYo{ zzLwX4DxB}2%dMHpyss4qy>ebzn{+dgMn91pz?P8Q>!~#{-g$asF6^`J?0>!gu!rk1 zF~ZUQu{I}g1?8T@j+l=(1nUL>+I8$tLdQzsj4`2sT&26&HU9#U(uLRjD~BGO<)8dx zz1l#Rv>>ncakHct>t0-Ytmiis%EcGpsoVTlemf^%$)4A*HoRQJGFJZSM`PgJ6H(?A z?`g$~LDjhE?ZZscu6}x^cHNIWs zT{nt0DXeY(qSi^UOWzc7vPk}R0jiAv<%x?A39CACM0mIuvH+*lXu1 zJpQpxzUxCEWHgKd#d%>Yc{mYK-c%e9xBCU_TKTUUPORq~Uz3{^f>yt?e=DNp)Xt%jm3tNjg}34^uj1jKxWZCf)(%yJ+(%XW{u^iCIe(Q~S;=jIh}Ie{)SX*2 zsi~MX6?C~$WnEayp*kHZ32D5kp|b+#xOFOJv2#b@Hpv@=p(&|I|f;>iMmJDF&UB7j22@7bQT#pvYpd z=bCkiErK#aq<;~`m1Y)ctQ=E1bjIbXFu|%xVujK66r1Ht)rX{UEwA-3CC?asHAbK+ zMSqvkKUakXW~g_)g}X=LjA6f%|MIB5tOGS~mb)09=>CecUU|$}i<7k&%y9&h zwL;dzP;ly7^|=qxPT11VlIh2dcs8lCovPOPAxCe*pq}T5UYx^49n-&9aS@iA!gD58 zLwT%9_sS8xFtzF!d#)m9XRLhy=lJw%O-rk}?5%`oLM8phvk;?$4z3ax+<73?JTqo}eows@?cxuvb{V{0{+e5^wCh(G7R!hn2`k9)_o zMumG=kaA=phPhfZ%RL9@qSP)tMiW6y(0pyxP>~oDF;O4nz!A&J2nHs8)Lg8L0Y(fb zU3qq-Q&7bUB(2WjAshB4#;MKL9P*dfKRZ5uPNQw34pSba1W_6bjzaLedaQ2U-h*--k`lL<-cH@DwrH`DP=5c>X`mn>KIoT)QXAD z8RuHwRA+4bFnbTkzKXwpeGoPCTo}~(G@0qRgr+jArJ@`5>!k?)jTmK%YRsn$kq2xeo)U| z#h?bH(fLu);FKoBM9l~2{mW#M|hjQ2^J?Y#t{XL=;!@XYkuyp#Yk!o ztPLjKl$CySbI+0cm)eT@Odz2B8?C)DRzs0z|8juBvwxo7c^FF_$SAK!jvK+{)z6Im z3o5ee&$SP%N{9n02Z=4yJ`WZ8Qu>MA%UuxvW<#%0%4Kt6VSNVVOjH-KnN?^ULyR_mYdn-`!?mq{p{>AGprJru^txD0bB7Sv9eP1J`d% z(R}Uxb&McxYRVS#)NH`z)h`@tJZJS{rcjK@wbodb#>dvDA{k+K5m7 zM^a~1eTdyh5_JYhE?Pm$s7@J0ic3c@TyI+~z=UhLU zRGGMhS568>6&pqYm&JU=|8)c43oqu@r8bwjEzH)Z(e*RVA|%M3_a)-Q<<#zxxzZn; zcuS|~q8V6`he1FQC_``C`_DB4YJx=h4@3&n=bTF)cj@cA#~rN2-^V{G`26y@p8m1^ zBEJ>zmzd+ASsTViF|FBlo|o?LV#N5nb3hbK zQ>|I9IA-1V>{-hcFU;YWN)#_0#Ns1rr5_~P2bcb;7Xym+8sbyyKE+55)U%B)#VH|J ztA>g6sPkvOH6-Vzym|nmLGoJb!Ownhutju*TP4J*+ywDZ>ZKuT(^jK!qgK4~h*dv7 ztP-gFOMrBSkLm7Lw7h@vz|!)aSwj1g0tsmUdH~83`KzI-*egJ5C=Nxvmi~!@hUlfX z)&h9e@h>;=FYN>cXHmA~Ue?DmBL*Tc$3OSRx}Ncj$uZ;3lW%~jK13&9U=Af_uT`sh z@2w*_Yhe*1lQ(OfAL_lYQ&xX#n%axC{S(2~jRe!S9oUu(&(US+zAMr z-R55!3d_SSFD0zFM%*{x1{~l;r704^3!2>j@DrkEfw`1V4h9p)%&q7c#BjaW%*JQV zmKpQZhL-T&>h`(T+a~0R&@%|e*d9HJV?WjLd`k!8=0*~6T51253aoP^ zSnEov0QrpbUR{&q>^l2f_uMU5%)z;En25DTtmp;5I8%%L13%XCDxu*h|Kwsl;ZCW( zZh_iz9sj{_4kj@*q|ziPTe=rkA^Bi4dg5?npTBiEKDmeuj#`7We~P*Aj|xk{=_pUu zaNL=Hb|r`EyPEwGBOONk^PX_KN79Ah_JTkf&_!P8WC*)%~IB9P(dvnt0&{(}Z&Zwjf5hww7mSp9XAY`#4Y;@-{kdnBTDUg0 zo)BtK|3aw_YtHzc8c|3jI3&Q`6el(@t=W!w48~RGtp&Ij`GqPd5h-JcABEi3!ttUp z?;dL|{Tp_B|LO?=^q2F)=F9pU018{=7S=Bos)*|p_S8vVn>e*(v z7Pbyc{iLH9N0eFJi^m}isONm)nYvle8$gNtSd;d3q|Onaab=xefvkNIBSEbm za)6f1IcC3J^XfhFDmPAARe^eds4r(CyvkKz|9K_~m-gYI7CnE+$7^ObUhBdYfuC7x zC5MOmdX0P1#Vfx0QomdR?s?(eEB;;0t38vSwJc`~@n9z2imIYw#0Tem)i8k}9?ZF4 zEqnMUqvXav3%514tI)aZEr$gm;lgWSsETI|U<5LvT&ogZ8cIxb$3T~>uH@7j^g1VZ za_h7TPdcHs2KSM!mWsM+U32D0rmxGjSACJ-j=8bLcrDRnLocmU%yJYKr)CJ1e?qtp zv_VWu6 z@Y~=%G0y5&h%4(rV_0mY2q`p4cPKD-j%yIiy!{Yt6m!(dUDnjcHDmFQr2yQAUp1`7 z-~Qi?&1VI!Zd}I?=iCT3D~OwjFb$VjD^@?*tS>r&OVCpIy1W>PkkxAt`mw45gIVE0VjZ-79rGLfc*+-)q zvszb`t7rTx=JE*Wvk_P2vX%z67XeD@{GyGxkI-_xX%l{J7H4xzQj-L1rY1P=LvC+@ASqJWY`~x9v)}LHv=D3bi z&XU72`d6*js-e}?R8ICAS7I@3=U+9+x_|GosJYG~%@dWd-9J<9MYy_8bfo$cCSN!~ zl`l-!rx)h<+5Ni@%L&z)6?O2)C?^@hr8hIt>1;o&?cvv!tqp$^fi0-PnW6>qg^wQ| zW)0OdUb(G5V}+Gx?Ma5JaBx;F<6N$q_-BvZKLdhmW(j_KT`Shu6&BTte2n+~t5aZ{ zzf?mq4%7s%EiTdn)H*+FQ2egrGx$CLcYT~86@rb&Og_(SfPE8@b_sXR%`HY!%LMCC zA0uW_G2@`cz{SlxS7?h7X93J=sZH*#-2ebU07*naR0S`U(?kpwY8QIW=`RizVWB+L zbIC+$)hcVu&=!xz0Wd~#B$8JycoWE(SodciepF93XU-wau3?>GOeMBTS-xrtD<{NY z-|NCH|85i?#PL^j&-jc5lYHx~UeTDdlBLqE*zj)!l<$>N6=VtI_@iT3FovOg#is^m z0ZJ|foK;=~1qh73NG`8}GJ_~YZT?H7e6pDcL~N{tQJA%Y5Pp#OZ zfSk`UWc9lbhMMFh?DWOKnX&Ah54U-ZiDqK`ywvL!;OyH!slm%iomCOqGwYF6U1ppo zCF`PMmTMU6HPfxM^4DT4cDm6Z;JS*M#rIKy)FPzrXPCqq!e$7{uNo)Ene@6;7yDhF)ac(_ z%q7m5`^1#07z>!6ScHtl#I@?PW&i^*<-BxASL2u?bv!ebb?2Z*?R8}VcNmBZtG>&B zzb0A7{`7-duB)H&MXe1;M zd!(a*bsA~U4cFjFee*J|T7oc;80~6L-A=E1q1umn7sg0lSD^hTranHGUAF}!rY^Xo zZ{{3~_H2U>)u>=8=oK-{6;`)1`v!TjZnbN;kDl}orsE>siIQWKYw_eD(K~zLs0$B=5a3o-#rK{I|>SWovZ)@dF&hl!rWCUCFUmh99N9)Qf!(m+Q zm3fsDU$LAA%M~>yOU24SmFv8eCr8O~EdqOaV48zzmnbH6@5jo5SyokPghZd8bVUGg zY0$+TP=B3|;EuaI^(h~@bO(f3W;CB^E2l6n#N?`F?N@y{9{ydct4mCRruCd+FW;zX z|Ka70(T6pz7slWtmY6ZCMW3rv#n301D6wG+O`fuj2dvo!lH+;MwRoDK?mzazRos=; zud$R5)X6`-4bUgAU$#=`{CI!nIn4m;t@#x6H{WxBY6 zs`KjK_0dV~=q+|nbNsJZJmhe7TJr!}uK&>A^G~nor`f;GKV#OHc|M_N)PJlnXNv0T zpflA|vXnq{SJLcbKp67MNyHv2rZvw6{P=fHTQZ`#ZaP?Ud`GylCpJcF{ktyQ&}Pk) z3HtvfeazX@&!%KkI+?p$C)x zBVYg0@GPO{EJog3rdI$a-&cy!BU5k1y2s2|Pxtwot9t$C`HhYn;c+^!=a2IYK#fk< z)%~nMe9hkImzs0J0bMl(#K&Xycqpc|^@D$DSI6-+<9q(ioq6!%eOI)IKYaR@{u6*t z{}dxMO;U2x%%giMn)8#3=nOl_MTBo|2|(1)3U*&BE*NTYVGz!+)g=MH`-zo%q}YVM z{7xLYimu1YrDkHL!&iRF4emYv8GyE2oSivJ=ls&oMGtW5oW;2&X5jyzeG; zE=nnjf5p_eu6l7!4u_d?<@tqja;~H*o z60B!Py#7Ovm5KfQ9JiQ09)WPLXFX{-81eSYti{@mp|_r~O27?Yr!f@dq7{FMU;PII z7Jj+uO)Vc96N78)Rkw1?PqlQabJFcwmVvH=G2vCbFCb9pZN#3-v(13w+P^TU`4VUS zE8)03gaU^odE{UHyCp&+2$r0$=CqkmHNK-<%@B2-e1FSdoafc9^TTC}471YIv2Kp5 zkn|WZ;f;S&(2GA~rD?^{W#NNr_}W_;`66c z`uO+O!w^|v`+N9fK-`iC!oIoUC{51jq0r7HZg)J}7iEuGf50=pg3%^tZ(4bleb3nD ziuZ^A&I({S?Q1?c{sEx>qdmXOlxeI+GfrQ^K0ep$#$B%mLD3o>6%p^7Wp$mCY0XeJ z3W&L;?%7=@=ZbHXdpR6Oo{@w;r0`JewxsN2NcOjf6ybP|LSt)B)9|~QK5Rt?XKd)x zYkIn+svRnXd8p}aRl*VZP^^G*JaizSyAtk1UtO#f@X z@EI(6Z~P1EnOW&S^H=flA{!vkrO!-bqXJGPCjwY4x5v1jg3$;^l>RI4S(=Y5O7X8~ z>Tfy0QuY@v!*~W`r-Av3ql5A@MupeUZP?%gkS_{}PLBElRQz0=T)k&oQ(e@qjUu2o zB{q7A9hEN92|-a%s)EuxN+?nT(us(IN>fofiHLxJH0d=UO&}y7CG<`Lgcd?bA!YMC z@7`bDpRm@k<~qh4=QziO57}6_H9BZEqutuxhm7mDUyK>R-YbeEOSdrfz}8Da4j%~n z!vz_H12eCxtdI-MuQ57XElu}hHg9ugWx38hHPQe$Y2Bmo)xl@{{oi}0`HsIba?pTC@>b{6 zv!;6^eTP1^dVGDn@!$+0b-UbOOJnerFrneI9lX2pO5~wg1otD!HatA|#y8xpp#Off zBh`Kdo6m>b(U2J~tx{=7V!!F|kuZBfs0bMMCq-1TFsN-av)HFd*GJ=5ip;8VC&|R$ zALf}<89JDUk>s={Eb7ZM-TBp@u6sE zaSMPyn%jR)p8yoqQB|%IKJgji31WdbY}_=BC`So6#a%lzm~(G>!1oq<@1IWg0;ovq z?Kv2a(fXoO1lWl<2+F?(i1~vPd=;GbLp%au_lomq8Wj=gYQIWbP52jLD<2~AlN9~V zunlqyzlacX^H2fB-Ycy(&k&Kds;clVL8ZUb*lq~kld&ag{5<$3W!5ncl-lg8Rs?t0 zvH$*?)`tH0DcC@(Jh8??g>9gW=E}~;uHJl!{efpK!MwG3 zJ6Pn)#gisz+7WciNf0-H1oVH}9vTt2b>_$(Ky|0$ z&4e(rbWL*kz_TXndo(^opk6NZ#ynk3D5}$#qe1z~&L};hjV{%|5e|FFw#^^X_&qm) z8f$ua6p8vm%OJAuOw_+pR`HwimPrG(D4|Mw{|Z(-<8R2XqUh#Pq_jx5m*`LEF364B zw@J3pg#YurzW!#K1JmF#Ync8Ewl2mysXqS9`W>|)iZliMHz`tIkh7z)N>8{%5iACp z=k{{U+K+_e6H@-F82Ya@U9q443LjY46iMP5AGxE}{zCYjDJX0{cwqbbS>oXp@|syX zi2W|Dk76qv?Ve12D0bDWd>&I=^07?!8?WS>ns8pVJW})H@{9wO=+P(2TZLx1kyk^? z(!B5#zuS9<=~eEgH3`^_&COxX5P}%*cE56jQV?TrdY)zgWUn9?3emgM;NA85hl<~M zI&wbtUH7rozjp~R;KKub=JGaV@%VGp*cA85w!5y7Fcq@i(>5fnb~$1n&3F+U;S=Ad z^v7Q3_YG{l6-L_;NP^9=2TSpTwToDklamikyJWeqVL-2NNgWMsgce2dJiwJQ@Ux={ zSeGEvY;T|!^s#b~r8^7*K9_-8Kt)DeB~VrbUp&%sf8NHt#J_`HHP#e=Z9iPWCac&k z#)`AwGzn2!slb-jtla5rXbF~gg!VbBI#f8mqJp?XKV+?UMiRVh+R(5E!OH6FujFl8B?S?D;sK^gni}zzMyzyfQ6*YaPMc=Q_D1UGo zpEd6^RkJ;npzvj_ktJO&jeXm9)4qF-h_n@L&k|4=)DxbfIlPuGA}qKs%hD)O!`Y=R z;^~H7J9Kjl%XGlzo(FWQEt~}Aj>AI^6u`^{VdQ}~_^{#)lONs;6ICu}DhhOf9P9dI zq)vQn`QSVGD@gWs>IuKbnFniPd@gH-Hw>DB_MnaRd!{C{x>t>XaN+gJajX5b{fR$v z@@M^HTLE9ckT6H>M2_;X3z@6p7F`) zOwMg0z!pqmY|cMYvnzA48*Ej1A`9@P9;8(z`q z{Id)Xpo@9`Q)KN(g*9~A}Q4)c$FUf1)`BsLS&Oa*L{X=cDhpz^cSIj_a-mCb(dUzyfQ;9GV4AhCL9-QDweikj# z%WrMK_XPUizw^Hx3_9Yc?8CmaiEu>ru!w}PS_{j(GyhtsH`R`u$DDf z1u&6|(;u`NgvMJ!Z9~QAa1TdBa^+{LBC+VSqf^Qm=l-8G`PA8MuGj z{lbl0d#2vWgM-pvxH=%=4SpuaPDqJpm7>53!#??7$z6F3)cQOC)Ibd>{utX?FONjN zkHpA@n;}=?YW}8bX=WngXudoaY|#}Jt2+%SsU+&tKW)SLX-#g|G?OeW&>~*vyfe@B z8-h&r&$X|b%;sLi{{6mPn|b@D!s`));HNfrJmFZ8f#7uo_ApKSW9;}J%7TQRFY zpm_Y2H__1{lRi38z)cH{q8mI+2W(kp>{L5|eP7$mihI$eGzQ6g~xqn!+7U}er%*d>baCr);-KI#yGb_ z?!1Z2qvc!I^4!enulApcWIwFa_w1KfaZjEGkFLB6N`k>bVW9`^j+H$Ps(5`LOj4Wc zV{CkFsO3&UF`EgV|B=f+s*MwL!Pt%`d#}{{0hQ3_p((3{$7xxQ^P2NgZ%4L1P&%K! z5qG<2?CJr%Ms>|xD0UrfEd{*-0OmCgSxnHxr8|w(+O9qs+pv25H3uMco_;1!V2&OF zx0A=pzX&V;jQJ##6GTp(ji5t>2&wB8LWd*?Jk6rS?{M1X zUtA6oP_dQjCPeKv-Q1jc0zF*PM>a-%j5FBrDNkN08pK_sy@322H3eVMLVl+{Nf?~8 zJNPT*13358E!4mkTM~%50{X0cZM2mqV&nbN$Eht9jB4ZPJ1Fa(@*8OBN1io%Cg-d- z&LCb>Ir$iYOiNW}->y3miXe{OAQi*ld?I)V|1f3bznQ0sm_r)q-fX8v*laQzWsur} zv}|ESd|Y>p5qO#y>U$?TFuMAIt*ZB&s;;jfJDK$6r*6CjaK-$ozeCS|wZZO>TD9Nw zlSnby$9~5cUR!)JVLc74GhR&0vZVy_ky&rCgzSEH4$ucm$EK3v5Ar2;t+I?>c=|G?Gf z8y`i3?}6xHy0-`XQ}sIK9~lm6zN_glLXC@+M7-J412UC(MyQ1d~an~P> z)Y7jcUe$sMyqlyEH=nF;m0}dA9yj2VuRB{zWX(gblgs*jjn5WV!S1LkvpaIl2r61;yk)2;nZcd7gs@qIiCiFy${qfgUP)%JT$0SC^PXR+ zf!7A#+-(my8l-mb&4UEZTJWr#axS=bOXXlJWG3XtlC7L)lkbX47OEuWXd$q&z?H_A zYGw^(?^O*{gF<*^a~0P_f3^=8P#BMscAoF?=n=b-VxNvpi~XYGGgS^0g}PxpLmv!u z9(=n;Er;p7ewy1D8G~pnv0#^SJ=4K8MBV-iO-gvAKb!v}hrS=+=RSmwRv5{L7hI2I zo@)j)nqt0rIM(thZnuk(h<=@eV2-wI7{8(-_#OYur)jDx&8vX9yyfqP4PFKeHY}K} zJzp9u-Ofy~kbJj3s%>@qrA{S1$kr_7P6jLgCv(t12|IXEp0Wt!CweYVdwd`4mH2Nf zZ;p&qu8HGraZEacn3&oATFNb_DQvx0_(}zI9A3nd20yscABarF@IIDTzBixLgX9U_ z75SE9d!9^1tZ2Zl9kn1qPq>Xbj>jrpQT~{h&a<1lp3~?w{vsiMLm{Uz-q}FwpvP= zxM3g027AGE0Uq@P3w}rTV?1&h*0o ztIh2{RIP=mk73c4e|5JufA5h~UULn*Q8R~aEddrP>E66+T~Xn)`9m2~+a+>Ras(oj zB?lW^kHVX`Ney5&*JrXcE($OcNC*zbt}x7)O?s7r1YFg-yeU6d-+Tqg0MXj>G zbpv4f&3h?N8Sj{^th@gsCb4Q|GNnA}=5y${dVB&JN+11I@ZY&Qossaku12h9xTW2s z@+*8E7<{??UZy852p)+6VVTjyhAb zxu927qavN!gAt^iwz^avT{kB`IPe=h|5OK!)l~8K?jAK)_DZVsLooZmDHf?kR(u1`T=_KgzJF`^J91_O9 zTS~67I~ccus!kVX2l?~nCKG;IE=Q06lKiA}6Sg<~fuY7R%pV_5wc*KB&fcQQ_h+sm z@_5V>EwQO+j?q2F;tJziDyBL2Q3UpKMRQeoaV`Q0eNqTI+r_-@+~n9bYHcRbMMM~? z^glny5&XJeFUB%{ICmdV{4SWGWi~U@1z>lQ{tQ}Y6}$zRW$$?~3tO`#=`Ui%wH-f} z2;hP1EpA=qyzx5q`@kSW_HxVf8``V22P~xNBo3U-dScoiNYrq3v)m{wOe3 zb~cdzQPrwOh0#3k<(KSlPs}d#6l)NIJ_j7H+E2>;cLpA=GINJfttP_mWCaMSdf?0|3Wi6?#Qem35cBYz%$f*aUz9JIgy#K+3~N)Z*4@vbMfDe zFLfoRS{M7eqbD3=VXYL@#S;z!5xuGYAz7cE2QMErcCl;^Tf5K^(d3M;1Dexw9YF!w zTW?8-g7YI^J>N$tnKjrHlUtS0oo_hhF$p`jb*0Z@IO<^p)J!tbree2oTcYZ)VBfK2 ze;v;?nK#3F7}hmja|;<|A|E|*FGj5Tw>K(rtQaP8WwO3r1LcQVy3MVraX1!jvr8tD ziJkMPc^V0PJY&iWw6{{C++~&h3uc59Iaem>x3x(s2*CoRDy)Imf zulTpDYqZxI?Z-%P9*%VAs=q{f@7R)kJu z!SO^kU!_QuSOxr+M=Kmg8=bM3@_#oQ5l;44EP}c#gk>RmG6CL z{QK=@H}F9rVSCH4xHr&)YM5Kn@_PoyiJM5F92l{}mdT?!SJOu$-fO%D=JrL4v-JVsXKUMTO&e0ry>t|8 zPp(vp`USPEnPDy!q%}$qaKT*^8jlFVXLC$0B%?j3sIZ#>>YRqP>_;xob6?m>on%$H zPv)?8QsAP+?)8DW|hp1ESZKk4mN&>q~}UtI^jml}L|2YB~$ zrl{l|JbNrYGs=H@@=n!j`Ch=WL0la;ifU}8ru?^&Y^-y+5v(~EuQ?%GMw^MRRyw_! z%sx5FqaHn8n%zRRD$m&Cc88S@R&Ut2s3|-iZ-&V|DaG{XqZwWzNZBZW1cCvI{oUk| z^_Te7t;e2>aM)X(-{)&@p=3UbexC7mAi;M@M2lv{2Jw)%igf-PH@GQsX=74tdqX)( zQv=|fN55oXPsU{vbjVZhd=+yAbLoRK;Vf=PmS>TpVlml%)9KJFSCEg@klEs`*f$9q zh>cL-(O8|fihMH{3D4&V%=wQ~AGo}t`Gerc%#Z&<&a)$| zeA61!v4AM0=$^5t?0vfYYx3OTXVWDrMF7*^EU5Gjr~T@d6?^RaxVFq8&OS@x#D-d$ zOXAqw7twBKo4!EhT+G{e4EWc>3r9n}Hx>`t^qh-~!rW#l)cKt&+H~dohOW+;vD#r0 z?bRwNOD|?-rGSAxs;{W659|0X{xzv_-vJYZnu_Y9rLe76)2xpi4M)V=k_FpybI{X| zbt3bB8T7RdoVxwz++k%wi?Ss)GkfjN(#-w(gDqS39ORY1@x(Dxj<_jjrg}5@x&i)i zd}kZ99;TFCziaVR<#*Tk`kj&xO3&b#AV#XPB1^CioYi7RfQ3(7?jF9puX0#NaT(g| z@iAVsCuX^VXm5qCL~I+OHJXKz&^Acl4I$d=#XKlTtJAC}_^lt)DQR2uU(U|4Kl>2^yDvZUOV!*3gMkKf6xVlrR{Nx(vpkQl+xM5Krd#2s1p`M5@!Lj=*~{WuCJ@m9?W@KF>^lG{Yj23$i&^!;*Ad2%q^X~9U? zQb*yS*ZMnh2X^vrQ-C5=EjV6{SuA$`@q4+J7nDrIydg9 zv|#Jpp&=g^tSwgVBX}374%Jmd5?j$nVT0c(Dl9D+XwV+DP$c~coCsTqbf?hNA37rV z){R?3l~wTD6Wy9o<+I1|zia4prVE&u3)Xn@yt2X5fx1VKf!}*@iA>L&fVRn68dbdx|BYiVy7Fw)4L3Uw$6_WB~K{ z3f@}8F%c)XBc9241WPzo#MFH~{0DKJx-feIK#)&{gbppi-aXZQ2MBhvMbrk@b*sEI ze7M&X$a?Wr*C^es%&)aTKNr}HrHMO%o*!O1iUv6@bC}FN(#znNBwWf)#a6wP=V%)G zJvS=WO#LMc`Lj?W8Gi-|O=8aIJOA8nZ(+=^GE;eLsgM6SUQdki>cH|i<*+-Ev@vI{ z{FRk+SIlN8R-);Fdh999mUBs~&RrP3+gjwFBU}qdGDZN69^Fn$N|mhGW8&;w74`61 zAfXgi=snAzdyA9b$C}u~IqdF~@>~cwK^poTJEz~5Vaw)ABl&@2x@_2mPTcPDS2`}R zH|FKm1v!1q&{ohXX(MsvWV~aPeW<;zmk<#7j`Bzde_lVRFkqKSuUzM@Dx$ zO1()|57>5))-%}k3BlOUEnv0lz}ZH*$2!8p!<)(zvkpa*V2T#^9#tt1NlJ?Zq!q<< zv{)#NbMmEhhxvN;ZpqvUW8%>xC*Ok2j!9Ck4TrAKz}8j!}%fTPL_$<@+XbhwwlsB?c$*V`5?x*v!dqsOw?*XEP7INd`=sqsWxbIM!&&Mf&>vYA}9u?c^uRbTykX_rEgM|X&!$1Uw$ zXFuRv_#>g$AR;J}O;BgDB%)Dx?RJkv%qeh7>F1Zp*JsWj4w!%KbHl;V>(1 zG+c6t)%%c<)p@0fJ7RoAC%S`unQfl}<6yvG`;=%y)I6>`QBV%G7-b%^d1++7OC-9; z!ZE!*6>=l(qiZBuI4`KIJhqP`dCiA^>)j$QZ{uT z{+H==(<c@lR&{^)6>L26p{2&Jn6(*g-PN*? zevNNx?vJ@zQc1FXV1`AUvU+7J9d)U45`+j1SQe_)=V)})I9xe59Rul4cWYUE79SHu zvyu{et`1<2is;FZ9mlioZ2wx-2)~BSvhs~+rOrpH%s#SNv==7}47rcy9(7EeF+Au_4Ux^; z*?D>?$0%pjqL)3RO4jKAlw}$}^`EmSU3b4i-t>!(dfU=;_PBT4{f?&>t^YM{wfn^v=16{npGE9W9g*1# z(LVB_9eYj&5h7Xe9!!{j#jAEW&^}DxpN8!(BZuaR_;3d2LZ=#%7Lm14L95N^T>q9S zbXhFBud(TDvy0<({8sWJpz`yxc1po9zZ`N93E{E2da|yyDB+&5NaJ!wIE?;{uZi1V zijF#iD5@{0Enpu&HAD!rYExEDr%A<#stM3uy)QnXJn96J)V(tm-JPTp?06;mU@^bX zKDOAG31uxfa6kBoX=WXpZEODnSrsc^)-uZz=`1(leyq{>i?R3M@&U${9-KHWZL$O= zMF`l|=VEUa2b#Kb-TYSdht~R})e7=JXL7?`()Uyrl8mb5;#zx|{7X$0T3bGQ!}g62 z{v8{w!EyjPG&Qye{Q6|a2zi9at!#H-=X4mQ zs5k6d*A~7mw)tp9#$Id7{0N2$5%B*dG-_L=#a>)?I=lw!b;6Q>9Ov5(7Zs)xuO;5- z+ILyXaw)h2_2K@dQSs+Gw$WbP(Ou{P%$Nr+)HmE5`T~OJybz5GRLtg`3JC;dNVwPJ zSa3$qWY}>(NLG4M|C=-^oJ#JGSv-qD^!(jE_Hp#S(dw)?!#)?$F?NQD-%uCX4_CNo)2pr zo|{HsBX(|V_rmG5G0 z{f*~uN&o))n*}R;dz&4Tzz95de%|KS9G(`23}kI|^Xd1V>^aH>je?mh!6WD~8f=sN z6zHbF9``SkPho2V4-^+MsI$ht6I736_Z}I%_H^gv5Jo9?Pms%yA}G|*-i-a2M}3v} z?38dO3mCxY(r>{?}U_?0BpwRNrv%qUCiLa8FUDx70A$KY147grD_M=>P2Nx zUmXiR3@86wmG2a5wZ~!p=rm8mR(Mz+;)dDoy5Ud1Bq>9l6TB6>dIS6mQV}xx_*dyA z9Lj$h3hKVBVfr7Zr5kqrd|vLKpp50aeDLu@eHz42QWRV+`mJ>n_>NR_Gul!vn^U>L zDx=oZyv6ccTb9L=pw$-r=IXCYP#eG#^eH$DM_cU{bv}Hn|1UkloBR6qfF?tr^h-tI zy0`44*ewP14g2wH4aBSJ2A@hVbrIV)6#l&P@%vxmJ1h{<1GW7=~3%Y z1*BVT2-djIA=N3rzK!vp$fT(JYm((~S+Mr$UKnZ26zSTTgz<0hfRt5u1tnSDc4XRJ zEy&dg4O&-AcXNNSG)j{FTO_h(0)ME=<*Rx-Qy(?1UVQd94vq)T5J(jp#!on}B_D|^ zT6SX)7b+PsoLs7x4!b-8InRao8xIZF(t4AB=dzii&l49@jsq}%OUBoxgSi+bNdcgc z6}no~X|yP65?82IybR=OLq7h8Q$91Z^!jVhLCSAZ?jqkS4_uX5IPWO|ogWpuekQyR zhJ5~9eanrl^YSa0Uy1WQe)hjV+z~XOiGLYFr{nG660!yD%(~gYqr=R-FKVwrMbdqw zGGvOzOw1W?MU`(fZm4!@=*6fgZJr20 zrjg;|c;7liiQB{mq(@Nb==e|X`4vf8!5wUP2Un-wDY$EN<};2B+{1TzNv>W(y`$?;`Bir|AC-k78+DT}k$tO^RFTVw=lPEj|w2_jTo+HM|*- zaXo3_wr~;EwFQV?`F(HX<`v_?jeI4K6_NJ|IVYR&)RVYNo6`MpH+w^_NewFdwi)=3 z$==eAZP1ywvMtGqNhGv%Txh?_*AP7>~Tw`u#!DDIXPBeVHkY;9)eQ*aD}ARyi0g zf9i6!x?!wPcYFURq8GY@uCT=w0h0FmLTrhv^R>etzHLjHLsYbd^frLsyk$Kgb+62u z4(In>vr7S2A|4Nf@ZaAWz1+CouA!;t`8{NQepJ~T-L*!iZ00_z5q-@_3P{ew8Glgv zLVMuv8uP=xBFDKfOfd~CpT}zU=80xlWkzza0X+U!DoXyH4(>F(yeS*7$ng++d zjQ*?_1H_|JP9CldNA|e-nqJ_&Px8N$!&;1e60VaP3nm@vR)}WByOjl&UY1g(XNf@@ zcz#ETj0&Ya92AXarYgh6|1atl;3vBrG{;#UT6oV)hF%-nf04Mwqe4(o&RM;TswFFp zOK^ELSO1!zs11;|UN zN}lYT5Y&^c?Z|L~yhDUZF|BWxzoFWArB1ecNN}Qqb+`v3|2(%YE${6Et&iB% zeo57bNzpA_*eSZ1D-Ep`0>5GBln-LTNLpZO1v!L1O&F*z1^}Z8_0dcUY|S8g zf8L^qUD7G07QaR!#F$^$E)K4c@N_F4)FDhrqVwYe=fn}5pwHJYeFgft&&LOEod08X z$j6Zij_lW9rn)%IjK{@B(~_GZlHf9{&ais&;M8Bx-e%*5lS`puV2JKZ9o41@YyxFV zx4AettsG@-3UX((d>ahrLZ;u2nvH%q6QLK>99B)_v$}Zr04H^})y!mgKe$c#-N(r6 z5I|c1C=VVv)tTA)=laaVE=|7HVH_2Le~b&qp;5(BPWvFNWGblP=(a?SV(%_pXrLNK zMr3LGFITHUeTUWB+a8AeiD&E9_0&xSw2#KRW=>qPYKym9*#sSqXF#9W0E2vzV;0^V zGZzrIa1VYVkO9v*+23dqz%3AhiwOTNH1szNp9m2-)*uA0CFV-wypqUg>Wy`(cAVwe zxa&M!tm3b?WNLL}d;jN&FVcK=qCR@eNxUS>XS}rFDZdj;JJ2%dyEl`1#)ZfVLc49UniLfuh6+0|x4i23t>K;sK2ss>gONrO&&==(#`IE5x zc6z0sfc*FZ?Y__JWuG^R%s5*h)Yr55QLZ%L)^5_!AbaU|T_A)5V8DL5D>3z)gK!u( zgz_8iIsbmpEuSzqB7T>4vmS;Y=;YsqyA{@73Gi|`V^tIxKAJ!N#+asiwW9AZ({+NV0_Ub)_=em`^=AX7{29eTg@_0mJ{ zM3~PY58NEh2e7xT@-=ZwHAS2=Te-?it$KC$Hv0Z$9h1TUG#DxvQPVDj%}iBJffVQ6 z5~g_+Rhae$KPe@)(&|6^;uNx3TOZZ?hjBJPv?eoS{T2KpKV*qK-UG=NB@AzbXSj`0 zr;%sjf?EaoR_rUm9RcE7l3IB4y^*wT;B1`N$J#{{58uueED`ub#eSu5uEX3#%j=gw zYDg8N!z-uyefH%FbC!4q7V?60or_m~QZ5zhscRZ74d=m{@RFAW zUYb7maYT2+KG=})yBFhp%zB0&3s=Rz*3;JTS~+^317~M~jv>Y(%uTPDsXtg21K*&G z#8SqQwO8#G)?Rt<&>=`7pLw#%O6QT7`RdNO8~zvr(Nt^Q~HFpSQ=0ZUg4DMK)i zN-3lP?%B4VB_5T>C$i2Ko`Lb zm2}CUS8G?6)RZWjPt`!`7_T_$aU~~ASLlezJGRmz`)R9q8wTsWd4+>#Gis zyi&618p)1K3hEfw|6<;u{miejCbOZgt4EV7Bp#ltZq`0-S9q^-6n;r{eDB#;GO$%^ zE|cbI$J433=w2{R=}`wj!&e=aL(b^(UV$I;Wsg^}ho4bZUcD;UGHAg)jgWSv?~`8t z>RzClg%Fxg;p!_Mb^)@lKXUVZnY2?~4in*+_#e=Viq}bsF-1q$ItfKaMSe|n2y$FH z_}q>;x}MWAkz;!V`Xr3w1a%hqC|+vQT{$s#qSI3Qvh-_jhUo2{9JBR+3o6(CWJ#Q& zw#d>hdfE``xQqV1KdPlvr!v_qvk(k4@xe6h3@=RVb9Rf>b%ImJXuq0OV?500#L4p7 za{vDUy(>-_WF#~^dY@$T!@$eS(5cnB@76{RNd@MeiWVPD4XVxT4sRO6 ztV@~ioe9Bw@|$s-Oqe~UpAqTpuz0%ODpG%fH8^OK(K)edJZLFo6}!0Ut3F%A-WEfN zrR;!^QcHto+MItMQV8=UefXb%xX(l#8K0VQ%m~ci_#lj(qw%3}oAwawzHM~3txC5L z^7r=Lk}NbB0j?$U*0F~76!CpxYTUlb%L3(Nr@#EIrR;kS<{J;E5B1AyX>;`BB@8+*3 z&26sFYs;upldq0XX(Wu!JQyRx0&FAp>*pVLj!=86!HJVCZWprNN|~$M@2BT+%G|0$PBVq1CfsF>A^9$%S0w?86kW#(zRr?9IG!IhapnJGg>6+m5DUB^PsTm^BJnGeQ z5p>etq| zZBg+uoNL4;%2s|&*yyuOK`RU78%?DC@?|eQA^g%bt5ro_Mr+D_|2bo-85~-n5n0&s z6+>71d~JQ#`qHngED={=7)vTNjzEI?M)ZQav?)aC#v${NB%uw44LkFuoS^d71VSQn zIO8X){*T0SA8ifp*M6`QbA^xNOk5c$F{1aJ-DI5u*LuO>aM8BoAj@!QA@+sIxw5S8$zUV-qL`Q>HMU zd0&(ZW118N>hx?;X%VH=)mJIIF?T}F!o8wl1-dhH4kTYly(0ku&mXi;o`j?dzNi>) z!j>QzY@%8;o`?1CYxue7RWL{NqHwf1n>R*>rBRK$=X#w-tq3n&neU;WGWmugaW5Pg zjjAkKiO|r(JzVM7T{0_gLQCW{ms;jDS0auzx&IB*9U?3BzL8uw;rx?-Jf-?DHZVvk zSbbB=>`=Hmj2HaY_Oj87T&W-7A$}6j^$n^t#F-)U0_N5XEY~n-508*>ABU6NHk<3Z zP?@m_Df_0EDK*VkE8e^Bff|^luHkSXyxgr(vpJPgj@`L%a5$z9P(IcgU4fp5lhOM3QJ zt6;VKef@BX)t49-fzqTW6Zp+W%~$?Dl`AIC-e9PdMmdm#QGK)OW^riL*W9d;rnlM6 zhEPx6`->^d1F(iR9;YVvmp`+k5~^8AIiR^c1CuoER>-kV74IOqoX8JtR(V_px#hQ_ z22`DP3x>qd0Y15c8Tg9U0E+!q{RD+cN^ior`HvT~1EH@8hXK*f!UiMCN~-(pTsCHP z$N%MylM@ZVaUHz7#m?2a|NnLYD6m^3Sf(>FcShC+VyK4wVF&O|jaQ7}#j%MM=(mf3 zUs$53@tM&N<@rxuIR31;k`A^#3hWCEQ@@$=vPwQfsGR0}VfQh$p$Wn@-U(-Hl+0v7 z$xe3``+zBvsmfAq(JjpwJtA&#WWF^%-j`US@p6{)8GtL1l|!uVB`j87?39HLM%x;% zF5ML|wyB7|$@P(D2Qk~(w)0l78;i3^yi{j!KHliCBS(y^{&W0jcv>3@X6QZ5!#{Zx z{N2ccGYJ$BQJ2xDWfX)L&6EP%`j!j-o&7oCC9~JCve(Vu8HBXW6=R^!$|-Jtu8Aa?JToi^RVJSte$_YX!2=cjO&{M>;+)GZ*{eRBbRi?SuSc zoT_(W&ME#60+A<2dLGNPpQUWmc4SEcg(@dz@q2!JNjcEe+xnqArw7634=hzD9)ySTwZ5Qn@!u~l0M{OM)V6qjv2h7~1|Kh92lfcSeD zbG;)&$~+)m8b1*%u;>0qIu)V)&dT`UdCY!ZGf(6V4~}S&SFB{CU5=t{VcFXm@2#m5 z?GKC84>tr20s=#jIzQ0otY4wFwEG%w*=!%MZy_~woX^|_{-y758~_Sg2}L{-U#GB& zksDdWbEux3C(G?pWFAldvBFG9^~X3UsJ^t7Zeu1XR~~WC?dvOX)_S~s%}=#x zGD16;YM|9X#nf~yG<;znlN_vjM;rJfo`3DWPo4(xmr`FXIgWPR(crX}Vg zvf6xHR|n|o&=uX&g;syq(X0bgo$l2W5VPkE0aninEF!6;Fe_LHY>-^b=6J>4Qa~~x zwG4uktOf-U#`y4Qs2957C`)MX33|!;ytQk24x}KQ*fU<3S1x6%`VDvgykt(5sB@Ku zakU!$PUZk}q&W*>{d~wPQ%YrQfAs&sVpHbxxt759PY)O?jMp9^ga+0TF^>NV-l1Y6 z$(Y-@u$~;9>x5gnGVq4~ny~Z`(|RlRwW*E--FxfEy>YL(d(b6hH7;Rq z2QQGfbli46JU*s%gHzSLpBNSB93fH(7RJ+ZH5>SXN$1p@K)CpM$K`oNjTZVjojIqi zVj!Cos#$oX^{O-;E&R9)o-gHmdsXb`Q7rKAePgCjn>rl!;C=POdC^BRL$y}4&}6pD zJ;8663YXm3neCSwH!g8(*yt@V9yBezRXh@VEe2#QmVc4YHSK(;y(Gr@IKNojMz_B4 zn{nGFUs8!Y$^CgsQ-mq%Rul?f2(Z@~YP>lz@oI<6a&@zC`@*hBmI3Av!KD43(jjMiDu=>{jonX_O=V<~k2yBZCVsb7Zn6ZdMK_=#0 zbWU`XOe9~FyGY^J&khbD5zpN_OMUqDG$eMJmu6+Yz#e@_hLME2J2;T3L6ATR=x48TOAiX6u8vD_tV~b4 ziyL5Mt{?cNaSU~g@(4D59MR$v9G*G*;#e3GsUf|d z2s^y@085l@i%$+D8RL7)NIHN;uei;8N}%imI8Gc9Msb)W zGaAx8S`go}PTf@M?19k(#XNo~TO=f(64;67EC+|Q#a&bfMB$uY#p|d8%1C|q3b`r#_4$qEAzaR6%XwA=*lQ=G`CAsUf1JkE;BKNkU$OMV#3XwMf9B2PW z;{7%)Y|W?)R8?T}LJ^2TBJBCD~`2U48>~!Y=^r?@l zhlf&&Ltdt6x26TD_W0bOkK8w_XX&bFT>`29&`dFEYAht9g`G6DojS_e{G+VG<=gLO zi{2KBFyonhb>aGHVk4>Cls{DP{cdLXKA{Hh_W~~*7%Z4!g|IB>i!9gHaPt)5GO#=? z4=Mc8XmJTvB{b63e&s(evge?q)!w99J%RoncFw)09tpc0LA0F)2!!X1lx0)W=Z&P1 zq{dHz5|if|j9}f{+H0;SJ9R>??&Cw+h^sp=Gj;{+-43hbxXuS@x)qy&Wvv33L?H7_ zFts88Bat7DlMH;5m8!^jv+Pa?wCqRP6=ry-Q78mmcgXEV>FBb zWBudv{r;ZkJm)-r!h7eu&%N!suj`eIT9zmf?l;-vttDOjCyBRsC6kHZE28yIIq9yD zIrA1mEU$Uma0|N0P!NY`&$;HKoc2-@#mt?YU3p!CdCZK#`$U>OR0GbOhpcc5;2vEvc|QO1}~>Jp>EhvQtc<1l3krrAUp`W{ix4RFJ!Tbl|a4aavBU?Q>3s3 zjsldI=kYJTLlsWDrX1slV&5=?-7h*_%Toyf1D6t z+DcG=zLc^*@dVRWsJBqvU(kcgtPWpGrIBt==hLs?J{lGTNuv^d`Y3j~Ib;=DC$$FQ zB*CYlmpnGP)0@i=dnlcvBTbW#w&L*=g6Rek_bGPBv~k!w$a!c<=FaLz60x^yO`Xsx zr-|h;j~imftgKOpYCsBwc-TTpUG^tyQT@ZUL6=Bk0AL`I#Vk-gJ+|VZn~16li-2OX z=u9sE;7x7gMn$@bp;Ogd`Ny8V1!;^IZf{+`V)TjBK=7@@Cx#*m2?(m;y!V_o<)g>H zsUhp#Nd1TME)(U#Yk-FH96<8w-+k6OG88w#UC+F79FFY|G{okqj;*HUJA*@Z2V0q# z4AYi-la{%Z%Oh|1T`d6x^jp=?c}SX#D4TW?Q@CI zZ>??^{?HQr$H;#E4@~2<^M5(7oz>8VU?)drlw9Dp$>tDotsVcfFjK4;Ww;)AD~K({ z!>uQ|;pipaMbjA^E^;Db=5lsUV#k|;b0!`zkmexApyCxsO@YFKLfUXg7v^C<^6z)t zqc1lfnVCmQ3A{SearG7HOiH4Te6=5*L{k}qq&fiY4WDt{yH(NT{X>p#d}kc&d0;di za5%5gb}*n{qZsKKBet?J3I%P>!25P_*6S{>X<^~xDRr_}*s|gqMO*?UP#W<%EpDgi zQmcc_OrDWc8Rqwc_mugtlfcskd3*aw{*eZJTD;<+wrOhP6Uw z$LCG%5PXJZ>|UDGtEK?R$2Enc%)i}*$$cCkf2vty*H*ikxUJqCt4#af?##4n-Nj9; zDFlv1gsZ+;_;D#I6lEL2vuI55%Xt_`M3y>L%pK>B2uh?4@3`1`znk!aFZbD3{U){r zcWh*mNb%#(hUAC*VlI8hd)yvx>aWW68n&q{F8H|kV#P%G=$}8=N35*sL2NdR?@P;O zTgZ+-pGWNB>`{TCD}x!${TW(vYlECS4Cq4}(~MR1$SYBckAG|of?kT7oqb`T4%vfJ z@(0(5Kih;+$)I-)SHv*z<3I4q>$ien`#vSV{56~cm;9+tc(J-u1q_rm;Xq6Q`LNty>IH1jYSPCC{i(olX-7XDBMaMl&V!3$Sl0e5h#E`>68dHT4+J3But@s?|AnzWSB zt|r~slOS!HoB1-<97eJ*U`|CUWbc^ZvbFztPVtpPY+80V+REQw;n2XOOq`rIXaNH@ z3VxlrEB-B;8btY}&MeE9^TVe(T_&{f9Of4bVKulAi#BibU1P;9&**9Nxjs=Ap8Tf4 z(b!i3xz~nU8r<){d2op2_O1t)Bj`sJb~B=AjMKp!583X654xTzf{%BAAxGKno9(0p zbY}*}_Q1>;;ypiuPjJ$-uV@NjO)R_A@9SphC~s|(5&UHt7{u24NG3mhe)Ss9$@>i1 z1Uq`ul=b65DZ%tsrv`Yv>P+MuYIwoD0u0XyVD+5%@(RcV-c6DXEsvi%3u32ja(~fS z-)rNM-ZK2{4r>uzT@+IW;hFxkkn3hLd2=&JQN+%&jM>+h!xu*Y%UyM74-oL;Dv#eo z4K+`aKVcPi!+ZQv(2Q) zc~Win7}Eg39gV|~aW^O2;~Dbp$B3X_NA9XNX<)vn{PrE2*^=$rkD4Iw^7p#~g20NQ zuQQ!Pm@f1V-asGr#^Rn?a_^!r6ps(|K~g?9Eg4vb*jZm*utoX3)OWI*e6sRQq9;-a zh&cDl10Rpbx*q*iO+xO&yE>6o2#`m1&g}rd1{Bwloo{){4ES`Ixb@arQP<+v$=m() zrKr7;S7 zd5Dg?eq=b9TndmU#gp*sm1ODf0zp2cfoa&wj=zIjR)ERHo?mkPwc$uv6s zq!-obQ?iDRfyiFg8Tv%SJ0s1{G3mRUzxnFv!f(-}4_3J3MS0GtbFD_|bvw@G1rs&~! ztS}T7DKmHu!&>0M3Cm^B>k@4p&h`h(M#$&)Tpe6@=z1oE?WU~UVPxnyD~#N-MC7AP z0bAXd)QA^_&gHJ~CGybHxeAcj2RQ+L45lPJw*Rg%)Xxw;%5fI(wnj4S)qGO9KOj8% z5BAf|c%Wg#pe@v9+8FSeK~0nkWqp0=6ng*jgaSa7=xr6JxGdrhd! z@}B?3)-s2-T|}7y=8o&HQvly);=*s%l=0%tcQe)pEFM zmjtnIks8(Ex7HI}5qHj7|LsyGzsgQE^p#EsZrLX4yt91rNqlW^Eb=Iml`zA;tH5Ti zWBJIV`df~<`lAG~GXz!C?k$Dwbw0Cnv~ysyP@w&-0v>S5>~;ojPEG(ND;CIwZE!u) zyWJ|`??xTBe=&0wp!$A_Iw+|>7PZqv%e1z)X z!*E@FZ)c$38r@aY*4-3t{f#2Ql2A?b+O(Je&`U{d2;_1zSekcg#&7{#%9zp$5_cDA zySN75H<9E(JWeiHu1&+E^3bd0vKwPLW+vXKlWS@kEl1*OLvVs05g%?I>vZ=AFUlwK z@sp`t&%<)T>M&LaDn*6mc@n4eU(08j0^973Q*qkLw!1*%`^Y=;CXCI&wKJcELWk*vv!%Q*SZi)^5vy;iIM&h^ zj}E0VOd|1lTtpIlSv>WiV1QLZsbvT0_$^InxyDb+?z2F^HQe`3dc+9R#kT>Uklm|8ik=}mfQ%UM54Rs3 z;n-7Io%8=OWYw#%NF2krPm3lRF%;)Of*fRB3w|jO8gWPfP{GfzkTYZ1LZC8WI9O@T zb4$rB;8tfl!yeajz4NOb9L5vqsP&!iE*(z{tfaHfJCn<2dE8Rzd_xc!=d}*I<|;L$ zF=+h*0`mLyq$af}%HEZU*8g#0hlCSebLMC{6c8+=Q@pQ!5!yK$C{4gnY0?S&6Ixt~ zvvV)^wjd~6-;Q1`Z-B_LsdxVmFwOhK?u6EgI;UY0-d*xwT7}Tv)n_<^{t$vgvd8W9 zIbc36yg$4pYc3Wp@HQ~e?FcRMNg1o1SBaTatnW1TZt$-6Rc?!WljAoPnZ9b2rS^EP zim3#2PHSfIiHQ#Wj~2^eH;|W#nsPe5J4F>g*|86d`{lC+@)-lOjlI+~hwc?rB-vks zv2SG6>U!<~ZU~)mFK~goWU-ktA-@Md9s1m9t+D@-?^ODLzinA%I-_+77c7=g{4LZ7 zJSX@4L3Lm7oUx2x)b>p`2@OFh{KRvh zkWU+inm;(+`IaV>*VT(`gvi?H&|C~WlfUu7J*fK+>`v2wf9Uhtw6STEm6V?)3<%%C z)RMaLagGL7|Kkngz11`^o|WHfvHTL@n=mvgy#i;vOWY(}6GBL8c{R-7_u5QDV7|7XLN(gI~R1r*U#txcQbilnD5 zJvys3kV71^Kjz}W;0#F+T0UfHsvADsGKi)DdG3QpBK`pnDd|ZqAIG?1nS|(0$5L1M z_hIV1w60?t0mF8rC~bFdU-^KWIIn1$1mfC zN$*Mb8Rljc=oSKUtWc5x;#$4;T**#WZenr0Z+A#E$gdA_%}d_bChEXCzfOh~YZr^7 zneABw2H_(>IDg>KQs)x(G#pPuWeO*3uR#a?HTWfS>HNn<1K(nEdsfT20Q3x1O)?=| z20Tr5(u{TlmXNjh2Py*$Z$$~Kcb%fcY;$sYpiKsLP;!UPl_^}ygN1WX!NUVb1fsum z&vAR$l!P=8>q2OOo@ArMI!>4HjM{HleLy#j=B(Th3ICx{7})U%^CmnK{@Ojr?eNmo zsxSXv1=gwf(VeSeWA|lpoJzdicsfq--?X@fy1mOA$4fHIRy_3@Pt%%~&`@zyHi46J zr}$z{o2Q2oE1h%A@};HMXi=B{(1_ednMedU@&m><*1ynEah8!qPb0aD`ibF^rPbMg z{%i1nI{-3jxRn1o?N<6Vl1@9bh~Y=C!>3DV?!PKvxr?RSNa z_5Z&D>*d$PT!llMQ^#Y}b-W_egpLD#^xojtUn{faX%Nm9d*WP5h}Az|KI97Oel*2n ziZ!JXO+SU8ID%Jp?DlgdrHqApOq=%0C_oA*;Jg=Vns>-gRH9HX8^h0Q;N+DkEnx4b zW0Y^kYaTk`nf3J$UTe5ayy9+p{}+a`p8JlkcxamM zgVe!?ergLc7P}`|B^UCmXi|AB#O+#s#h){&dcsPo`<2p$1`8WczRDK(T4V@!#ii!( zQBvp+1+3cVt)cZ6SAu>|vOG}Uv@b5-ADX;-a|zE{8KdbkEvCj44!od^uJ1G|`7*IF zg||^D6!QkjB7FE=E^wzV<=o9;*xEw)|7x!eY6_hLo(PKrnV-N5>lOU{)6DIv@q7yU ztr;w%WGOn=6RN+H9}F`=Qa9IhihDTG(FG18uLG%%SDs;WX~eM>lX-r;Z3O#5DtXG*u74z8W&H`Rp!FJjx{ z!x+xx_0rrl)Bz@Y(|*{JXdGU1ys^-26c7ZyFkcyZbtb#s1(z9F<9|7lJD9D*Ca2eC zW%)&rApf<;fXx%*JpviWz2(lH?zsb>Pst+_hzdf}J)e%%iIwh-Ea;6s0HFs)DFVS& zm-NpANtDYz`zLc0q7-?Nfzl5p7t*i89R&N**47s*IQfA=_hi46J?)R}LV{TCiaPXi zm5ToOaGmb~POXUVb@|kIo1=Vy--XqXW=x2s7WV>km4izS+ymLC`Gsg4NKm<72;sd+ zdE?egedkAlOWW0`A+cx1UGr>*zsfU6 zDt%R9h}NQ+LG$EhGY%b;o0+SEEMzLgHpa=DOA%}KfPSj8w+O~Qt+Q^utQxR`{=M>1 zE_s-4(I9D4fQKA<`_#OB7T$solSGOs2Opiy8<@t(id8w*yNXikoy{MD=gtkbt_S$^2Jsn!#=GC~d||Kkd#3%6|1o zV9Y_JwAo9^!+t#c7hZcUJ5(|E-2yoYMA81iuW&!5c9PrHvi?nu4t$Lqd+EK_J^(4c znjYqizGxDCl219jO#_;&H+L{-<8jnR^g=DfbW7lW|%ByX+-bYEUE@l6-9EcnXuCM`N~Hr2Of* zR$b$GJnywW<-OC$A?JF>qr>^f=F;(;q5uYwqRB*i-mRG6=VcePo*ep=bHHbll-zhN z#0|W#2fBd2oB!;uP$*~Z!jn^b0``aQHuxzPfwkKDXZyKl*|)BIRufK*ywRJ`{e=U4 zS%bs!;Tz{}IdQ+N_d@7v*=#wFCurY|-(ljobLRowkU{aa^!#4`JoNQ|B{&x*dVCR3;wMdJz@DTYJj5ypA=0 z>m3ha1MzPX)D(du4?LCI_-bxIcmwgvG%eFdv<)aKmeitY~oc{1^_pBH^P!Qwym`?Zf_ zF~`5Fb9FChbiw(?>!G{%u7`<%kD0VMy8zcDI>b(WpU$_XZh#w8q|jnhmC(j+>FgAR zp2<*4{cU4}-<3i2=^~@4HxLW&RBHdlM_S{L>C{y)DC{YVkUsUC`L;C_Q<7RAQx%i5Tm zmNsIxf`siIEFmI9QDEah#Zv1PyiiHU_Wr9&sxpo@3klAHvv1!`%vSTC#&3D6>q^RR z&O}dMxm&*km?Md5hkf0*ND$_%Ag4q&u>~)of3lT2~I-Liz zOP9>iDnK0Y-!zU;NmDoEG?11$gv!eq3S8SnZ;ZT3Ltf`*l&TJ!oVR);M5E;MHq`&| zXP&0&lpH1VyF%Yhn%@Hz*zc8ndwsjPa}~_BFC&+``aXyB#iC0)`Y?s%_|l2VN=Au? zb;Wn*6U!k-D|z=_smJsy_16wkkE2TeSm0K!B?~Fl@mDz(pW%vF&l_!io^u-$f)l#A z*T_=%ZA;E%eWV$`pxD@Zjh};z2SN2oq?ya^mVH{S@sFEC&XT8_3ohi%onMd9)4&)V zlKPd&ymg#AgDELjfn#+s^5zj`uO2&Lnlu#D7?v&|=JH z!k9F2ou&G|Z0Ub>nq6V}ZS=j)%>DyUSjSUHc#FQph*nyau7>)j^>OE~Wl{I=&!41K z{r#Og4x{@rQF@)3T2geyy5#FDtHfo0> ztF8M-A#Xtcy|q%$Edh1)dKxA9yOBfL-N$4jsmf8|4An0_68*2gJI_@m8mJS@P~wPG>y;RIU(#r?$Ap@T zOQ68YF9taq%`#8fMdI@OUcI5i9(_`CbgJ!GM}NLeMv7+%Z1YbNF*W05QzQMP`)XGg zA{`GJYO4KHBI#&q)SDDkU(~wyRl3Pz?fF$yvm;I8^QO_YN51%>jqZ%!Zxc)OMdCm< zag{%siWd~kNAG(B$K~$0dKoh#B`(pq6RVjmTJbs#iKv#`od`kAMJ3<0vDTd@Pjd!= zm}!-8g7N$h87;U(ne^sHpV-fIojH@q36-xr0`n@HI;4k%cCNaC1@QHfa_rBh1trwf z=)JDs68K7uW^0+qY#NI{dr3t@YY)sJo2zHMsuXGHr za;RMIuDJr=)bdW|(?svzr@?8M)p`6y{HiRaz-KkC&)=vqpbG0Jg=J^7bz9|DvwYwB z{6{4My;54+xNd)}3hls91{7Ew(02V6Me@ zcqRUGsomyix0Idi-RfJhrb)#a z)LR_ORfeB@Lx za6>;+90~*YY~EBcV9E%p;4idEDpvHOn~X23+d%}hetftz=)A++;SG@+cI82CIOC5O=+%O2v$z#!`6g3glgCd;%v}-a z^qiu8&LBpJ~gVB0YyS_nZof5_7kh&G?W1A|EDxZ|jjv;L0S` z24T)nu+oVUJC_Ni8@k}<=k^~!-4Y)^7ZpD;AzrF_@+b6H-jHEks9wk4uV1*ejNbkT zLSbu;f6=3qUTr?iUrI;l7+tdvyyA|(x|MJP(5gx;v{Vvp8HM{JMqSt3&apdtTyZVI zKyBL!YF;_zM75~PLNxjjU(Yu9Fw!unIWg_cxeLskSu^#o|LF7PT5~doBQpz+YtePH z=DWXmB6W&tz2(7eHFm#G;yZkz#|PW|snqh}Wo`PYA+xOP0BhRV^d^6+_ahbJik5X|LjiR2WpTkHu&s41}hyz5#Y6i;@XV@}1i zhD(}M@Jv%iDm4B}>-{#2#Mn%kG10)R=cF^L@3r)TJ3rflH{{|ZP%hb?YiblK-Rj8* zCaHA9O5{iXn@i)#CXp!1oy_@d`{OD`?wc}l1VkHl-fyZP2$LnuRRdG}>xbO(S%BdW z8q5|O(StshKwZIW!XWLrr;BV~XLN<$*);ZSTtkWyze=%M;|KMC_b_WB!j`LNVw{$` zXiVPR1k5c!hBPzTW`WjqBz?j~!mA-4Cy zr{)@(n~fjUK{`vdtj_ydl`?z`RkkhqOZ|&+^e2laA9~L}v0akIyAIDLWWl9w;1b;Y z$@G2hy;*w2a^2*%jKylcVJ?p$CU_gUSRTJg&uFdhI0jNW9dq68A9dg6g@|h>N*cJ; zYl-?$-PS|bid`RQdThAu*|@7=WyQy3u<|a|Y!H9Wv?60jpg{?l;anX)pD4SyHD#`TH2Me0p? z5m<%LhDnoJ!pww2a5-|sav?(sJD|dUxnzae>q; z@NTeTlb}Cn$HDI?l(##T@N@7jvHx*C&kD*qx%EsrNhtM6`g^s|;v z%HeCc^i6%5%D9Zj4xK*f)4lzhM`xi80hI|kT}?bWYc%<(F4~Yqwc`X@cZZ(mqJf@4 ze2UA_YcjA*QIT+AfKS>X@+TGZS89_KyWc!THP}3QTg!(^wYON^fSx1-(~u5iFXl~L zpCUKTgS;v#ij!a(TxO>ueXU3fCkwARpHrGt6wxJs_E?BifIe5x!rQhXbl=j6!eNuN z9Oa(mF7vE-BO%)^rys_8chEyh%EwvE;kdV1Sfj^kfwQfP{cqPv_zy_Wa`!}{UTt8? zs3Uqe&mCu1m_PK0gLHU0)>HadxP^x|jI?br;?j|b-@hKwH2uSq{Zx$bDoh2vV`2m} zx4j=LN`PvZ5W>fm=XxAm&mQueZ`)P?5AV49H^Oz&70QT+{S}0nKP1q^xV1ky9T;ek zMrbtx=42ceZRuMne2^mDj=f`<2fsenu+f{-op{wN!ybPs%QbpC{8k+Od~4enWK-=$vaD}uleZh zk7fEf&%8j(z+$E3Dz>`7iH(WI0e;iIu1|BoE1tj~;@mnEqn~PLJBHrB2E6f@;m3fd zh;4~m57;Lh{{=1h>;{?@gsk@BGf=S@Vy-=Q59S( zW*MR0Lcm{y?>c*ba4vxc+S2<3$`M}G}6r7Xv3 zYVLx?I~=*^Wh}66IsZS6cP_jWre0 zm)?)*m74!{NXU_DEWTBC{8LN>{HSr?ivQw#O(52a-T_T8vp5`9#MX;$D_gzBuf`k~ zK+Fqu+&!7o@MGf{+u#{~8lBg4;8=6By0k&YcI| zcr-b(?2y&HgfzYqz-L+D8^*QeM@I$(tXsnIwDDN=S>!nUs>?Kn6?pm+)F=-myiA|e zugjR{qT|+dOmH|r%yY+m>v;@c_0O*m3HmJI_bm%_^-B_9oW3in_yKd+`SxLe*I+?Z z2;1?fl;fshz_^lNeLOK;OT5PUgXew6nvb6Rym424X}TrDRc4Bq1IpxhTZ9_!-V+S$ zorgOrwuOOKHjDI?wSh7RFyC(d#>=JfD^S^ zZSB)HJp+X%7T~>K*jg$+TU#DWZL$#PGG?707sf?Ai6$QHdCR-fZ*yn9k*hW2IwtH)y`{O6;a-+&MJz0D+KYAkgl`4>p@m`FHJ|K0JNryLio4-|U|5WTM z4S)P#7Mr1yi$V?RVM+#Dxi#zW9mWpB&=moP1rYNq>3{{eZ}#Y|E`@2EEN$C=G;e>~ z%nA&!2mAgRKsc@Og`aZornJw4(8Y(b`qV_0pM_T@s4u;uV#>KzdCgFUy~M};G@Z?{ z=NF~pz(wNsxZil(t-`X8+EIq)W{1$1`*3yeJy@F?v;RRI?j*VL4B7UUkMJhPG2Y1F z1U%Jrz;h>O{bpp(m1<)!dz%@eja>2Pw3K`V=11-Ts0@BUt~W}<1B4|p{Ovy#9RQXy zfll)GuDE6_slQ6|!Fh;uUq^3to?Yb&eOS6{eq0>K3bMgllw8BCK)RVKth)KlL`w(0 zHg`w(4glnzGlK;GRw!=ovSC74N0LO*n|8{Ojfqs{xYnC?5h^FSd>5CYu4rbb8a#EB za>x%HVR-ZCF%M`nPx+yM8nCO9;(4W-xvPF9hZ`{1H;mAfB!EN@{-LXKY~hRDUE7%r zc=j=)gfABeQla%H7qhE(Qy@gLm zWSpo8IepOfATen3gS=Kk&e5HJ{#TF@K~?;Voph|iEqca?ZaaPngXew*z9CWvZ0+t` z2WpZ$sr z?VwW8oia?|R)=vYw8>$vfczDKkBD&AfqZ@+J!$tvqvJ_hR zeUNlHACLdArY>uAQvUJzpN=TAz;`R`flRzf@aCnRH0;wuMvS-dy3L-%HB>-`^RZRS z*AwoBDO>0bTYucb911xIA?JV-rpE$>klAYn$P56Vt4+y3V_c=#UhSjfo z)e2ndO}xdhvf?J>aQ$s+oACGyvD=Do!>RW}u#rq`V|N|v!i%$->5kL#pUHGFUwMrj z&*y122OBfUN6-1RDn3Pfe3dC7Ca=l;!6xfWN)<`$pXInrWasY;`G2d6b2=ULP9%#r zPO+t9Jlh92>u0CSA&1ASdu_;G;E~kddR!@m`^!i2f>zLZ7m5{p*m*zTjT9$12Y{H* z%E_8zpaEy{YI(rZnE60%-!})9zK0JpYLZ2M7!LcXN7o14(`y|h8yZYt`s z+U&%y`A2p{>e5NL-rejmDroJFm-BVxi^wUP(43$u1vWlOmmZG z$&;{Fc{Y+4bhn2M*Z&BNcs4??)3@H@rc-sQex%`KxOU&Z{>ynI;A5G5#_wT(J^H4% z)r7s!9F2VeoMQ3Y=dY3mS42#z3kMCj0)kIiKVj4;zVMhRbd=&H_tP%ef;C)IF7VfF za^JUMq!USM?_BOV>08r6 zJ7I-vlK{lP`TD4aVCHOg+t>=*`aEV!n*Ckn1gp`BtAeBVUVAC)qN-=<5_wiNR89Xx zzoJx$UilOtQV6uzDjva8NBiQ4oo?r&&4o;9_3Sb=MxQ>oCl8sDM^&<~e(!2lq~yU8 zmSeH0(&1f6n|twXyG`Ok0+YuIh9t!87?Fj-vHDx@;~30D5Q~gq`T-(k;bi)T-ieaI zCmiO(CWIsthk@GmvvnGNeZtmA%R?3K!s~0DB7GmPB=y!(qM?(2^2FFsbhNiYPu0u| zoXxf?JfFV??wT^gOx$CsdUy*k3vVEJ=;O0*L2U*7UqD(a@Z5a{gL}Kn4QY2@iM93d zmHLbU{D;HwSz6feyuUVdSiR=wfbDST+G|R;EnrLSAhv?-5yG+nSh-o`JIgsbQ?cNV zEQ24ZlU0K1H)8dXGfS7=%7KXEN}xvE^agfiG~gbq3TqUn6wOqpoz8}k`c(TK`COy} zOZ6)wK+uv;VG0KzH9M^H0W5vqOZ$M2fNhK4qQ`{kiA|;Sg>3)W`a}=Y8NZ!!#vd5V z6q91$qtd^928OOpq_qyTi_d}O&9o={J(MXwOz?~^Np86{e|7OQgovOGEiTlkf%LA9 z&eT|x;qUbNHRsQcSMFi&lTQDfrBrRNKou|%v|rjVQZncgZo#c^6-gPc5$E)aWJ?n1 zyTpm8$s;yXNB77wAC*Vxi^U1e4?nsz8kJnSpYbo;CVJ(m)oa^qSj;=1bOyKRB*Gu$ zb>&Oq;y@Op0wh|hd&BVmVN2LV-s@#2Yc1*Ln?<0~QJ`g$7021%>}vD{TnY=-wdBJy z{|T7n6LNcIxv}war4C}if9kVFRauNKHr6>Afy+=bVC?<6*wUxK+jpnf6NB9T&ft4= zscRFmsx4=49{KquUFBwjoZB?q%p52RuX8@p zFP&%BVdr9q<~7)*`GqNfdq;Gy%m34IdXwjMp!!4BC=(XAJjo9rw4?ChK!RhSEm
UXW|NCr>gbV2EZ=eQJM?7br?^rUi<&-u)xW|5WBv z75e(Glt!Ht+BsJCH!f~osW-}ATHJj4)dIX!FwdOBsCxyN!tkkn*yKjL6gt)Ki{#t869&sa!&e z-=Ki5P#&b>v4xl&#bnix=x>U^w5v$=UQelsFwjZoy zJ8rs!!2-9xZGOmqduogwAdRUAvc=ecRar49&w9nzzIE|28ou-UOWRpTL)-=^#~8tl z4#<-X<9n=}KjqMQGXCN%-`h#)JX~wK()^jacGObt%2Q1~mcPooYui^Z*e(3TpZELy z@Ne=rUg|^)mim3xSV&x&XYP|w5kIAicI9c(H&eE4)x9(rK&%GzRKCEKrT9oETbGk} zJD5xc^O{bjEYD!_x(^bofzj+m2C2NMo9grAtq0YOX6UDvlI>vC^eeYPqS>dt1u|aW zz8Z+G@ku&Bc%FL4Xo=Oc>wE7bD>5bg8ir3JCKVLsSdZN=ArCwjDv;>@EcGe0ZGCuJ{4k%VR;6{3-2DZ;9~nZ9apVV zi&aW%xthbUm4wDn0-?moaPsai^nAJMs{34bJ3f#h;LzA9a{(_bjc(w9$v*Iy3b3`2 z2N{xHl*qK>U2D;3%OUw)8lJ=Ws(HkDNR5Ed3&rChPq~|DUfn1y5xEStIGJ%0{x4#a%yRQmMA!I}49$@i&BTNt&zkJ7X^^?_^42+exW1 zuHjRG7SBdh%+ zg$XTZgNM~JSMQp%+n@6F>EeHm!96l3;i{styphuGxiw8@T*jQ5aZ@)00FCE)f#F%s zCbeG7cj#DA?1HM zlAF<=4n@oPdZp>JTB-U6=2t5D;KuFW?Y`F9*&W}`{VDgW$^ALRHTu(l=eD^JUx%1H zyIpa=)x)=YL52E{jJdECHjF*e@_7RWZX*p-sZfZ$ayni)wfA-bDUyLXTTZMcMRJO( z^LYy^Vba|$Z%9;>_tnI_CacJfL6H6Rdbfq9AYFY`d4rnAi7HuJ1BltN%~W1C9m5pQ zG6_Q_F@Cl-`$otk<}GP#Uo@ND(M{43j{IdR=Ay~AjK9rW_MHC_j}Fk5jhnuu*}SpP z#QmenKHo6@4s9AOoDY!yP8rrn2 zv|)(Oj|VgEB1uPbOaiTQJ6gf%{#qYsl3i@-GABU$%%lNEQsS-wMcq*5X9mZ#J8f=^ zD<7U!i~TM*x{luC(qM}!kkV(ysU#xy^cUJhiE}FD-7>v8eL8=v|NE-M4*N$ee{zjA zJMCpD`sZ*gB@s#KRf4x%%z$rX_>xcDM{kgusEA3_!jxNF*pcnQZYBezWO|Dz*Nrt3 z*9fe`8=4V?9Jw_AncluNk*`1JnIiNGDr zT0hhFp{OqC?d+OcoH4!rfMoU{H`3~T`_*Jwr5iQVLmO(DWm0J}BXgh7Ev%avXjx>*1#;$zFDR) zULH~XkUoX3n8bdM9+@1Ic76U2P6Qv6j|!G|=6jN~1skrgvIDEj&?K)9k}bPLXDIEk}!fl4Z&<8jBM3 zv+}1vKQCXh6aaS~e6FP&{?0rcEtEb;8r#VJiCbTHF=}jYtT$qfy&uhNeMx#UPD`{&ep1F#|mmepW z7%XRAK#tl75wyLEZ16@4sxby1{67zIRbb$~Jg4`Q*Y3S$e;EgzFYb6@!8NE)Rq4_B z-I~?#I=!BC8b$g}?Y)u*AAy?O33+foU1^?2FLZvLh&m2EACxi`08P*Y+TKbM zfU8A!6j}@lYnPv_DJuw4McEZko#@jfy60aDIgeLs}Mf$gm#bwl}8 z=XOABOLc#Bdg9z&xtv)>I1q-|djDaZSkfI`ud+*4Ho8qWl~jc!ByX|Vf!<3$4X820 zQ}YSogWXwr$)8$ucYi_|qPu;0Oyn3&`Sc&XI38_;y}SPSc!FVD8cxJ-Pxoa>-!d&Z!s`$}`4kyV0Tq9?+)9XO`xY%K8A_M!bSQ}F1H>y8u+a>u_X z1X8+mDORoLMB-G#AFYXfs5m@O(=e7Mm&#aMFnR7)u$>PG?hO1|`Qs?{L$Vm=Y2Lo$ zxzO-lV%@Ry{Cx57YBQL`$Wk5n(etbA0G;)oEK>>f6>)Jbxyv2>qzT1@AbVZu8;iGD z*y>B1ML$T-_9@@EEqV4$i2l`0gLnamlq}bD?a!eb@n+<50dCE7`FSS$tEaN0#3H5( zlmhvzGW|0M?Tw%LH1##5kxZYMR(tMX`RL2eFXR&F9v^-P{sD4s2{KTz+=4tibROMv z*HAc8@bmR0V}np@4CL@z6yH{Jif|zs-_S~GH-^F}51r;gOH#wMeJi`Qr<>33C{!~W zw{+4z_uZ#1Wqr@8ad*LJL6C-xOCF^6a9C5!b5C`o0YpFLRC->63fV^yJ2$j}ev7*n z#J{w=H%S(qD90jz$LMvfmH(HN*j1e2*>f3H_AgcT6gaPJI>T_6#&1QF5}fA@uJRog zky-joFAuFu=4c*WZy!M-^l>+ryfP<7k;D=)q2{kEpG*vLk7ypf@i++VOjPSmWHtB? zmIR|7hODp^Wg1w}tRU`bd4x7Z0F?g>2Jqo~-dlK5@z2=)VTtaGaEx|{O?RKV0v8){ z86(V~V?@}4x;p86h2@e=vl@lN#$`eaT6bpHiJ4-eedZC@Kc{0h2{}J9s6si_^7j?F z&p)V~PVu(nKBxV4>Tx3WQd|tXxcK=YJHAWqc-y4M^7*m-<6oT$r3%hL0W@NThmh4t zwV*u=>^x6}VIFC`6`2#Tcs3W)cJ}pD`Ul_L!hQE8CYD zNY|5CR@rJO)7^EDx2CnG2o|s)y2D$l0FMz8Z3y>FU){6a06SgCRI|9Y)BlAcD09aH zj2$W3tGyr_^z-!X!ikc!YIGv`f%?)fPUszdmTAxO?WtXtw@O@+Z)FXX(&{FJ>!n8I zsja+C^a~hh%LgA0*@}A0U7l)Q8^~hlLJ#N0b@)KG4eg(c%DVchCb{5CAecKD(xn5a zd(P8yfnOJ5uhyz*4^(6Fse9ZnCP#^aQ5_6aWt$j(`41 zHygY6t!e~%+P!_)wqh|+CUo7-YGFmL_188}4ey!knDbg8KoIe>@#6g)EDvIGPe5|M z=T=ZAjnu$u7(yw%xR^lzVNV5k66L>0`zfO);QoozUpN?h_Xfnrspqh!vu6|7Y{IskAIXu1Q`&;eVW&J6M1br zqkxy@$>>g|uk<_&Tpka^@a&S;sRok-=Ah^JphS9di$wb#YPV=p~c@U645H2m9p+dIn#Y=V5+zIS7oPt?r4nF7h?eqgz_0q!H zT{R9c9B$I=mNrLvbNJwuBdM4tXOWn!>ET%TwDSjc5Lye_NH~*2z>1fHq!AM>1i_4c zE;F`6!)& z4s7P*9%p2oSxyxVV`(9>-dE#1gM2fxFXG{mI*C!*((iKyqQX8)YBH`um3P>T&1hl( z&zpUE68aJSH8k^6H(z$|Kl}9m{-xjFW_{tU#!kKPn%{T)$KLqP*WEn7{0F!mKZ;Ml zOZ{T37m<8^3We)}@$&iOoy*CKmdlZuw~ahS55MZy1)F!MsGWyA`Dt*+PcEgB+BXRZ z(o3NL26H%pb#T4<-}DFG)J7)9MnBfk_*C^rcV^azoq1xktv?$<;K&Y^nLG}!{juQs z3jO)u)J!Hve`M+-vC)48Mjh2OzhkXWD11)_8<{F6k*P8EIzJU8O{4rOO5j+3nwtCM za0~FgV)%$hPGuk1dN_BGi!Iajcs`Cjd-X23g@mM4Y&k`jfnQirFHg4mDE+=Ns_V`V`jh3we^!u z-|3+k%iUJ69|zn(j){JWLGE?_tgVWA#B^WQPsiK~`ASD3t(rAqfiS;O$~kne{;gm6 zhhI5Me{%^QVDo;(ulPK&k6ClxVvnJ}pSf}K@qhm5hd=zh{CE0i>vw8v&+nA{LgBsP z&F}gXkKcOY$8VgS{|#K7m;1A}zijgZ17*CAJaArN?ut!eiXYhW@gk31VGPF}n`<-t ztQr%#+I9nZ!NT*}CG(nulR295lIUI|L0-P(n)-A16C+Og?Y-IRPfYT0KE;r z6D$4o0f=JH?!)~vTgCBk*g&0Wv=k(tK16O{bN=Z&F{JF|pJ65us)xZ`r!^?_IR8O}5eshA4ib~}9Wb7ouyQ_gi|mD@g5DHaiq z11yba4u|Uu9W29vrSML`HXNjiSH;9szKO|ZYWxl@bJ2y1ZzbCo_%=fzQUh0hq_BkNozUwYeBuTGSWVIJN0 z!dp`~J>QA`;xL8{vFZ>IvznPMopJW)ig3cctI@Zg>yVl^W8zaHK8Ak%5NV7CGjn8b zwptDk5?rNZ*j4)Jt1&Wf=^iuDArnf*p5{kiKs7l%`L z9mBkKC{Oy0w(O%xc$Vf(4B@l0FCxKDU0mGz(=W7tvcI+6fl<442h0~R<&AIrvoHJF z`B(U_4gPIh#lexZOdwub2FNiQg{lNV<2W?bSquXidRE}|@Atz3Cb zY})13`6OogZRdl~`WR*bFuW&w{iLw(=vuwflkn$CzdBGKdbm-q+eY(}k9`S-**gMi z^as;AdjWrkTjPm-Rs%TCpLLR-Gly^0nvMDyAB*W-5}XWN1P_KerXsjBrgKsV)}_VV zt$vl@gT?ts&n2+a)6G#DMaPnU4u@YKpImc}fNj3$WahAK_cvy0q6=&0xEuPJPJ4#E zI2kU|@a(P{=xwGk{juj9#zsHt`u&5qY{u|x*87LMmICxefAxHF{u8Io{VR;&9Lc@6 z<;-KJKQQKxTwa|2%X_|Y@B2Uf>0kQ#z`rR?oyRw&vMWTr{>|_B?u#cb{}wLIkAU(m z{y_OfWyt*S^`*rH(|d|H!`QlWeg8H$ABl3o=Bxt4$94R)HS)1dLV|hKXsuoszV(yt z7ti>Ni6&(#-ok+HUa-7OccNcXSkeuwNf>)#e)_K)=(hS=ECyvij_ifa{?$OAnd6t% zRmT{s%^!U3H9cGZ7E?rNQueZ0H$V7@vpWvkt5>7@C;Xx)V>w5RH^+F}Tff7rnVo>v zkJv4bDX-CQcb$#*K~TzYfx(=7?)67a?&6-uC~|Use&N)ii|r49mE=wXRpCU})<5;L zW?#w`-mvpmd2xr0b#>P>Da5!*`N74s;mL(n_J*F=RlfB*zWjC0%2l?qUZJ0LmM%nf z4Di^TIdVT_;{kZ~OL&gyn|3(FuGNpa*_%Djp~_%RxHZS0ZxYvS&--D=R;xC6?8R39 z|AXlNc;o!?Uw`JKANpSmf3Xbj_KQXB(E0k;zx@@rUUvRNfc`2r;Ro<0zI3?PTfiOC z<-wfY;u>~d(#Dyfd^AtTE}33Frt*uv<%gRYJiQ2HOKup3lJH*(um8`hHSEXeW~nQ**R;W9pRsr1h`wR5Y_h>}~$&HyX`6 zyV&es@wG3TJqs3X)vE%^wtnPii3}yr;@bJ^=;ecm{ZF@E`Hc^J{v#jx>b2}|Qs>;d zZ&Hc3m-U)Azw6a6y>;<}7iYJA`276F_v2!{(jPU%Vuy6mbRqc~t{aP6e zI-GU}+f&NSa4^mzdA$N<_=u^3W}RcN>BC6k4ZdOwuk%}c1=n&kHia`iey{$MryT7s z9P5Nn9kJ(3s`mi;@ju%NZ=0OfpYwA^cFA4)x4}z4_l9E$82berelg>pT-q4+C+yDj zE2v_b!@8GFt~u0Q>oS%-@`|L~kKG)?@KdMafmP8;3RKvbSzQf)a*ci^=&%?-E0z<( zvlf3mkkG7`!ziB|`Kuwg6zWfgar*NzWH!bpYve)0LoYu}-0Z2)B4#Rdtl%0bbPF9j~0h{T+w{44W#cQ{oH^G>$dm$yE+W# zOn(VshGw<3GA~~VN}y+pWOAp8wk4>1^M`VJs69fabm5O7L#*sjKPy*TX1Nu?JA8D{ z6RE%s$2yqj(OD%O8TJ~<2$nQ=q@RJ#dm3K#kWVSG6elx7$4FVu9E|3e>&3CP;UszO z!}5WPU%GcN^8UI&SS&=+ELc9`9P0qh6;_XMxXjQ6cI`l#kv63!&MGNimF2GL8=vkJ z@^5%bn&iv3?qA+p&+f%M)!1q9et{MINI#PVSHk(Y{3wEd{`~yngRi>rmH+e+;d*tTy7;1R`-=}26os# z%;QKYZhf$_N#49p<}!bNkSSr+4Cef<8LWA3K<@{0CzogD-}cYSdtUBCa&ZoUw0!k|~6jvW#hc;d^=nA{1Ni&qQ!G44cvdbJ|QIhF~K zIa;w47g+A9_kujq=v}?YC&WIT6*!66eWM-+>wjLHoj-`4AH4UTCm#INZ$9*e3jf3D zUk4R4)EV7c+}ZQCf9tKLOsI#+WBSS zGn{?_fcRCZ4_LoA#`(@^fRvYnlzJC6)4wogLzToSxE^**WZC6Qp5>s$WF*XSJ1l

o>ms@`qmx|Kxscz+IWLn|CGIi#ZAZp~B01FTefr z=8ZSu^1l%q`zJ3hFTM?L&V6|KzYVweKH#syZ~PYu&t8I8p8wF{OYr-q_l-xvkAOcg z#^Tvxc?sj4aa&(8>IF@1bLewDu~1$gA#u^Ayz*?mJqWRre%Sp!np#eh=9t-t@7D{v zuK&bsInEU;N^C7RpBhEd${1Twdp;$-@*!WV4r7`HsaM;qCG-Pf5fKYmtpBbzz zXOx_pclx)S>Y$ojdsF$Y($B=>0#~?GZ;E$k>5v(vhqP6twhO)HhdK3eq^IT{=}%m1 zb!;VY&iFDrd7o43i7p*7s!nn22fx+Z_5fTeKCS#{eqmF8XyHU_aL&PwIKcSd(Z}DG zKYt8&6$^ZZW literal 0 HcmV?d00001 diff --git a/docs/assets/icon.svg b/docs/assets/icon.svg new file mode 100644 index 0000000..36bdee6 --- /dev/null +++ b/docs/assets/icon.svg @@ -0,0 +1,51 @@ + + helm-apps icon + Layered deployment library icon with Helm wheel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/.helm/values.schema.json b/tests/.helm/values.schema.json index 9cc24c2..fb1119e 100644 --- a/tests/.helm/values.schema.json +++ b/tests/.helm/values.schema.json @@ -77,10 +77,13 @@ { "type": "string" }, + { + "type": "null" + }, { "type": "object", "additionalProperties": { - "type": "string" + "type": ["string", "null"] } } ] From 30fbe879e18c2eb7f06545a49218095f745f4fff Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 03:20:12 +0300 Subject: [PATCH 22/45] ci: switch workflows to werf and fix include-merge contract expectation --- .github/workflows/ci.yml | 33 ++++++++++++++++----------------- .github/workflows/release.yml | 23 +++++++++++------------ 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df61e0f..15c460a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,10 @@ jobs: LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl - - name: Install Helm3 - run: | - curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + - name: Install werf CLI + uses: werf/actions/install@v1.2 + with: + channel: ea - name: Install Dyff run: | @@ -30,37 +31,35 @@ jobs: - name: Update test chart dependencies run: | - helm dependency update tests/.helm + werf helm dependency update tests/.helm - name: Validate values schema run: | - helm lint tests/.helm --values tests/.helm/values.yaml + werf helm lint tests/.helm --values tests/.helm/values.yaml - name: Render run: | set -e - helm template tests tests/.helm \ - --set "global.env=prod" \ - --set "global._includes.apps-defaults.enabled=true" > /tmp/tests_render.yaml + source $(werf ci-env github --as-file) + cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod - name: Test render snapshot run: | set -e - helm template tests tests/.helm \ - --set "global.env=prod" \ - --set "global._includes.apps-defaults.enabled=true" > /tmp/test_render_check.yaml - dyff between tests/test_render.yaml /tmp/test_render_check.yaml | tee /tmp/test_render_check + source $(werf ci-env github --as-file) + cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod | sed '/werf.io\//d' > test_render_check.yaml + dyff between test_render.yaml test_render_check.yaml | tee /tmp/test_render_check check_tests=$(sed 1,7d /tmp/test_render_check | wc -l) if [ $check_tests -gt "7" ]; then exit 1; fi - name: Update contract chart dependencies run: | - helm dependency update tests/contracts + werf helm dependency update tests/contracts - name: Contract test for include merge behavior run: | set -euo pipefail - helm template contracts tests/contracts > /tmp/contracts_render.yaml + werf helm template contracts tests/contracts > /tmp/contracts_render.yaml # Include order override and local override precedence. grep -q '"A": "2"' /tmp/contracts_render.yaml @@ -73,9 +72,9 @@ jobs: grep -q '"fromBaseB": "B"' /tmp/contracts_render.yaml # Env-map behavior: - # production resolves explicit env key from base profile. - grep -q '"ENV_SWITCH": "base-production"' /tmp/contracts_render.yaml + # production also resolves to override-default from higher-priority include. + grep -q '"ENV_SWITCH": "override-default"' /tmp/contracts_render.yaml # non-production resolves _default from higher-priority include. - helm template contracts tests/contracts --set global.env=dev > /tmp/contracts_render_dev.yaml + werf helm template contracts tests/contracts --set global.env=dev > /tmp/contracts_render_dev.yaml grep -q '"ENV_SWITCH": "override-default"' /tmp/contracts_render_dev.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e88b8d7..dca5f78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,9 +25,10 @@ jobs: LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl - - name: Install Helm3 - run: | - curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + - name: Install werf CLI + uses: werf/actions/install@v1.2 + with: + channel: ea - name: Install Dyff run: | @@ -35,26 +36,24 @@ jobs: - name: Update test chart dependencies run: | - helm dependency update tests/.helm + werf helm dependency update tests/.helm - name: Validate values schema run: | - helm lint tests/.helm --values tests/.helm/values.yaml + werf helm lint tests/.helm --values tests/.helm/values.yaml - name: Render run: | set -e - helm template tests tests/.helm \ - --set "global.env=prod" \ - --set "global._includes.apps-defaults.enabled=true" > /tmp/tests_render.yaml + source $(werf ci-env github --as-file) + cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod - name: Test render run: | set -e - helm template tests tests/.helm \ - --set "global.env=prod" \ - --set "global._includes.apps-defaults.enabled=true" > /tmp/test_render_check.yaml - dyff between tests/test_render.yaml /tmp/test_render_check.yaml | tee /tmp/test_render_check + source $(werf ci-env github --as-file) + cd tests && werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod | sed '/werf.io\//d' > test_render_check.yaml + dyff between test_render.yaml test_render_check.yaml | tee /tmp/test_render_check check_tests=$(sed 1,7d /tmp/test_render_check | wc -l) if [ $check_tests -gt "7" ]; then exit 1; fi From d0c2d2115939bfb6d9c7f0a6f57e7072005d6cf4 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:34:35 +0300 Subject: [PATCH 23/45] feat(compat): add kubernetes version-aware api/spec compatibility checks --- .github/workflows/ci.yml | 54 +++++++++++++++++++ .github/workflows/release.yml | 36 +++++++++++++ README.md | 12 +++++ .../templates/_apps-api-versions.tpl | 33 ++++++++++++ charts/helm-apps/templates/_apps-compat.tpl | 32 +++++++++++ .../helm-apps/templates/_apps-components.tpl | 11 ++-- charts/helm-apps/templates/_apps-cronjobs.tpl | 6 +-- .../templates/_apps-kafka-strimzi.tpl | 9 ++-- charts/helm-apps/templates/_apps-stateful.tpl | 3 +- tests/contracts/values.yaml | 30 +++++++++++ 10 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 charts/helm-apps/templates/_apps-api-versions.tpl create mode 100644 charts/helm-apps/templates/_apps-compat.tpl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15c460a..35c5356 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,14 @@ jobs: run: | curl --silent --location https://git.io/JYfAY | bash + - name: Install kubeconform + run: | + set -euo pipefail + KUBECONFORM_VERSION=0.6.7 + curl -sSL -o /tmp/kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/v${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" + tar -xzf /tmp/kubeconform.tar.gz -C /tmp kubeconform + sudo install -m 0755 /tmp/kubeconform /usr/local/bin/kubeconform + - name: Update test chart dependencies run: | werf helm dependency update tests/.helm @@ -37,6 +45,34 @@ jobs: run: | werf helm lint tests/.helm --values tests/.helm/values.yaml + - name: Verify Kubernetes API compatibility + run: | + set -euo pipefail + + # New clusters: stable APIs must be rendered. + werf helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" \ + --kube-version 1.29.0 > /tmp/tests_k8s_129.yaml + grep -q '^apiVersion: policy/v1$' /tmp/tests_k8s_129.yaml + grep -q '^apiVersion: batch/v1$' /tmp/tests_k8s_129.yaml + grep -q '^apiVersion: autoscaling/v2$' /tmp/tests_k8s_129.yaml + ! grep -q '^apiVersion: policy/v1beta1$' /tmp/tests_k8s_129.yaml + ! grep -q '^apiVersion: batch/v1beta1$' /tmp/tests_k8s_129.yaml + ! grep -q '^apiVersion: autoscaling/v2beta2$' /tmp/tests_k8s_129.yaml + kubeconform -strict -summary -ignore-missing-schemas -kubernetes-version 1.29.0 /tmp/tests_k8s_129.yaml + + # Legacy clusters: beta APIs remain supported. + werf helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" \ + --kube-version 1.20.15 > /tmp/tests_k8s_120.yaml + grep -q '^apiVersion: policy/v1beta1$' /tmp/tests_k8s_120.yaml + grep -q '^apiVersion: batch/v1beta1$' /tmp/tests_k8s_120.yaml + grep -q '^apiVersion: autoscaling/v2beta2$' /tmp/tests_k8s_120.yaml + ! grep -q '^apiVersion: autoscaling/v2$' /tmp/tests_k8s_120.yaml + kubeconform -strict -summary -ignore-missing-schemas -kubernetes-version 1.20.15 /tmp/tests_k8s_120.yaml + - name: Render run: | set -e @@ -78,3 +114,21 @@ jobs: # non-production resolves _default from higher-priority include. werf helm template contracts tests/contracts --set global.env=dev > /tmp/contracts_render_dev.yaml grep -q '"ENV_SWITCH": "override-default"' /tmp/contracts_render_dev.yaml + + # Service spec compatibility by Kubernetes version. + werf helm template contracts tests/contracts --kube-version 1.29.0 > /tmp/contracts_render_129.yaml + grep -q 'loadBalancerClass: "internal-vip"' /tmp/contracts_render_129.yaml + grep -q 'internalTrafficPolicy: "Local"' /tmp/contracts_render_129.yaml + + werf helm template contracts tests/contracts --kube-version 1.20.15 > /tmp/contracts_render_120.yaml + ! grep -q 'loadBalancerClass:' /tmp/contracts_render_120.yaml + ! grep -q 'internalTrafficPolicy:' /tmp/contracts_render_120.yaml + grep -q 'ipFamilyPolicy: "SingleStack"' /tmp/contracts_render_120.yaml + grep -q 'allocateLoadBalancerNodePorts: true' /tmp/contracts_render_120.yaml + + werf helm template contracts tests/contracts --kube-version 1.19.16 > /tmp/contracts_render_119.yaml + ! grep -q 'loadBalancerClass:' /tmp/contracts_render_119.yaml + ! grep -q 'internalTrafficPolicy:' /tmp/contracts_render_119.yaml + ! grep -q 'ipFamilyPolicy:' /tmp/contracts_render_119.yaml + ! grep -q 'ipFamilies:' /tmp/contracts_render_119.yaml + ! grep -q 'allocateLoadBalancerNodePorts:' /tmp/contracts_render_119.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dca5f78..ceb073e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,14 @@ jobs: run: | curl --silent --location https://git.io/JYfAY | bash + - name: Install kubeconform + run: | + set -euo pipefail + KUBECONFORM_VERSION=0.6.7 + curl -sSL -o /tmp/kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/v${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" + tar -xzf /tmp/kubeconform.tar.gz -C /tmp kubeconform + sudo install -m 0755 /tmp/kubeconform /usr/local/bin/kubeconform + - name: Update test chart dependencies run: | werf helm dependency update tests/.helm @@ -42,6 +50,34 @@ jobs: run: | werf helm lint tests/.helm --values tests/.helm/values.yaml + - name: Verify Kubernetes API compatibility + run: | + set -euo pipefail + + # New clusters: stable APIs must be rendered. + werf helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" \ + --kube-version 1.29.0 > /tmp/tests_k8s_129.yaml + grep -q '^apiVersion: policy/v1$' /tmp/tests_k8s_129.yaml + grep -q '^apiVersion: batch/v1$' /tmp/tests_k8s_129.yaml + grep -q '^apiVersion: autoscaling/v2$' /tmp/tests_k8s_129.yaml + ! grep -q '^apiVersion: policy/v1beta1$' /tmp/tests_k8s_129.yaml + ! grep -q '^apiVersion: batch/v1beta1$' /tmp/tests_k8s_129.yaml + ! grep -q '^apiVersion: autoscaling/v2beta2$' /tmp/tests_k8s_129.yaml + kubeconform -strict -summary -ignore-missing-schemas -kubernetes-version 1.29.0 /tmp/tests_k8s_129.yaml + + # Legacy clusters: beta APIs remain supported. + werf helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" \ + --kube-version 1.20.15 > /tmp/tests_k8s_120.yaml + grep -q '^apiVersion: policy/v1beta1$' /tmp/tests_k8s_120.yaml + grep -q '^apiVersion: batch/v1beta1$' /tmp/tests_k8s_120.yaml + grep -q '^apiVersion: autoscaling/v2beta2$' /tmp/tests_k8s_120.yaml + ! grep -q '^apiVersion: autoscaling/v2$' /tmp/tests_k8s_120.yaml + kubeconform -strict -summary -ignore-missing-schemas -kubernetes-version 1.20.15 /tmp/tests_k8s_120.yaml + - name: Render run: | set -e diff --git a/README.md b/README.md index e1cce2a..0b29f20 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,18 @@ helm lint .helm helm template my-app .helm --set global.env=prod ``` +### 6. Совместимость с версиями Kubernetes + +Библиотека автоматически учитывает версию Kubernetes через `.Capabilities`: +- выбирает подходящий `apiVersion` для `CronJob`, `PodDisruptionBudget`, `HorizontalPodAutoscaler`, `VerticalPodAutoscaler`; +- учитывает различия в полях `spec` между версиями (например, в `Service` и `StatefulSet`). + +Практика для проверки: +- новый кластер: `helm template ... --kube-version 1.29.0` +- legacy-кластер: `helm template ... --kube-version 1.20.15` + +Текущий CI также проверяет рендер на нескольких версиях Kubernetes. + ## Маршрут по документации Стартовая точка: diff --git a/charts/helm-apps/templates/_apps-api-versions.tpl b/charts/helm-apps/templates/_apps-api-versions.tpl new file mode 100644 index 0000000..342fb87 --- /dev/null +++ b/charts/helm-apps/templates/_apps-api-versions.tpl @@ -0,0 +1,33 @@ +{{- define "apps-api-versions.cronJob" -}} +{{- if or (.Capabilities.APIVersions.Has "batch/v1/CronJob") (semverCompare ">=1.21-0" .Capabilities.KubeVersion.GitVersion) -}} +batch/v1 +{{- else -}} +batch/v1beta1 +{{- end -}} +{{- end -}} + +{{- define "apps-api-versions.podDisruptionBudget" -}} +{{- if or (.Capabilities.APIVersions.Has "policy/v1/PodDisruptionBudget") (semverCompare ">=1.21-0" .Capabilities.KubeVersion.GitVersion) -}} +policy/v1 +{{- else -}} +policy/v1beta1 +{{- end -}} +{{- end -}} + +{{- define "apps-api-versions.horizontalPodAutoscaler" -}} +{{- if or (.Capabilities.APIVersions.Has "autoscaling/v2/HorizontalPodAutoscaler") (semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion) -}} +autoscaling/v2 +{{- else -}} +autoscaling/v2beta2 +{{- end -}} +{{- end -}} + +{{- define "apps-api-versions.verticalPodAutoscaler" -}} +{{- if .Capabilities.APIVersions.Has "autoscaling.k8s.io/v1/VerticalPodAutoscaler" -}} +autoscaling.k8s.io/v1 +{{- else if .Capabilities.APIVersions.Has "autoscaling.k8s.io/v1beta2/VerticalPodAutoscaler" -}} +autoscaling.k8s.io/v1beta2 +{{- else -}} +autoscaling.k8s.io/v1 +{{- end -}} +{{- end -}} diff --git a/charts/helm-apps/templates/_apps-compat.tpl b/charts/helm-apps/templates/_apps-compat.tpl new file mode 100644 index 0000000..e8443c3 --- /dev/null +++ b/charts/helm-apps/templates/_apps-compat.tpl @@ -0,0 +1,32 @@ +{{- define "apps-compat.normalizeServiceSpec" -}} +{{- $ := index . 0 -}} +{{- $service := index . 1 -}} +{{- if and $service (kindIs "map" $service) -}} + {{- if not (semverCompare ">=1.20-0" $.Capabilities.KubeVersion.GitVersion) -}} + {{- $_ := unset $service "allocateLoadBalancerNodePorts" -}} + {{- $_ := unset $service "clusterIPs" -}} + {{- $_ := unset $service "ipFamilies" -}} + {{- $_ := unset $service "ipFamilyPolicy" -}} + {{- end -}} + {{- if not (semverCompare ">=1.21-0" $.Capabilities.KubeVersion.GitVersion) -}} + {{- $_ := unset $service "loadBalancerClass" -}} + {{- end -}} + {{- if not (semverCompare ">=1.22-0" $.Capabilities.KubeVersion.GitVersion) -}} + {{- $_ := unset $service "internalTrafficPolicy" -}} + {{- end -}} +{{- end -}} +{{- end -}} + +{{- define "apps-compat.normalizeStatefulSetSpec" -}} +{{- $ := index . 0 -}} +{{- $app := index . 1 -}} +{{- if and $app (kindIs "map" $app) -}} + {{- $_ := unset $app "progressDeadlineSeconds" -}} + {{- if not (semverCompare ">=1.23-0" $.Capabilities.KubeVersion.GitVersion) -}} + {{- $_ := unset $app "persistentVolumeClaimRetentionPolicy" -}} + {{- end -}} + {{- if not (semverCompare ">=1.25-0" $.Capabilities.KubeVersion.GitVersion) -}} + {{- $_ := unset $app "minReadySeconds" -}} + {{- end -}} +{{- end -}} +{{- end -}} diff --git a/charts/helm-apps/templates/_apps-components.tpl b/charts/helm-apps/templates/_apps-components.tpl index a129212..24dcc22 100644 --- a/charts/helm-apps/templates/_apps-components.tpl +++ b/charts/helm-apps/templates/_apps-components.tpl @@ -8,7 +8,7 @@ {{- if include "fl.isTrue" (list $ . $verticalPodAutoscaler.enabled) }} --- {{- include "apps-utils.printPath" $ }} -apiVersion: autoscaling.k8s.io/v1 +apiVersion: {{ include "apps-api-versions.verticalPodAutoscaler" $ }} kind: VerticalPodAutoscaler {{- include "apps-helpers.metadataGenerator" (list $ $verticalPodAutoscaler ) }} spec: @@ -61,11 +61,7 @@ spec: {{- if include "fl.isTrue" (list $ . .enabled) }} --- {{- include "apps-utils.printPath" $ }} -{{- if semverCompare ">=1.21.0-0" $.Capabilities.KubeVersion.GitVersion }} -apiVersion: policy/v1 -{{- else }} -apiVersion: policy/v1beta1 -{{- end }} +apiVersion: {{ include "apps-api-versions.podDisruptionBudget" $ }} kind: PodDisruptionBudget {{- include "apps-helpers.metadataGenerator" (list $ $podDisruptionBudget) }} spec: @@ -113,6 +109,7 @@ spec: {{- define "apps-components._service" }} {{- $ := index . 0 }} {{- $RelatedScope := index . 1 }} +{{- include "apps-compat.normalizeServiceSpec" (list $ $RelatedScope) }} apiVersion: v1 kind: Service {{- include "apps-helpers.metadataGenerator" (list $ $RelatedScope) }} @@ -138,7 +135,7 @@ spec: {{- if include "fl.isTrue" (list $ . $.CurrentApp.horizontalPodAutoscaler.enabled) }} --- {{- include "apps-utils.printPath" $ }} -apiVersion: autoscaling/v2beta2 +apiVersion: {{ include "apps-api-versions.horizontalPodAutoscaler" $ }} kind: HorizontalPodAutoscaler {{- include "apps-helpers.metadataGenerator" (list $ $.CurrentApp.horizontalPodAutoscaler ) }} spec: diff --git a/charts/helm-apps/templates/_apps-cronjobs.tpl b/charts/helm-apps/templates/_apps-cronjobs.tpl index 80e83bf..0cf225f 100644 --- a/charts/helm-apps/templates/_apps-cronjobs.tpl +++ b/charts/helm-apps/templates/_apps-cronjobs.tpl @@ -15,11 +15,7 @@ {{- if not .containers }} {{- fail (printf "Установлено значение enabled для не настроенной '%s' в %s джобы!" $.CurrentApp.name "apps-cronjobs") }} {{- end }} -{{- if semverCompare ">=1.21-0" $.Capabilities.KubeVersion.GitVersion }} -apiVersion: batch/v1 -{{- else }} -apiVersion: batch/v1beta1 -{{- end }} +apiVersion: {{ include "apps-api-versions.cronJob" $ }} kind: CronJob {{- include "apps-helpers.metadataGenerator" (list $ .) }} spec: diff --git a/charts/helm-apps/templates/_apps-kafka-strimzi.tpl b/charts/helm-apps/templates/_apps-kafka-strimzi.tpl index f7badfe..9748689 100644 --- a/charts/helm-apps/templates/_apps-kafka-strimzi.tpl +++ b/charts/helm-apps/templates/_apps-kafka-strimzi.tpl @@ -146,7 +146,7 @@ spec: {{- include "kafka-topics" (list $ . .topics) }} --- -apiVersion: autoscaling.k8s.io/v1beta2 +apiVersion: {{ include "apps-api-versions.verticalPodAutoscaler" $ }} kind: VerticalPodAutoscaler metadata: name: {{ $.CurrentKafka.name }}-{{ $.Values.global.env }}-kafka @@ -159,7 +159,7 @@ spec: updateMode: "Off" --- -apiVersion: autoscaling.k8s.io/v1beta2 +apiVersion: {{ include "apps-api-versions.verticalPodAutoscaler" $ }} kind: VerticalPodAutoscaler metadata: name: {{ $.CurrentKafka.name }}-{{ $.Values.global.env }}-zookeeper @@ -172,7 +172,7 @@ spec: updateMode: "Off" --- -apiVersion: autoscaling.k8s.io/v1beta2 +apiVersion: {{ include "apps-api-versions.verticalPodAutoscaler" $ }} kind: VerticalPodAutoscaler metadata: name: {{ $.CurrentKafka.name }}-{{ $.Values.global.env }}-entity-operator @@ -185,7 +185,7 @@ spec: updateMode: "Off" --- -apiVersion: autoscaling.k8s.io/v1beta2 +apiVersion: {{ include "apps-api-versions.verticalPodAutoscaler" $ }} kind: VerticalPodAutoscaler metadata: name: {{ $.CurrentKafka.name }}-{{ $.Values.global.env }}-kafka-exporter @@ -223,4 +223,3 @@ spec: min.insync.replicas: {{ include "fl.value" (list $ . .min_insync_replicas) }} {{- end }} {{- end }} - diff --git a/charts/helm-apps/templates/_apps-stateful.tpl b/charts/helm-apps/templates/_apps-stateful.tpl index 3635833..7090c61 100644 --- a/charts/helm-apps/templates/_apps-stateful.tpl +++ b/charts/helm-apps/templates/_apps-stateful.tpl @@ -34,6 +34,7 @@ kind: StatefulSet {{- end }} {{- include "apps-helpers.metadataGenerator" (list $ .) }} spec: + {{- include "apps-compat.normalizeStatefulSetSpec" (list $ .) }} {{- /* https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#statefulset-v1-apps */ -}} {{- $specs := dict }} {{- $_ = set $specs "Maps" (list "apps-helpers.podTemplate" "apps-specs.selector" "persistentVolumeClaimRetentionPolicy" "updateStrategy") }} @@ -56,4 +57,4 @@ spec: {{ $serviceAccount -}} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/tests/contracts/values.yaml b/tests/contracts/values.yaml index 53fc3cb..8102062 100644 --- a/tests/contracts/values.yaml +++ b/tests/contracts/values.yaml @@ -37,3 +37,33 @@ apps-configmaps: data: key2: "local-value-2" key3: "value-3" + +apps-stateless: + compat-service: + enabled: true + containers: + main: + image: + name: alpine + staticTag: "3" + command: | + - sh + args: | + - -c + - sleep 3600 + ports: | + - name: http + containerPort: 8080 + service: + enabled: true + type: LoadBalancer + allocateLoadBalancerNodePorts: true + loadBalancerClass: internal-vip + internalTrafficPolicy: Local + ipFamilyPolicy: SingleStack + ipFamilies: | + - IPv4 + ports: | + - name: http + port: 80 + targetPort: 8080 From bb973b7972b78c60e17a8434fbae56cc4319c9f8 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:48:52 +0300 Subject: [PATCH 24/45] feat: support kubernetes entity passthrough fields and bump library to 1.4.0 --- .github/workflows/ci.yml | 9 +++ README.md | 5 ++ charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-compat.tpl | 13 ++++ .../helm-apps/templates/_apps-components.tpl | 12 ++++ .../helm-apps/templates/_apps-configmaps.tpl | 3 + charts/helm-apps/templates/_apps-cronjobs.tpl | 3 + charts/helm-apps/templates/_apps-helpers.tpl | 9 +++ .../helm-apps/templates/_apps-ingresses.tpl | 3 + charts/helm-apps/templates/_apps-jobs.tpl | 5 +- charts/helm-apps/templates/_apps-pvcs.tpl | 3 + charts/helm-apps/templates/_apps-secrets.tpl | 3 + charts/helm-apps/templates/_apps-stateful.tpl | 3 + .../helm-apps/templates/_apps-stateless.tpl | 3 + tests/.helm/Chart.lock | 6 +- tests/contracts/Chart.lock | 6 +- tests/contracts/values.yaml | 71 +++++++++++++++++++ 17 files changed, 151 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35c5356..a2d18a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,15 @@ jobs: werf helm template contracts tests/contracts --set global.env=dev > /tmp/contracts_render_dev.yaml grep -q '"ENV_SWITCH": "override-default"' /tmp/contracts_render_dev.yaml + # Generic passthrough fields support. + grep -q 'paused: true' /tmp/contracts_render.yaml + grep -q 'resizePolicy:' /tmp/contracts_render.yaml + grep -q 'podFailurePolicy:' /tmp/contracts_render.yaml + grep -q 'defaultBackend:' /tmp/contracts_render.yaml + grep -q 'volumeMode: Filesystem' /tmp/contracts_render.yaml + grep -q 'immutable: true' /tmp/contracts_render.yaml + grep -q 'stringData:' /tmp/contracts_render.yaml + # Service spec compatibility by Kubernetes version. werf helm template contracts tests/contracts --kube-version 1.29.0 > /tmp/contracts_render_129.yaml grep -q 'loadBalancerClass: "internal-vip"' /tmp/contracts_render_129.yaml diff --git a/README.md b/README.md index 0b29f20..d216a50 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,11 @@ helm template my-app .helm --set global.env=prod Библиотека автоматически учитывает версию Kubernetes через `.Capabilities`: - выбирает подходящий `apiVersion` для `CronJob`, `PodDisruptionBudget`, `HorizontalPodAutoscaler`, `VerticalPodAutoscaler`; - учитывает различия в полях `spec` между версиями (например, в `Service` и `StatefulSet`). +- поддерживает passthrough для редких/новых полей через: + - `extraSpec` (ресурсный `spec`); + - `podSpecExtra` (Pod template `spec`); + - `jobTemplateExtraSpec` (`Job.spec` / `CronJob.spec.jobTemplate.spec`); + - `extraFields` (top-level поля ресурса/контейнера). Практика для проверки: - новый кластер: `helm template ... --kube-version 1.29.0` diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 6bce3e1..85ec083 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.3.2 +version: 1.4.0 icon: https://raw.githubusercontent.com/alvnukov/helm-apps/main/docs/assets/icon.png maintainers: - name: alvnukov diff --git a/charts/helm-apps/templates/_apps-compat.tpl b/charts/helm-apps/templates/_apps-compat.tpl index e8443c3..b377ca8 100644 --- a/charts/helm-apps/templates/_apps-compat.tpl +++ b/charts/helm-apps/templates/_apps-compat.tpl @@ -30,3 +30,16 @@ {{- end -}} {{- end -}} {{- end -}} + +{{- define "apps-compat.renderRaw" -}} +{{- $ := index . 0 -}} +{{- $scope := index . 1 -}} +{{- $value := index . 2 -}} +{{- if kindIs "string" $value -}} +{{ include "fl.value" (list $ $scope $value) }} +{{- else if or (kindIs "map" $value) (kindIs "slice" $value) -}} +{{ toYaml $value }} +{{- else -}} +{{ include "fl.value" (list $ $scope $value) }} +{{- end -}} +{{- end -}} diff --git a/charts/helm-apps/templates/_apps-components.tpl b/charts/helm-apps/templates/_apps-components.tpl index 24dcc22..de9f2fd 100644 --- a/charts/helm-apps/templates/_apps-components.tpl +++ b/charts/helm-apps/templates/_apps-components.tpl @@ -23,6 +23,9 @@ spec: {{- else }} resourcePolicy: {} {{- end }} +{{- with include "apps-compat.renderRaw" (list $ . $verticalPodAutoscaler.extraSpec) | trim }} + {{- . | nindent 2 }} +{{- end }} {{- end }} {{- end }} {{- include "apps-utils.leaveScope" $ }} @@ -78,6 +81,9 @@ spec: {{- with include "fl.value" (list $ . .minAvailable) }} minAvailable: {{ . }} {{- end }} + {{- with include "apps-compat.renderRaw" (list $ . .extraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} {{- end }} {{- end }} {{- include "apps-utils.leaveScope" $ }} @@ -122,6 +128,9 @@ spec: {{- $_ = set $specs "Numbers" (list "healthCheckNodePort") }} {{- $_ = set $specs "Maps" (list "sessionAffinityConfig" "selector") }} {{- include "apps-utils.generateSpecs" (list $ $RelatedScope $specs) | nindent 2 }} + {{- with include "apps-compat.renderRaw" (list $ $RelatedScope $RelatedScope.extraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} {{- end }} @@ -154,6 +163,9 @@ spec: {{- include "apps-helpers.generateHPAMetrics" (list $ $RelatedScope) | trim | nindent 2 }} {{- end }} {{- end }} + {{- with include "apps-compat.renderRaw" (list $ . $.CurrentApp.horizontalPodAutoscaler.extraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} {{- range $_customMetricResourceName, $_customMetricResource := $.CurrentApp.horizontalPodAutoscaler.customMetricResources }} {{- include "apps-utils.enterScope" (list $ $_customMetricResourceName) }} diff --git a/charts/helm-apps/templates/_apps-configmaps.tpl b/charts/helm-apps/templates/_apps-configmaps.tpl index ed2b880..2df7e1b 100644 --- a/charts/helm-apps/templates/_apps-configmaps.tpl +++ b/charts/helm-apps/templates/_apps-configmaps.tpl @@ -14,6 +14,9 @@ apiVersion: v1 kind: ConfigMap {{- include "apps-helpers.metadataGenerator" (list $ .) }} +{{- with include "apps-compat.renderRaw" (list $ . .extraFields) | trim }} +{{- . | nindent 0 }} +{{- end }} {{- $data := "" }} {{- with include "apps.generateConfigMapEnvVars" (list $ . .envVars) }} {{- $data = printf "%s\n%s" $data . | trim }} diff --git a/charts/helm-apps/templates/_apps-cronjobs.tpl b/charts/helm-apps/templates/_apps-cronjobs.tpl index 0cf225f..b86efc4 100644 --- a/charts/helm-apps/templates/_apps-cronjobs.tpl +++ b/charts/helm-apps/templates/_apps-cronjobs.tpl @@ -24,6 +24,9 @@ spec: {{- $_ = set $specs "Numbers" (list "failedJobsHistoryLimit" "startingDeadlineSeconds" "successfulJobsHistoryLimit") }} {{- $_ = set $specs "Bools" (list "suspend") }} {{- include "apps-utils.generateSpecs" (list $ . $specs) | indent 2 }} + {{- with include "apps-compat.renderRaw" (list $ . .extraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} jobTemplate: {{ include "apps-helpers.jobTemplate" (list $ .) | nindent 4 -}} {{- include "apps-components.generateConfigMapsAndSecrets" $ -}} diff --git a/charts/helm-apps/templates/_apps-helpers.tpl b/charts/helm-apps/templates/_apps-helpers.tpl index fc20228..de38350 100644 --- a/charts/helm-apps/templates/_apps-helpers.tpl +++ b/charts/helm-apps/templates/_apps-helpers.tpl @@ -130,6 +130,9 @@ {{- $_ = set $specsContainers "Strings" (list "imagePullPolicy" "terminationMessagePath" "terminationMessagePolicy" "workingDir") }} {{- $_ = set $specsContainers "Bools" (list "stdin" "stdinOnce" "tty" "workingDir" ) }} {{- include "apps-utils.generateSpecs" (list $ . $specsContainers) | trim | nindent 2 }} + {{- with include "apps-compat.renderRaw" (list $ . .extraFields) | trim }} + {{- . | nindent 2 }} + {{- end }} {{- end }} {{- include "apps-utils.leaveScope" $ }} {{- end }} @@ -158,6 +161,9 @@ spec: {{- $_ = set $specsTemplate "Numbers" (list "activeDeadlineSeconds" "priority" "terminationGracePeriodSeconds") }} {{- $_ = set $specsTemplate "Bools" (list "automountServiceAccountToken" "enableServiceLinks" "hostIPC" "hostNetwork" "hostPID" "setHostnameAsFQDN" "shareProcessNamespace") }} {{- include "apps-utils.generateSpecs" (list $ . $specsTemplate) | trim | nindent 2 }} + {{- with include "apps-compat.renderRaw" (list $ . .podSpecExtra) | trim }} + {{- . | nindent 2 }} + {{- end }} {{- $_ := set . "__specName__" "template"}} {{- end }} {{- end -}} @@ -220,6 +226,9 @@ spec: {{- $_ = set $specs "Numbers" (list "activeDeadlineSeconds" "backoffLimit" "completions" "parallelism" "ttlSecondsAfterFinished") }} {{- $_ = set $specs "Bools" (list "manualSelector" "suspend") }} {{ include "apps-utils.generateSpecs" (list $ . $specs) | trim | indent 2 }} + {{- with include "apps-compat.renderRaw" (list $ . .jobTemplateExtraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} template: {{ include "apps-helpers.podTemplate" (list $ .) | trim | nindent 4 }} {{- end }} {{- end -}} diff --git a/charts/helm-apps/templates/_apps-ingresses.tpl b/charts/helm-apps/templates/_apps-ingresses.tpl index ad97750..9cfcea8 100644 --- a/charts/helm-apps/templates/_apps-ingresses.tpl +++ b/charts/helm-apps/templates/_apps-ingresses.tpl @@ -48,6 +48,9 @@ spec: - host: {{ include "fl.valueQuoted" (list $ . .host) }} http: paths: {{- include "fl.value" (list $ . .paths) | nindent 6 }} + {{- with include "apps-compat.renderRaw" (list $ . .extraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} {{- if .tls }} {{- if include "fl.isTrue" (list $ . .tls.enabled) }} {{- if not (include "fl.value" (list $ . .tls.secret_name)) }} diff --git a/charts/helm-apps/templates/_apps-jobs.tpl b/charts/helm-apps/templates/_apps-jobs.tpl index 45843bf..d66929c 100644 --- a/charts/helm-apps/templates/_apps-jobs.tpl +++ b/charts/helm-apps/templates/_apps-jobs.tpl @@ -16,6 +16,9 @@ {{- if not .containers }} {{- fail (printf "Установлено значение enabled для не настроенной '%s' в %s джобы!" $.CurrentApp.name "apps-jobs") }} {{- end }} +{{- if and (kindIs "invalid" .jobTemplateExtraSpec) (not (kindIs "invalid" .extraSpec)) }} +{{- $_ := set . "jobTemplateExtraSpec" .extraSpec }} +{{- end }} apiVersion: batch/v1 kind: Job {{- include "apps-helpers.metadataGenerator" (list $ .) -}} @@ -27,4 +30,4 @@ kind: Job {{- include "apps-components.verticalPodAutoscaler" (list $ . .verticalPodAutoscaler "Job") -}} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/helm-apps/templates/_apps-pvcs.tpl b/charts/helm-apps/templates/_apps-pvcs.tpl index 6933251..bce9ad4 100644 --- a/charts/helm-apps/templates/_apps-pvcs.tpl +++ b/charts/helm-apps/templates/_apps-pvcs.tpl @@ -19,5 +19,8 @@ spec: {{- $_ := set $specsPVCs "Maps" (list "resources" ) }} {{- $_ := set $specsPVCs "Strings" (list "storageClassName") }} {{- include "apps-utils.generateSpecs" (list $ . $specsPVCs) | indent 2 }} + {{- with include "apps-compat.renderRaw" (list $ . .extraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} {{- end }} {{- end }} diff --git a/charts/helm-apps/templates/_apps-secrets.tpl b/charts/helm-apps/templates/_apps-secrets.tpl index e668c39..f80675f 100644 --- a/charts/helm-apps/templates/_apps-secrets.tpl +++ b/charts/helm-apps/templates/_apps-secrets.tpl @@ -14,6 +14,9 @@ apiVersion: v1 kind: Secret {{- include "apps-helpers.metadataGenerator" (list $ .) }} +{{- with include "apps-compat.renderRaw" (list $ . .extraFields) | trim }} +{{- . | nindent 0 }} +{{- end }} type: {{- include "fl.value" (list $ . .type) | default "Opaque" | nindent 2 }} data: {{- if (include "fl.value" (list $ . .data)) }} diff --git a/charts/helm-apps/templates/_apps-stateful.tpl b/charts/helm-apps/templates/_apps-stateful.tpl index 7090c61..585fe5a 100644 --- a/charts/helm-apps/templates/_apps-stateful.tpl +++ b/charts/helm-apps/templates/_apps-stateful.tpl @@ -42,6 +42,9 @@ spec: {{- $_ = set $specs "Strings" (list "apps-specs.serviceName" "podManagementPolicy") }} {{- $_ = set $specs "Lists" (list "apps-specs.volumeClaimTemplates") }} {{- include "apps-utils.generateSpecs" (list $ . $specs) | nindent 2 }} + {{- with include "apps-compat.renderRaw" (list $ . .extraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} {{- $_ = unset . "__annotations__" -}} {{- include "apps-components.generateConfigMapsAndSecrets" $ -}} diff --git a/charts/helm-apps/templates/_apps-stateless.tpl b/charts/helm-apps/templates/_apps-stateless.tpl index cf6ba34..e0d39b2 100644 --- a/charts/helm-apps/templates/_apps-stateless.tpl +++ b/charts/helm-apps/templates/_apps-stateless.tpl @@ -37,6 +37,9 @@ spec: {{- $_ = set $specs "Numbers" (list "minReadySeconds" "progressDeadlineSeconds" "revisionHistoryLimit" "replicas") }} {{- $_ = set $specs "Maps" (list "strategy" "apps-helpers.podTemplate" "apps-specs.selector") }} {{- include "apps-utils.generateSpecs" (list $ . $specs) | indent 2 }} + {{- with include "apps-compat.renderRaw" (list $ . .extraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} {{- $_ = unset . "__annotations__" }} {{- include "apps-components.generateConfigMapsAndSecrets" $ -}} diff --git a/tests/.helm/Chart.lock b/tests/.helm/Chart.lock index 74958b2..5a99c04 100644 --- a/tests/.helm/Chart.lock +++ b/tests/.helm/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.3.2 -digest: sha256:22f6f4667bf7cfbbbaa3c6996ab55fedbf3622821dadcc04fece2d76b5b05995 -generated: "2026-02-16T00:49:42.490482+03:00" + version: 1.4.0 +digest: sha256:0580e836ccfa9db51f193b802d1ab1d34a07808f9e5c4ed5c162bd89b63390be +generated: "2026-02-16T13:47:02.039717+03:00" diff --git a/tests/contracts/Chart.lock b/tests/contracts/Chart.lock index 9efd48a..10135b3 100644 --- a/tests/contracts/Chart.lock +++ b/tests/contracts/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.3.2 -digest: sha256:22f6f4667bf7cfbbbaa3c6996ab55fedbf3622821dadcc04fece2d76b5b05995 -generated: "2026-02-16T01:34:38.607167+03:00" + version: 1.4.0 +digest: sha256:0580e836ccfa9db51f193b802d1ab1d34a07808f9e5c4ed5c162bd89b63390be +generated: "2026-02-16T13:47:03.180343+03:00" diff --git a/tests/contracts/values.yaml b/tests/contracts/values.yaml index 8102062..8abf29e 100644 --- a/tests/contracts/values.yaml +++ b/tests/contracts/values.yaml @@ -37,10 +37,21 @@ apps-configmaps: data: key2: "local-value-2" key3: "value-3" + compat-config: + enabled: true + extraFields: | + immutable: true + data: + key: value apps-stateless: compat-service: enabled: true + extraSpec: | + paused: true + podSpecExtra: | + os: + name: linux containers: main: image: @@ -54,6 +65,10 @@ apps-stateless: ports: | - name: http containerPort: 8080 + extraFields: | + resizePolicy: + - resourceName: cpu + restartPolicy: NotRequired service: enabled: true type: LoadBalancer @@ -67,3 +82,59 @@ apps-stateless: - name: http port: 80 targetPort: 8080 + extraSpec: | + externalTrafficPolicy: Local + +apps-jobs: + compat-job: + enabled: true + containers: + main: + image: + name: alpine + staticTag: "3" + command: | + - sh + args: | + - -c + - echo test + jobTemplateExtraSpec: | + podFailurePolicy: + rules: [] + +apps-ingresses: + compat-ingress: + enabled: true + host: app.example.com + paths: | + - path: / + pathType: Prefix + backend: + service: + name: compat-service + port: + number: 80 + extraSpec: | + defaultBackend: + service: + name: compat-service + port: + number: 80 + +apps-pvcs: + compat-pvc: + enabled: true + accessModes: | + - ReadWriteOnce + resources: | + requests: + storage: 1Gi + extraSpec: | + volumeMode: Filesystem + +apps-secrets: + compat-secret: + enabled: true + extraFields: | + stringData: + token: value From 48e8b0867b9cfdaa96e71473ce758c7902820ce8 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:58:39 +0300 Subject: [PATCH 25/45] feat(network-policy): add cni-aware network policy entity with type-based rendering --- .github/workflows/ci.yml | 8 ++ README.md | 7 + .../templates/_apps-default-values.yaml | 8 ++ .../templates/_apps-network-policies.tpl | 125 ++++++++++++++++++ charts/helm-apps/templates/_apps-utils.tpl | 3 +- docs/library-guide.md | 1 + docs/reference-values.md | 1 + tests/.helm/values.schema.json | 3 + tests/.helm/values.yaml | 38 ++++++ tests/contracts/values.yaml | 47 +++++++ 10 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 charts/helm-apps/templates/_apps-network-policies.tpl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2d18a2..7d0707f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,14 @@ jobs: grep -q 'volumeMode: Filesystem' /tmp/contracts_render.yaml grep -q 'immutable: true' /tmp/contracts_render.yaml grep -q 'stringData:' /tmp/contracts_render.yaml + grep -q '^apiVersion: networking.k8s.io/v1$' /tmp/contracts_render.yaml + grep -q '^kind: NetworkPolicy$' /tmp/contracts_render.yaml + grep -q '^apiVersion: cilium.io/v2$' /tmp/contracts_render.yaml + grep -q '^kind: CiliumNetworkPolicy$' /tmp/contracts_render.yaml + grep -q '^apiVersion: projectcalico.org/v3$' /tmp/contracts_render.yaml + grep -q 'selector: "app == '\''compat-service'\''"' /tmp/contracts_render.yaml + grep -q 'kubernetes.io/metadata.name: ingress-nginx' /tmp/contracts_render.yaml + grep -q 'port: 53' /tmp/contracts_render.yaml # Service spec compatibility by Kubernetes version. werf helm template contracts tests/contracts --kube-version 1.29.0 > /tmp/contracts_render_129.yaml diff --git a/README.md b/README.md index d216a50..a769122 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - `apps-cronjobs` (`CronJob`) - `apps-services` (`Service`) - `apps-ingresses` (`Ingress`) +- `apps-network-policies` (`NetworkPolicy`) - `apps-configmaps` (`ConfigMap`) - `apps-secrets` (`Secret`) - `apps-pvcs` (`PersistentVolumeClaim`) @@ -36,6 +37,12 @@ - `apps-kafka-strimzi` - `apps-infra` +Для `apps-network-policies` можно выбрать API через `type`: +- `kubernetes` (default) -> `networking.k8s.io/v1`, `NetworkPolicy` +- `cilium` -> `cilium.io/v2`, `CiliumNetworkPolicy` +- `calico` -> `projectcalico.org/v3`, `NetworkPolicy` +- для любого другого CNI можно явно задать `apiVersion`, `kind` и `spec`. + ## Быстрый старт ### 1. Подключить dependency diff --git a/charts/helm-apps/templates/_apps-default-values.yaml b/charts/helm-apps/templates/_apps-default-values.yaml index d13de04..688fa51 100644 --- a/charts/helm-apps/templates/_apps-default-values.yaml +++ b/charts/helm-apps/templates/_apps-default-values.yaml @@ -163,4 +163,12 @@ global: enabled: true apps-configmaps-defaultConfigmap: _include: ["apps-default-library-app"] + apps-network-policies-defaultNetworkPolicy: + _include: ["apps-default-library-app"] + # CNI-agnostic baseline: use standard Kubernetes NetworkPolicy spec only. + type: kubernetes + policyTypes: | + - Ingress + ingress: | + - {} {{- end }} diff --git a/charts/helm-apps/templates/_apps-network-policies.tpl b/charts/helm-apps/templates/_apps-network-policies.tpl new file mode 100644 index 0000000..4509549 --- /dev/null +++ b/charts/helm-apps/templates/_apps-network-policies.tpl @@ -0,0 +1,125 @@ +{{- define "apps-network-policies" }} + {{- $ := index . 0 }} + {{- $RelatedScope := index . 1 }} + {{- if not (kindIs "invalid" $RelatedScope) }} + {{- $_ := set $RelatedScope "__GroupVars__" (dict "type" "apps-network-policies" "name" "apps-network-policies") }} + {{- include "apps-utils.renderApps" (list $ $RelatedScope) }} + {{- end -}} +{{- end -}} + +{{- define "apps-network-policies.render" }} +{{- $ := . }} +{{- with $.CurrentApp }} +{{- $type := include "fl.value" (list $ . .type) | default "kubernetes" }} +{{- $apiVersion := include "fl.value" (list $ . .apiVersion) }} +{{- $kind := include "fl.value" (list $ . .kind) }} +{{- if not $apiVersion }} + {{- if eq $type "kubernetes" }} + {{- $apiVersion = "networking.k8s.io/v1" }} + {{- else if eq $type "cilium" }} + {{- $apiVersion = "cilium.io/v2" }} + {{- else if eq $type "calico" }} + {{- $apiVersion = "projectcalico.org/v3" }} + {{- else }} + {{- fail (printf "apps-network-policies.%s: set apiVersion/kind for unsupported type=%s" $.CurrentApp.name $type) }} + {{- end }} +{{- end }} +{{- if not $kind }} + {{- if eq $type "kubernetes" }} + {{- $kind = "NetworkPolicy" }} + {{- else if eq $type "cilium" }} + {{- $kind = "CiliumNetworkPolicy" }} + {{- else if eq $type "calico" }} + {{- $kind = "NetworkPolicy" }} + {{- else }} + {{- fail (printf "apps-network-policies.%s: set apiVersion/kind for unsupported type=%s" $.CurrentApp.name $type) }} + {{- end }} +{{- end }} +apiVersion: {{ $apiVersion }} +kind: {{ $kind }} +{{- include "apps-helpers.metadataGenerator" (list $ .) }} +spec: + {{- if include "fl.value" (list $ . .spec) }} + {{- include "apps-compat.renderRaw" (list $ . .spec) | trim | nindent 2 }} + {{- else if eq $type "kubernetes" }} + {{- with include "fl.value" (list $ . .podSelector) | trim }} + podSelector: + {{- . | nindent 4 }} + {{- else }} + podSelector: + matchLabels: +{{- include "fl.generateSelectorLabels" (list $ . $.CurrentApp.name) | trim | nindent 6 }} + {{- end }} + {{- with include "fl.value" (list $ . .policyTypes) }} + policyTypes: + {{- . | nindent 2 }} + {{- end }} + {{- with include "fl.value" (list $ . .ingress) }} + ingress: + {{- . | nindent 2 }} + {{- end }} + {{- with include "fl.value" (list $ . .egress) }} + egress: + {{- . | nindent 2 }} + {{- end }} + {{- else if eq $type "cilium" }} + {{- with include "fl.value" (list $ . .endpointSelector) | trim }} + endpointSelector: + {{- . | nindent 4 }} + {{- else }} + endpointSelector: + matchLabels: +{{- include "fl.generateSelectorLabels" (list $ . $.CurrentApp.name) | trim | nindent 6 }} + {{- end }} + {{- with include "fl.value" (list $ . .ingress) }} + ingress: + {{- . | nindent 2 }} + {{- end }} + {{- with include "fl.value" (list $ . .egress) }} + egress: + {{- . | nindent 2 }} + {{- end }} + {{- with include "fl.value" (list $ . .ingressDeny) }} + ingressDeny: + {{- . | nindent 2 }} + {{- end }} + {{- with include "fl.value" (list $ . .egressDeny) }} + egressDeny: + {{- . | nindent 2 }} + {{- end }} + {{- else if eq $type "calico" }} + {{- with include "fl.value" (list $ . .selector) }} + selector: {{ . | quote }} + {{- else }} + selector: {{ print "app == '" $.CurrentApp.name "'" | quote }} + {{- end }} + {{- with include "fl.value" (list $ . .types) }} + types: + {{- . | nindent 2 }} + {{- else }} + {{- if or (include "fl.value" (list $ . .ingress)) (include "fl.value" (list $ . .egress)) }} + types: + {{- if include "fl.value" (list $ . .ingress) }} + - Ingress + {{- end }} + {{- if include "fl.value" (list $ . .egress) }} + - Egress + {{- end }} + {{- end }} + {{- end }} + {{- with include "fl.value" (list $ . .ingress) }} + ingress: + {{- . | nindent 2 }} + {{- end }} + {{- with include "fl.value" (list $ . .egress) }} + egress: + {{- . | nindent 2 }} + {{- end }} + {{- else }} + {{- fail (printf "apps-network-policies.%s: unsupported type=%s (use kubernetes|cilium|calico or set apiVersion/kind+spec)" $.CurrentApp.name $type) }} + {{- end }} + {{- with include "apps-compat.renderRaw" (list $ . .extraSpec) | trim }} + {{- . | nindent 2 }} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index cb9fd86..465fdb6 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -225,6 +225,7 @@ "pvcs" "certificates" "services" +"network-policies" }} {{- range $app := $Library }} {{- include (printf "apps-%s" $app) (list $ (index $.Values (printf "apps-%s" $app))) }} @@ -304,4 +305,4 @@ {{- $ := index . 0 }} {{- $value := index . 1 }} {{- tpl $value $ }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/docs/library-guide.md b/docs/library-guide.md index 99b19ca..08aa63f 100644 --- a/docs/library-guide.md +++ b/docs/library-guide.md @@ -79,6 +79,7 @@ helm template my-app .helm --set global.env=prod - `apps-cronjobs` (`CronJob`); - `apps-ingresses` (`Ingress`, optional `Certificate`, optional `DexAuthenticator`); - `apps-services` (`Service`); +- `apps-network-policies` (`NetworkPolicy`); - `apps-configmaps` (`ConfigMap`); - `apps-secrets` (`Secret`); - `apps-pvcs` (`PersistentVolumeClaim`); diff --git a/docs/reference-values.md b/docs/reference-values.md index 0c8b075..51a95e8 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -30,6 +30,7 @@ - `apps-cronjobs` - `apps-services` - `apps-ingresses` +- `apps-network-policies` - `apps-configmaps` - `apps-secrets` - `apps-pvcs` diff --git a/tests/.helm/values.schema.json b/tests/.helm/values.schema.json index fb1119e..8116e4c 100644 --- a/tests/.helm/values.schema.json +++ b/tests/.helm/values.schema.json @@ -55,6 +55,9 @@ "apps-services": { "$ref": "#/$defs/appMap" }, + "apps-network-policies": { + "$ref": "#/$defs/appMap" + }, "apps-infra": { "$ref": "#/$defs/appsInfra" }, diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index e4b0170..2b6efb6 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -2000,6 +2000,44 @@ apps-grafana-dashboards: enabled: false folder: "Custom" +apps-network-policies: + netpol-example: + _include: ["apps-network-policies-defaultNetworkPolicy"] + enabled: false + type: kubernetes + # CNI-agnostic policy: only Kubernetes standard NetworkPolicy fields. + podSelector: | + matchLabels: + app: demo + policyTypes: | + - Ingress + - Egress + ingress: | + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + egress: | + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + cilium-netpol-example: + enabled: false + type: cilium + # For Cilium/other CNI-specific policies pass native spec directly. + spec: | + endpointSelector: + matchLabels: + app: demo + egress: + - toEndpoints: + - matchLabels: + k8s:io.kubernetes.pod.namespace: kube-system + apps-infra: node-users: infra-user-example: diff --git a/tests/contracts/values.yaml b/tests/contracts/values.yaml index 8abf29e..e762cdb 100644 --- a/tests/contracts/values.yaml +++ b/tests/contracts/values.yaml @@ -121,6 +121,53 @@ apps-ingresses: port: number: 80 +apps-network-policies: + compat-netpol: + enabled: true + type: kubernetes + podSelector: | + matchLabels: + app: compat-service + policyTypes: | + - Ingress + - Egress + ingress: | + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + egress: | + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + compat-cilium-netpol: + enabled: true + type: cilium + spec: | + endpointSelector: + matchLabels: + app: compat-service + egress: + - toEndpoints: + - matchLabels: + k8s:io.kubernetes.pod.namespace: kube-system + compat-calico-netpol: + enabled: true + type: calico + selector: "app == 'compat-service'" + ingress: | + - action: Allow + source: + selector: "kubernetes.io/metadata.name == 'ingress-nginx'" + egress: | + - action: Allow + destination: + selector: "kubernetes.io/metadata.name == 'kube-system'" + apps-pvcs: compat-pvc: enabled: true From 2f4860d09c8d0dcf42a0a40bb2e6ad8328a20ff5 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:00:16 +0300 Subject: [PATCH 26/45] chore(release): bump helm-apps chart version to 1.5.0 --- charts/helm-apps/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 85ec083..50f72f9 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.4.0 +version: 1.5.0 icon: https://raw.githubusercontent.com/alvnukov/helm-apps/main/docs/assets/icon.png maintainers: - name: alvnukov From dfe76e39d7120c70bce5776eaf49813036975119 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:18:35 +0300 Subject: [PATCH 27/45] chore: add strict-validation contract docs and refresh test chart locks --- .gitignore | 3 + docs/reference-values.md | 7 +++ tests/.helm/Chart.lock | 6 +- tests/.helm/values.schema.json | 103 ++++++++++++++++++++++++++++++++- tests/.helm/values.yaml | 2 + tests/contracts/Chart.lock | 6 +- 6 files changed, 120 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index cc498fd..7f9f705 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ /.packages/ tests/test_render_check.yaml .idea/ + +# Internal planning +.internal/ diff --git a/docs/reference-values.md b/docs/reference-values.md index 51a95e8..46d3f42 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -53,6 +53,7 @@ Типичные поля: - `env`: текущее окружение (`dev`, `prod`, `production`, etc.); - `_includes`: библиотека include-блоков; +- `validation.strict`: opt-in strict contract для проверки values; - произвольные project-level переменные (`ci_url`, `baseUrl` и т.д.). Пример: @@ -61,6 +62,8 @@ global: env: production ci_url: example.org + validation: + strict: false _includes: apps-stateless-defaultApp: replicas: @@ -68,6 +71,10 @@ global: production: 4 ``` +Примечание по `validation.strict`: +- В ветке `1.x` значение по умолчанию — `false` (совместимость). +- Флаг добавлен как контракт для постепенного перехода к более строгой валидации без breaking changes. + ### 2.1 `global._includes` + `_include`: примеры merge diff --git a/tests/.helm/Chart.lock b/tests/.helm/Chart.lock index 5a99c04..e157286 100644 --- a/tests/.helm/Chart.lock +++ b/tests/.helm/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.4.0 -digest: sha256:0580e836ccfa9db51f193b802d1ab1d34a07808f9e5c4ed5c162bd89b63390be -generated: "2026-02-16T13:47:02.039717+03:00" + version: 1.5.0 +digest: sha256:7c0fef479853504d4cb9b348a8cf5cf5b59ed871b6eaf134dc61d66448eeae3a +generated: "2026-02-16T14:07:07.38608+03:00" diff --git a/tests/.helm/values.schema.json b/tests/.helm/values.schema.json index 8116e4c..ab33468 100644 --- a/tests/.helm/values.schema.json +++ b/tests/.helm/values.schema.json @@ -56,7 +56,7 @@ "$ref": "#/$defs/appMap" }, "apps-network-policies": { - "$ref": "#/$defs/appMap" + "$ref": "#/$defs/networkPolicyAppMap" }, "apps-infra": { "$ref": "#/$defs/appsInfra" @@ -659,6 +659,97 @@ }, "additionalProperties": false }, + "networkPolicyType": { + "description": "Тип реализации NetworkPolicy.", + "oneOf": [ + { + "type": "string", + "enum": ["kubernetes", "cilium", "calico"] + }, + { + "type": "object", + "additionalProperties": { + "type": "string", + "enum": ["kubernetes", "cilium", "calico"] + } + } + ] + }, + "networkPolicyApp": { + "type": "object", + "properties": { + "_include": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "$ref": "#/$defs/envValue" + }, + "name": { + "$ref": "#/$defs/envValue" + }, + "annotations": { + "$ref": "#/$defs/envStringValue" + }, + "labels": { + "$ref": "#/$defs/envStringValue" + }, + "type": { + "$ref": "#/$defs/networkPolicyType" + }, + "apiVersion": { + "$ref": "#/$defs/envValue" + }, + "kind": { + "$ref": "#/$defs/envValue" + }, + "spec": { + "$ref": "#/$defs/yamlScalar" + }, + "podSelector": { + "$ref": "#/$defs/envStringValue" + }, + "policyTypes": { + "$ref": "#/$defs/envStringValue" + }, + "ingress": { + "$ref": "#/$defs/envStringValue" + }, + "egress": { + "$ref": "#/$defs/envStringValue" + }, + "endpointSelector": { + "$ref": "#/$defs/envStringValue" + }, + "ingressDeny": { + "$ref": "#/$defs/envStringValue" + }, + "egressDeny": { + "$ref": "#/$defs/envStringValue" + }, + "selector": { + "$ref": "#/$defs/envValue" + }, + "types": { + "$ref": "#/$defs/envStringValue" + }, + "extraSpec": { + "$ref": "#/$defs/yamlScalar" + } + }, + "additionalProperties": true + }, + "networkPolicyAppMap": { + "type": "object", + "patternProperties": { + "^[A-Za-z0-9][A-Za-z0-9_.-]*$": { + "$ref": "#/$defs/networkPolicyApp" + } + }, + "additionalProperties": false + }, "customGroupVars": { "type": "object", "properties": { @@ -773,6 +864,16 @@ "_includes": { "type": "object", "additionalProperties": true + }, + "validation": { + "type": "object", + "properties": { + "strict": { + "type": "boolean", + "description": "Opt-in strict mode contract. Default=false for 1.x compatibility." + } + }, + "additionalProperties": true } }, "additionalProperties": true diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index 2b6efb6..4b0addc 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -1,5 +1,7 @@ global: ci_url: example.com + # validation: + # strict: false ## Альтернатива ограниченным yaml-алиасам Helm'а. Даёт возможность не дублировать одну и ту же конфигурацию много раз. # # Здесь, в "global._includes", объявляются блоки конфигурации, которые потом можно использовать в любых values-файлах. diff --git a/tests/contracts/Chart.lock b/tests/contracts/Chart.lock index 10135b3..2c228d0 100644 --- a/tests/contracts/Chart.lock +++ b/tests/contracts/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.4.0 -digest: sha256:0580e836ccfa9db51f193b802d1ab1d34a07808f9e5c4ed5c162bd89b63390be -generated: "2026-02-16T13:47:03.180343+03:00" + version: 1.5.0 +digest: sha256:7c0fef479853504d4cb9b348a8cf5cf5b59ed871b6eaf134dc61d66448eeae3a +generated: "2026-02-16T14:07:08.311384+03:00" From 18e1a6f90e039a2ade2d1ef94df37fe1c088e5c9 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:22:33 +0300 Subject: [PATCH 28/45] feat(strict): add opt-in unknown-key validation for network policies --- .github/workflows/ci.yml | 7 ++++ charts/helm-apps/templates/_apps-compat.tpl | 14 ++++++++ .../templates/_apps-default-values.yaml | 1 - .../templates/_apps-network-policies.tpl | 34 +++++++++++++++++++ docs/reference-values.md | 1 + tests/.helm/values.yaml | 1 + 6 files changed, 57 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d0707f..63d3979 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,13 @@ jobs: grep -q 'kubernetes.io/metadata.name: ingress-nginx' /tmp/contracts_render.yaml grep -q 'port: 53' /tmp/contracts_render.yaml + # Strict mode (opt-in) for network policies: + # valid config should render, unknown key should fail. + werf helm template contracts tests/contracts --set global.validation.strict=true > /tmp/contracts_render_strict.yaml + ! werf helm template contracts tests/contracts \ + --set global.validation.strict=true \ + --set apps-network-policies.compat-netpol.typoField=1 >/tmp/contracts_render_strict_fail.yaml + # Service spec compatibility by Kubernetes version. werf helm template contracts tests/contracts --kube-version 1.29.0 > /tmp/contracts_render_129.yaml grep -q 'loadBalancerClass: "internal-vip"' /tmp/contracts_render_129.yaml diff --git a/charts/helm-apps/templates/_apps-compat.tpl b/charts/helm-apps/templates/_apps-compat.tpl index b377ca8..e3f8a9b 100644 --- a/charts/helm-apps/templates/_apps-compat.tpl +++ b/charts/helm-apps/templates/_apps-compat.tpl @@ -43,3 +43,17 @@ {{ include "fl.value" (list $ $scope $value) }} {{- end -}} {{- end -}} + +{{- define "apps-compat.enforceAllowedKeys" -}} +{{- $ := index . 0 -}} +{{- $scope := index . 1 -}} +{{- $allowed := index . 2 -}} +{{- $scopePath := index . 3 -}} +{{- if kindIs "map" $scope -}} +{{- range $key, $_ := $scope }} +{{- if and (not (has $key $allowed)) (not (hasPrefix "__" $key)) }} +{{- fail (printf "Strict mode: unknown key '%s' at %s" $key $scopePath) }} +{{- end }} +{{- end }} +{{- end }} +{{- end -}} diff --git a/charts/helm-apps/templates/_apps-default-values.yaml b/charts/helm-apps/templates/_apps-default-values.yaml index 688fa51..2cf353f 100644 --- a/charts/helm-apps/templates/_apps-default-values.yaml +++ b/charts/helm-apps/templates/_apps-default-values.yaml @@ -164,7 +164,6 @@ global: apps-configmaps-defaultConfigmap: _include: ["apps-default-library-app"] apps-network-policies-defaultNetworkPolicy: - _include: ["apps-default-library-app"] # CNI-agnostic baseline: use standard Kubernetes NetworkPolicy spec only. type: kubernetes policyTypes: | diff --git a/charts/helm-apps/templates/_apps-network-policies.tpl b/charts/helm-apps/templates/_apps-network-policies.tpl index 4509549..f7d4a9e 100644 --- a/charts/helm-apps/templates/_apps-network-policies.tpl +++ b/charts/helm-apps/templates/_apps-network-policies.tpl @@ -10,6 +10,40 @@ {{- define "apps-network-policies.render" }} {{- $ := . }} {{- with $.CurrentApp }} +{{- $strict := false }} +{{- with $.Values.global.validation }} +{{- if include "fl.isTrue" (list $ . .strict) }} +{{- $strict = true }} +{{- end }} +{{- end }} +{{- if $strict }} +{{- $allowedKeys := list +"_include" +"__AppType__" +"enabled" +"name" +"randomName" +"werfWeight" +"annotations" +"labels" +"_preRenderHook" +"type" +"apiVersion" +"kind" +"spec" +"podSelector" +"policyTypes" +"ingress" +"egress" +"endpointSelector" +"ingressDeny" +"egressDeny" +"selector" +"types" +"extraSpec" +}} +{{- include "apps-compat.enforceAllowedKeys" (list $ . $allowedKeys (printf "apps-network-policies.%s" $.CurrentApp.name)) }} +{{- end }} {{- $type := include "fl.value" (list $ . .type) | default "kubernetes" }} {{- $apiVersion := include "fl.value" (list $ . .apiVersion) }} {{- $kind := include "fl.value" (list $ . .kind) }} diff --git a/docs/reference-values.md b/docs/reference-values.md index 46d3f42..abeb052 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -74,6 +74,7 @@ global: Примечание по `validation.strict`: - В ветке `1.x` значение по умолчанию — `false` (совместимость). - Флаг добавлен как контракт для постепенного перехода к более строгой валидации без breaking changes. +- Текущая реализация strict-check сначала покрывает `apps-network-policies` (неизвестные ключи дают fail). ### 2.1 `global._includes` + `_include`: примеры merge diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index 4b0addc..b94b51c 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -1,6 +1,7 @@ global: ci_url: example.com # validation: + # # Opt-in strict checks (currently applied for apps-network-policies). # strict: false ## Альтернатива ограниченным yaml-алиасам Helm'а. Даёт возможность не дублировать одну и ту же конфигурацию много раз. # From be5bf5e9e11a4141cce3370a6ec162c2c3f8c9cc Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:30:27 +0300 Subject: [PATCH 29/45] feat(strict): validate unknown top-level apps groups with custom-group allowance --- .github/workflows/ci.yml | 6 +++++- charts/helm-apps/templates/_apps-compat.tpl | 15 +++++++++++++++ charts/helm-apps/templates/_apps-utils.tpl | 15 +++++++++++++++ docs/reference-values.md | 4 ++++ tests/contracts/values.yaml | 8 ++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63d3979..2c540ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,11 +133,15 @@ jobs: grep -q 'port: 53' /tmp/contracts_render.yaml # Strict mode (opt-in) for network policies: - # valid config should render, unknown key should fail. + # valid config should render, unknown keys should fail. werf helm template contracts tests/contracts --set global.validation.strict=true > /tmp/contracts_render_strict.yaml + grep -Eq '"custom": ?"ok"|custom: ?"?ok"?' /tmp/contracts_render_strict.yaml ! werf helm template contracts tests/contracts \ --set global.validation.strict=true \ --set apps-network-policies.compat-netpol.typoField=1 >/tmp/contracts_render_strict_fail.yaml + ! werf helm template contracts tests/contracts \ + --set global.validation.strict=true \ + --set apps-typo.bad.enabled=true >/tmp/contracts_render_strict_top_fail.yaml # Service spec compatibility by Kubernetes version. werf helm template contracts tests/contracts --kube-version 1.29.0 > /tmp/contracts_render_129.yaml diff --git a/charts/helm-apps/templates/_apps-compat.tpl b/charts/helm-apps/templates/_apps-compat.tpl index e3f8a9b..99a9f76 100644 --- a/charts/helm-apps/templates/_apps-compat.tpl +++ b/charts/helm-apps/templates/_apps-compat.tpl @@ -57,3 +57,18 @@ {{- end }} {{- end }} {{- end -}} + +{{- define "apps-compat.validateTopLevelStrict" -}} +{{- $ := index . 0 -}} +{{- $values := index . 1 -}} +{{- $knownTopLevel := index . 2 -}} +{{- if kindIs "map" $values -}} +{{- range $key, $val := $values }} +{{- if has $key $knownTopLevel }} +{{- else if and (kindIs "map" $val) (hasKey $val "__GroupVars__") }} +{{- else if hasPrefix "apps-" $key }} +{{- fail (printf "Strict mode: unknown top-level apps group '%s'. Use built-in apps-* group or define custom group with __GroupVars__.type" $key) }} +{{- end }} +{{- end }} +{{- end }} +{{- end -}} diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index 465fdb6..a8d1f19 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -227,6 +227,21 @@ "services" "network-policies" }} +{{- $knownTopLevel := list "global" "enabled" "_include" "werf" "helm-apps" }} +{{- range $app := $Library }} +{{- $knownTopLevel = append $knownTopLevel (printf "apps-%s" $app) }} +{{- end }} +{{- $strict := false }} +{{- with $.Values.global }} +{{- with .validation }} +{{- if include "fl.isTrue" (list $ . .strict) }} +{{- $strict = true }} +{{- end }} +{{- end }} +{{- end }} +{{- if $strict }} +{{- include "apps-compat.validateTopLevelStrict" (list $ $.Values $knownTopLevel) }} +{{- end }} {{- range $app := $Library }} {{- include (printf "apps-%s" $app) (list $ (index $.Values (printf "apps-%s" $app))) }} {{- end }} diff --git a/docs/reference-values.md b/docs/reference-values.md index abeb052..6b66c97 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -75,6 +75,10 @@ global: - В ветке `1.x` значение по умолчанию — `false` (совместимость). - Флаг добавлен как контракт для постепенного перехода к более строгой валидации без breaking changes. - Текущая реализация strict-check сначала покрывает `apps-network-policies` (неизвестные ключи дают fail). +- На top-level strict-check валидирует только `apps-*` имена: + - встроенные `apps-*` группы разрешены; + - custom-группы разрешены через `__GroupVars__.type`; + - неизвестная `apps-*` секция без `__GroupVars__` даёт fail. ### 2.1 `global._includes` + `_include`: примеры merge diff --git a/tests/contracts/values.yaml b/tests/contracts/values.yaml index e762cdb..64d0c10 100644 --- a/tests/contracts/values.yaml +++ b/tests/contracts/values.yaml @@ -185,3 +185,11 @@ apps-secrets: extraFields: | stringData: token: value + +custom-group-contract: + __GroupVars__: + type: apps-configmaps + custom-group-cm: + enabled: true + data: + custom: "ok" From f4db8a2bb974b330b2850b4fa9085e92ea10310d Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:06:30 +0300 Subject: [PATCH 30/45] Add release matrix mode with image tag fallback, schema/docs/contracts, and CI coverage --- .github/workflows/ci.yml | 4 ++ README.md | 11 +++++ charts/helm-apps/Chart.yaml | 2 +- charts/helm-apps/templates/_apps-helpers.tpl | 6 +++ charts/helm-apps/templates/_apps-release.tpl | 43 ++++++++++++++++++ charts/helm-apps/templates/_apps-utils.tpl | 3 ++ docs/parameter-index.md | 2 + docs/reference-values.md | 48 +++++++++++++++++++- tests/.helm/Chart.lock | 6 +-- tests/.helm/values.schema.json | 27 +++++++++++ tests/.helm/values.yaml | 7 +++ tests/contracts/Chart.lock | 6 +-- tests/contracts/values.yaml | 19 ++++++++ 13 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 charts/helm-apps/templates/_apps-release.tpl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c540ad..f77b29c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,6 +131,10 @@ jobs: grep -q 'selector: "app == '\''compat-service'\''"' /tmp/contracts_render.yaml grep -q 'kubernetes.io/metadata.name: ingress-nginx' /tmp/contracts_render.yaml grep -q 'port: 53' /tmp/contracts_render.yaml + grep -q 'name: "release-auto-app"' /tmp/contracts_render.yaml + grep -q 'image: alpine:3.19' /tmp/contracts_render.yaml + grep -q 'helm-apps/release: "production-v1"' /tmp/contracts_render.yaml + grep -q 'helm-apps/app-version: "3.19"' /tmp/contracts_render.yaml # Strict mode (opt-in) for network policies: # valid config should render, unknown keys should fail. diff --git a/README.md b/README.md index a769122..05efe7a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - Быстрее ревью: одинаковая структура конфигов между проектами. - Переиспользование через [`_include`](docs/parameter-index.md#core) и [`global._includes`](docs/parameter-index.md#core). - Поддержка окружений через [`global.env`](docs/parameter-index.md#core) (`_default`, env overrides, regex env keys). +- Режим релиз-матрицы через [`global.release`](docs/reference-values.md#param-global-release) для автоподстановки тегов и централизованного переключения версий. - Поддержка связанных ресурсов (Service, Ingress, ConfigMap, Secret, HPA, VPA, PDB и др.) в одной модели. ## Какие ресурсы поддерживаются @@ -163,6 +164,16 @@ apps-stateless: 3. Локальные поля приложения имеют приоритет над значениями из include-блоков. 4. Это главный механизм DRY в библиотеке: стандартные профили задаются один раз и переиспользуются во всех сервисах. +## Release mode (`global.release`) + +Опциональный режим для централизованного управления версиями приложений: +- задаете текущий релиз в `global.release.current`; +- храните матрицу `release -> app -> version` в `global.release.versions`; +- app получает `CurrentAppVersion`, и если `image.staticTag` не задан, тег берется из релизной матрицы; +- в рендер добавляются аннотации `helm-apps/release` и `helm-apps/app-version`. + +Практический референс и пример: [`docs/reference-values.md#param-global-release`](docs/reference-values.md#param-global-release) + ### Примеры merge-поведения #### Пример 1: Рекурсивный merge map diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 50f72f9..1e29065 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.5.0 +version: 1.6.0 icon: https://raw.githubusercontent.com/alvnukov/helm-apps/main/docs/assets/icon.png maintainers: - name: alvnukov diff --git a/charts/helm-apps/templates/_apps-helpers.tpl b/charts/helm-apps/templates/_apps-helpers.tpl index de38350..41b15f3 100644 --- a/charts/helm-apps/templates/_apps-helpers.tpl +++ b/charts/helm-apps/templates/_apps-helpers.tpl @@ -245,6 +245,12 @@ spec: {{- if hasKey $.CurrentApp "werfWeight" }} {{- $_ := set $libAnnotations "werf.io/weight" (include "fl.value" (list $ . $.CurrentApp.werfWeight)) }} {{- end }} +{{- if hasKey $ "CurrentReleaseVersion" }} +{{- $_ := set $libAnnotations "helm-apps/release" (include "fl.value" (list $ . $.CurrentReleaseVersion)) }} +{{- end }} +{{- if hasKey $.CurrentApp "CurrentAppVersion" }} +{{- $_ := set $libAnnotations "helm-apps/app-version" (include "fl.value" (list $ . $.CurrentApp.CurrentAppVersion)) }} +{{- end }} {{- $libVersion := include "apps-version.getLibraryVersion" $ | trim }} {{- with $libVersion }} {{- if not (eq . "_FLANT_APPS_LIBRARY_VERSION_") }} diff --git a/charts/helm-apps/templates/_apps-release.tpl b/charts/helm-apps/templates/_apps-release.tpl new file mode 100644 index 0000000..1fc94fd --- /dev/null +++ b/charts/helm-apps/templates/_apps-release.tpl @@ -0,0 +1,43 @@ +{{- define "apps-release.prepareApp" -}} +{{- $ := . -}} +{{- $releaseCfg := dict -}} +{{- if and (hasKey $.Values "global") (kindIs "map" $.Values.global) (hasKey $.Values.global "release") (kindIs "map" $.Values.global.release) -}} + {{- $releaseCfg = $.Values.global.release -}} +{{- end -}} +{{- if kindIs "map" $releaseCfg -}} + {{- if and (hasKey $releaseCfg "enabled") (include "fl.isTrue" (list $ $.CurrentApp $releaseCfg.enabled)) -}} + {{- $currentRelease := include "fl.value" (list $ $.CurrentApp $releaseCfg.current) -}} + {{- if empty $currentRelease -}} + {{- fail "global.release.enabled=true requires global.release.current" -}} + {{- end -}} + {{- $_ := set $ "CurrentReleaseVersion" $currentRelease -}} + + {{- $versions := $releaseCfg.versions -}} + {{- if not (kindIs "map" $versions) -}} + {{- fail "global.release.enabled=true requires global.release.versions map" -}} + {{- end -}} + + {{- $releaseVersions := index $versions $currentRelease -}} + {{- if not $releaseVersions -}} + {{- fail (printf "Release not found in global.release.versions: %s" $currentRelease) -}} + {{- end -}} + + {{- $releaseKey := $.CurrentApp.name -}} + {{- if hasKey $.CurrentApp "releaseKey" -}} + {{- $releaseKey = include "fl.value" (list $ $.CurrentApp $.CurrentApp.releaseKey) -}} + {{- end -}} + + {{- $appVersion := index $releaseVersions $releaseKey -}} + {{- if $appVersion -}} + {{- $_ := set $.CurrentApp "CurrentAppVersion" (include "fl.value" (list $ $.CurrentApp $appVersion)) -}} + {{- $autoEnable := true -}} + {{- if hasKey $releaseCfg "autoEnableApps" -}} + {{- $autoEnable = include "fl.isTrue" (list $ $.CurrentApp $releaseCfg.autoEnableApps) -}} + {{- end -}} + {{- if $autoEnable -}} + {{- $_ := set $.CurrentApp "enabled" true -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- end -}} diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index a8d1f19..686f2dd 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -18,6 +18,8 @@ {{- $imageName := include "fl.value" (list $ . $imageConfig.name) }} {{- if include "fl.value" (list $ . $imageConfig.staticTag) }} {{- $imageName }}:{{ include "fl.value" (list $ . $imageConfig.staticTag) }} +{{- else if hasKey $.CurrentApp "CurrentAppVersion" }} +{{- $imageName }}:{{ include "fl.value" (list $ . $.CurrentApp.CurrentAppVersion) }} {{- else -}} {{- with $.Values.werf }} {{- index .image $imageName }} @@ -122,6 +124,7 @@ {{- end }} {{- define "apps-utils.preRenderHooks" }} {{- $ := . }} +{{- include "apps-release.prepareApp" $ }} {{- if hasKey $ "CurrentGroupVars" }} {{- if hasKey $.CurrentGroupVars "_preRenderAppHook" }} {{- $_ := include "fl.value" (list $ $.CurrentApp $.CurrentGroupVars._preRenderAppHook) }} diff --git a/docs/parameter-index.md b/docs/parameter-index.md index a32dd63..88dc924 100644 --- a/docs/parameter-index.md +++ b/docs/parameter-index.md @@ -8,6 +8,7 @@ |---|---|---| | `global.env` | [Описание](reference-values.md#param-global-env) | [Пример](cookbook.md#example-global-env) | | `global._includes` | [Описание](reference-values.md#param-global-includes) | [Пример](../README.md#example-global-includes-merge) | +| `global.release` | [Описание](reference-values.md#param-global-release) | [Пример](reference-values.md#example-global-release) | | `_include` | [Описание](reference-values.md#param-include) | [Пример](../README.md#example-include-concat) | ## Workload @@ -16,6 +17,7 @@ |---|---|---| | `containers` | [Описание](reference-values.md#param-containers) | [Пример](cookbook.md#example-basic-api) | | `service` | [Описание](reference-values.md#param-service) | [Пример](cookbook.md#example-basic-api) | +| `releaseKey` | [Описание](reference-values.md#param-releasekey) | [Пример](reference-values.md#example-global-release) | | `podDisruptionBudget` | [Описание](reference-values.md#param-pdb) | [Пример](../tests/.helm/values.yaml) | | `serviceAccount` | [Описание](reference-values.md#param-serviceaccount) | [Пример](cookbook.md#example-serviceaccount) | diff --git a/docs/reference-values.md b/docs/reference-values.md index 6b66c97..d428762 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -53,6 +53,7 @@ Типичные поля: - `env`: текущее окружение (`dev`, `prod`, `production`, etc.); - `_includes`: библиотека include-блоков; +- `release`: декларативное управление версиями приложений; - `validation.strict`: opt-in strict contract для проверки values; - произвольные project-level переменные (`ci_url`, `baseUrl` и т.д.). @@ -80,7 +81,51 @@ global: - custom-группы разрешены через `__GroupVars__.type`; - неизвестная `apps-*` секция без `__GroupVars__` даёт fail. -### 2.1 `global._includes` + `_include`: примеры merge +### 2.1 `global.release` + + + +`global.release` включает режим декларативных релизов: +- `enabled`: включает release-логику; +- `current`: имя текущего релиза; +- `autoEnableApps`: автоматически включает app, если для него найдена версия; +- `versions`: матрица `релиз -> appKey -> tag/version`. + +Связанные app-параметры: +- `releaseKey` — ключ приложения в `global.release.versions.`. + + +Пример: + +```yaml +global: + release: + enabled: true + current: "production-v1" + autoEnableApps: true + versions: + production-v1: + release-web: "3.19" + +apps-stateless: + api: + enabled: false + releaseKey: release-web + containers: + main: + image: + name: alpine +``` + +Поведение: +- библиотека выставляет `CurrentReleaseVersion` и `CurrentAppVersion`; +- если `image.staticTag` не задан, используется `CurrentAppVersion`; +- в metadata добавляются аннотации: + - `helm-apps/release` + - `helm-apps/app-version` +- при `autoEnableApps=true` app автоматически включается, когда версия найдена в матрице релиза. + +### 2.2 `global._includes` + `_include`: примеры merge @@ -213,6 +258,7 @@ apps-stateless: - `enabled` - `name` - `werfWeight` +- `releaseKey` - `annotations` - `labels` diff --git a/tests/.helm/Chart.lock b/tests/.helm/Chart.lock index e157286..c501daf 100644 --- a/tests/.helm/Chart.lock +++ b/tests/.helm/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.5.0 -digest: sha256:7c0fef479853504d4cb9b348a8cf5cf5b59ed871b6eaf134dc61d66448eeae3a -generated: "2026-02-16T14:07:07.38608+03:00" + version: 1.6.0 +digest: sha256:08756333a2b6bd235943589cc06861ba457d1ae704bbf27989a9d1d7e4c098e4 +generated: "2026-02-16T15:05:12.235534+03:00" diff --git a/tests/.helm/values.schema.json b/tests/.helm/values.schema.json index ab33468..4031ab5 100644 --- a/tests/.helm/values.schema.json +++ b/tests/.helm/values.schema.json @@ -460,6 +460,9 @@ "werfWeight": { "$ref": "#/$defs/envValue" }, + "releaseKey": { + "$ref": "#/$defs/envValue" + }, "annotations": { "$ref": "#/$defs/envStringValue" }, @@ -874,6 +877,30 @@ } }, "additionalProperties": true + }, + "release": { + "type": "object", + "properties": { + "enabled": { + "$ref": "#/$defs/envValue" + }, + "current": { + "$ref": "#/$defs/envValue" + }, + "autoEnableApps": { + "$ref": "#/$defs/envValue" + }, + "versions": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/envValue" + } + } + } + }, + "additionalProperties": true } }, "additionalProperties": true diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index b94b51c..56ecd86 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -1,5 +1,12 @@ global: ci_url: example.com + release: + enabled: false + current: "2026.02" + autoEnableApps: true + versions: + "2026.02": + app-1: "1.2.3" # validation: # # Opt-in strict checks (currently applied for apps-network-policies). # strict: false diff --git a/tests/contracts/Chart.lock b/tests/contracts/Chart.lock index 2c228d0..2296839 100644 --- a/tests/contracts/Chart.lock +++ b/tests/contracts/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.5.0 -digest: sha256:7c0fef479853504d4cb9b348a8cf5cf5b59ed871b6eaf134dc61d66448eeae3a -generated: "2026-02-16T14:07:08.311384+03:00" + version: 1.6.0 +digest: sha256:08756333a2b6bd235943589cc06861ba457d1ae704bbf27989a9d1d7e4c098e4 +generated: "2026-02-16T15:05:11.411021+03:00" diff --git a/tests/contracts/values.yaml b/tests/contracts/values.yaml index 64d0c10..c4c83bf 100644 --- a/tests/contracts/values.yaml +++ b/tests/contracts/values.yaml @@ -1,5 +1,12 @@ global: env: production + release: + enabled: true + current: "production-v1" + autoEnableApps: true + versions: + production-v1: + release-web: "3.19" _includes: base-a: data: @@ -45,6 +52,18 @@ apps-configmaps: key: value apps-stateless: + release-auto-app: + enabled: false + releaseKey: release-web + containers: + main: + image: + name: alpine + command: | + - sh + args: | + - -c + - sleep 3600 compat-service: enabled: true extraSpec: | From df20bf5827ad258d1ac324e15c23469fa83bf38b Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:09:10 +0300 Subject: [PATCH 31/45] Add autogenerated GitHub release notes for chart releases --- .github/workflows/release.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ceb073e..9d840f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: - name: Set lib version run: | LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) + echo "LIB_VERSION=${LIB_VERSION}" >> "$GITHUB_ENV" sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl - name: Install werf CLI @@ -102,6 +103,38 @@ jobs: env: CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + - name: Generate and update GitHub release notes + if: ${{ github.ref == 'refs/heads/main' }} + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + set -euo pipefail + + TAG="helm-apps-${LIB_VERSION}" + + if ! gh release view "${TAG}" >/dev/null 2>&1; then + echo "Release ${TAG} not found (nothing new to publish), skipping notes update." + exit 0 + fi + + GENERATED_BODY=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f tag_name="${TAG}" \ + -f target_commitish="${GITHUB_SHA}" \ + --jq '.body') + + { + echo "## Helm chart" + echo + echo "- Chart: \`helm-apps\`" + echo "- Version: \`${LIB_VERSION}\`" + echo + echo "${GENERATED_BODY}" + } > /tmp/release_notes.md + + gh release edit "${TAG}" \ + --title "helm-apps ${LIB_VERSION}" \ + --notes-file /tmp/release_notes.md + # - name: Publish to CR # env: # CR_PAT: ${{ secrets.CR_PAT }} From 29158602334726f54e17b6d5acc286688f78bf04 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:51:04 +0300 Subject: [PATCH 32/45] fix: improve schema compatibility and add internal-like test coverage --- .github/workflows/backfill-release-notes.yml | 78 +++++++++++++++++ .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 20 +++-- CHANGELOG.md | 88 ++++++++++++++++++++ README.md | 2 + charts/helm-apps/templates/_apps-utils.tpl | 2 + tests/.helm/values.schema.json | 71 ++++++++++++---- tests/.helm/values.yaml | 39 +++++++++ tests/contracts/values.yaml | 25 ++++++ 9 files changed, 307 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/backfill-release-notes.yml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/backfill-release-notes.yml b/.github/workflows/backfill-release-notes.yml new file mode 100644 index 0000000..a3c08e4 --- /dev/null +++ b/.github/workflows/backfill-release-notes.yml @@ -0,0 +1,78 @@ +name: Backfill Release Notes + +on: + workflow_dispatch: + +jobs: + backfill: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Backfill notes for helm-apps releases + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + mapfile -t TAGS < <( + gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \ + --jq '.[] | select(.draft == false and .prerelease == false and (.tag_name | startswith("helm-apps-"))) | .tag_name' \ + | sort -V + ) + + if [ "${#TAGS[@]}" -eq 0 ]; then + echo "No helm-apps-* releases found." + exit 0 + fi + + for i in "${!TAGS[@]}"; do + tag="${TAGS[$i]}" + prev="" + if [ "$i" -gt 0 ]; then + prev="${TAGS[$((i-1))]}" + fi + + version="${tag#helm-apps-}" + echo "Updating notes for ${tag} (previous: ${prev:-none})" + + if [ -n "$prev" ]; then + generated_body="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f tag_name="${tag}" \ + -f previous_tag_name="${prev}" \ + --jq '.body')" + else + generated_body="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f tag_name="${tag}" \ + --jq '.body')" + fi + + changelog_body="$(awk -v ver="${version}" ' + $0 ~ "^## \\[" ver "\\]" {capture=1; next} + capture && $0 ~ "^## \\[" {exit} + capture {print} + ' CHANGELOG.md)" + + if [ -n "${changelog_body}" ]; then + body="${changelog_body}" + else + body="${generated_body}" + fi + + { + echo "## Helm Apps Library" + echo + echo "- Chart: \`helm-apps\`" + echo "- Version: \`${version}\`" + echo + echo "${body}" + } > /tmp/release_notes.md + + gh release edit "${tag}" \ + --title "helm-apps ${version}" \ + --notes-file /tmp/release_notes.md + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f77b29c..0c5a5c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,8 @@ jobs: grep -q 'image: alpine:3.19' /tmp/contracts_render.yaml grep -q 'helm-apps/release: "production-v1"' /tmp/contracts_render.yaml grep -q 'helm-apps/app-version: "3.19"' /tmp/contracts_render.yaml + grep -q 'name: "compat-route"' /tmp/contracts_render.yaml + grep -q 'host: "route.example.com"' /tmp/contracts_render.yaml # Strict mode (opt-in) for network policies: # valid config should render, unknown keys should fail. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d840f9..9306be3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,13 +117,23 @@ jobs: exit 0 fi - GENERATED_BODY=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ - -f tag_name="${TAG}" \ - -f target_commitish="${GITHUB_SHA}" \ - --jq '.body') + CHANGELOG_BODY="$(awk -v ver="${LIB_VERSION}" ' + $0 ~ "^## \\[" ver "\\]" {capture=1; next} + capture && $0 ~ "^## \\[" {exit} + capture {print} + ' CHANGELOG.md)" + + if [ -n "${CHANGELOG_BODY}" ]; then + GENERATED_BODY="${CHANGELOG_BODY}" + else + GENERATED_BODY=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f tag_name="${TAG}" \ + -f target_commitish="${GITHUB_SHA}" \ + --jq '.body') + fi { - echo "## Helm chart" + echo "## Helm Apps Library" echo echo "- Chart: \`helm-apps\`" echo "- Version: \`${LIB_VERSION}\`" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..088644c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,88 @@ +# Changelog + +All notable changes to the `helm-apps` library are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Automated GitHub Release notes generation in `release.yml`. + +## [1.6.0] - 2026-02-16 + +### Added +- New release matrix mode via `global.release`: + - `enabled`, `current`, `autoEnableApps`, `versions`. +- Added app-level `releaseKey` to map an app to release matrix keys. +- Automatic release annotations in rendered manifests: + - `helm-apps/release` + - `helm-apps/app-version` +- Added release-mode contract checks in CI. + +### Changed +- If `image.staticTag` is not set, image tag can be resolved from `CurrentAppVersion`. +- Extended `tests/.helm/values.schema.json` with `global.release` and `releaseKey`. +- Updated docs (`README.md`, `docs/reference-values.md`, `docs/parameter-index.md`) with release mode examples. + +## [1.5.0] - 2026-02-16 + +### Added +- Added `apps-network-policies` entity with `type`-based implementation selection: + - `kubernetes` -> `networking.k8s.io/v1`, `NetworkPolicy` + - `cilium` -> `cilium.io/v2`, `CiliumNetworkPolicy` + - `calico` -> `projectcalico.org/v3`, `NetworkPolicy` +- Added contract tests and CI checks for multiple NetworkPolicy implementations. +- Added opt-in strict validation: + - unknown keys in `apps-network-policies` fail; + - unknown top-level `apps-*` groups fail unless declared via `__GroupVars__.type`. + +### Changed +- Expanded user documentation and parameter navigation. +- Added values schema validation in CI. + +## [1.4.0] - 2026-02-16 + +### Added +- Added passthrough fields for Kubernetes entities: + - workload/container `extra*` fields for new or uncommon API fields without library changes. +- Added compatibility tests for multiple Kubernetes API versions. + +### Changed +- Improved compatibility across legacy and modern Kubernetes versions. + +## [1.3.2] - 2024-02-13 + +### Fixed +- Fixed handling of `null` variables in ConfigMap YAML. + +## [1.3.1] - 2024-02-12 + +### Fixed +- Additional fixes for `null` variable handling in ConfigMap YAML. + +## [1.3.0] - 2022-03-22 + +### Added +- Helm 3 compatibility. + +## [1.2.9] - 2021-12-07 + +### Fixed +- Error handling for include blocks loaded from files. + +## [1.2.8] - 2021-07-13 + +### Fixed +- Support for `tpl` in include file names. + +## [1.2.7] - 2021-06-03 + +### Fixed +- Correct merge behavior with `_default`. + +## [1.2.6] - 2021-05-21 + +### Fixed +- Added `_include_files` support. diff --git a/README.md b/README.md index 05efe7a..56cbc34 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ > Практически, для командного daily workflow werf часто удобнее: он объединяет рендер и процесс поставки в единый поток, снижая количество ручных шагов в CI/CD. > При этом весь функционал библиотеки доступен и через чистый Helm. +История изменений: [`CHANGELOG.md`](CHANGELOG.md) + ## Зачем использовать библиотеку - Единый стандарт деплоя для всех сервисов команды. diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index 686f2dd..119f4bc 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -192,6 +192,8 @@ {{- if not (kindIs "invalid" $.CurrentGroupVars) }} {{- $_ = set $groupScope.__GroupVars__ "type" (include "apps-utils.requiredValue" (list $ $.CurrentGroupVars "type")) }} {{- end }} +{{- else }} +{{- $_ = set $groupScope.__GroupVars__ "type" (include "fl.value" (list $ $groupScope $groupScope.__GroupVars__.type)) }} {{- end }} {{- $_ = set $ "CurrentGroupVars" $groupScope.__GroupVars__ }} {{- $_ = set $ "CurrentGroup" $groupScope }} diff --git a/tests/.helm/values.schema.json b/tests/.helm/values.schema.json index 4031ab5..f6b37b4 100644 --- a/tests/.helm/values.schema.json +++ b/tests/.helm/values.schema.json @@ -121,7 +121,7 @@ "envMap": { "type": "object", "additionalProperties": { - "$ref": "#/$defs/envValue" + "$ref": "#/$defs/yamlScalar" } }, "resources": { @@ -183,7 +183,6 @@ "$ref": "#/$defs/yamlScalar" } }, - "required": ["mountPath"], "additionalProperties": true }, "container": { @@ -263,10 +262,17 @@ } }, "configFilesYAML": { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/configFile" - } + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configFile" + } + }, + { + "type": "null" + } + ] }, "secretConfigFiles": { "type": "object", @@ -475,13 +481,35 @@ "containers": { "type": "object", "additionalProperties": { - "$ref": "#/$defs/container" + "anyOf": [ + { + "$ref": "#/$defs/container" + }, + { + "$ref": "#/$defs/yamlScalar" + }, + { + "type": "object", + "additionalProperties": true + } + ] } }, "initContainers": { "type": "object", "additionalProperties": { - "$ref": "#/$defs/container" + "anyOf": [ + { + "$ref": "#/$defs/container" + }, + { + "$ref": "#/$defs/yamlScalar" + }, + { + "type": "object", + "additionalProperties": true + } + ] } }, "verticalPodAutoscaler": { @@ -494,7 +522,17 @@ "$ref": "#/$defs/pdb" }, "service": { - "$ref": "#/$defs/service" + "anyOf": [ + { + "$ref": "#/$defs/service" + }, + { + "$ref": "#/$defs/envValue" + }, + { + "$ref": "#/$defs/envStringValue" + } + ] }, "serviceAccount": { "$ref": "#/$defs/serviceAccount" @@ -533,7 +571,7 @@ "$ref": "#/$defs/envStringValue" }, "nodeSelector": { - "$ref": "#/$defs/envStringValue" + "$ref": "#/$defs/yamlScalar" }, "topologySpreadConstraints": { "$ref": "#/$defs/envStringValue" @@ -596,7 +634,7 @@ "$ref": "#/$defs/envStringValue" }, "resources": { - "$ref": "#/$defs/envStringValue" + "$ref": "#/$defs/yamlScalar" }, "clusterIssuer": { "$ref": "#/$defs/envValue" @@ -655,6 +693,11 @@ }, "appMap": { "type": "object", + "properties": { + "__GroupVars__": { + "$ref": "#/$defs/customGroupVars" + } + }, "patternProperties": { "^[A-Za-z0-9][A-Za-z0-9_.-]*$": { "$ref": "#/$defs/app" @@ -766,7 +809,7 @@ "$ref": "#/$defs/envValue" }, "type": { - "type": "string" + "$ref": "#/$defs/envValue" }, "_preRenderGroupHook": { "$ref": "#/$defs/envStringValue" @@ -906,7 +949,5 @@ "additionalProperties": true } }, - "additionalProperties": { - "$ref": "#/$defs/customGroup" - } + "additionalProperties": true } diff --git a/tests/.helm/values.yaml b/tests/.helm/values.yaml index 56ecd86..d96b607 100644 --- a/tests/.helm/values.yaml +++ b/tests/.helm/values.yaml @@ -33,6 +33,45 @@ global: test-include-from-file: _include_from_file: config/test-include-from-file.yaml +# Compatibility examples for real-world project layouts: +# top-level service keys that are not rendered by helm-apps directly, +# but should still pass schema validation. +jwtSigningMethod: rsa +_include_files: + - deployments-values.yaml + - values-app-versions.yaml + +custom-services: + __GroupVars__: + type: custom-services + minio: + enabled: false + host: + ip: + _default: 127.0.0.1 + port: 9000 + +apps-routes: + __GroupVars__: + type: + _default: apps-ingresses + paths: | + - path: / + pathType: Prefix + backend: + service: + name: compat-service + port: + number: 80 + _groupPreRenderHook: | + {{- if not (hasKey $.CurrentApp "paths") }} + {{- $_ := set $.CurrentApp "paths" $.CurrentGroupVars.paths }} + {{- end }} + route-disabled: + enabled: false + host: route.example.com + service: compat-service + ## Имя чарта. Ниже перечисляются ConfigMaps для развертывания. # Указано в .helm/requirements.yaml в репозитории приложения в ключах dependencies.name или dependencies.alias. # https://helm.sh/docs/topics/charts/#managing-dependencies-with-the-dependencies-field diff --git a/tests/contracts/values.yaml b/tests/contracts/values.yaml index c4c83bf..c5b3a1e 100644 --- a/tests/contracts/values.yaml +++ b/tests/contracts/values.yaml @@ -35,6 +35,10 @@ global: key2: "override-value-2" key4: "value-4" +jwtSigningMethod: rsa +_include_files: + - deployments-values.yaml + apps-configmaps: merge-contract: _include: ["profile-base", "profile-override"] @@ -212,3 +216,24 @@ custom-group-contract: enabled: true data: custom: "ok" + +apps-routes-contract: + __GroupVars__: + type: + _default: apps-ingresses + paths: | + - path: / + pathType: Prefix + backend: + service: + name: compat-service + port: + number: 80 + _groupPreRenderHook: | + {{- if not (hasKey $.CurrentApp "paths") }} + {{- $_ := set $.CurrentApp "paths" $.CurrentGroupVars.paths }} + {{- end }} + compat-route: + enabled: true + host: route.example.com + service: "compat-service" From c3cae638f6bff2a1f4889352d4aa1ed42d9b91ad Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:59:10 +0300 Subject: [PATCH 33/45] fix(docs): clarify optional releaseKey and app name fallback --- README.md | 1 + docs/reference-values.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 56cbc34..8a7eafa 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ apps-stateless: Опциональный режим для централизованного управления версиями приложений: - задаете текущий релиз в `global.release.current`; - храните матрицу `release -> app -> version` в `global.release.versions`; +- ключ приложения берется из `releaseKey`, а если он не задан — из имени приложения (`app.name`); - app получает `CurrentAppVersion`, и если `image.staticTag` не задан, тег берется из релизной матрицы; - в рендер добавляются аннотации `helm-apps/release` и `helm-apps/app-version`. diff --git a/docs/reference-values.md b/docs/reference-values.md index d428762..10453aa 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -93,6 +93,8 @@ global: Связанные app-параметры: - `releaseKey` — ключ приложения в `global.release.versions.`. + - параметр опционален; + - если `releaseKey` не задан, библиотека использует `app.name`. Пример: From 614e0a01ed4fd11bb405ab9af5d01dd1c2cecf66 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:03:55 +0300 Subject: [PATCH 34/45] fix(docs): clarify release defaults, fallbacks, and custom group type behavior --- README.md | 6 ++++++ docs/library-guide.md | 8 +++++++- docs/reference-values.md | 8 +++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a7eafa..513227f 100644 --- a/README.md +++ b/README.md @@ -169,12 +169,18 @@ apps-stateless: ## Release mode (`global.release`) Опциональный режим для централизованного управления версиями приложений: +- `global.release.enabled` по умолчанию `false`; - задаете текущий релиз в `global.release.current`; - храните матрицу `release -> app -> version` в `global.release.versions`; - ключ приложения берется из `releaseKey`, а если он не задан — из имени приложения (`app.name`); +- `autoEnableApps` по умолчанию `true`; - app получает `CurrentAppVersion`, и если `image.staticTag` не задан, тег берется из релизной матрицы; - в рендер добавляются аннотации `helm-apps/release` и `helm-apps/app-version`. +Важно: +- если для app не найдена версия в `global.release.versions.`, приложение рендерится по обычной логике; +- если не задан ни `image.staticTag`, ни `CurrentAppVersion`, используется стандартный путь через `Values.werf.image`. + Практический референс и пример: [`docs/reference-values.md#param-global-release`](docs/reference-values.md#param-global-release) ### Примеры merge-поведения diff --git a/docs/library-guide.md b/docs/library-guide.md index 08aa63f..bffe0c8 100644 --- a/docs/library-guide.md +++ b/docs/library-guide.md @@ -99,13 +99,19 @@ helm template my-app .helm --set global.env=prod ```yaml payment-group: __GroupVars__: - type: apps-stateless + type: + _default: apps-stateless + prod: apps-stateful api: _include: ["apps-stateless-defaultApp"] worker: _include: ["apps-stateless-defaultApp"] ``` +`__GroupVars__.type` поддерживает: +- строку (`apps-stateless`, `apps-ingresses`, ...); +- env-map с выбором через `global.env`. + Для отдельного приложения можно переопределить тип: ```yaml diff --git a/docs/reference-values.md b/docs/reference-values.md index 10453aa..6085670 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -91,6 +91,11 @@ global: - `autoEnableApps`: автоматически включает app, если для него найдена версия; - `versions`: матрица `релиз -> appKey -> tag/version`. +Дефолты и поведение: +- `enabled`: `false` по умолчанию; +- `autoEnableApps`: `true` по умолчанию; +- если версия для app не найдена в `versions.`, библиотека не проставляет `CurrentAppVersion` и не меняет стандартную логику рендера. + Связанные app-параметры: - `releaseKey` — ключ приложения в `global.release.versions.`. - параметр опционален; @@ -122,6 +127,7 @@ apps-stateless: Поведение: - библиотека выставляет `CurrentReleaseVersion` и `CurrentAppVersion`; - если `image.staticTag` не задан, используется `CurrentAppVersion`; +- если `CurrentAppVersion` тоже не задан, image резолвится через стандартный путь `Values.werf.image`; - в metadata добавляются аннотации: - `helm-apps/release` - `helm-apps/app-version` @@ -641,7 +647,7 @@ group-name: ``` Важные поля `__GroupVars__`: -- `type` (required) +- `type` (required, может быть как строкой, так и env-map через `global.env`) - `enabled` - `_include` - `_preRenderGroupHook` From 01b51641c4e386df2a5e2ac2d48640fbe79b1dc8 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:18:28 +0300 Subject: [PATCH 35/45] fix(ci): add internal-like contract scenario for release/deploy flow --- .github/workflows/ci.yml | 12 +++ tests/contracts/values.internal-compat.yaml | 91 +++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 tests/contracts/values.internal-compat.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c5a5c9..84a1e4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,3 +166,15 @@ jobs: ! grep -q 'ipFamilyPolicy:' /tmp/contracts_render_119.yaml ! grep -q 'ipFamilies:' /tmp/contracts_render_119.yaml ! grep -q 'allocateLoadBalancerNodePorts:' /tmp/contracts_render_119.yaml + + - name: Contract test for internal-like release/deploy flow + run: | + set -euo pipefail + werf helm template contracts tests/contracts \ + --values tests/contracts/values.internal-compat.yaml > /tmp/contracts_internal_like.yaml + grep -q 'name: "compat-web"' /tmp/contracts_internal_like.yaml + grep -q 'image: alpine:1.2.3' /tmp/contracts_internal_like.yaml + grep -q 'helm-apps/release:' /tmp/contracts_internal_like.yaml + grep -q 'helm-apps/app-version: "1.2.3"' /tmp/contracts_internal_like.yaml + grep -q 'name: "compat-route"' /tmp/contracts_internal_like.yaml + grep -q 'host: "compat.example.com"' /tmp/contracts_internal_like.yaml diff --git a/tests/contracts/values.internal-compat.yaml b/tests/contracts/values.internal-compat.yaml new file mode 100644 index 0000000..4c84cdd --- /dev/null +++ b/tests/contracts/values.internal-compat.yaml @@ -0,0 +1,91 @@ +global: + env: dev + _includes: + internal-default-app: + enabled: false + _preRenderHook: | + {{- $_ := set $ "CurrentReleaseVersion" (include "fl.value" (list $ . $.Values.deploy.release)) }} + {{- $release := index $.Values.releases $.CurrentReleaseVersion }} + {{- if empty $release }}{{ fail (printf "Not such release! [%s]" $.CurrentReleaseVersion) }}{{ end }} + {{- $appVersion := index $release $.CurrentApp.name }} + {{- if $appVersion }} + {{- if $.Values.deploy.enabled }}{{ $_ := set $.CurrentApp "enabled" true }}{{ end }} + {{- $_ = set $.CurrentApp "CurrentAppVersion" (include "fl.value" (list $ . $appVersion)) }} + {{- end }} + +deploy: + enabled: true + release: "r1" + +releases: + r1: + compat-web: "1.2.3" + +jwtSigningMethod: rsa +_include_files: + - deployments-values.yaml + +custom-services: + __GroupVars__: + type: custom-services + minio: + enabled: false + host: + ip: + _default: 127.0.0.1 + port: 9000 + +apps-stateless: + __GroupVars__: + type: + _default: apps-stateless + _groupPreRenderHook: | + {{- range $_, $container := .containers }} + {{- if and (hasKey $container "resources") (kindIs "map" $container.resources) (hasKey $container.resources "limits") (kindIs "map" $container.resources.limits) }} + {{- if not (hasKey $container.resources "requests") }} + {{- $_ := set $container.resources "requests" dict }} + {{- end }} + {{- if hasKey $container.resources.limits "memoryMb" }} + {{- $_ := set $container.resources.requests "memoryMb" $container.resources.limits.memoryMb }} + {{- end }} + {{- end }} + {{- end }} + compat-web: + _include: ["internal-default-app"] + enabled: false + containers: + main: + image: + name: alpine + command: | + - sh + args: | + - -c + - sleep 3600 + ports: | + - name: http + containerPort: 8080 + resources: + limits: + memoryMb: "128" + +apps-routes-contract: + __GroupVars__: + type: + _default: apps-ingresses + paths: | + - path: / + pathType: Prefix + backend: + service: + name: compat-web + port: + number: 80 + _groupPreRenderHook: | + {{- if not (hasKey $.CurrentApp "paths") }} + {{- $_ := set $.CurrentApp "paths" $.CurrentGroupVars.paths }} + {{- end }} + compat-route: + enabled: true + host: compat.example.com + service: "compat-web" From 9c28e306d564c46ce56021c6b548473ffa9fccdf Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:24:48 +0300 Subject: [PATCH 36/45] fix(validation): fail on unexpected native lists and show exact values path --- .github/workflows/ci.yml | 15 +++++++++++++++ README.md | 1 + charts/helm-apps/templates/_apps-compat.tpl | 19 +++++++++++++++++++ charts/helm-apps/templates/_apps-utils.tpl | 1 + docs/reference-values.md | 1 + 5 files changed, 37 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84a1e4a..23c85e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,6 +167,21 @@ jobs: ! grep -q 'ipFamilies:' /tmp/contracts_render_119.yaml ! grep -q 'allocateLoadBalancerNodePorts:' /tmp/contracts_render_119.yaml + # Native YAML lists are forbidden in values (except _include/_include_files). + cat > /tmp/contracts_invalid_native_list.yaml <<'EOF' + apps-stateless: + compat-service: + service: + ports: + - name: http + port: 80 + targetPort: 8080 + EOF + ! werf helm template contracts tests/contracts \ + --values /tmp/contracts_invalid_native_list.yaml \ + >/tmp/contracts_invalid_native_list.out 2>/tmp/contracts_invalid_native_list.err + grep -q "list value is not allowed at Values.apps-stateless.compat-service.service.ports" /tmp/contracts_invalid_native_list.err + - name: Contract test for internal-like release/deploy flow run: | set -euo pipefail diff --git a/README.md b/README.md index 513227f..702fea4 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ apps-stateless: 2. Порядок `_include` важен: каждый следующий профиль может переопределять предыдущий. 3. Локальные поля приложения имеют приоритет над значениями из include-блоков. 4. Это главный механизм DRY в библиотеке: стандартные профили задаются один раз и переиспользуются во всех сервисах. +5. Native YAML list в values запрещены (кроме `_include` и `_include_files`): для Kubernetes list-полей используйте YAML block string (`|`). ## Release mode (`global.release`) diff --git a/charts/helm-apps/templates/_apps-compat.tpl b/charts/helm-apps/templates/_apps-compat.tpl index 99a9f76..7fe77b5 100644 --- a/charts/helm-apps/templates/_apps-compat.tpl +++ b/charts/helm-apps/templates/_apps-compat.tpl @@ -72,3 +72,22 @@ {{- end }} {{- end }} {{- end -}} + +{{- define "apps-compat.assertNoUnexpectedLists" -}} +{{- $ := index . 0 -}} +{{- $value := index . 1 -}} +{{- $path := index . 2 -}} +{{- if kindIs "slice" $value -}} + {{- $last := "" -}} + {{- if gt (len $path) 0 -}} + {{- $last = last $path -}} + {{- end -}} + {{- if not (or (eq $last "_include") (eq $last "_include_files")) -}} + {{- fail (printf "Invalid values: list value is not allowed at %s. Use YAML block string ('|') for Kubernetes list fields. Allowed native lists: _include, _include_files." (join "." $path)) -}} + {{- end -}} +{{- else if kindIs "map" $value -}} + {{- range $k, $v := $value -}} + {{- include "apps-compat.assertNoUnexpectedLists" (list $ $v (append $path $k)) -}} + {{- end -}} +{{- end -}} +{{- end -}} diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index 119f4bc..0534ec4 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -209,6 +209,7 @@ {{- $ := . }} {{- include "apps-utils.includesFromFiles" $ }} {{- $_ := include "fl.expandIncludesInValues" (list $ $.Values) }} +{{- include "apps-compat.assertNoUnexpectedLists" (list $ $.Values (list "Values")) }} {{- include "apps-utils.findApps" $ }} --- # Source: apps.utils: fl.expandIncludesInValues diff --git a/docs/reference-values.md b/docs/reference-values.md index 6085670..07f63d2 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -679,6 +679,7 @@ group-name: Практика: - если поле описано как Kubernetes-блок, используйте YAML строку (`|`); +- native YAML list в values запрещены (исключения: `_include`, `_include_files`); - для env-значений используйте scalar/env-map; - итог всегда проверяйте через `helm template ... --set global.env=`. From 80f1f9ff32a7db2b55b9b13509ab509e32af3e97 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:25:33 +0300 Subject: [PATCH 37/45] chore(release): bump helm-apps chart version to 1.6.1 --- charts/helm-apps/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 1e29065..79dd3c6 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.6.0 +version: 1.6.1 icon: https://raw.githubusercontent.com/alvnukov/helm-apps/main/docs/assets/icon.png maintainers: - name: alvnukov From 1128e6168453225ddda20db242929a2831a24ff0 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:28:39 +0300 Subject: [PATCH 38/45] fix(validation): allow native lists in approved template-driven paths --- charts/helm-apps/templates/_apps-compat.tpl | 10 ++++++++-- tests/.helm/Chart.lock | 6 +++--- tests/contracts/Chart.lock | 6 +++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/charts/helm-apps/templates/_apps-compat.tpl b/charts/helm-apps/templates/_apps-compat.tpl index 7fe77b5..9b5d67c 100644 --- a/charts/helm-apps/templates/_apps-compat.tpl +++ b/charts/helm-apps/templates/_apps-compat.tpl @@ -78,12 +78,18 @@ {{- $value := index . 1 -}} {{- $path := index . 2 -}} {{- if kindIs "slice" $value -}} + {{- $pathString := join "." $path -}} {{- $last := "" -}} {{- if gt (len $path) 0 -}} {{- $last = last $path -}} {{- end -}} - {{- if not (or (eq $last "_include") (eq $last "_include_files")) -}} - {{- fail (printf "Invalid values: list value is not allowed at %s. Use YAML block string ('|') for Kubernetes list fields. Allowed native lists: _include, _include_files." (join "." $path)) -}} + {{- $isAllowedKafkaHosts := regexMatch "^Values\\.apps-kafka-strimzi\\..*\\.kafka\\.brokers\\.hosts\\.[^.]+$" $pathString -}} + {{- $isAllowedKafkaDexGroups := regexMatch "^Values\\.apps-kafka-strimzi\\..*\\.kafka\\.ui\\.dex\\.allowedGroups\\.[^.]+$" $pathString -}} + {{- $isAllowedGlobalInclude := regexMatch "^Values\\.global\\._includes\\..*" $pathString -}} + {{- $isAllowedConfigFilesYAMLContent := regexMatch "^Values\\..*\\.configFilesYAML\\..*\\.content\\..*" $pathString -}} + {{- $isAllowedEnvYAML := regexMatch "^Values\\..*\\.envYAML\\..*" $pathString -}} + {{- if not (or (eq $last "_include") (eq $last "_include_files") $isAllowedGlobalInclude $isAllowedKafkaHosts $isAllowedKafkaDexGroups $isAllowedConfigFilesYAMLContent $isAllowedEnvYAML) -}} + {{- fail (printf "Invalid values: list value is not allowed at %s. Use YAML block string ('|') for Kubernetes list fields. Allowed native lists: _include, _include_files, global._includes.*, *.configFilesYAML.*.content.*, *.envYAML.*, apps-kafka-strimzi.*.kafka.brokers.hosts.*, apps-kafka-strimzi.*.kafka.ui.dex.allowedGroups.*." (join "." $path)) -}} {{- end -}} {{- else if kindIs "map" $value -}} {{- range $k, $v := $value -}} diff --git a/tests/.helm/Chart.lock b/tests/.helm/Chart.lock index c501daf..e0b4cb5 100644 --- a/tests/.helm/Chart.lock +++ b/tests/.helm/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.6.0 -digest: sha256:08756333a2b6bd235943589cc06861ba457d1ae704bbf27989a9d1d7e4c098e4 -generated: "2026-02-16T15:05:12.235534+03:00" + version: 1.6.1 +digest: sha256:c78cbe4c3c383be4816798447f86a450297b7b3962a1ead595d59a2bf9101ca4 +generated: "2026-02-16T16:27:28.344362+03:00" diff --git a/tests/contracts/Chart.lock b/tests/contracts/Chart.lock index 2296839..627bb2e 100644 --- a/tests/contracts/Chart.lock +++ b/tests/contracts/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: helm-apps repository: file://../../charts/helm-apps/ - version: 1.6.0 -digest: sha256:08756333a2b6bd235943589cc06861ba457d1ae704bbf27989a9d1d7e4c098e4 -generated: "2026-02-16T15:05:11.411021+03:00" + version: 1.6.1 +digest: sha256:c78cbe4c3c383be4816798447f86a450297b7b3962a1ead595d59a2bf9101ca4 +generated: "2026-02-16T16:27:29.256809+03:00" From b4b50ded62107593ec53ba5d340c2c0da5842e08 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:59:08 +0300 Subject: [PATCH 39/45] fix(stability): keep full list validation traversal and apply safe hasKey checks --- charts/helm-apps/templates/_apps-compat.tpl | 2 +- charts/helm-apps/templates/_apps-utils.tpl | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/charts/helm-apps/templates/_apps-compat.tpl b/charts/helm-apps/templates/_apps-compat.tpl index 9b5d67c..e057ab9 100644 --- a/charts/helm-apps/templates/_apps-compat.tpl +++ b/charts/helm-apps/templates/_apps-compat.tpl @@ -77,8 +77,8 @@ {{- $ := index . 0 -}} {{- $value := index . 1 -}} {{- $path := index . 2 -}} +{{- $pathString := join "." $path -}} {{- if kindIs "slice" $value -}} - {{- $pathString := join "." $path -}} {{- $last := "" -}} {{- if gt (len $path) 0 -}} {{- $last = last $path -}} diff --git a/charts/helm-apps/templates/_apps-utils.tpl b/charts/helm-apps/templates/_apps-utils.tpl index 0534ec4..179f576 100644 --- a/charts/helm-apps/templates/_apps-utils.tpl +++ b/charts/helm-apps/templates/_apps-utils.tpl @@ -43,9 +43,11 @@ {{ $relativeScope.__specName__ }}: {{ print . | nindent 0 }} {{- end }} {{- else }} +{{- if hasKey $relativeScope $specName }} {{- with include "fl.value" (list $ $relativeScope (index $relativeScope .)) | trim }} {{ $specName }}: {{ print . | trim | nindent 0 }} {{- end }} +{{- end }} {{- end }} {{- end }} {{- end }} @@ -56,10 +58,12 @@ {{ $relativeScope.__specName__ }}: {{ print . | nindent 2 }} {{- end }} {{- else }} +{{- if hasKey $relativeScope $specName }} {{- with include "fl.value" (list $ $relativeScope (index $relativeScope .)) | trim }} {{ $specName }}: {{ print . | nindent 2 }} {{- end }} {{- end }} +{{- end }} {{- end }} {{- end }} {{- with $specs.Strings }} @@ -69,10 +73,12 @@ {{ $relativeScope.__specName__ }}: {{ print . | quote }} {{- end }} {{- else }} +{{- if hasKey $relativeScope $specName }} {{- with include "fl.valueQuoted" (list $ $relativeScope (index $relativeScope .)) }} {{ $specName }}: {{ . }} {{- end }} {{- end }} +{{- end }} {{- end }} {{- end }} {{- with $specs.Numbers }} @@ -82,10 +88,12 @@ {{ $relativeScope.__specName__ }}: {{ print . }} {{- end }} {{- else }} +{{- if hasKey $relativeScope $specName }} {{- with include "fl.value" (list $ $relativeScope (index $relativeScope .)) }} {{ $specName }}: {{ . }} {{- end }} {{- end }} +{{- end }} {{- end }} {{- end }} {{- with $specs.Bools }} @@ -96,7 +104,7 @@ {{ $relativeScope.__specName__ }}: {{ print $specValue }} {{- end }} {{- else }} -{{- if ne (include "fl.value" (list $ $relativeScope (index $relativeScope .))) "" }} +{{- if and (hasKey $relativeScope $specName) (ne (include "fl.value" (list $ $relativeScope (index $relativeScope .))) "") }} {{ $specName }}: {{ include "fl.isTrue" (list $ $relativeScope (index $relativeScope .)) }} {{- end }} {{- end }} From 30d796c6a3e3866c1e770169e2c6ef75f9f6d593 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:01:08 +0300 Subject: [PATCH 40/45] chore(release): bump helm-apps chart version to 1.6.2 --- charts/helm-apps/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/helm-apps/Chart.yaml b/charts/helm-apps/Chart.yaml index 79dd3c6..b0a6aea 100644 --- a/charts/helm-apps/Chart.yaml +++ b/charts/helm-apps/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: helm-apps description: A Helm applications library type: library -version: 1.6.1 +version: 1.6.2 icon: https://raw.githubusercontent.com/alvnukov/helm-apps/main/docs/assets/icon.png maintainers: - name: alvnukov From 339d1937946bfb5f4d32a60b69e826f62f7b2392 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:19:54 +0300 Subject: [PATCH 41/45] docs: add custom renderer contract via __GroupVars__.type --- AGENTS.md | 161 +++++++++++++++++++++++++++++++++++++++ docs/library-guide.md | 37 +++++++++ docs/reference-values.md | 33 ++++++++ 3 files changed, 231 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4bf862a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,161 @@ +# AGENTS.md + +This file defines how AI agents should work with the `helm-apps` library in this repository. + +## 1. Goal + +When a user asks to deploy/update an app with this library, the agent should: + +1. Pick the correct `apps-*` entity. +2. Produce valid values in library style. +3. Keep behavior compatible across environments and Kubernetes versions. +4. Run required checks before finishing. + +## 2. Required Library Entry Point + +Any consumer chart must initialize the library with: + +```yaml +{{- include "apps-utils.init-library" $ }} +``` + +## 3. Supported Top-Level Sections + +Use these built-in groups when possible: + +- `apps-stateless` +- `apps-stateful` +- `apps-jobs` +- `apps-cronjobs` +- `apps-services` +- `apps-ingresses` +- `apps-network-policies` +- `apps-configmaps` +- `apps-secrets` +- `apps-pvcs` +- `apps-limit-range` +- `apps-certificates` +- `apps-dex-clients` +- `apps-dex-authenticators` +- `apps-custom-prometheus-rules` +- `apps-grafana-dashboards` +- `apps-kafka-strimzi` +- `apps-infra` + +Custom groups are allowed via: + +```yaml +my-group: + __GroupVars__: + type: apps-stateless +``` + +`__GroupVars__.type` may be a string or env-map. + +Custom renderers are also supported: + +1. Set `__GroupVars__.type` to your custom renderer name. +2. Define template `".render"` in the consumer chart templates. +3. Library will call `include (printf "%s.render" $type) $`. + +Minimal example: + +```yaml +custom-services: + __GroupVars__: + type: custom-services + minio: + enabled: true +``` + +```yaml +{{- define "custom-services.render" -}} +{{- $ := . -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $.CurrentApp.name | quote }} +spec: + type: ExternalName + externalName: "example.local" +{{- end -}} +``` + +## 4. Values Rules (Critical) + +1. Environment selection is done through `global.env`. +2. Prefer env-maps (`_default`, `prod`, regex keys) for env-specific values. +3. For most Kubernetes blocks, use YAML block strings (`|`) instead of native YAML lists/maps. +4. Native YAML lists are forbidden except allowed paths: + - `_include` + - `_include_files` + - `global._includes.*` + - `*.configFilesYAML.*.content.*` + - `*.envYAML.*` + - `apps-kafka-strimzi.*.kafka.brokers.hosts.*` + - `apps-kafka-strimzi.*.kafka.ui.dex.allowedGroups.*` + +If a forbidden list is used, render must fail with exact values path. + +## 5. Includes and Merge + +1. Reuse profiles via `global._includes` + `_include`. +2. Merge is recursive for maps. +3. `_include` chains are concatenated. +4. Local app values override included values. + +## 6. Release Mode + +Optional release matrix mode: + +- `global.release.enabled` (default `false`) +- `global.release.current` +- `global.release.versions` +- `global.release.autoEnableApps` (default `true`) +- app-level `releaseKey` (optional, fallback to app name) + +Behavior: + +- resolves `CurrentAppVersion`; +- uses it as image tag when `image.staticTag` is absent; +- adds annotations `helm-apps/release` and `helm-apps/app-version`. + +## 7. Network Policies + +For `apps-network-policies`, select implementation via `type`: + +- `kubernetes` -> `networking.k8s.io/v1` + `NetworkPolicy` +- `cilium` -> `cilium.io/v2` + `CiliumNetworkPolicy` +- `calico` -> `projectcalico.org/v3` + `NetworkPolicy` + +## 8. Mandatory Checks Before Final Answer + +Run (or equivalent): + +```bash +werf helm lint tests/.helm --values tests/.helm/values.yaml +werf helm template contracts tests/contracts +``` + +If you changed compatibility behavior, also check: + +```bash +werf helm template tests tests/.helm --set global.env=prod --set global._includes.apps-defaults.enabled=true --kube-version 1.29.0 +werf helm template tests tests/.helm --set global.env=prod --set global._includes.apps-defaults.enabled=true --kube-version 1.20.15 +``` + +## 9. If You Modify Library Behavior + +Update all relevant artifacts: + +1. Templates in `charts/helm-apps/templates/`. +2. Examples in `tests/.helm/values.yaml`. +3. Schema in `tests/.helm/values.schema.json`. +4. Contract tests in `tests/contracts/`. +5. CI checks in `.github/workflows/ci.yml`. +6. Docs (`README.md`, `docs/*`) and release notes/changelog when needed. + +## 10. Stability Priority + +For this repository, stability is higher priority than micro-optimizations. +Avoid risky shortcuts that reduce validation coverage or change merge semantics implicitly. diff --git a/docs/library-guide.md b/docs/library-guide.md index bffe0c8..ae3a255 100644 --- a/docs/library-guide.md +++ b/docs/library-guide.md @@ -122,6 +122,43 @@ payment-group: __AppType__: apps-ingresses ``` +### 4.3 Пользовательские рендер-шаблоны через `__GroupVars__.type` + +Можно рендерить собственные сущности через библиотечный цикл `renderApps`. + +Идея: +1. В группе задается `__GroupVars__.type: `. +2. В chart приложения объявляется шаблон `define ".render"`. +3. Библиотека вызывает его автоматически как `include (printf "%s.render" $type) $`. + +Пример values: + +```yaml +custom-services: + __GroupVars__: + type: custom-services + minio: + enabled: true + host: + ip: 10.0.0.10 + port: 9000 +``` + +Пример шаблона в chart приложения: + +```yaml +{{- define "custom-services.render" -}} +{{- $ := . -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $.CurrentApp.name | quote }} +spec: + type: ExternalName + externalName: {{ printf "%v" $.CurrentApp.host.ip | quote }} +{{- end -}} +``` + ## 5. Переиспользование конфигурации ### 5.1 `global._includes` + `_include` diff --git a/docs/reference-values.md b/docs/reference-values.md index 07f63d2..836d997 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -653,6 +653,39 @@ group-name: - `_preRenderGroupHook` - `_preRenderAppHook` +### 15.1 Custom renderer через `__GroupVars__.type` + +`type` может указывать не только на встроенный `apps-*` рендерер, но и на пользовательский. + +Контракт: +1. В values: + - `__GroupVars__.type: my-custom-type` +2. В шаблонах chart приложения: + - `define "my-custom-type.render"` +3. Библиотека передает стандартный контекст (`$`, `$.CurrentApp`, `$.CurrentGroupVars`, `$.Values`). + +Минимальный пример: + +```yaml +custom-services: + __GroupVars__: + type: custom-services + service-a: + enabled: true +``` + +```yaml +{{- define "custom-services.render" -}} +{{- $ := . -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $.CurrentApp.name | quote }} +data: + kind: "custom-services" +{{- end -}} +``` + ## 16. Полезные ссылки - Общая концепция: [docs/library-guide.md](library-guide.md) From e0228224204f46238b94173851535eecdf345028 Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:25:30 +0300 Subject: [PATCH 42/45] docs: clarify CurrentApp passthrough in custom renderer examples --- AGENTS.md | 29 ++++++++++++++++++++++++++++- docs/library-guide.md | 27 ++++++++++++++++++++++++++- docs/reference-values.md | 27 ++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4bf862a..abdfeae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,20 @@ Custom renderers are also supported: 2. Define template `".render"` in the consumer chart templates. 3. Library will call `include (printf "%s.render" $type) $`. +Context available inside custom renderer: + +- `$` (root context) +- `$.Values` +- `$.CurrentApp` +- `$.CurrentGroupVars` +- `$.CurrentGroup` +- `$.CurrentPath` +- `$.Release` +- `$.Capabilities` +- `$.Files` + +Any app fields from `custom-services..*` are passed as-is into `$.CurrentApp`. + Minimal example: ```yaml @@ -66,6 +80,11 @@ custom-services: type: custom-services minio: enabled: true + host: + ip: minio.example.local + port: 9000 + extraLabels: + app.kubernetes.io/component: storage ``` ```yaml @@ -75,9 +94,17 @@ apiVersion: v1 kind: Service metadata: name: {{ $.CurrentApp.name | quote }} + labels: + app.kubernetes.io/name: {{ $.CurrentApp.name | quote }} + app.kubernetes.io/enabled: {{ printf "%v" $.CurrentApp.enabled | quote }} +{{- with $.CurrentApp.extraLabels }} +{{ toYaml . | indent 4 }} +{{- end }} spec: type: ExternalName - externalName: "example.local" + externalName: {{ printf "%v" $.CurrentApp.host.ip | quote }} + ports: + - port: {{ $.CurrentApp.host.port }} {{- end -}} ``` diff --git a/docs/library-guide.md b/docs/library-guide.md index ae3a255..996f3dd 100644 --- a/docs/library-guide.md +++ b/docs/library-guide.md @@ -131,6 +131,19 @@ payment-group: 2. В chart приложения объявляется шаблон `define ".render"`. 3. Библиотека вызывает его автоматически как `include (printf "%s.render" $type) $`. +В custom renderer доступны переменные контекста: +- `$` (root context), +- `$.Values`, +- `$.CurrentApp`, +- `$.CurrentGroupVars`, +- `$.CurrentGroup`, +- `$.CurrentPath`, +- `$.Release`, +- `$.Capabilities`, +- `$.Files`. + +Любые поля приложения из `custom-services..*` автоматически пробрасываются в `$.CurrentApp.*`. + Пример values: ```yaml @@ -140,8 +153,10 @@ custom-services: minio: enabled: true host: - ip: 10.0.0.10 + ip: minio.example.local port: 9000 + extraLabels: + app.kubernetes.io/component: storage ``` Пример шаблона в chart приложения: @@ -153,12 +168,22 @@ apiVersion: v1 kind: Service metadata: name: {{ $.CurrentApp.name | quote }} + labels: + app.kubernetes.io/name: {{ $.CurrentApp.name | quote }} + app.kubernetes.io/enabled: {{ printf "%v" $.CurrentApp.enabled | quote }} +{{- with $.CurrentApp.extraLabels }} +{{ toYaml . | indent 4 }} +{{- end }} spec: type: ExternalName externalName: {{ printf "%v" $.CurrentApp.host.ip | quote }} + ports: + - port: {{ $.CurrentApp.host.port }} {{- end -}} ``` +В этом примере `name`, `enabled`, `host.ip`, `host.port` и `extraLabels` приходят из `values` текущего app через `$.CurrentApp`. + ## 5. Переиспользование конфигурации ### 5.1 `global._includes` + `_include` diff --git a/docs/reference-values.md b/docs/reference-values.md index 836d997..e4a4c98 100644 --- a/docs/reference-values.md +++ b/docs/reference-values.md @@ -664,7 +664,20 @@ group-name: - `define "my-custom-type.render"` 3. Библиотека передает стандартный контекст (`$`, `$.CurrentApp`, `$.CurrentGroupVars`, `$.Values`). -Минимальный пример: +Важно: любые поля app из `group..*` доступны в custom renderer через `$.CurrentApp.*`. + +Полный набор полезных переменных в custom renderer: +- `$` (root context), +- `$.Values`, +- `$.CurrentApp`, +- `$.CurrentGroupVars`, +- `$.CurrentGroup`, +- `$.CurrentPath`, +- `$.Release`, +- `$.Capabilities`, +- `$.Files`. + +Пример с явным пробросом app-полей в `$.CurrentApp`: ```yaml custom-services: @@ -672,6 +685,11 @@ custom-services: type: custom-services service-a: enabled: true + host: + ip: service-a.example.local + port: 8080 + extraLabels: + app.kubernetes.io/part-of: platform ``` ```yaml @@ -681,8 +699,15 @@ apiVersion: v1 kind: ConfigMap metadata: name: {{ $.CurrentApp.name | quote }} + labels: + app.kubernetes.io/name: {{ $.CurrentApp.name | quote }} + app.kubernetes.io/enabled: {{ printf "%v" $.CurrentApp.enabled | quote }} +{{- with $.CurrentApp.extraLabels }} +{{ toYaml . | indent 4 }} +{{- end }} data: kind: "custom-services" + host: {{ printf "%v:%v" $.CurrentApp.host.ip $.CurrentApp.host.port | quote }} {{- end -}} ``` From 646c5350f184849ff89c854225ce15531caa3dbf Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:59:56 +0300 Subject: [PATCH 43/45] ci: add Kubernetes compatibility matrix checks and local runner --- .github/workflows/ci.yml | 75 ++++++++++++ Makefile | 2 + scripts/ci-local.sh | 247 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100755 scripts/ci-local.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23c85e3..52cf130 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,3 +193,78 @@ jobs: grep -q 'helm-apps/app-version: "1.2.3"' /tmp/contracts_internal_like.yaml grep -q 'name: "compat-route"' /tmp/contracts_internal_like.yaml grep -q 'host: "compat.example.com"' /tmp/contracts_internal_like.yaml + + kube-compatibility-matrix: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - kube_version: "1.19.16" + expect_policy: "policy/v1beta1" + expect_cronjob: "batch/v1beta1" + expect_hpa: "autoscaling/v2beta2" + - kube_version: "1.20.15" + expect_policy: "policy/v1beta1" + expect_cronjob: "batch/v1beta1" + expect_hpa: "autoscaling/v2beta2" + - kube_version: "1.23.17" + expect_policy: "policy/v1" + expect_cronjob: "batch/v1" + expect_hpa: "autoscaling/v2" + - kube_version: "1.29.0" + expect_policy: "policy/v1" + expect_cronjob: "batch/v1" + expect_hpa: "autoscaling/v2" + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set lib version + run: | + LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) + sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl + + - name: Install werf CLI + uses: werf/actions/install@v1.2 + with: + channel: ea + + - name: Install kubeconform + run: | + set -euo pipefail + KUBECONFORM_VERSION=0.6.7 + curl -sSL -o /tmp/kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/v${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" + tar -xzf /tmp/kubeconform.tar.gz -C /tmp kubeconform + sudo install -m 0755 /tmp/kubeconform /usr/local/bin/kubeconform + + - name: Update chart dependencies + run: | + werf helm dependency update tests/.helm + werf helm dependency update tests/contracts + + - name: Render and verify APIs for Kubernetes ${{ matrix.kube_version }} + run: | + set -euo pipefail + + werf helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" \ + --kube-version "${{ matrix.kube_version }}" > "/tmp/tests_k8s_${{ matrix.kube_version }}.yaml" + + grep -q "^apiVersion: ${{ matrix.expect_policy }}$" "/tmp/tests_k8s_${{ matrix.kube_version }}.yaml" + grep -q "^apiVersion: ${{ matrix.expect_cronjob }}$" "/tmp/tests_k8s_${{ matrix.kube_version }}.yaml" + grep -q "^apiVersion: ${{ matrix.expect_hpa }}$" "/tmp/tests_k8s_${{ matrix.kube_version }}.yaml" + + kubeconform -strict -summary -ignore-missing-schemas \ + -kubernetes-version "${{ matrix.kube_version }}" \ + "/tmp/tests_k8s_${{ matrix.kube_version }}.yaml" + + - name: Render contracts for Kubernetes ${{ matrix.kube_version }} + run: | + set -euo pipefail + werf helm template contracts tests/contracts \ + --kube-version "${{ matrix.kube_version }}" > "/tmp/contracts_k8s_${{ matrix.kube_version }}.yaml" + grep -q '^apiVersion:' "/tmp/contracts_k8s_${{ matrix.kube_version }}.yaml" diff --git a/Makefile b/Makefile index 07f0838..a4e0a46 100644 --- a/Makefile +++ b/Makefile @@ -4,3 +4,5 @@ deps: werf helm dependency update tests/.helm save_tests: cd tests; werf render --set "global._includes.apps-defaults.enabled=true" --env=prod --dev | sed '/werf.io\//d' > test_render.yaml +ci_local: + bash scripts/ci-local.sh diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh new file mode 100755 index 0000000..c1c7a2f --- /dev/null +++ b/scripts/ci-local.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +RUN_SNAPSHOT=1 +RUN_CONTRACTS=1 +RUN_API=1 + +usage() { + cat <<'EOF' +Usage: scripts/ci-local.sh [options] + +Runs local equivalent of .github/workflows/ci.yml:validate. + +Options: + --skip-snapshot Skip render snapshot diff check. + --skip-contracts Skip contracts checks. + --skip-api Skip Kubernetes API compatibility checks. + -h, --help Show this help. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-snapshot) + RUN_SNAPSHOT=0 + shift + ;; + --skip-contracts) + RUN_CONTRACTS=0 + shift + ;; + --skip-api) + RUN_API=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 2 + ;; + esac +done + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +need_cmd werf + +if ! command -v kubeconform >/dev/null 2>&1; then + echo "Missing required command: kubeconform" >&2 + echo "Install: https://github.com/yannh/kubeconform" >&2 + exit 1 +fi + +if [[ "${RUN_SNAPSHOT}" -eq 1 ]] && ! command -v dyff >/dev/null 2>&1; then + echo "dyff not found: fallback to diff -u for snapshot check." +fi + +APPS_VERSION_FILE="charts/helm-apps/templates/_apps-version.tpl" +TESTS_LOCK="tests/.helm/Chart.lock" +CONTRACTS_LOCK="tests/contracts/Chart.lock" + +backup_file() { + local file="$1" + if [[ -f "${file}" ]]; then + cp "${file}" "${file}.bak.ci-local" + fi +} + +restore_file() { + local file="$1" + if [[ -f "${file}.bak.ci-local" ]]; then + mv "${file}.bak.ci-local" "${file}" + fi +} + +cleanup() { + restore_file "${APPS_VERSION_FILE}" + restore_file "${TESTS_LOCK}" + restore_file "${CONTRACTS_LOCK}" +} +trap cleanup EXIT + +backup_file "${APPS_VERSION_FILE}" +backup_file "${TESTS_LOCK}" +backup_file "${CONTRACTS_LOCK}" + +echo "==> Set library version in ${APPS_VERSION_FILE}" +LIB_VERSION="$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml)" +sed -i.bak "s/_FLANT_APPS_LIBRARY_VERSION_/${LIB_VERSION}/" "${APPS_VERSION_FILE}" +rm -f "${APPS_VERSION_FILE}.bak" + +echo "==> Update test chart dependencies" +werf helm dependency update tests/.helm + +echo "==> Validate values schema" +werf helm lint tests/.helm --values tests/.helm/values.yaml + +if [[ "${RUN_API}" -eq 1 ]]; then + echo "==> Verify Kubernetes API compatibility" + werf helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" \ + --kube-version 1.29.0 > /tmp/tests_k8s_129.yaml + grep -q '^apiVersion: policy/v1$' /tmp/tests_k8s_129.yaml + grep -q '^apiVersion: batch/v1$' /tmp/tests_k8s_129.yaml + grep -q '^apiVersion: autoscaling/v2$' /tmp/tests_k8s_129.yaml + ! grep -q '^apiVersion: policy/v1beta1$' /tmp/tests_k8s_129.yaml + ! grep -q '^apiVersion: batch/v1beta1$' /tmp/tests_k8s_129.yaml + ! grep -q '^apiVersion: autoscaling/v2beta2$' /tmp/tests_k8s_129.yaml + kubeconform -strict -summary -ignore-missing-schemas -kubernetes-version 1.29.0 /tmp/tests_k8s_129.yaml + + werf helm template tests tests/.helm \ + --set "global.env=prod" \ + --set "global._includes.apps-defaults.enabled=true" \ + --kube-version 1.20.15 > /tmp/tests_k8s_120.yaml + grep -q '^apiVersion: policy/v1beta1$' /tmp/tests_k8s_120.yaml + grep -q '^apiVersion: batch/v1beta1$' /tmp/tests_k8s_120.yaml + grep -q '^apiVersion: autoscaling/v2beta2$' /tmp/tests_k8s_120.yaml + ! grep -q '^apiVersion: autoscaling/v2$' /tmp/tests_k8s_120.yaml + kubeconform -strict -summary -ignore-missing-schemas -kubernetes-version 1.20.15 /tmp/tests_k8s_120.yaml +fi + +if [[ "${RUN_SNAPSHOT}" -eq 1 ]]; then + echo "==> Render snapshot check" + ( + cd tests + if source "$(werf ci-env github --as-file)" >/dev/null 2>&1; then + echo "Using werf ci-env github context." + else + echo "Cannot load werf ci-env github context; using local context." + fi + + werf render --dev --set "global._includes.apps-defaults.enabled=true" --env=prod | sed '/werf.io\//d' > test_render_check.yaml + + if command -v dyff >/dev/null 2>&1; then + dyff between test_render.yaml test_render_check.yaml | tee /tmp/test_render_check + check_tests="$(sed 1,7d /tmp/test_render_check | wc -l | tr -d ' ')" + if [[ "${check_tests}" -gt "7" ]]; then + echo "Snapshot mismatch: dyff output lines=${check_tests}" >&2 + exit 1 + fi + else + diff -u test_render.yaml test_render_check.yaml + fi + ) +fi + +if [[ "${RUN_CONTRACTS}" -eq 1 ]]; then + echo "==> Update contract chart dependencies" + werf helm dependency update tests/contracts + + echo "==> Contract checks" + werf helm template contracts tests/contracts > /tmp/contracts_render.yaml + grep -q '"A": "2"' /tmp/contracts_render.yaml + grep -q '"LOCAL": "ok"' /tmp/contracts_render.yaml + grep -q '"key2": "local-value-2"' /tmp/contracts_render.yaml + grep -q '"key1": "value-1"' /tmp/contracts_render.yaml + grep -q '"fromBaseA": "A"' /tmp/contracts_render.yaml + grep -q '"fromBaseB": "B"' /tmp/contracts_render.yaml + grep -q '"ENV_SWITCH": "override-default"' /tmp/contracts_render.yaml + werf helm template contracts tests/contracts --set global.env=dev > /tmp/contracts_render_dev.yaml + grep -q '"ENV_SWITCH": "override-default"' /tmp/contracts_render_dev.yaml + grep -q 'paused: true' /tmp/contracts_render.yaml + grep -q 'resizePolicy:' /tmp/contracts_render.yaml + grep -q 'podFailurePolicy:' /tmp/contracts_render.yaml + grep -q 'defaultBackend:' /tmp/contracts_render.yaml + grep -q 'volumeMode: Filesystem' /tmp/contracts_render.yaml + grep -q 'immutable: true' /tmp/contracts_render.yaml + grep -q 'stringData:' /tmp/contracts_render.yaml + grep -q '^apiVersion: networking.k8s.io/v1$' /tmp/contracts_render.yaml + grep -q '^kind: NetworkPolicy$' /tmp/contracts_render.yaml + grep -q '^apiVersion: cilium.io/v2$' /tmp/contracts_render.yaml + grep -q '^kind: CiliumNetworkPolicy$' /tmp/contracts_render.yaml + grep -q '^apiVersion: projectcalico.org/v3$' /tmp/contracts_render.yaml + grep -q 'selector: "app == '\''compat-service'\''"' /tmp/contracts_render.yaml + grep -q 'kubernetes.io/metadata.name: ingress-nginx' /tmp/contracts_render.yaml + grep -q 'port: 53' /tmp/contracts_render.yaml + grep -q 'name: "release-auto-app"' /tmp/contracts_render.yaml + grep -q 'image: alpine:3.19' /tmp/contracts_render.yaml + grep -q 'helm-apps/release: "production-v1"' /tmp/contracts_render.yaml + grep -q 'helm-apps/app-version: "3.19"' /tmp/contracts_render.yaml + grep -q 'name: "compat-route"' /tmp/contracts_render.yaml + grep -q 'host: "route.example.com"' /tmp/contracts_render.yaml + + werf helm template contracts tests/contracts --set global.validation.strict=true > /tmp/contracts_render_strict.yaml + grep -Eq '"custom": ?"ok"|custom: ?"?ok"?' /tmp/contracts_render_strict.yaml + ! werf helm template contracts tests/contracts \ + --set global.validation.strict=true \ + --set apps-network-policies.compat-netpol.typoField=1 >/tmp/contracts_render_strict_fail.yaml + ! werf helm template contracts tests/contracts \ + --set global.validation.strict=true \ + --set apps-typo.bad.enabled=true >/tmp/contracts_render_strict_top_fail.yaml + + werf helm template contracts tests/contracts --kube-version 1.29.0 > /tmp/contracts_render_129.yaml + grep -q 'loadBalancerClass: "internal-vip"' /tmp/contracts_render_129.yaml + grep -q 'internalTrafficPolicy: "Local"' /tmp/contracts_render_129.yaml + + werf helm template contracts tests/contracts --kube-version 1.20.15 > /tmp/contracts_render_120.yaml + ! grep -q 'loadBalancerClass:' /tmp/contracts_render_120.yaml + ! grep -q 'internalTrafficPolicy:' /tmp/contracts_render_120.yaml + grep -q 'ipFamilyPolicy: "SingleStack"' /tmp/contracts_render_120.yaml + grep -q 'allocateLoadBalancerNodePorts: true' /tmp/contracts_render_120.yaml + + werf helm template contracts tests/contracts --kube-version 1.19.16 > /tmp/contracts_render_119.yaml + ! grep -q 'loadBalancerClass:' /tmp/contracts_render_119.yaml + ! grep -q 'internalTrafficPolicy:' /tmp/contracts_render_119.yaml + ! grep -q 'ipFamilyPolicy:' /tmp/contracts_render_119.yaml + ! grep -q 'ipFamilies:' /tmp/contracts_render_119.yaml + ! grep -q 'allocateLoadBalancerNodePorts:' /tmp/contracts_render_119.yaml + + cat > /tmp/contracts_invalid_native_list.yaml <<'EOF' +apps-stateless: + compat-service: + service: + ports: + - name: http + port: 80 + targetPort: 8080 +EOF + ! werf helm template contracts tests/contracts \ + --values /tmp/contracts_invalid_native_list.yaml \ + >/tmp/contracts_invalid_native_list.out 2>/tmp/contracts_invalid_native_list.err + grep -q "list value is not allowed at Values.apps-stateless.compat-service.service.ports" /tmp/contracts_invalid_native_list.err + + werf helm template contracts tests/contracts \ + --values tests/contracts/values.internal-compat.yaml > /tmp/contracts_internal_like.yaml + grep -q 'name: "compat-web"' /tmp/contracts_internal_like.yaml + grep -q 'image: alpine:1.2.3' /tmp/contracts_internal_like.yaml + grep -q 'helm-apps/release:' /tmp/contracts_internal_like.yaml + grep -q 'helm-apps/app-version: "1.2.3"' /tmp/contracts_internal_like.yaml + grep -q 'name: "compat-route"' /tmp/contracts_internal_like.yaml + grep -q 'host: "compat.example.com"' /tmp/contracts_internal_like.yaml +fi + +echo "Local CI validate checks passed." From 8508880e2af75aecce1ce2db4d00383cd2a4c06a Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:10:08 +0300 Subject: [PATCH 44/45] docs: add Flant upstream PR draft and migration checklist --- docs/flant-pr-draft.md | 99 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/flant-pr-draft.md diff --git a/docs/flant-pr-draft.md b/docs/flant-pr-draft.md new file mode 100644 index 0000000..d6bc927 --- /dev/null +++ b/docs/flant-pr-draft.md @@ -0,0 +1,99 @@ +# Flant Upstream PR Draft + +This file prepares an upstream PR package for merging key `helm-apps` improvements into Flant library flow. + +## 1. Recommended PR Scope + +To keep review safe and predictable, split into 3 PRs: + +1. **Compatibility + validation safety** +2. **NetworkPolicy entity (CNI-aware)** +3. **Release matrix mode + docs/contracts** + +This avoids one huge diff and keeps rollback simple. + +## 2. Commit Candidates (from current repo) + +### PR-1: Compatibility + validation safety + +- `d0c2d21` feat(compat): kubernetes version-aware api/spec compatibility checks +- `9c28e30` fix(validation): fail on unexpected native lists and show exact values path +- `1128e61` fix(validation): allow native lists in approved template-driven paths +- `b4b50de` fix(stability): keep full list validation traversal and safe hasKey checks +- `18e1a6f` feat(strict): opt-in unknown-key validation for network policies +- `be5bf5e` feat(strict): unknown top-level apps groups validation with custom-group allowance + +### PR-2: NetworkPolicy entity + +- `48e8b08` feat(network-policy): add cni-aware network policy entity with type-based rendering + +### PR-3: Release matrix mode + +- `f4db8a2` Add release matrix mode with image tag fallback, schema/docs/contracts, and CI coverage +- `2915860` fix: improve schema compatibility and add internal-like test coverage +- `c3cae63` fix(docs): clarify optional releaseKey and app name fallback +- `614e0a0` fix(docs): clarify release defaults, fallbacks, and custom group type behavior +- `01b5164` fix(ci): add internal-like contract scenario for release/deploy flow + +## 3. PR Text Template (English) + +Use this in GitHub PR description: + +```md +## What + +This PR introduces compatibility and validation improvements for the helm-apps library: + +- Kubernetes-version-aware API rendering for resources with changed API versions. +- Safer values validation: + - fail on unexpected native YAML lists with exact values path in error; + - preserve allowed list-based subtrees used by template-driven fields; + - opt-in strict validation for selected entities/groups. +- Extended contract checks for backward/forward compatibility. + +## Why + +The main goal is to improve deployment reliability across mixed Kubernetes versions and reduce silent misconfiguration risks in large values trees. + +## Backward compatibility + +- Behavior remains backward-compatible by default. +- Strict unknown-key checks are opt-in via `global.validation.strict`. +- Existing templates using string-based Kubernetes blocks continue to work unchanged. + +## Validation + +Locally validated with: + +- `werf helm lint tests/.helm --values tests/.helm/values.yaml` +- contract templates checks (`tests/contracts`) +- Kubernetes compatibility matrix (render + kubeconform): + - 1.19.16 + - 1.20.15 + - 1.23.17 + - 1.29.0 + +## Notes for reviewers + +- Validation failure messages now include exact values path for faster troubleshooting. +- Custom top-level groups remain supported through `__GroupVars__.type`. +``` + +## 4. Upstream Checklist + +- [ ] Confirm target Flant repo and base branch. +- [ ] Create feature branch in upstream fork. +- [ ] Cherry-pick commits for selected PR scope. +- [ ] Resolve path conflicts in templates/schema/tests. +- [ ] Run local checks: + - [ ] `bash scripts/ci-local.sh --skip-snapshot` + - [ ] matrix render checks for 1.19/1.20/1.23/1.29 +- [ ] Push branch and open PR with template text. +- [ ] Attach rendered diff snippets for risky API-switch parts. + +## 5. Suggested Branch Names + +- `flant/compat-validation-safety` +- `flant/network-policy-cni-aware` +- `flant/release-matrix-mode` + From 799bd03449fd818284f6711ff8771d703f22facb Mon Sep 17 00:00:00 2001 From: alvnukov <80969382+alvnukov@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:47:38 +0300 Subject: [PATCH 45/45] ci: add kind server-side dry-run validation with compatibility CRDs --- .github/workflows/ci.yml | 52 ++++++ tests/crds/compat-crds.yaml | 326 ++++++++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 tests/crds/compat-crds.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52cf130..f707537 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -268,3 +268,55 @@ jobs: werf helm template contracts tests/contracts \ --kube-version "${{ matrix.kube_version }}" > "/tmp/contracts_k8s_${{ matrix.kube_version }}.yaml" grep -q '^apiVersion:' "/tmp/contracts_k8s_${{ matrix.kube_version }}.yaml" + + kube-server-dry-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set lib version + run: | + LIB_VERSION=$(sed -n '/version/{s/version: //;p;}' charts/helm-apps/Chart.yaml) + sed -i 's/_FLANT_APPS_LIBRARY_VERSION_/'${LIB_VERSION}'/' charts/helm-apps/templates/_apps-version.tpl + + - name: Install werf CLI + uses: werf/actions/install@v1.2 + with: + channel: ea + + - name: Setup kubectl + uses: azure/setup-kubectl@v4 + with: + version: v1.29.0 + + - name: Create kind cluster + uses: helm/kind-action@v1.10.0 + with: + node_image: kindest/node:v1.29.2 + + - name: Install compatibility CRDs + run: | + set -euo pipefail + kubectl apply -f tests/crds/compat-crds.yaml + kubectl wait --for=condition=Established --timeout=120s crd --all + + - name: Update chart dependencies + run: | + werf helm dependency update tests/.helm + werf helm dependency update tests/contracts + + - name: Render contracts for server-side validation + run: | + set -euo pipefail + werf helm template contracts tests/contracts \ + --kube-version 1.29.0 \ + --set "apps-jobs.compat-job.restartPolicy=Never" \ + > /tmp/contracts_k8s_129_server.yaml + + - name: Server-side dry-run apply + run: | + set -euo pipefail + kubectl apply --dry-run=server -f /tmp/contracts_k8s_129_server.yaml diff --git a/tests/crds/compat-crds.yaml b/tests/crds/compat-crds.yaml new file mode 100644 index 0000000..0c336f1 --- /dev/null +++ b/tests/crds/compat-crds.yaml @@ -0,0 +1,326 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: certificates.cert-manager.io +spec: + group: cert-manager.io + scope: Namespaced + names: + plural: certificates + singular: certificate + kind: Certificate + listKind: CertificateList + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: verticalpodautoscalers.autoscaling.k8s.io + annotations: + api-approved.kubernetes.io: "https://github.com/kubernetes/autoscaler/tree/master/vertical-pod-autoscaler" +spec: + group: autoscaling.k8s.io + scope: Namespaced + names: + plural: verticalpodautoscalers + singular: verticalpodautoscaler + kind: VerticalPodAutoscaler + listKind: VerticalPodAutoscalerList + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: kafkas.kafka.strimzi.io +spec: + group: kafka.strimzi.io + scope: Namespaced + names: + plural: kafkas + singular: kafka + kind: Kafka + listKind: KafkaList + versions: + - name: v1beta2 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: kafkatopics.kafka.strimzi.io +spec: + group: kafka.strimzi.io + scope: Namespaced + names: + plural: kafkatopics + singular: kafkatopic + kind: KafkaTopic + listKind: KafkaTopicList + versions: + - name: v1beta1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: ciliumnetworkpolicies.cilium.io +spec: + group: cilium.io + scope: Namespaced + names: + plural: ciliumnetworkpolicies + singular: ciliumnetworkpolicy + kind: CiliumNetworkPolicy + listKind: CiliumNetworkPolicyList + versions: + - name: v2 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: networkpolicies.projectcalico.org +spec: + group: projectcalico.org + scope: Namespaced + names: + plural: networkpolicies + singular: networkpolicy + kind: NetworkPolicy + listKind: NetworkPolicyList + versions: + - name: v3 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: customprometheusrules.deckhouse.io +spec: + group: deckhouse.io + scope: Namespaced + names: + plural: customprometheusrules + singular: customprometheusrule + kind: CustomPrometheusRules + listKind: CustomPrometheusRulesList + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: grafanadashboarddefinitions.deckhouse.io +spec: + group: deckhouse.io + scope: Namespaced + names: + plural: grafanadashboarddefinitions + singular: grafanadashboarddefinition + kind: GrafanaDashboardDefinition + listKind: GrafanaDashboardDefinitionList + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: dexauthenticators.deckhouse.io +spec: + group: deckhouse.io + scope: Namespaced + names: + plural: dexauthenticators + singular: dexauthenticator + kind: DexAuthenticator + listKind: DexAuthenticatorList + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: dexclients.deckhouse.io +spec: + group: deckhouse.io + scope: Namespaced + names: + plural: dexclients + singular: dexclient + kind: DexClient + listKind: DexClientList + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: nodeusers.deckhouse.io +spec: + group: deckhouse.io + scope: Cluster + names: + plural: nodeusers + singular: nodeuser + kind: NodeUser + listKind: NodeUserList + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: nodegroups.deckhouse.io +spec: + group: deckhouse.io + scope: Cluster + names: + plural: nodegroups + singular: nodegroup + kind: NodeGroup + listKind: NodeGroupList + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: podmetrics.deckhouse.io +spec: + group: deckhouse.io + scope: Namespaced + names: + plural: podmetrics + singular: podmetric + kind: PodMetric + listKind: PodMetricList + versions: + - name: v1beta1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + x-kubernetes-preserve-unknown-fields: true + status: + x-kubernetes-preserve-unknown-fields: true