From 75e69218eca07ed001fc7648f14115875e4f511f Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:05:24 +0300 Subject: [PATCH 01/27] feat: mvp (#1) --- .dmtlint.yaml | 31 + .github/workflows/build_dev.yaml | 115 + .github/workflows/deploy_dev.yaml | 43 + .gitignore | 45 + .helmignore | 12 + .werf/consts.yaml | 21 + .werf/defines/image-build.tmpl | 20 + .werf/defines/image-mountpoints.tmpl | 32 + .werf/defines/images.tmpl | 49 + .werf/defines/packages-clean.tmpl | 12 + .werf/defines/packages-proxies.tmpl | 70 + .werf/defines/parse-base-images-map.tmpl | 41 + .werf/images.yaml | 56 + CODE_OF_CONDUCT.md | 132 + CONTRIBUTING.md | 166 + Chart.yaml | 6 + LICENSE | 214 + MAINTAINERS.md | 6 + SECURITY.md | 7 + Taskfile.yaml | 105 + api/Taskfile.dist.yaml | 42 + .../clientset/versioned/clientset.go | 120 + .../versioned/fake/clientset_generated.go | 105 + .../generated/clientset/versioned/fake/doc.go | 20 + .../clientset/versioned/fake/register.go | 56 + .../clientset/versioned/scheme/doc.go | 20 + .../clientset/versioned/scheme/register.go | 56 + .../typed/api/v1alpha1/api_client.go | 111 + .../versioned/typed/api/v1alpha1/doc.go | 20 + .../versioned/typed/api/v1alpha1/fake/doc.go | 20 + .../api/v1alpha1/fake/fake_api_client.go | 48 + .../v1alpha1/fake/fake_helmclusteraddon.go | 52 + .../fake/fake_helmclusteraddonchart.go | 52 + .../fake/fake_helmclusteraddonrepository.go | 52 + .../typed/api/v1alpha1/generated_expansion.go | 25 + .../typed/api/v1alpha1/helmclusteraddon.go | 70 + .../api/v1alpha1/helmclusteraddonchart.go | 70 + .../v1alpha1/helmclusteraddonrepository.go | 72 + .../externalversions/api/interface.go | 46 + .../api/v1alpha1/helmclusteraddon.go | 101 + .../api/v1alpha1/helmclusteraddonchart.go | 101 + .../v1alpha1/helmclusteraddonrepository.go | 101 + .../api/v1alpha1/interface.go | 59 + .../informers/externalversions/factory.go | 263 ++ .../informers/externalversions/generic.go | 66 + .../internalinterfaces/factory_interfaces.go | 40 + .../api/v1alpha1/expansion_generated.go | 31 + .../listers/api/v1alpha1/helmclusteraddon.go | 48 + .../api/v1alpha1/helmclusteraddonchart.go | 48 + .../v1alpha1/helmclusteraddonrepository.go | 48 + api/doc.go | 17 + api/go.mod | 84 + api/go.sum | 171 + api/scripts/boilerplate.go.txt | 15 + api/scripts/update-codegen.sh | 108 + api/v1alpha1/doc.go | 21 + api/v1alpha1/helm_cluster_addon.go | 183 + api/v1alpha1/helm_cluster_addon_chart.go | 82 + api/v1alpha1/helm_cluster_addon_repository.go | 92 + api/v1alpha1/register.go | 66 + api/v1alpha1/zz_generated.deepcopy.go | 417 ++ build/base-images/deckhouse_images.yml | 306 ++ build/components/README.md | 14 + build/components/versions.yml | 4 + charts/deckhouse_lib_helm-1.55.1.tgz | Bin 0 -> 26935 bytes crds/embedded/helm-controller.yaml | 2631 +++++++++++ crds/embedded/nelm-source-controller.yaml | 4102 +++++++++++++++++ crds/helmclusteraddoncharts.yaml | 133 + crds/helmclusteraddonrepositories.yaml | 166 + crds/helmclusteraddons.yaml | 208 + docs/CONFIGURATION.md | 4 + docs/CONFIGURATION_RU.md | 4 + docs/CR.md | 4 + docs/CR_RU.md | 4 + docs/EXAMPLE.md | 86 + docs/EXAMPLE_RU.md | 85 + docs/README.md | 20 + docs/README_RU.md | 20 + docs/USAGE.md | 52 + docs/USAGE_RU.md | 50 + docs/images/.keep | 0 docs/internal/components_placement.md | 3 + enabled | 11 + hooks/.keep | 0 images/distroless/werf.inc.yaml | 53 + images/helm-controller/werf.inc.yaml | 3 + .../cmd/operator-helm-module-hooks/main.go | 25 + .../operator-helm-module-hooks/register.go | 21 + images/hooks/go.mod | 97 + images/hooks/go.sum | 273 ++ .../hooks/tls-certificates-controller/hook.go | 40 + images/hooks/pkg/settings/certificate.go | 21 + images/hooks/pkg/settings/module.go | 23 + images/hooks/werf.inc.yaml | 52 + images/kube-api-rewriter/.dockerignore | 9 + images/kube-api-rewriter/.gitignore | 1 + images/kube-api-rewriter/METRICS.md | 166 + images/kube-api-rewriter/STRUCTURE.md | 3 + images/kube-api-rewriter/Taskfile.dist.yaml | 111 + .../cmd/kube-api-rewriter/main.go | 224 + images/kube-api-rewriter/go.mod | 73 + images/kube-api-rewriter/go.sum | 164 + images/kube-api-rewriter/local/Dockerfile | 45 + .../local/kube-api-rewriter.kubeconfig | 11 + .../local/proxy-gen-certs.sh | 93 + .../local/proxy-kubeconfig-cm.yaml | 20 + images/kube-api-rewriter/local/proxy.yaml | 158 + .../local/test-controller/go.mod | 90 + .../local/test-controller/go.sum | 484 ++ .../local/test-controller/main.go | 369 ++ images/kube-api-rewriter/mount-points.yaml | 1 + .../pkg/labels/context_values.go | 104 + images/kube-api-rewriter/pkg/log/attrs.go | 31 + images/kube-api-rewriter/pkg/log/body.go | 83 + images/kube-api-rewriter/pkg/log/differ.go | 133 + .../pkg/log/pretty_handler.go | 248 + .../pkg/log/pretty_handler_test.go | 72 + images/kube-api-rewriter/pkg/log/setup.go | 120 + .../pkg/monitoring/healthz/handler.go | 35 + .../pkg/monitoring/metrics/handler.go | 34 + .../pkg/monitoring/metrics/registry.go | 40 + .../pkg/monitoring/profiler/handler.go | 35 + .../pkg/operatornelm/operatornelm_rules.go | 158 + .../operatornelm/operatornelm_rules_test.go | 33 + .../pkg/proxy/bytes_counter.go | 76 + images/kube-api-rewriter/pkg/proxy/doc.go | 55 + images/kube-api-rewriter/pkg/proxy/handler.go | 573 +++ images/kube-api-rewriter/pkg/proxy/logger.go | 35 + images/kube-api-rewriter/pkg/proxy/metrics.go | 126 + .../pkg/proxy/metrics_provider.go | 276 ++ .../pkg/proxy/stream_handler.go | 311 ++ .../pkg/rewriter/3rdparty.go | 32 + .../pkg/rewriter/admission_configuration.go | 89 + .../rewriter/admission_configuration_test.go | 85 + .../pkg/rewriter/admission_policy.go | 67 + .../pkg/rewriter/admission_review.go | 238 + .../pkg/rewriter/admission_review_test.go | 225 + .../pkg/rewriter/affinity.go | 187 + .../pkg/rewriter/api_endpoint.go | 313 ++ .../pkg/rewriter/api_endpoint_test.go | 292 ++ images/kube-api-rewriter/pkg/rewriter/app.go | 91 + .../pkg/rewriter/app_test.go | 253 + images/kube-api-rewriter/pkg/rewriter/core.go | 87 + .../pkg/rewriter/core_test.go | 379 ++ images/kube-api-rewriter/pkg/rewriter/crd.go | 257 ++ .../pkg/rewriter/crd_test.go | 336 ++ .../pkg/rewriter/discovery.go | 574 +++ .../pkg/rewriter/discovery_test.go | 606 +++ .../kube-api-rewriter/pkg/rewriter/events.go | 52 + .../pkg/rewriter/events_test.go | 123 + images/kube-api-rewriter/pkg/rewriter/gvk.go | 69 + .../pkg/rewriter/indexer/map_indexer.go | 58 + images/kube-api-rewriter/pkg/rewriter/list.go | 101 + images/kube-api-rewriter/pkg/rewriter/load.go | 38 + images/kube-api-rewriter/pkg/rewriter/map.go | 39 + .../pkg/rewriter/metadata.go | 144 + images/kube-api-rewriter/pkg/rewriter/path.go | 191 + .../kube-api-rewriter/pkg/rewriter/policy.go | 28 + .../pkg/rewriter/prefixed_name_rewriter.go | 288 ++ images/kube-api-rewriter/pkg/rewriter/rbac.go | 159 + .../pkg/rewriter/rbac_test.go | 184 + .../pkg/rewriter/resource.go | 165 + .../pkg/rewriter/resource_test.go | 383 ++ .../pkg/rewriter/rule_rewriter.go | 431 ++ .../pkg/rewriter/rule_rewriter_test.go | 418 ++ .../kube-api-rewriter/pkg/rewriter/rules.go | 438 ++ .../pkg/rewriter/rules_test.go | 119 + .../pkg/rewriter/source_ref.go | 109 + .../pkg/rewriter/source_ref_test.go | 217 + .../pkg/rewriter/target_request.go | 306 ++ .../pkg/rewriter/transformers.go | 111 + .../kube-api-rewriter/pkg/rewriter/webhook.go | 17 + .../pkg/server/http_server.go | 158 + .../pkg/server/runnable_group.go | 90 + .../pkg/target/kubernetes.go | 55 + .../kube-api-rewriter/pkg/target/webhook.go | 106 + .../pkg/tls/certmanager/certmanager.go | 27 + .../filesystem/file-cert-manager.go | 170 + images/kube-api-rewriter/pkg/tls/util/util.go | 52 + images/kube-api-rewriter/werf.inc.yaml | 62 + images/nelm-source-controller/werf.inc.yaml | 3 + images/operator-helm-artifact/.gitignore | 30 + .../cmd/operator-helm-controller/main.go | 116 + images/operator-helm-artifact/go.mod | 83 + images/operator-helm-artifact/go.sum | 189 + .../controller/helmclusteraddon/constants.go | 69 + .../controller/helmclusteraddon/controller.go | 49 + .../controller/helmclusteraddon/reconciler.go | 726 +++ .../helmclusteraddonchart/constants.go | 34 + .../helmclusteraddonchart/controller.go | 43 + .../helmclusteraddonchart/reconciler.go | 80 + .../helmclusteraddonrepository/client.go | 111 + .../helmclusteraddonrepository/constants.go | 66 + .../helmclusteraddonrepository/controller.go | 54 + .../helmclusteraddonrepository/reconciler.go | 519 +++ .../pkg/utils/mapper.go | 59 + .../operator-helm-artifact/pkg/utils/name.go | 112 + .../pkg/utils/repository.go | 45 + images/operator-helm-artifact/werf.inc.yaml | 57 + .../mount-points.yaml | 1 + images/operator-helm-controller/werf.inc.yaml | 16 + module.yaml | 23 + openapi/config-values.yaml | 26 + openapi/doc-ru-config-values.yaml | 24 + openapi/values.yaml | 38 + oss.yaml | 12 + requirements.lock | 6 + templates/_helpers.tpl | 19 + templates/admision-policy.yaml | 62 + templates/helm-controller/_helpers.tpl | 6 + templates/helm-controller/deployment.yaml | 137 + templates/helm-controller/rbac-for-us.yaml | 148 + .../helm-controller/service-metrics.yaml | 15 + .../helm-controller/service-monitor.yaml | 23 + .../_customize_patch_helpers.tpl | 69 + templates/kube-api-rewriter/_settings.tpl | 32 + .../kube-api-rewriter/_sidecar_helpers.tpl | 199 + .../cm-kubeconfig-local.yaml | 20 + templates/kube-rbac-proxy/_helpers.tpl | 92 + templates/namespace.yaml | 8 + templates/nelm-source-controller/_helpers.tpl | 8 + .../nelm-source-controller/deployment.yaml | 143 + .../nelm-source-controller/rbac-for-us.yaml | 169 + .../service-metrics.yaml | 15 + .../service-monitor.yaml | 23 + templates/nelm-source-controller/service.yaml | 15 + .../operator-helm-controller/deployment.yaml | 141 + .../operator-helm-controller/rbac-for-us.yaml | 216 + .../operator-helm-controller/secret-tls.yaml | 12 + .../service-metrics.yaml | 16 + .../service-monitor.yaml | 24 + .../operator-helm-controller/service.yaml | 19 + .../validation-webhook.yaml | 23 + templates/rbac-to-us.yaml | 32 + templates/registry-secret.yaml | 16 + tmp/mc-operator-helm.yaml | 8 + tmp/modulepulloverride.yaml | 8 + tmp/modulesource.yaml | 8 + tools/validation/diff.go | 149 + tools/validation/doc_changes.go | 143 + tools/validation/go.mod | 3 + tools/validation/main.go | 97 + tools/validation/messages.go | 176 + tools/validation/no_cyrillic.go | 160 + tools/validation/no_cyrillic_test.go | 96 + werf-giterminism.yaml | 28 + werf.yaml | 114 + werf_cleanup.yaml | 18 + 248 files changed, 31837 insertions(+) create mode 100644 .dmtlint.yaml create mode 100644 .github/workflows/build_dev.yaml create mode 100644 .github/workflows/deploy_dev.yaml create mode 100644 .gitignore create mode 100644 .helmignore create mode 100644 .werf/consts.yaml create mode 100644 .werf/defines/image-build.tmpl create mode 100644 .werf/defines/image-mountpoints.tmpl create mode 100644 .werf/defines/images.tmpl create mode 100644 .werf/defines/packages-clean.tmpl create mode 100644 .werf/defines/packages-proxies.tmpl create mode 100644 .werf/defines/parse-base-images-map.tmpl create mode 100644 .werf/images.yaml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Chart.yaml create mode 100644 LICENSE create mode 100644 MAINTAINERS.md create mode 100644 SECURITY.md create mode 100644 Taskfile.yaml create mode 100644 api/Taskfile.dist.yaml create mode 100644 api/client/generated/clientset/versioned/clientset.go create mode 100644 api/client/generated/clientset/versioned/fake/clientset_generated.go create mode 100644 api/client/generated/clientset/versioned/fake/doc.go create mode 100644 api/client/generated/clientset/versioned/fake/register.go create mode 100644 api/client/generated/clientset/versioned/scheme/doc.go create mode 100644 api/client/generated/clientset/versioned/scheme/register.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/api_client.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/doc.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/doc.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddon.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddonchart.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddonrepository.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddon.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddonchart.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddonrepository.go create mode 100644 api/client/generated/informers/externalversions/api/interface.go create mode 100644 api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddon.go create mode 100644 api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddonchart.go create mode 100644 api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddonrepository.go create mode 100644 api/client/generated/informers/externalversions/api/v1alpha1/interface.go create mode 100644 api/client/generated/informers/externalversions/factory.go create mode 100644 api/client/generated/informers/externalversions/generic.go create mode 100644 api/client/generated/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 api/client/generated/listers/api/v1alpha1/expansion_generated.go create mode 100644 api/client/generated/listers/api/v1alpha1/helmclusteraddon.go create mode 100644 api/client/generated/listers/api/v1alpha1/helmclusteraddonchart.go create mode 100644 api/client/generated/listers/api/v1alpha1/helmclusteraddonrepository.go create mode 100644 api/doc.go create mode 100644 api/go.mod create mode 100644 api/go.sum create mode 100644 api/scripts/boilerplate.go.txt create mode 100755 api/scripts/update-codegen.sh create mode 100644 api/v1alpha1/doc.go create mode 100644 api/v1alpha1/helm_cluster_addon.go create mode 100644 api/v1alpha1/helm_cluster_addon_chart.go create mode 100644 api/v1alpha1/helm_cluster_addon_repository.go create mode 100644 api/v1alpha1/register.go create mode 100644 api/v1alpha1/zz_generated.deepcopy.go create mode 100644 build/base-images/deckhouse_images.yml create mode 100644 build/components/README.md create mode 100644 build/components/versions.yml create mode 100644 charts/deckhouse_lib_helm-1.55.1.tgz create mode 100644 crds/embedded/helm-controller.yaml create mode 100644 crds/embedded/nelm-source-controller.yaml create mode 100644 crds/helmclusteraddoncharts.yaml create mode 100644 crds/helmclusteraddonrepositories.yaml create mode 100644 crds/helmclusteraddons.yaml create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/CONFIGURATION_RU.md create mode 100644 docs/CR.md create mode 100644 docs/CR_RU.md create mode 100644 docs/EXAMPLE.md create mode 100644 docs/EXAMPLE_RU.md create mode 100644 docs/README.md create mode 100644 docs/README_RU.md create mode 100644 docs/USAGE.md create mode 100644 docs/USAGE_RU.md create mode 100644 docs/images/.keep create mode 100644 docs/internal/components_placement.md create mode 100755 enabled create mode 100644 hooks/.keep create mode 100644 images/distroless/werf.inc.yaml create mode 100644 images/helm-controller/werf.inc.yaml create mode 100644 images/hooks/cmd/operator-helm-module-hooks/main.go create mode 100644 images/hooks/cmd/operator-helm-module-hooks/register.go create mode 100644 images/hooks/go.mod create mode 100644 images/hooks/go.sum create mode 100644 images/hooks/pkg/hooks/tls-certificates-controller/hook.go create mode 100644 images/hooks/pkg/settings/certificate.go create mode 100644 images/hooks/pkg/settings/module.go create mode 100644 images/hooks/werf.inc.yaml create mode 100644 images/kube-api-rewriter/.dockerignore create mode 100644 images/kube-api-rewriter/.gitignore create mode 100644 images/kube-api-rewriter/METRICS.md create mode 100644 images/kube-api-rewriter/STRUCTURE.md create mode 100644 images/kube-api-rewriter/Taskfile.dist.yaml create mode 100644 images/kube-api-rewriter/cmd/kube-api-rewriter/main.go create mode 100644 images/kube-api-rewriter/go.mod create mode 100644 images/kube-api-rewriter/go.sum create mode 100644 images/kube-api-rewriter/local/Dockerfile create mode 100644 images/kube-api-rewriter/local/kube-api-rewriter.kubeconfig create mode 100755 images/kube-api-rewriter/local/proxy-gen-certs.sh create mode 100644 images/kube-api-rewriter/local/proxy-kubeconfig-cm.yaml create mode 100644 images/kube-api-rewriter/local/proxy.yaml create mode 100644 images/kube-api-rewriter/local/test-controller/go.mod create mode 100644 images/kube-api-rewriter/local/test-controller/go.sum create mode 100644 images/kube-api-rewriter/local/test-controller/main.go create mode 100644 images/kube-api-rewriter/mount-points.yaml create mode 100644 images/kube-api-rewriter/pkg/labels/context_values.go create mode 100644 images/kube-api-rewriter/pkg/log/attrs.go create mode 100644 images/kube-api-rewriter/pkg/log/body.go create mode 100644 images/kube-api-rewriter/pkg/log/differ.go create mode 100644 images/kube-api-rewriter/pkg/log/pretty_handler.go create mode 100644 images/kube-api-rewriter/pkg/log/pretty_handler_test.go create mode 100644 images/kube-api-rewriter/pkg/log/setup.go create mode 100644 images/kube-api-rewriter/pkg/monitoring/healthz/handler.go create mode 100644 images/kube-api-rewriter/pkg/monitoring/metrics/handler.go create mode 100644 images/kube-api-rewriter/pkg/monitoring/metrics/registry.go create mode 100644 images/kube-api-rewriter/pkg/monitoring/profiler/handler.go create mode 100644 images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules.go create mode 100644 images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules_test.go create mode 100644 images/kube-api-rewriter/pkg/proxy/bytes_counter.go create mode 100644 images/kube-api-rewriter/pkg/proxy/doc.go create mode 100644 images/kube-api-rewriter/pkg/proxy/handler.go create mode 100644 images/kube-api-rewriter/pkg/proxy/logger.go create mode 100644 images/kube-api-rewriter/pkg/proxy/metrics.go create mode 100644 images/kube-api-rewriter/pkg/proxy/metrics_provider.go create mode 100644 images/kube-api-rewriter/pkg/proxy/stream_handler.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/3rdparty.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/admission_configuration.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/admission_configuration_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/admission_policy.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/admission_review.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/admission_review_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/affinity.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/api_endpoint.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/api_endpoint_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/app.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/app_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/core.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/core_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/crd.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/crd_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/discovery.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/discovery_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/events.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/events_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/gvk.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/indexer/map_indexer.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/list.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/load.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/map.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/metadata.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/path.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/policy.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/prefixed_name_rewriter.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/rbac.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/rbac_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/resource.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/resource_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/rule_rewriter.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/rule_rewriter_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/rules.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/rules_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/source_ref.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/source_ref_test.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/target_request.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/transformers.go create mode 100644 images/kube-api-rewriter/pkg/rewriter/webhook.go create mode 100644 images/kube-api-rewriter/pkg/server/http_server.go create mode 100644 images/kube-api-rewriter/pkg/server/runnable_group.go create mode 100644 images/kube-api-rewriter/pkg/target/kubernetes.go create mode 100644 images/kube-api-rewriter/pkg/target/webhook.go create mode 100644 images/kube-api-rewriter/pkg/tls/certmanager/certmanager.go create mode 100644 images/kube-api-rewriter/pkg/tls/certmanager/filesystem/file-cert-manager.go create mode 100644 images/kube-api-rewriter/pkg/tls/util/util.go create mode 100644 images/kube-api-rewriter/werf.inc.yaml create mode 100644 images/nelm-source-controller/werf.inc.yaml create mode 100644 images/operator-helm-artifact/.gitignore create mode 100644 images/operator-helm-artifact/cmd/operator-helm-controller/main.go create mode 100644 images/operator-helm-artifact/go.mod create mode 100644 images/operator-helm-artifact/go.sum create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddon/constants.go create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddon/controller.go create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddon/reconciler.go create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/constants.go create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/controller.go create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/reconciler.go create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/client.go create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/constants.go create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/controller.go create mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/reconciler.go create mode 100644 images/operator-helm-artifact/pkg/utils/mapper.go create mode 100644 images/operator-helm-artifact/pkg/utils/name.go create mode 100644 images/operator-helm-artifact/pkg/utils/repository.go create mode 100644 images/operator-helm-artifact/werf.inc.yaml create mode 100644 images/operator-helm-controller/mount-points.yaml create mode 100644 images/operator-helm-controller/werf.inc.yaml create mode 100644 module.yaml create mode 100644 openapi/config-values.yaml create mode 100644 openapi/doc-ru-config-values.yaml create mode 100644 openapi/values.yaml create mode 100644 oss.yaml create mode 100644 requirements.lock create mode 100644 templates/_helpers.tpl create mode 100644 templates/admision-policy.yaml create mode 100644 templates/helm-controller/_helpers.tpl create mode 100644 templates/helm-controller/deployment.yaml create mode 100644 templates/helm-controller/rbac-for-us.yaml create mode 100644 templates/helm-controller/service-metrics.yaml create mode 100644 templates/helm-controller/service-monitor.yaml create mode 100644 templates/kube-api-rewriter/_customize_patch_helpers.tpl create mode 100644 templates/kube-api-rewriter/_settings.tpl create mode 100644 templates/kube-api-rewriter/_sidecar_helpers.tpl create mode 100644 templates/kube-api-rewriter/cm-kubeconfig-local.yaml create mode 100644 templates/kube-rbac-proxy/_helpers.tpl create mode 100644 templates/namespace.yaml create mode 100644 templates/nelm-source-controller/_helpers.tpl create mode 100644 templates/nelm-source-controller/deployment.yaml create mode 100644 templates/nelm-source-controller/rbac-for-us.yaml create mode 100644 templates/nelm-source-controller/service-metrics.yaml create mode 100644 templates/nelm-source-controller/service-monitor.yaml create mode 100644 templates/nelm-source-controller/service.yaml create mode 100644 templates/operator-helm-controller/deployment.yaml create mode 100644 templates/operator-helm-controller/rbac-for-us.yaml create mode 100644 templates/operator-helm-controller/secret-tls.yaml create mode 100644 templates/operator-helm-controller/service-metrics.yaml create mode 100644 templates/operator-helm-controller/service-monitor.yaml create mode 100644 templates/operator-helm-controller/service.yaml create mode 100644 templates/operator-helm-controller/validation-webhook.yaml create mode 100644 templates/rbac-to-us.yaml create mode 100644 templates/registry-secret.yaml create mode 100644 tmp/mc-operator-helm.yaml create mode 100644 tmp/modulepulloverride.yaml create mode 100644 tmp/modulesource.yaml create mode 100644 tools/validation/diff.go create mode 100644 tools/validation/doc_changes.go create mode 100644 tools/validation/go.mod create mode 100644 tools/validation/main.go create mode 100644 tools/validation/messages.go create mode 100644 tools/validation/no_cyrillic.go create mode 100644 tools/validation/no_cyrillic_test.go create mode 100644 werf-giterminism.yaml create mode 100644 werf.yaml create mode 100644 werf_cleanup.yaml diff --git a/.dmtlint.yaml b/.dmtlint.yaml new file mode 100644 index 0000000..7c2aab5 --- /dev/null +++ b/.dmtlint.yaml @@ -0,0 +1,31 @@ +global: + linters-settings: + documentation: + impact: error +linters-settings: + openapi: + exclude-rules: + enum: + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.sts.properties.provider" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.provider" + - "spec.versions[1].schema.openAPIV3Schema.properties.spec.properties.provider" + - "spec.versions[1].schema.openAPIV3Schema.properties.spec.properties.sts.properties.provider" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.upgrade.properties.remediation.properties.strategy.properties" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.uninstall.properties.deletionPropagation" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.driftDetection.properties.mode" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.upgrade.properties.remediation.properties.strategy" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.chart.properties.spec.properties.verify.properties.provider" + - "spec.versions[0].schema.openAPIV3Schema.properties.status.properties.lastAttemptedReleaseAction" + - "spec.versions[1].schema.openAPIV3Schema.properties.spec.properties.chart.properties.spec.properties.verify.properties.provider" + - "spec.versions[1].schema.openAPIV3Schema.properties.spec.properties.driftDetection.properties.mode" + - "spec.versions[1].schema.openAPIV3Schema.properties.spec.properties.postRenderers.items.properties.kustomize.properties.patchesJson6902.items.properties.patch.items.properties.op" + - "spec.versions[1].schema.openAPIV3Schema.properties.spec.properties.uninstall.properties.deletionPropagation" + - "spec.versions[1].schema.openAPIV3Schema.properties.spec.properties.upgrade.properties.remediation.properties.strategy" + - "spec.versions[1].schema.openAPIV3Schema.properties.status.properties.lastAttemptedReleaseAction" + - "properties.logLevel" + - "properties.logFormat" + rbac: + exclude-rules: + wildcards: + - kind: ClusterRole + name: d8:operator-helm:helm-controller diff --git a/.github/workflows/build_dev.yaml b/.github/workflows/build_dev.yaml new file mode 100644 index 0000000..78e246c --- /dev/null +++ b/.github/workflows/build_dev.yaml @@ -0,0 +1,115 @@ +name: Build and Push for Dev + +on: + workflow_dispatch: + inputs: + pr_number: + description: | + Pull request number, like 563, or leave empty and choose a branch + For branches main, release-*, tag will be generated as branch name + required: false + type: number + svace_enabled: + description: "Enable svace build" + type: boolean + required: false + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] + push: + branches: + - main + - release-* + +jobs: + lint: + runs-on: ubuntu-latest + continue-on-error: true + name: Lint + steps: + - uses: actions/checkout@v4 + - uses: deckhouse/modules-actions/lint@main + env: + DMT_METRICS_URL: ${{ secrets.DMT_METRICS_URL }} + DMT_METRICS_TOKEN: ${{ secrets.DMT_METRICS_TOKEN }} + + build_dev: + runs-on: ubuntu-latest + name: Build and Push images + outputs: + MODULES_MODULE_TAG: ${{ steps.modules_module_tag.outputs.MODULES_MODULE_TAG }} + MODULES_MODULE_NAME: ${{ steps.modules_module_name.outputs.MODULES_MODULE_NAME }} + steps: + - name: Set vars + id: modules_module_tag + run: | + if [[ "${{ github.ref_name }}" == 'main' ]]; then + MODULES_MODULE_TAG="${{ github.ref_name }}" + elif [[ "${{ github.ref_name }}" =~ ^release-[0-9]+\.[0-9]+ ]]; then + MODULES_MODULE_TAG="${{ github.ref_name }}" + elif [[ -n "${{ github.event.pull_request.number }}" ]]; then + MODULES_MODULE_TAG="pr${{ github.event.pull_request.number }}" + elif [[ -n "${{ github.event.inputs.pr_number }}" ]]; then + MODULES_MODULE_TAG="pr${{ github.event.inputs.pr_number }}" + else + echo "::error title=Module image tag is required::Can't detect module tag from workflow context. Dev build uses branch name as tag for main and release branches, and PR number for builds from pull requests. Check workflow for correctness." + exit 1 + fi + + echo "MODULES_MODULE_TAG=$MODULES_MODULE_TAG" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + + - uses: deckhouse/modules-actions/setup@main + with: + registry: ${{ vars.DEV_REGISTRY }} + registry_login: ${{ vars.DEV_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.DEV_MODULES_REGISTRY_PASSWORD }} + + - name: Get the repository name + id: modules_module_name + run: | + FULL_REPO="${{ github.repository }}" + REPO_NAME="${FULL_REPO#*/}" + echo "MODULES_MODULE_NAME=$REPO_NAME" >> "$GITHUB_OUTPUT" + + - uses: deckhouse/modules-actions/build@main + with: + module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules/${{ steps.modules_module_name.outputs.MODULES_MODULE_NAME }} + module_name: ${{ steps.modules_module_name.outputs.MODULES_MODULE_NAME }} + module_tag: ${{ steps.modules_module_tag.outputs.MODULES_MODULE_TAG }} + svace_enabled: false + + show_dev_manifest: + runs-on: ubuntu-latest + name: Show manifest + needs: build_dev + steps: + - name: Show dev config + run: | + cat << OUTER + Create ModuleConfig and ModulePullOverride resources to test this MR: + + cat <> $GITHUB_OUTPUT + + - uses: deckhouse/modules-actions/deploy@main + with: + module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules/${{ steps.repo_name.outputs.REPO_NAME }} + module_name: ${{ steps.repo_name.outputs.REPO_NAME }} + module_tag: ${{ github.event.inputs.tag }} + release_channel: ${{ github.event.inputs.release_channel }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0798ce6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ + +# vim +*.swp + +# IDE +.project +.settings +.idea/ +.vscode +venv/ + +# macOS Finder files +*.DS_Store +._* + +# Python +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ + +#werf +/base_images.yml + +# opencode +**/.opencode/ + +# Go +go.work +go.work.sum + diff --git a/.helmignore b/.helmignore new file mode 100644 index 0000000..4deeb35 --- /dev/null +++ b/.helmignore @@ -0,0 +1,12 @@ +crds +docs +enabled +hooks +images +lib +Makefile +openapi +*.md +release.yaml +werf*.yaml +NOTES.txt diff --git a/.werf/consts.yaml b/.werf/consts.yaml new file mode 100644 index 0000000..36403e0 --- /dev/null +++ b/.werf/consts.yaml @@ -0,0 +1,21 @@ +# Edition module settings +{{- $_ := set . "MODULE_EDITION" (env "MODULE_EDITION" "EE") }} + +# Component versions +{{- $_ := set . "Package" dict -}} +{{- $_ := set . "Core" dict -}} +{{- $versions_path := "/build/components/versions.yml" -}} + +{{- if .ModuleDir -}} +{{- $versions_path = (printf "%s%s" (trimPrefix "/" .ModuleDir ) $versions_path) -}} +{{- end -}} + +{{- $versions_ctx := (.Files.Get $versions_path | fromYaml) -}} + +{{- range $k, $v := $versions_ctx.package -}} +{{- $_ := set $.Package $k $v -}} +{{- end -}} + +{{- range $k, $v := $versions_ctx.core -}} +{{- $_ := set $.Core $k $v -}} +{{- end -}} diff --git a/.werf/defines/image-build.tmpl b/.werf/defines/image-build.tmpl new file mode 100644 index 0000000..bc7afe2 --- /dev/null +++ b/.werf/defines/image-build.tmpl @@ -0,0 +1,20 @@ +{{- define "image-build.build" }} +{{- if ne $.SVACE_ENABLED "false" }} +svace build --init --clear-build-dir {{ .BuildCommand }} +attempt=0 +retries=5 +success=0 +set +e +while [[ $attempt -lt $retries ]]; do + ssh -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=12 {{ $.SVACE_ANALYZE_SSH_USER }}@{{ $.SVACE_ANALYZE_HOST }} mkdir -p /svace-analyze/{{ $.Commit.Hash }}/{{ $.ProjectName }}/.svace-dir + rsync -zr --timeout=10 --compress-choice=zstd --partial --append-verify .svace-dir {{ $.SVACE_ANALYZE_SSH_USER }}@{{ $.SVACE_ANALYZE_HOST }}:/svace-analyze/{{ $.Commit.Hash }}/{{ $.ProjectName }}/ && success=1 && break + sleep 10 + attempt=$((attempt + 1)) +done +set -e +[[ $success == 1 ]] && rm -rf .svace-dir || exit 1 +{{ .BuildCommand }} +{{- else }} +{{ .BuildCommand }} +{{- end }} +{{- end }} diff --git a/.werf/defines/image-mountpoints.tmpl b/.werf/defines/image-mountpoints.tmpl new file mode 100644 index 0000000..9c76a3f --- /dev/null +++ b/.werf/defines/image-mountpoints.tmpl @@ -0,0 +1,32 @@ +{{/* + +Template to bake mount points in the image. These static mount points +are required so containerd can start a container with image integrity check. + +Problem: each directory specified in volumeMounts items should exist +in image, containerd is unable to create mount point for us when +integrity check is enabled. + +Solution: define all possible mount points in mount-points.yaml file and +include this template in git section of the werf.inc.yaml. + +*/}} +{{/* NOTE: Keep in sync with version in Deckhouse CSE */}} +{{- define "image mount points" }} +{{- $mountPoints := ($.Files.Get (printf "images/%s/mount-points.yaml" $.ImageName) | fromYaml) }} +{{- $context := . }} +{{- range $v := $mountPoints.dirs }} +- add: /tools/mounts/mountdir + to: {{ $v | trimSuffix "/" }} + stageDependencies: + install: + - "**/*" +{{- end }} +{{- range $v := $mountPoints.files }} +- add: /tools/mounts/mountfile + to: {{ $v }} + stageDependencies: + install: + - "**/*" +{{- end }} +{{- end }} diff --git a/.werf/defines/images.tmpl b/.werf/defines/images.tmpl new file mode 100644 index 0000000..51152c5 --- /dev/null +++ b/.werf/defines/images.tmpl @@ -0,0 +1,49 @@ +{{/* +Template for ease of use of multiple image imports +Default stage "install". +Important! To render properly in "embedded module" mode, ensure that caller passes context with "ModuleNamePrefix" variable. + +Usage: +{{- $images := list "swtpm" "numactl" "libfuse3" -}} +{{- include "importPackageImages" (list . $images "install") -}} # install stage (default) +Result: +... + - image: packages/binaries/libfuse3 + add: /libfuse3 + to: /libfuse3 + before: install +... + +{{- include "importPackageImages" (list . $images "setup") -}} # setup stage +Result: +... + - image: packages/binaries/libfuse3 + add: /libfuse3 + to: /libfuse3 + before: setup +... +*/}} + +{{ define "importPackageImages" }} +{{- if not (eq (kindOf .) "slice") }} +{{- fail "importPackageImages: invalid type of argument, slice is expected" }} +{{- end }} +{{- $context := index . 0 }} +{{- $ImageNameList := index . 1 }} +{{- $stage := "install" }} +{{- if gt (len .) 2 }} +{{- $stage = index . 2 }} +{{- end }} +{{- range $imageName := $ImageNameList }} +{{- $packages := splitList " " $imageName -}} +{{- range $packages -}} +{{- $image := trim . -}} +{{- if ne $image "" }} +- image: {{ $context.ModuleNamePrefix }}packages/{{ $image }} + add: /{{ $image }} + to: /{{ $image }} + before: {{ $stage }} +{{- end }} +{{- end -}} +{{- end }} +{{ end }} diff --git a/.werf/defines/packages-clean.tmpl b/.werf/defines/packages-clean.tmpl new file mode 100644 index 0000000..0e77725 --- /dev/null +++ b/.werf/defines/packages-clean.tmpl @@ -0,0 +1,12 @@ +{{- define "alt packages clean" }} +- apt-get clean +- rm --recursive --force /var/lib/apt/lists/ftp.altlinux.org* /var/cache/apt/*.bin + {{- if $.DistroPackagesProxy }} +- rm --recursive --force /var/lib/apt/lists/{{ $.DistroPackagesProxy }}* + {{- end }} +{{- end }} + +{{- define "debian packages clean" }} +- apt-get clean +- find /var/lib/apt/ /var/cache/apt/ -type f -delete +{{- end }} diff --git a/.werf/defines/packages-proxies.tmpl b/.werf/defines/packages-proxies.tmpl new file mode 100644 index 0000000..e93f9d2 --- /dev/null +++ b/.werf/defines/packages-proxies.tmpl @@ -0,0 +1,70 @@ +{{- define "alt packages proxy" }} +# Replace altlinux repos with our proxy + {{- if $.DistroPackagesProxy }} +- sed -i "s|ftp.altlinux.org/pub/distributions/archive|{{ $.DistroPackagesProxy }}/repository/archive-ALT-Linux-APT-Repository|g" /etc/apt/sources.list.d/alt.list + {{- end }} +# TODO: remove this when http becomes available +# change scheme from http to ftp +- sed -i "s|rpm \[p11\] http://|#rpm [p11] http://|g" /etc/apt/sources.list.d/alt.list +- sed -i "s|#rpm \[p11\] ftp://|rpm [p11] ftp://|g" /etc/apt/sources.list.d/alt.list +- export DEBIAN_FRONTEND=noninteractive +- apt-get update -y +{{- end }} + +{{- define "alt dist upgrade" }} +- apt-get dist-upgrade -y +- find /var/cache/apt/ -type f -delete +- rm -rf /var/log/*log /var/log/apt/* /var/lib/dpkg/*-old /var/cache/debconf/*-old +{{- end }} + +{{- define "debian packages proxy" }} +# 5 years 157680000 +- | + echo "Acquire::Check-Valid-Until false;" >> /etc/apt/apt.conf + echo "Acquire::Check-Date false;" >> /etc/apt/apt.conf + echo "Acquire::Max-FutureTime 157680000;" >> /etc/apt/apt.conf +# Replace debian repos with our proxy + {{- if $.DistroPackagesProxy }} +- if [ -f /etc/apt/sources.list ]; then sed -i "s|http://deb.debian.org|http://{{ $.DistroPackagesProxy }}/repository|g" /etc/apt/sources.list; fi +- if [ -f /etc/apt/sources.list.d/debian.sources ]; then sed -i "s|http://deb.debian.org|http://{{ $.DistroPackagesProxy }}/repository|g" /etc/apt/sources.list.d/debian.sources; fi + {{- end }} +- export DEBIAN_FRONTEND=noninteractive +- apt-get update +{{- end }} + +{{- define "ubuntu packages proxy" }} + # Replace ubuntu repos with our proxy + {{- if $.DistroPackagesProxy }} +- sed -i 's|http://archive.ubuntu.com|http://{{ $.DistroPackagesProxy }}/repository/archive-ubuntu|g' /etc/apt/sources.list +- sed -i 's|http://security.ubuntu.com|http://{{ $.DistroPackagesProxy }}/repository/security-ubuntu|g' /etc/apt/sources.list + {{- end }} +- export DEBIAN_FRONTEND=noninteractive +# one year +- apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false -o Acquire::Max-FutureTime=31536000 update +{{- end }} + +{{- define "alpine packages proxy" }} +# Replace alpine repos with our proxy + {{- if $.DistroPackagesProxy }} +- sed -i 's|https://dl-cdn.alpinelinux.org|http://{{ $.DistroPackagesProxy }}/repository|g' /etc/apk/repositories + {{- end }} +- apk update +{{- end }} + +{{- define "node packages proxy" }} + {{- if $.DistroPackagesProxy }} +- npm config set registry http://{{ $.DistroPackagesProxy }}/repository/npmjs/ + {{- end }} +{{- end }} + +{{- define "pypi proxy" }} + {{- if $.DistroPackagesProxy }} +- | + cat <<"EOD" > /etc/pip.conf + [global] + index = http://{{ $.DistroPackagesProxy }}/repository/pypi-proxy/pypi + index-url = http://{{ $.DistroPackagesProxy }}/repository/pypi-proxy/simple + trusted-host = {{ $.DistroPackagesProxy }} + EOD + {{- end }} +{{- end }} diff --git a/.werf/defines/parse-base-images-map.tmpl b/.werf/defines/parse-base-images-map.tmpl new file mode 100644 index 0000000..0a6d8b1 --- /dev/null +++ b/.werf/defines/parse-base-images-map.tmpl @@ -0,0 +1,41 @@ +{{- define "project_images"}} +{{- $globImages := "images/*/werf.inc.yaml" }} +{{- $globPackages := "images/packages/*/werf.inc.yaml" }} +{{- $globRootWerf := "werf.yaml" }} +{{- $regexp := "(builder|tools|libs|base)/([a-zA-Z0-9._-]+)" }} +{{- $globAll := merge (.Files.Glob $globImages) (.Files.Glob $globPackages) (.Files.Glob $globRootWerf) }} +{{- $imagesMap := dict }} +{{- range $path, $content := $globAll }} +{{- $findImg := regexFindAll $regexp $content -1 }} +{{- range $findImg }} +{{- $_ := set $imagesMap . "" }} +{{- end }} +{{- end }} +{{- $imagesMap | toJson }} +{{- end }} + +{{- define "parse_base_images_map" }} +{{- $deckhouseImages := .Files.Get "build/base-images/deckhouse_images.yml" | fromYaml }} +{{/* + # deckhouse_images has a format + # /: "sha256:abcde12345 +*/}} +{{- $usedImagesDict := (include "project_images" . | fromJson) }} +{{- range $k, $v := $deckhouseImages }} +{{- $baseImagePath := (printf "%s@%s" $deckhouseImages.REGISTRY_PATH (trimSuffix "/" $v)) }} +{{- if ne $k "REGISTRY_PATH" }} +{{- $_ := set $deckhouseImages $k $baseImagePath }} +{{- end }} +{{- end }} +{{- $_ := unset $deckhouseImages "REGISTRY_PATH" }} +{{- $_ := set . "Images" (mustMerge $deckhouseImages) }} +{{/* # base images artifacts */}} +{{- range $k, $v := .Images }} +{{- if hasKey $usedImagesDict $k }} +--- +image: {{ $k }} +from: {{ $v }} +final: false +{{- end }} +{{- end }} +{{- end }} diff --git a/.werf/images.yaml b/.werf/images.yaml new file mode 100644 index 0000000..61c7b53 --- /dev/null +++ b/.werf/images.yaml @@ -0,0 +1,56 @@ +{{/* # Common dirs */}} +{{- define "module_image_template" }} + {{- if eq .ImageInstructionType "Dockerfile" }} +--- +image: images/{{ .ImageName }} +context: images/{{ .ImageName }} +dockerfile: Dockerfile + {{- else }} + {{- tpl .ImageBuildData . }} + {{- end }} +{{- end }} + + +{{/* # Context inside folder images */}} +{{- $Root := . }} + +{{ $ImagesBuildFiles := .Files.Glob "images/*/{Dockerfile,werf.inc.yaml}" }} + +{{- range $path, $content := $ImagesBuildFiles }} + +{{- $ctx := dict }} +{{- $_ := set $ctx "ImageInstructionType" "Stapel" }} + +{{- $ImageData := regexReplaceAll "^images/([0-9a-z-_]+)/(Dockerfile|werf.inc.yaml)$" $path "${1}#${2}" | split "#" }} + +{{- $_ := set $ctx "ImageName" $ImageData._0 }} +{{- $_ := set $ctx "ModuleDir" "" }} +{{- $_ := set $ctx "ModuleNamePrefix" "" }} +{{- $_ := set $ctx "ImageBuildData" $content }} +{{- $_ := set $ctx "Files" $Root.Files }} +{{- $_ := set $ctx "SOURCE_REPO" $Root.SOURCE_REPO }} +{{- $_ := set $ctx "SOURCE_REPO_GIT" $Root.SOURCE_REPO_GIT }} +{{- $_ := set $ctx "MODULE_EDITION" $Root.MODULE_EDITION }} +{{- $_ := set $ctx "DEBUG_COMPONENT" $Root.DEBUG_COMPONENT }} +{{- $_ := set $ctx "Package" $Root.Package }} +{{- $_ := set $ctx "Core" $Root.Core }} +{{- $_ := set $ctx "GOPROXY" (env "GOPROXY" "https://proxy.golang.org,direct") }} +{{- $_ := set $ctx "ProjectName" $ctx.ImageName }} +{{- $_ := set $ctx "Commit" $Root.Commit }} +{{- $_ := set $ctx "SVACE_ENABLED" $Root.SVACE_ENABLED }} +{{- $_ := set $ctx "SVACE_ANALYZE_SSH_USER" $Root.SVACE_ANALYZE_SSH_USER }} +{{- $_ := set $ctx "SVACE_ANALYZE_HOST" $Root.SVACE_ANALYZE_HOST }} +{{- $_ := set $ctx "SVACE_IMAGE_SUFFIX" $Root.SVACE_IMAGE_SUFFIX }} + +{{- include "module_image_template" $ctx }} + +{{- range $ImageYamlMainfest := regexSplit "\n?---[ \t]*\n" (include "module_image_template" $ctx) -1 }} +{{- $ImageManifest := $ImageYamlMainfest | fromYaml }} +{{- if $ImageManifest | dig "final" true }} +{{- if $ImageManifest.image }} +{{- $_ := set $ "ImagesIDList" (append $.ImagesIDList $ImageManifest.image) }} +{{- end }} +{{- end }} +{{- end }} + +{{- end }} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3bed631 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +community@deckhouse.io. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7c2fa4b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,166 @@ +# Contributing + +## Feedback + +The first thing we recommend is to check the existing [issues](https://github.com/deckhouse/operator-helm/issues) — there may already be a discussion or solution on your topic. If not, choose the appropriate way to address the issue on [the new issue form](https://github.com/deckhouse/operator-helm/issues/new/choose). + +## Code contributions + +1. Prepare an environment. To build and run common workflows locally, you'll need to _at least_ have the following installed: + + - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) + - [Go](https://golang.org/doc/install) + - [Docker](https://docs.docker.com/get-docker/) + - [go-task](https://taskfile.dev/installation/) (task runner) + - [ginkgo](https://onsi.github.io/ginkgo/#installing-ginkgo) (testing framework required to run tests) + +2. [Fork the project](https://github.com/deckhouse/operator-helm/fork). + +3. Clone the project: + + ```shell + git clone https://github.com/[GITHUB_USERNAME]/operator-helm + ``` + +4. Create branch following the [branch name convention](#branch-name): + + ```shell + git checkout -b feat/core/add-new-feature + ``` + +5. Make changes. + +6. Commit changes: + + - Follow [the commit message convention](#commit-message). + - Sign off every commit you contributed as an acknowledgment of the [DCO](https://developercertificate.org/). + +7. Push commits. + +8. Create a pull request following the [pull request name convention](#pull-request-name). + +## Images + +The module images are located in the ./images directory. + +Images, such as build images or images with binary artifacts, should not be included in the module. To do so, they must be labeled as follows in the `werf.inc.yaml` file: `final: false`. + +## Conventions + +### Commit message + + + +**Examples:** + + + +#### Type + +Must be one of the following: + +* **feat**: new features or capabilities that enhance the user's experience. +* **fix**: bug fixes that enhance the user's experience. +* **refactor**: a code changes that neither fixes a bug nor adds a feature. +* **docs**: updates or improvements to documentation. +* **test**: additions or corrections to tests. +* **chore**: updates that don't fit into other types. + +#### Scope + +Scope indicates the area of the project affected by the changes. The scope can consist of a top-level scope, which broadly categorizes the changes, and can optionally include nested scopes that provide further detail. + +Supported scopes are the following: + + + +#### Subject + +The subject contains a succinct description of the change: + + - use the imperative, present tense: "change" not "changed" nor "changes" + - don't capitalize the first letter + - no dot (.) at the end + +#### Body + +Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". +The body should include the motivation for the change and contrast this with previous behavior. + +### Branch name + +Each branch name consists of a [**type**](#type), [**scope**](#scope), and a [**short-description**](#short-description): + +``` +// +``` + +When naming branches, only the top-level scope should be used. Multiple or nested scopes are not allowed in branch names, ensuring that each branch is clearly associated with a broad area of the project. + +**Examples:** + + + +### Changes Block + +When submitting a pull request, include a **changes block** to document modifications for the changelog. This block helps automate the release changelog creation, tracks updates, and prepares release notes. + +#### Format + +The changes block consists of YAML documents, each detailing a specific change. Use the following structure: + +```` +```changes +section: +type: +summary: +impact_level: # Optional +impact: | + +``` +```` + +#### Fields Description + + - **section**: (Required) Specifies the affected scope of the project. Should be in kebab-case, choose one of [available scopes](#scope). If PR affects multiple scopes, add change block for each scope. + - Examples: `api`, `core`, `ci` + + - **type**: (Required) Defines the nature of the change: + - `feature`: Adds new functionality. + - `fix`: Resolves user-facing issues. + - `chore`: Maintenance tasks without direct user impact. + - `docs`: Changes to documentation. + + - **summary**: (Required) A concise explanation of the change, ending with a period. + + - **impact_level**: (Optional) Indicates the significance of the change. + - `high`: Requires an **impact** description and will be included in "Know before update" sections. + - `low`: Minor changes, omitted from user-facing changelogs. If this level is specified, all other fields are not validated by GitHub workflow. + + - **impact**: (Required if `impact_level` is high) Describes the change's effects, such as expected restarts or downtime. + - Examples: + - "Ingress controller will restart." + - "Expect slow downtime due to kube-apiserver restarts." + +#### Example + + + +For full guidelines, refer to [here](https://github.com/deckhouse/deckhouse/wiki/Guidelines-for-working-with-PRs). + +#### Short description + +A concise, hyphen-separated phrase in kebab-case that clearly describes the main focus of the branch. + +### Pull request name + +Each pull request title should clearly reflect the changes introduced, adhering to [**the header format** of a commit message](#commit-message), typically mirroring the main commit's text in the PR. + +**Examples** + + + +## Coding + + - [Effective Go](https://golang.org/doc/effective_go.html). + - [Go's commenting conventions](http://blog.golang.org/godoc-documenting-go-code). diff --git a/Chart.yaml b/Chart.yaml new file mode 100644 index 0000000..102d074 --- /dev/null +++ b/Chart.yaml @@ -0,0 +1,6 @@ +name: operator-helm +version: 0.0.1 +dependencies: + - name: deckhouse_lib_helm + version: 1.55.1 + repository: https://deckhouse.github.io/lib-helm diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..84e67aa --- /dev/null +++ b/LICENSE @@ -0,0 +1,214 @@ +Copyright (c) 2026 Flant JSC + +Portions of this software are licensed as follows: + +* All content residing under the "docs/" directory of this repository + is licensed under "Creative Commons: CC BY-SA 4.0 license". +* All client-side JavaScript (when served directly or after being compiled, + arranged, augmented, or combined), is licensed under the "MIT Expat" license. +* All third party components incorporated into this software are licensed under + the original license provided by the owner of the applicable component. +* Content outside of the above mentioned directories or restrictions above + is available under the "Apache License 2.0." license as defined below. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..c80979e --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,6 @@ +# Core maintainers + +| Name | Email | GitHub | +| ---------------- | -------------------------- | ------------------------------------------------------- | +| Ilya Lesikov | ilya.lesikov@flant.com | [@ilya-lesikov](https://github.com/ilya-lesikov) | +| Aleksei Igrychev | aleksei.igrychev@flant.com | [@alexey-igrychev](https://github.com/alexey-igrychev) | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..35c80b2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security + +Thank you for your concern regarding the security issues in Deckhouse project. + +Please submit any discovered vulnerabilities to security@deckhouse.io and wait for our reply within 48 hours. + +If we confirm an issue, a relevant private discussion will be created with you as its participant. Otherwise, we will reply to you, probably asking for clarifications needed to verify the security risk. diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..e6649c9 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,105 @@ +version: "3" + +silent: true + +vars: + deckhouse_lib_helm_ver: 1.55.1 + target: "" + VALIDATION_FILES: "tools/validation/{main,messages,diff,no_cyrillic,doc_changes}.go" + +tasks: + check-werf: + cmds: + - which werf >/dev/null || (echo "werf not found."; exit 1) + silent: true + + check-yq: + cmds: + - which yq >/dev/null || (echo "yq not found."; exit 1) + silent: true + + check-jq: + cmds: + - which jq >/dev/null || (echo "jq not found."; exit 1) + silent: true + + check-helm: + cmds: + - which helm >/dev/null || (echo "helm not found."; exit 1) + silent: true + + helm-update-subcharts: + deps: + - check-helm + cmds: + - helm repo add deckhouse https://deckhouse.github.io/lib-helm + - helm repo update deckhouse + - helm dep update + + helm-bump-helm-lib: + deps: + - check-yq + cmds: + - yq -i '.dependencies[] |= select(.name == "deckhouse_lib_helm").version = "{{ .deckhouse_lib_helm_ver }}"' Chart.yaml + - task: helm-update-subcharts + + build: + deps: + - check-werf + cmds: + - werf build {{ .target }} + + dev:format:yaml: + desc: "Format non-templated YAML files, e.g. CRDs" + cmds: + # TODO: update image reference + - | + docker run --rm \ + -v ./:/tmp/operator-helm ghcr.io/deckhouse/virtualization/prettier:3.2.5 \ + sh -c "cd /tmp/operator-helm ; prettier -w \"**/*.yaml\" \"**/*.yml\"" + + dev:addlicense: + desc: |- + Add Flant CE license to files sh,go,py. Default directory is root of project, custom directory path can be passed like: "task dev:addlicense -- " + cmds: + - | + {{if .CLI_ARGS}} + go run tools/addlicense/{main,variables,msg,utils}.go -directory {{ .CLI_ARGS }} + {{else}} + go run tools/addlicense/{main,variables,msg,utils}.go -directory ./ + {{end}} + + lint: + cmds: + - task: lint:doc-ru + - task: lint:prettier:yaml + - task: virtualization-controller:dvcr:lint + - task: virtualization-controller:lint + + lint:doc-ru: + desc: "Check the correspondence between description fields in the original crd and the Russian language version" + cmds: + - | + docker run \ + --rm -it -v "$PWD:/src" docker.io/fl64/d8-doc-ru-linter:v0.0.1-dev0 \ + sh -c \ + 'for crd in /src/crds/*.yaml; do [[ "$(basename "$crd")" =~ ^doc-ru ]] || (echo ${crd}; /d8-doc-ru-linter -s "$crd" -d "/src/crds/doc-ru-$(basename "$crd")" -n /dev/null); done' + + lint:prettier:yaml: + desc: "Check if yaml files are prettier-formatted." + cmds: + # TODO: update image referecne + - | + docker run --rm \ + -v ./:/tmp/operator-nelm ghcr.io/deckhouse/virtualization/prettier:3.2.5 \ + sh -c "cd /tmp/operator-nelm ; prettier -c \"**/*.yaml\" \"**/*.yml\"" + + validation:no-cyrillic: + desc: "No cyrillic" + cmds: + - go run {{ .VALIDATION_FILES }} --type no-cyrillic + + validation:doc-changes: + desc: "Doc-changes" + cmds: + - go run {{ .VALIDATION_FILES }} --type doc-changes diff --git a/api/Taskfile.dist.yaml b/api/Taskfile.dist.yaml new file mode 100644 index 0000000..f58ee84 --- /dev/null +++ b/api/Taskfile.dist.yaml @@ -0,0 +1,42 @@ +version: "3" + +silent: false + +tasks: + generate: + desc: "Regenerate all" + cmds: + - ./scripts/update-codegen.sh all + - task: format:yaml + + generate:v1alpha1: + desc: "Regenerate code for core components." + cmd: ./scripts/update-codegen.sh v1alpha1 + + ci:generate: + desc: "Run generations and check git diff to ensure all files are committed" + cmds: + - task: generate + - task: _ci:verify-gen + + generate:crds: + desc: "Regenerate crds" + cmds: + - ./scripts/update-codegen.sh crds + # - task: format:yaml + + format:yaml: + desc: "Format non-templated YAML files, e.g. CRDs" + cmds: + # TODO: replace prettier image + - | + cd ../ && docker run --rm \ + -v "$(pwd):/tmp/operator-helm" ghcr.io/deckhouse/virtualization/prettier:3.2.5 \ + sh -c "cd /tmp/operator-helm ; prettier -w \"crds/*.yaml\"" + + _ci:verify-gen: + desc: "Check generated files are up-to-date." + internal: true + cmds: + - | + git diff --exit-code || (echo "Please run task gen:api and commit changes" && exit 1) diff --git a/api/client/generated/clientset/versioned/clientset.go b/api/client/generated/clientset/versioned/clientset.go new file mode 100644 index 0000000..93bd3a5 --- /dev/null +++ b/api/client/generated/clientset/versioned/clientset.go @@ -0,0 +1,120 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + fmt "fmt" + http "net/http" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/typed/api/v1alpha1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + HelmV1alpha1() helmv1alpha1.HelmV1alpha1Interface +} + +// Clientset contains the clients for groups. +type Clientset struct { + *discovery.DiscoveryClient + helmV1alpha1 *helmv1alpha1.HelmV1alpha1Client +} + +// HelmV1alpha1 retrieves the HelmV1alpha1Client +func (c *Clientset) HelmV1alpha1() helmv1alpha1.HelmV1alpha1Interface { + return c.helmV1alpha1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +// If config's RateLimiter is not set and QPS and Burst are acceptable, +// NewForConfig will generate a rate-limiter in configShallowCopy. +// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), +// where httpClient was generated with rest.HTTPClientFor(c). +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + + if configShallowCopy.UserAgent == "" { + configShallowCopy.UserAgent = rest.DefaultKubernetesUserAgent() + } + + // share the transport between all clients + httpClient, err := rest.HTTPClientFor(&configShallowCopy) + if err != nil { + return nil, err + } + + return NewForConfigAndClient(&configShallowCopy, httpClient) +} + +// NewForConfigAndClient creates a new Clientset for the given config and http client. +// Note the http client provided takes precedence over the configured transport values. +// If config's RateLimiter is not set and QPS and Burst are acceptable, +// NewForConfigAndClient will generate a rate-limiter in configShallowCopy. +func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + if configShallowCopy.Burst <= 0 { + return nil, fmt.Errorf("burst is required to be greater than 0 when RateLimiter is not set and QPS is set to greater than 0") + } + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + + var cs Clientset + var err error + cs.helmV1alpha1, err = helmv1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfigAndClient(&configShallowCopy, httpClient) + if err != nil { + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + cs, err := NewForConfig(c) + if err != nil { + panic(err) + } + return cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.helmV1alpha1 = helmv1alpha1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/api/client/generated/clientset/versioned/fake/clientset_generated.go b/api/client/generated/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 0000000..947f1d9 --- /dev/null +++ b/api/client/generated/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,105 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/typed/api/v1alpha1" + fakehelmv1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any field management, validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +// +// Deprecated: NewClientset replaces this with support for field management, which significantly improves +// server side apply testing. NewClientset is only available when apply configurations are generated (e.g. +// via --with-applyconfig). +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + cs := &Clientset{tracker: o} + cs.discovery = &fakediscovery.FakeDiscovery{Fake: &cs.Fake} + cs.AddReactor("*", "*", testing.ObjectReaction(o)) + cs.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + var opts metav1.ListOptions + if watchAction, ok := action.(testing.WatchActionImpl); ok { + opts = watchAction.ListOptions + } + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns, opts) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return cs +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery + tracker testing.ObjectTracker +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +func (c *Clientset) Tracker() testing.ObjectTracker { + return c.tracker +} + +// IsWatchListSemanticsSupported informs the reflector that this client +// doesn't support WatchList semantics. +// +// This is a synthetic method whose sole purpose is to satisfy the optional +// interface check performed by the reflector. +// Returning true signals that WatchList can NOT be used. +// No additional logic is implemented here. +func (c *Clientset) IsWatchListSemanticsUnSupported() bool { + return true +} + +var ( + _ clientset.Interface = &Clientset{} + _ testing.FakeClient = &Clientset{} +) + +// HelmV1alpha1 retrieves the HelmV1alpha1Client +func (c *Clientset) HelmV1alpha1() helmv1alpha1.HelmV1alpha1Interface { + return &fakehelmv1alpha1.FakeHelmV1alpha1{Fake: &c.Fake} +} diff --git a/api/client/generated/clientset/versioned/fake/doc.go b/api/client/generated/clientset/versioned/fake/doc.go new file mode 100644 index 0000000..06b4977 --- /dev/null +++ b/api/client/generated/clientset/versioned/fake/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/api/client/generated/clientset/versioned/fake/register.go b/api/client/generated/clientset/versioned/fake/register.go new file mode 100644 index 0000000..0b233ce --- /dev/null +++ b/api/client/generated/clientset/versioned/fake/register.go @@ -0,0 +1,56 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) + +var localSchemeBuilder = runtime.SchemeBuilder{ + helmv1alpha1.AddToScheme, +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +var AddToScheme = localSchemeBuilder.AddToScheme + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + utilruntime.Must(AddToScheme(scheme)) +} diff --git a/api/client/generated/clientset/versioned/scheme/doc.go b/api/client/generated/clientset/versioned/scheme/doc.go new file mode 100644 index 0000000..14d115f --- /dev/null +++ b/api/client/generated/clientset/versioned/scheme/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/api/client/generated/clientset/versioned/scheme/register.go b/api/client/generated/clientset/versioned/scheme/register.go new file mode 100644 index 0000000..a1c34af --- /dev/null +++ b/api/client/generated/clientset/versioned/scheme/register.go @@ -0,0 +1,56 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) +var localSchemeBuilder = runtime.SchemeBuilder{ + helmv1alpha1.AddToScheme, +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +var AddToScheme = localSchemeBuilder.AddToScheme + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + utilruntime.Must(AddToScheme(Scheme)) +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/api_client.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/api_client.go new file mode 100644 index 0000000..29edccb --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/api_client.go @@ -0,0 +1,111 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + http "net/http" + + scheme "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/scheme" + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + rest "k8s.io/client-go/rest" +) + +type HelmV1alpha1Interface interface { + RESTClient() rest.Interface + HelmClusterAddonsGetter + HelmClusterAddonChartsGetter + HelmClusterAddonRepositoriesGetter +} + +// HelmV1alpha1Client is used to interact with features provided by the helm.deckhouse.io group. +type HelmV1alpha1Client struct { + restClient rest.Interface +} + +func (c *HelmV1alpha1Client) HelmClusterAddons() HelmClusterAddonInterface { + return newHelmClusterAddons(c) +} + +func (c *HelmV1alpha1Client) HelmClusterAddonCharts() HelmClusterAddonChartInterface { + return newHelmClusterAddonCharts(c) +} + +func (c *HelmV1alpha1Client) HelmClusterAddonRepositories() HelmClusterAddonRepositoryInterface { + return newHelmClusterAddonRepositories(c) +} + +// NewForConfig creates a new HelmV1alpha1Client for the given config. +// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), +// where httpClient was generated with rest.HTTPClientFor(c). +func NewForConfig(c *rest.Config) (*HelmV1alpha1Client, error) { + config := *c + setConfigDefaults(&config) + httpClient, err := rest.HTTPClientFor(&config) + if err != nil { + return nil, err + } + return NewForConfigAndClient(&config, httpClient) +} + +// NewForConfigAndClient creates a new HelmV1alpha1Client for the given config and http client. +// Note the http client provided takes precedence over the configured transport values. +func NewForConfigAndClient(c *rest.Config, h *http.Client) (*HelmV1alpha1Client, error) { + config := *c + setConfigDefaults(&config) + client, err := rest.RESTClientForConfigAndClient(&config, h) + if err != nil { + return nil, err + } + return &HelmV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new HelmV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *HelmV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new HelmV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *HelmV1alpha1Client { + return &HelmV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) { + gv := apiv1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = rest.CodecFactoryForGeneratedClient(scheme.Scheme, scheme.Codecs).WithoutConversion() + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *HelmV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/doc.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/doc.go new file mode 100644 index 0000000..51e5450 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/doc.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/doc.go new file mode 100644 index 0000000..ea82301 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go new file mode 100644 index 0000000..5b3bbb8 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/typed/api/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeHelmV1alpha1 struct { + *testing.Fake +} + +func (c *FakeHelmV1alpha1) HelmClusterAddons() v1alpha1.HelmClusterAddonInterface { + return newFakeHelmClusterAddons(c) +} + +func (c *FakeHelmV1alpha1) HelmClusterAddonCharts() v1alpha1.HelmClusterAddonChartInterface { + return newFakeHelmClusterAddonCharts(c) +} + +func (c *FakeHelmV1alpha1) HelmClusterAddonRepositories() v1alpha1.HelmClusterAddonRepositoryInterface { + return newFakeHelmClusterAddonRepositories(c) +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeHelmV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddon.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddon.go new file mode 100644 index 0000000..909f672 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddon.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + apiv1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/typed/api/v1alpha1" + v1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeHelmClusterAddons implements HelmClusterAddonInterface +type fakeHelmClusterAddons struct { + *gentype.FakeClientWithList[*v1alpha1.HelmClusterAddon, *v1alpha1.HelmClusterAddonList] + Fake *FakeHelmV1alpha1 +} + +func newFakeHelmClusterAddons(fake *FakeHelmV1alpha1) apiv1alpha1.HelmClusterAddonInterface { + return &fakeHelmClusterAddons{ + gentype.NewFakeClientWithList[*v1alpha1.HelmClusterAddon, *v1alpha1.HelmClusterAddonList]( + fake.Fake, + "", + v1alpha1.SchemeGroupVersion.WithResource("helmclusteraddons"), + v1alpha1.SchemeGroupVersion.WithKind("HelmClusterAddon"), + func() *v1alpha1.HelmClusterAddon { return &v1alpha1.HelmClusterAddon{} }, + func() *v1alpha1.HelmClusterAddonList { return &v1alpha1.HelmClusterAddonList{} }, + func(dst, src *v1alpha1.HelmClusterAddonList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.HelmClusterAddonList) []*v1alpha1.HelmClusterAddon { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.HelmClusterAddonList, items []*v1alpha1.HelmClusterAddon) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddonchart.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddonchart.go new file mode 100644 index 0000000..f0bf23d --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddonchart.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + apiv1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/typed/api/v1alpha1" + v1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeHelmClusterAddonCharts implements HelmClusterAddonChartInterface +type fakeHelmClusterAddonCharts struct { + *gentype.FakeClientWithList[*v1alpha1.HelmClusterAddonChart, *v1alpha1.HelmClusterAddonChartList] + Fake *FakeHelmV1alpha1 +} + +func newFakeHelmClusterAddonCharts(fake *FakeHelmV1alpha1) apiv1alpha1.HelmClusterAddonChartInterface { + return &fakeHelmClusterAddonCharts{ + gentype.NewFakeClientWithList[*v1alpha1.HelmClusterAddonChart, *v1alpha1.HelmClusterAddonChartList]( + fake.Fake, + "", + v1alpha1.SchemeGroupVersion.WithResource("helmclusteraddoncharts"), + v1alpha1.SchemeGroupVersion.WithKind("HelmClusterAddonChart"), + func() *v1alpha1.HelmClusterAddonChart { return &v1alpha1.HelmClusterAddonChart{} }, + func() *v1alpha1.HelmClusterAddonChartList { return &v1alpha1.HelmClusterAddonChartList{} }, + func(dst, src *v1alpha1.HelmClusterAddonChartList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.HelmClusterAddonChartList) []*v1alpha1.HelmClusterAddonChart { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.HelmClusterAddonChartList, items []*v1alpha1.HelmClusterAddonChart) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddonrepository.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddonrepository.go new file mode 100644 index 0000000..bee2b28 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusteraddonrepository.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + apiv1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/typed/api/v1alpha1" + v1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeHelmClusterAddonRepositories implements HelmClusterAddonRepositoryInterface +type fakeHelmClusterAddonRepositories struct { + *gentype.FakeClientWithList[*v1alpha1.HelmClusterAddonRepository, *v1alpha1.HelmClusterAddonRepositoryList] + Fake *FakeHelmV1alpha1 +} + +func newFakeHelmClusterAddonRepositories(fake *FakeHelmV1alpha1) apiv1alpha1.HelmClusterAddonRepositoryInterface { + return &fakeHelmClusterAddonRepositories{ + gentype.NewFakeClientWithList[*v1alpha1.HelmClusterAddonRepository, *v1alpha1.HelmClusterAddonRepositoryList]( + fake.Fake, + "", + v1alpha1.SchemeGroupVersion.WithResource("helmclusteraddonrepositories"), + v1alpha1.SchemeGroupVersion.WithKind("HelmClusterAddonRepository"), + func() *v1alpha1.HelmClusterAddonRepository { return &v1alpha1.HelmClusterAddonRepository{} }, + func() *v1alpha1.HelmClusterAddonRepositoryList { return &v1alpha1.HelmClusterAddonRepositoryList{} }, + func(dst, src *v1alpha1.HelmClusterAddonRepositoryList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.HelmClusterAddonRepositoryList) []*v1alpha1.HelmClusterAddonRepository { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.HelmClusterAddonRepositoryList, items []*v1alpha1.HelmClusterAddonRepository) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go new file mode 100644 index 0000000..911c8ea --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go @@ -0,0 +1,25 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +type HelmClusterAddonExpansion interface{} + +type HelmClusterAddonChartExpansion interface{} + +type HelmClusterAddonRepositoryExpansion interface{} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddon.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddon.go new file mode 100644 index 0000000..05aeac5 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddon.go @@ -0,0 +1,70 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + scheme "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/scheme" + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// HelmClusterAddonsGetter has a method to return a HelmClusterAddonInterface. +// A group's client should implement this interface. +type HelmClusterAddonsGetter interface { + HelmClusterAddons() HelmClusterAddonInterface +} + +// HelmClusterAddonInterface has methods to work with HelmClusterAddon resources. +type HelmClusterAddonInterface interface { + Create(ctx context.Context, helmClusterAddon *apiv1alpha1.HelmClusterAddon, opts v1.CreateOptions) (*apiv1alpha1.HelmClusterAddon, error) + Update(ctx context.Context, helmClusterAddon *apiv1alpha1.HelmClusterAddon, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterAddon, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, helmClusterAddon *apiv1alpha1.HelmClusterAddon, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterAddon, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha1.HelmClusterAddon, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.HelmClusterAddonList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha1.HelmClusterAddon, err error) + HelmClusterAddonExpansion +} + +// helmClusterAddons implements HelmClusterAddonInterface +type helmClusterAddons struct { + *gentype.ClientWithList[*apiv1alpha1.HelmClusterAddon, *apiv1alpha1.HelmClusterAddonList] +} + +// newHelmClusterAddons returns a HelmClusterAddons +func newHelmClusterAddons(c *HelmV1alpha1Client) *helmClusterAddons { + return &helmClusterAddons{ + gentype.NewClientWithList[*apiv1alpha1.HelmClusterAddon, *apiv1alpha1.HelmClusterAddonList]( + "helmclusteraddons", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *apiv1alpha1.HelmClusterAddon { return &apiv1alpha1.HelmClusterAddon{} }, + func() *apiv1alpha1.HelmClusterAddonList { return &apiv1alpha1.HelmClusterAddonList{} }, + ), + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddonchart.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddonchart.go new file mode 100644 index 0000000..8db564d --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddonchart.go @@ -0,0 +1,70 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + scheme "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/scheme" + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// HelmClusterAddonChartsGetter has a method to return a HelmClusterAddonChartInterface. +// A group's client should implement this interface. +type HelmClusterAddonChartsGetter interface { + HelmClusterAddonCharts() HelmClusterAddonChartInterface +} + +// HelmClusterAddonChartInterface has methods to work with HelmClusterAddonChart resources. +type HelmClusterAddonChartInterface interface { + Create(ctx context.Context, helmClusterAddonChart *apiv1alpha1.HelmClusterAddonChart, opts v1.CreateOptions) (*apiv1alpha1.HelmClusterAddonChart, error) + Update(ctx context.Context, helmClusterAddonChart *apiv1alpha1.HelmClusterAddonChart, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterAddonChart, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, helmClusterAddonChart *apiv1alpha1.HelmClusterAddonChart, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterAddonChart, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha1.HelmClusterAddonChart, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.HelmClusterAddonChartList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha1.HelmClusterAddonChart, err error) + HelmClusterAddonChartExpansion +} + +// helmClusterAddonCharts implements HelmClusterAddonChartInterface +type helmClusterAddonCharts struct { + *gentype.ClientWithList[*apiv1alpha1.HelmClusterAddonChart, *apiv1alpha1.HelmClusterAddonChartList] +} + +// newHelmClusterAddonCharts returns a HelmClusterAddonCharts +func newHelmClusterAddonCharts(c *HelmV1alpha1Client) *helmClusterAddonCharts { + return &helmClusterAddonCharts{ + gentype.NewClientWithList[*apiv1alpha1.HelmClusterAddonChart, *apiv1alpha1.HelmClusterAddonChartList]( + "helmclusteraddoncharts", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *apiv1alpha1.HelmClusterAddonChart { return &apiv1alpha1.HelmClusterAddonChart{} }, + func() *apiv1alpha1.HelmClusterAddonChartList { return &apiv1alpha1.HelmClusterAddonChartList{} }, + ), + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddonrepository.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddonrepository.go new file mode 100644 index 0000000..99494aa --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusteraddonrepository.go @@ -0,0 +1,72 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + scheme "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned/scheme" + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// HelmClusterAddonRepositoriesGetter has a method to return a HelmClusterAddonRepositoryInterface. +// A group's client should implement this interface. +type HelmClusterAddonRepositoriesGetter interface { + HelmClusterAddonRepositories() HelmClusterAddonRepositoryInterface +} + +// HelmClusterAddonRepositoryInterface has methods to work with HelmClusterAddonRepository resources. +type HelmClusterAddonRepositoryInterface interface { + Create(ctx context.Context, helmClusterAddonRepository *apiv1alpha1.HelmClusterAddonRepository, opts v1.CreateOptions) (*apiv1alpha1.HelmClusterAddonRepository, error) + Update(ctx context.Context, helmClusterAddonRepository *apiv1alpha1.HelmClusterAddonRepository, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterAddonRepository, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, helmClusterAddonRepository *apiv1alpha1.HelmClusterAddonRepository, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterAddonRepository, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha1.HelmClusterAddonRepository, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.HelmClusterAddonRepositoryList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha1.HelmClusterAddonRepository, err error) + HelmClusterAddonRepositoryExpansion +} + +// helmClusterAddonRepositories implements HelmClusterAddonRepositoryInterface +type helmClusterAddonRepositories struct { + *gentype.ClientWithList[*apiv1alpha1.HelmClusterAddonRepository, *apiv1alpha1.HelmClusterAddonRepositoryList] +} + +// newHelmClusterAddonRepositories returns a HelmClusterAddonRepositories +func newHelmClusterAddonRepositories(c *HelmV1alpha1Client) *helmClusterAddonRepositories { + return &helmClusterAddonRepositories{ + gentype.NewClientWithList[*apiv1alpha1.HelmClusterAddonRepository, *apiv1alpha1.HelmClusterAddonRepositoryList]( + "helmclusteraddonrepositories", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *apiv1alpha1.HelmClusterAddonRepository { return &apiv1alpha1.HelmClusterAddonRepository{} }, + func() *apiv1alpha1.HelmClusterAddonRepositoryList { + return &apiv1alpha1.HelmClusterAddonRepositoryList{} + }, + ), + } +} diff --git a/api/client/generated/informers/externalversions/api/interface.go b/api/client/generated/informers/externalversions/api/interface.go new file mode 100644 index 0000000..2977c43 --- /dev/null +++ b/api/client/generated/informers/externalversions/api/interface.go @@ -0,0 +1,46 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package api + +import ( + v1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/informers/externalversions/api/v1alpha1" + internalinterfaces "github.com/deckhouse/operator-helm/api/client/generated/informers/externalversions/internalinterfaces" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddon.go b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddon.go new file mode 100644 index 0000000..8a5f46d --- /dev/null +++ b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddon.go @@ -0,0 +1,101 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + versioned "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned" + internalinterfaces "github.com/deckhouse/operator-helm/api/client/generated/informers/externalversions/internalinterfaces" + apiv1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/listers/api/v1alpha1" + operatorhelmapiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// HelmClusterAddonInformer provides access to a shared informer and lister for +// HelmClusterAddons. +type HelmClusterAddonInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.HelmClusterAddonLister +} + +type helmClusterAddonInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewHelmClusterAddonInformer constructs a new informer for HelmClusterAddon type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewHelmClusterAddonInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHelmClusterAddonInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredHelmClusterAddonInformer constructs a new informer for HelmClusterAddon type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredHelmClusterAddonInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddons().List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddons().Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddons().List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddons().Watch(ctx, options) + }, + }, client), + &operatorhelmapiv1alpha1.HelmClusterAddon{}, + resyncPeriod, + indexers, + ) +} + +func (f *helmClusterAddonInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHelmClusterAddonInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *helmClusterAddonInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&operatorhelmapiv1alpha1.HelmClusterAddon{}, f.defaultInformer) +} + +func (f *helmClusterAddonInformer) Lister() apiv1alpha1.HelmClusterAddonLister { + return apiv1alpha1.NewHelmClusterAddonLister(f.Informer().GetIndexer()) +} diff --git a/api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddonchart.go b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddonchart.go new file mode 100644 index 0000000..5e14371 --- /dev/null +++ b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddonchart.go @@ -0,0 +1,101 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + versioned "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned" + internalinterfaces "github.com/deckhouse/operator-helm/api/client/generated/informers/externalversions/internalinterfaces" + apiv1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/listers/api/v1alpha1" + operatorhelmapiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// HelmClusterAddonChartInformer provides access to a shared informer and lister for +// HelmClusterAddonCharts. +type HelmClusterAddonChartInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.HelmClusterAddonChartLister +} + +type helmClusterAddonChartInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewHelmClusterAddonChartInformer constructs a new informer for HelmClusterAddonChart type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewHelmClusterAddonChartInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHelmClusterAddonChartInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredHelmClusterAddonChartInformer constructs a new informer for HelmClusterAddonChart type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredHelmClusterAddonChartInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddonCharts().List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddonCharts().Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddonCharts().List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddonCharts().Watch(ctx, options) + }, + }, client), + &operatorhelmapiv1alpha1.HelmClusterAddonChart{}, + resyncPeriod, + indexers, + ) +} + +func (f *helmClusterAddonChartInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHelmClusterAddonChartInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *helmClusterAddonChartInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&operatorhelmapiv1alpha1.HelmClusterAddonChart{}, f.defaultInformer) +} + +func (f *helmClusterAddonChartInformer) Lister() apiv1alpha1.HelmClusterAddonChartLister { + return apiv1alpha1.NewHelmClusterAddonChartLister(f.Informer().GetIndexer()) +} diff --git a/api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddonrepository.go b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddonrepository.go new file mode 100644 index 0000000..b314028 --- /dev/null +++ b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusteraddonrepository.go @@ -0,0 +1,101 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + versioned "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned" + internalinterfaces "github.com/deckhouse/operator-helm/api/client/generated/informers/externalversions/internalinterfaces" + apiv1alpha1 "github.com/deckhouse/operator-helm/api/client/generated/listers/api/v1alpha1" + operatorhelmapiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// HelmClusterAddonRepositoryInformer provides access to a shared informer and lister for +// HelmClusterAddonRepositories. +type HelmClusterAddonRepositoryInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.HelmClusterAddonRepositoryLister +} + +type helmClusterAddonRepositoryInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewHelmClusterAddonRepositoryInformer constructs a new informer for HelmClusterAddonRepository type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewHelmClusterAddonRepositoryInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHelmClusterAddonRepositoryInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredHelmClusterAddonRepositoryInformer constructs a new informer for HelmClusterAddonRepository type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredHelmClusterAddonRepositoryInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddonRepositories().List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddonRepositories().Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddonRepositories().List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterAddonRepositories().Watch(ctx, options) + }, + }, client), + &operatorhelmapiv1alpha1.HelmClusterAddonRepository{}, + resyncPeriod, + indexers, + ) +} + +func (f *helmClusterAddonRepositoryInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHelmClusterAddonRepositoryInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *helmClusterAddonRepositoryInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&operatorhelmapiv1alpha1.HelmClusterAddonRepository{}, f.defaultInformer) +} + +func (f *helmClusterAddonRepositoryInformer) Lister() apiv1alpha1.HelmClusterAddonRepositoryLister { + return apiv1alpha1.NewHelmClusterAddonRepositoryLister(f.Informer().GetIndexer()) +} diff --git a/api/client/generated/informers/externalversions/api/v1alpha1/interface.go b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go new file mode 100644 index 0000000..e8deecc --- /dev/null +++ b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go @@ -0,0 +1,59 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + internalinterfaces "github.com/deckhouse/operator-helm/api/client/generated/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // HelmClusterAddons returns a HelmClusterAddonInformer. + HelmClusterAddons() HelmClusterAddonInformer + // HelmClusterAddonCharts returns a HelmClusterAddonChartInformer. + HelmClusterAddonCharts() HelmClusterAddonChartInformer + // HelmClusterAddonRepositories returns a HelmClusterAddonRepositoryInformer. + HelmClusterAddonRepositories() HelmClusterAddonRepositoryInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// HelmClusterAddons returns a HelmClusterAddonInformer. +func (v *version) HelmClusterAddons() HelmClusterAddonInformer { + return &helmClusterAddonInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + +// HelmClusterAddonCharts returns a HelmClusterAddonChartInformer. +func (v *version) HelmClusterAddonCharts() HelmClusterAddonChartInformer { + return &helmClusterAddonChartInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + +// HelmClusterAddonRepositories returns a HelmClusterAddonRepositoryInformer. +func (v *version) HelmClusterAddonRepositories() HelmClusterAddonRepositoryInformer { + return &helmClusterAddonRepositoryInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} diff --git a/api/client/generated/informers/externalversions/factory.go b/api/client/generated/informers/externalversions/factory.go new file mode 100644 index 0000000..df93e62 --- /dev/null +++ b/api/client/generated/informers/externalversions/factory.go @@ -0,0 +1,263 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned" + api "github.com/deckhouse/operator-helm/api/client/generated/informers/externalversions/api" + internalinterfaces "github.com/deckhouse/operator-helm/api/client/generated/informers/externalversions/internalinterfaces" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// SharedInformerOption defines the functional option type for SharedInformerFactory. +type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + customResync map[reflect.Type]time.Duration + transform cache.TransformFunc + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool + // wg tracks how many goroutines were started. + wg sync.WaitGroup + // shuttingDown is true when Shutdown has been called. It may still be running + // because it needs to wait for goroutines. + shuttingDown bool +} + +// WithCustomResyncConfig sets a custom resync period for the specified informer types. +func WithCustomResyncConfig(resyncConfig map[v1.Object]time.Duration) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + for k, v := range resyncConfig { + factory.customResync[reflect.TypeOf(k)] = v + } + return factory + } +} + +// WithTweakListOptions sets a custom filter on all listers of the configured SharedInformerFactory. +func WithTweakListOptions(tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.tweakListOptions = tweakListOptions + return factory + } +} + +// WithNamespace limits the SharedInformerFactory to the specified namespace. +func WithNamespace(namespace string) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.namespace = namespace + return factory + } +} + +// WithTransform sets a transform on all informers. +func WithTransform(transform cache.TransformFunc) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.transform = transform + return factory + } +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory for all namespaces. +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewSharedInformerFactoryWithOptions(client, defaultResync) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +// +// Deprecated: Please use NewSharedInformerFactoryWithOptions instead +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return NewSharedInformerFactoryWithOptions(client, defaultResync, WithNamespace(namespace), WithTweakListOptions(tweakListOptions)) +} + +// NewSharedInformerFactoryWithOptions constructs a new instance of a SharedInformerFactory with additional options. +func NewSharedInformerFactoryWithOptions(client versioned.Interface, defaultResync time.Duration, options ...SharedInformerOption) SharedInformerFactory { + factory := &sharedInformerFactory{ + client: client, + namespace: v1.NamespaceAll, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + customResync: make(map[reflect.Type]time.Duration), + } + + // Apply all options + for _, opt := range options { + factory = opt(factory) + } + + return factory +} + +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + if f.shuttingDown { + return + } + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + f.wg.Add(1) + // We need a new variable in each loop iteration, + // otherwise the goroutine would use the loop variable + // and that keeps changing. + informer := informer + go func() { + defer f.wg.Done() + informer.Run(stopCh) + }() + f.startedInformers[informerType] = true + } + } +} + +func (f *sharedInformerFactory) Shutdown() { + f.lock.Lock() + f.shuttingDown = true + f.lock.Unlock() + + // Will return immediately if there is nothing to wait for. + f.wg.Wait() +} + +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + + resyncPeriod, exists := f.customResync[informerType] + if !exists { + resyncPeriod = f.defaultResync + } + + informer = newFunc(f.client, resyncPeriod) + informer.SetTransform(f.transform) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +// +// It is typically used like this: +// +// ctx, cancel := context.WithCancel(context.Background()) +// defer cancel() +// factory := NewSharedInformerFactory(client, resyncPeriod) +// defer factory.WaitForStop() // Returns immediately if nothing was started. +// genericInformer := factory.ForResource(resource) +// typedInformer := factory.SomeAPIGroup().V1().SomeType() +// factory.Start(ctx.Done()) // Start processing these informers. +// synced := factory.WaitForCacheSync(ctx.Done()) +// for v, ok := range synced { +// if !ok { +// fmt.Fprintf(os.Stderr, "caches failed to sync: %v", v) +// return +// } +// } +// +// // Creating informers can also be created after Start, but then +// // Start must be called again: +// anotherGenericInformer := factory.ForResource(resource) +// factory.Start(ctx.Done()) +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + + // Start initializes all requested informers. They are handled in goroutines + // which run until the stop channel gets closed. + // Warning: Start does not block. When run in a go-routine, it will race with a later WaitForCacheSync. + Start(stopCh <-chan struct{}) + + // Shutdown marks a factory as shutting down. At that point no new + // informers can be started anymore and Start will return without + // doing anything. + // + // In addition, Shutdown blocks until all goroutines have terminated. For that + // to happen, the close channel(s) that they were started with must be closed, + // either before Shutdown gets called or while it is waiting. + // + // Shutdown may be called multiple times, even concurrently. All such calls will + // block until all goroutines have terminated. + Shutdown() + + // WaitForCacheSync blocks until all started informers' caches were synced + // or the stop channel gets closed. + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + // ForResource gives generic access to a shared informer of the matching type. + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + + // InformerFor returns the SharedIndexInformer for obj using an internal + // client. + InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer + + Helm() api.Interface +} + +func (f *sharedInformerFactory) Helm() api.Interface { + return api.New(f, f.namespace, f.tweakListOptions) +} diff --git a/api/client/generated/informers/externalversions/generic.go b/api/client/generated/informers/externalversions/generic.go new file mode 100644 index 0000000..aca9bbb --- /dev/null +++ b/api/client/generated/informers/externalversions/generic.go @@ -0,0 +1,66 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + fmt "fmt" + + v1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=helm.deckhouse.io, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("helmclusteraddons"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Helm().V1alpha1().HelmClusterAddons().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("helmclusteraddoncharts"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Helm().V1alpha1().HelmClusterAddonCharts().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("helmclusteraddonrepositories"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Helm().V1alpha1().HelmClusterAddonRepositories().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/api/client/generated/informers/externalversions/internalinterfaces/factory_interfaces.go b/api/client/generated/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 0000000..77db6c7 --- /dev/null +++ b/api/client/generated/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,40 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +// NewInformerFunc takes versioned.Interface and time.Duration to return a SharedIndexInformer. +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +// TweakListOptionsFunc is a function that transforms a v1.ListOptions. +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/api/client/generated/listers/api/v1alpha1/expansion_generated.go b/api/client/generated/listers/api/v1alpha1/expansion_generated.go new file mode 100644 index 0000000..8e4f30f --- /dev/null +++ b/api/client/generated/listers/api/v1alpha1/expansion_generated.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +// HelmClusterAddonListerExpansion allows custom methods to be added to +// HelmClusterAddonLister. +type HelmClusterAddonListerExpansion interface{} + +// HelmClusterAddonChartListerExpansion allows custom methods to be added to +// HelmClusterAddonChartLister. +type HelmClusterAddonChartListerExpansion interface{} + +// HelmClusterAddonRepositoryListerExpansion allows custom methods to be added to +// HelmClusterAddonRepositoryLister. +type HelmClusterAddonRepositoryListerExpansion interface{} diff --git a/api/client/generated/listers/api/v1alpha1/helmclusteraddon.go b/api/client/generated/listers/api/v1alpha1/helmclusteraddon.go new file mode 100644 index 0000000..421d60b --- /dev/null +++ b/api/client/generated/listers/api/v1alpha1/helmclusteraddon.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// HelmClusterAddonLister helps list HelmClusterAddons. +// All objects returned here must be treated as read-only. +type HelmClusterAddonLister interface { + // List lists all HelmClusterAddons in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmClusterAddon, err error) + // Get retrieves the HelmClusterAddon from the index for a given name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.HelmClusterAddon, error) + HelmClusterAddonListerExpansion +} + +// helmClusterAddonLister implements the HelmClusterAddonLister interface. +type helmClusterAddonLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmClusterAddon] +} + +// NewHelmClusterAddonLister returns a new HelmClusterAddonLister. +func NewHelmClusterAddonLister(indexer cache.Indexer) HelmClusterAddonLister { + return &helmClusterAddonLister{listers.New[*apiv1alpha1.HelmClusterAddon](indexer, apiv1alpha1.Resource("helmclusteraddon"))} +} diff --git a/api/client/generated/listers/api/v1alpha1/helmclusteraddonchart.go b/api/client/generated/listers/api/v1alpha1/helmclusteraddonchart.go new file mode 100644 index 0000000..ee09591 --- /dev/null +++ b/api/client/generated/listers/api/v1alpha1/helmclusteraddonchart.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// HelmClusterAddonChartLister helps list HelmClusterAddonCharts. +// All objects returned here must be treated as read-only. +type HelmClusterAddonChartLister interface { + // List lists all HelmClusterAddonCharts in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmClusterAddonChart, err error) + // Get retrieves the HelmClusterAddonChart from the index for a given name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.HelmClusterAddonChart, error) + HelmClusterAddonChartListerExpansion +} + +// helmClusterAddonChartLister implements the HelmClusterAddonChartLister interface. +type helmClusterAddonChartLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmClusterAddonChart] +} + +// NewHelmClusterAddonChartLister returns a new HelmClusterAddonChartLister. +func NewHelmClusterAddonChartLister(indexer cache.Indexer) HelmClusterAddonChartLister { + return &helmClusterAddonChartLister{listers.New[*apiv1alpha1.HelmClusterAddonChart](indexer, apiv1alpha1.Resource("helmclusteraddonchart"))} +} diff --git a/api/client/generated/listers/api/v1alpha1/helmclusteraddonrepository.go b/api/client/generated/listers/api/v1alpha1/helmclusteraddonrepository.go new file mode 100644 index 0000000..5577c5f --- /dev/null +++ b/api/client/generated/listers/api/v1alpha1/helmclusteraddonrepository.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// HelmClusterAddonRepositoryLister helps list HelmClusterAddonRepositories. +// All objects returned here must be treated as read-only. +type HelmClusterAddonRepositoryLister interface { + // List lists all HelmClusterAddonRepositories in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmClusterAddonRepository, err error) + // Get retrieves the HelmClusterAddonRepository from the index for a given name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.HelmClusterAddonRepository, error) + HelmClusterAddonRepositoryListerExpansion +} + +// helmClusterAddonRepositoryLister implements the HelmClusterAddonRepositoryLister interface. +type helmClusterAddonRepositoryLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmClusterAddonRepository] +} + +// NewHelmClusterAddonRepositoryLister returns a new HelmClusterAddonRepositoryLister. +func NewHelmClusterAddonRepositoryLister(indexer cache.Indexer) HelmClusterAddonRepositoryLister { + return &helmClusterAddonRepositoryLister{listers.New[*apiv1alpha1.HelmClusterAddonRepository](indexer, apiv1alpha1.Resource("helmclusteraddonrepository"))} +} diff --git a/api/doc.go b/api/doc.go new file mode 100644 index 0000000..8cb1e54 --- /dev/null +++ b/api/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api diff --git a/api/go.mod b/api/go.mod new file mode 100644 index 0000000..c4ab374 --- /dev/null +++ b/api/go.mod @@ -0,0 +1,84 @@ +module github.com/deckhouse/operator-helm/api + +go 1.25.0 + +tool ( + k8s.io/code-generator + k8s.io/kube-openapi/cmd/openapi-gen + sigs.k8s.io/controller-tools/cmd/controller-gen +) + +require ( + k8s.io/apiextensions-apiserver v0.35.1 + k8s.io/apimachinery v0.35.1 + k8s.io/client-go v0.35.1 + sigs.k8s.io/controller-runtime v0.23.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/gobuffalo/flect v1.0.3 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/onsi/gomega v1.38.3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.40.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.35.1 // indirect + k8s.io/code-generator v0.35.1 // indirect + k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/controller-tools v0.17.2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/api/go.sum b/api/go.sum new file mode 100644 index 0000000..e4b8369 --- /dev/null +++ b/api/go.sum @@ -0,0 +1,171 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= +k8s.io/code-generator v0.35.1 h1:yLKR2la7Z9cWT5qmk67ayx8xXLM4RRKQMnC8YPvTWRI= +k8s.io/code-generator v0.35.1/go.mod h1:F2Fhm7aA69tC/VkMXLDokdovltXEF026Tb9yfQXQWKg= +k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ= +k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-tools v0.17.2 h1:jNFOKps8WnaRKZU2R+4vRCHnXyJanVmXBWqkuUPFyFg= +sigs.k8s.io/controller-tools v0.17.2/go.mod h1:4q5tZG2JniS5M5bkiXY2/potOiXyhoZVw/U48vLkXk0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/api/scripts/boilerplate.go.txt b/api/scripts/boilerplate.go.txt new file mode 100644 index 0000000..cc60635 --- /dev/null +++ b/api/scripts/boilerplate.go.txt @@ -0,0 +1,15 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ diff --git a/api/scripts/update-codegen.sh b/api/scripts/update-codegen.sh new file mode 100755 index 0000000..3c6e4da --- /dev/null +++ b/api/scripts/update-codegen.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +# Copyright 2024 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +set -o errexit +set -o nounset +set -o pipefail + +function usage { + cat <Dc zVQyr3R8em|NM&qo0POwycHB0yD2(sF^%NL7S)FC$_9ll9N3v zGarK{(cKuc0R{ji_t;+RJj8jz^CaKGjRZ)L&6ZlSk`ebFM@<4%g+igK02B%-O5V+A z!BL#yaXdrWeEV<)nHVkLJo~TpJUcr(J1?F+Q~%xB*{T0`XK#1!zjmMR>^^_K`}D=` z^Z(k}efIU&PyY+-tO2FSG{q+lLzMbC&W0WWFK7ZZKQ}N4Cg}q{sCok zPzaoHAkaL|pgtc1oTGiv6s!%g$k9H?@R&ii*toK2 zcSp~kk9IdcHz)oxpTPLHea*LHnB&;=Qw&KOzr!RQiF`%Huw?u{eg5L9KmPajc6XjX zj{p03K71I0?XSRF4yR}zeE0w`NwOkEAd<46ghF%J# zMFa5Z6ZmQy3_pF^FaQ}61tS~?Rv>^UK+F)}IZE&ZqZBa2X~7Z%BoMHfK#qo(a6~v3 z_zDdG=76D`A&!VZDZoSlNV0+p#9UNLAGK2Z>C*-Pve*|1B?7${Kt1~}0VXMW4@O`| zLIQ^9ORME<>1!-zVDuBr3dFa-NLj`$fJ9ea}~I9eZ7Vk$)9wF#y|?r1SD=z2~p-hGURD+z9kI4Ajxe(5Sa5B72=j~GJJ&y;yh<`jBW*~ z8js7sJl_d`S>i1DBR-x(f+xuH{H4K|;A!1YYB(qDkec3!=a@)JtM3y$^_`2V_N2%% z>wK7^qw?KHNf9hE0iuNCVMU3H-|5|BBE7a$!bv;1i(vulWR0YL|Y8`_Ay0x|z z(gu3>Z3HGzTC!$kL35hX>7rh}_P(9;f*kO3O2u1_SVg(}wjDWoFBm*z2nzHX37A3w z>vXgiX$M<%)yW)Az22>V`Un{Mt-uVWAZjb4rjojl+x3o~y2Nut3*jOPe02os#mKebYBI3EB}`_c-*;duZ`;A<-+n5Jss z6=sJVAC^*nM$-dAsDOG=)@^043Kc#*AcF|Y%E#oY4ToPQ1m?mhwKfpW_iic<*mNls zs!TVSpOgSCg=CBM0rr{}MD&Dc$}RzWcCZxM*})PZXKHr51dJ}!1_)wOA4H+=jv9Am z?gz@nDH7L|z4P>WTN!5%NXr#Vzr3JMfg0y8x|gb|zgj$pjZ#^J4|-FW>BZ zYcM9%PvsDP+J$f1sv~*vIO~xf#EBh zp(#pBVm;TYSA~E&DOZb%F=7M>#4phNCk>pV`ITB1G^-GL&1p2v=on_B6!U~$A+{J* zb?wrd1K6=e%I2RMr|@4C6A)R~Rr-O)?*(F#B~^{6N!aK${1;`R_n1&N2;0cgS!p`e zdTT6B=zLB|jAg-?(0BrIW{E3Z;b3D1ZfmXa>e07nW#khv%)f?coPP_I?8ut^=XNVzf5pUHr2i+Q4RwLeTSmn(0?7EAp#_ifz!@Q1$ z1{&kF)|=5UY40ny?5$eb#JYyIhMVHH7Ec1Y&R^c%Y|-IJ&-Y&I@Br`;%peER6=DJ> zFdODH9l}CTp1=&TVZu_i1+y_64maH31X~7^sk55>4wG~r{A7Sh&$a}$F*g&={S5$g zOu~l`&Z2>$UjD^%n4o=-elwKc%jIwt1#?=KRIO}da|XvK;|<1crZ^EGf_WY_Rv`uh z@DUJ9QbYvUlQ?*e5=pCoY>LD=n(Qm~x=fIu`1aLqq`v6JAE7*>i#Z}fy=-e~k*81~ zy&SIr>o>1yiuOSy`6i*-B_+3DVvW0ci4oVYht-Euh16a9i_EUx9Fv1Aqt__iH*adJ zY*{ZXc~~*&n`+;14&OJRs^zb}oq74fTLudgY|T(_YC}t;cR{KhbG4-<1GU)@Q?7SA z0;{Wc)r@irw`!HYZMKXJ(klGG}dERWGWLv}jdj5*G z)DNFmHCC0pVkpmW0=ecAL)uFJfVrS-@e0qe*av$z<{toRD<4gbkD+5j&rzcG3Zxfg zcA*Vehl}#bJPfTkoS{V-*qlLb7MY_YqeVJ2fjDd!g;+iSoBbADoroq_z1{Z-SP^*z z5&&IZFT)Iz8A{&qV(x9=>5zsBj*aLUeG54{gJK4v?GlPoFcZ()!LAX_>Kmy ze&aK^_xuGf<~}zaMH19p9gEFQqgGhc4rsP-mXPGUumUZgS+&Nf<)w86vV=C%ONcGS z&rXgSyg*XKl~6aR_P=*h3RJvuN$I z#tLDegWNKK4tcRmMjBcxXP7c97IBh6F4dMO@(fk-7x4ubXg)MipTmS;(3F==n7#2H&H8EEDl%I0;oss}R#&5#a_3ULyW#ZZzFf#@2G z*{~ogh#zM7-zZ(iPk)bi2_4214zhfFxOTD}l;E6glI_$9euI1xh& zFxm)>oo=XlJFHHsQ_pPY3@2RrXfM%w@vKHghM;s~l!n8Ugb1w_8I~UJ%P%%h4_+T% zoE;n<50+HQ5aei>7Fy4}e7eJzRA7jp>tTvunqh)o?)9i3*kYI?hH3ipdA+n=$s8sy zhY1#ox-gs9LwZdRV`wtW&=tyF?z!^Z5i(QfyDB=={_xQHza?+mdh1$65e&sl>I7aN zn>NP49h-<;xdU5kCo>ky_W)eMtU&uV&(u>MnwbO zuFOrfEX!Ro8Tze2TyUTCP9uWy8JZ&ovsg~!;1tCfo$^>Z1+fyi2<+}SCS!NcQ(c4# zu-Ud*d&7nPR@(co47*f%gTnIKF57-?OY3i=u5M=Q(PX!>u}-1?6b&BGj`dKmyHbsh zD(MfPl6r3cwCW=VWb5Wm)g9*h=T=%?Y{cq1s}{WasIqRNvbf92M^*Kxs+Ov%emlH9 zG*yQJ3-04OeWX5rZuRBHKlRgIwE_CD6vVDA-nC^|#XeW8Z!g%j%-OQt-5#^8A5R^% zM}gl8b5}TZmkQ|@!My@$|EVgbSHQ(BHB4(*g`;MwGM`=7)Z-9Wcb5WZbRJ!%ThHIcm4%-bo6n{q>=F$}o5N^!IX7f=*@G^1Q7 zh+)1}3;OFto71$y>NEIp#&;`?wMu~H(iQV6OlZUUV*Sp4)n*+xQDsOr848rfe8FRw z&M}Ge5>-H-g(Ubs^k_ha9hKTVtlptdAINMjbck0!T{F5`tre$Eo(guyrqtWtBjFF% zTo3jcf|;28+y6wF@U5Av)t9!|IsaP0>oafE6<2Ka8kOA#oz~+!$^aywAnyphCV(Nx zDFIg(>L3~L13$qvRSAy-WIEfIoM-&HE#~>JAjM3DDlMv1UCxj)Caf8+SdVJF(kIx0 z=1k1b z`>c*Xw*eY{c0q8M=9Ar9pncX=dZ`hv7EnlNo{Pm1X8YiS+kbbjiEgWhLRzS$F+OjW zYoJw>&kA9*;RKlS!wqseu`x`fGqYO$CS5CVV5B@L;>SV;4nAm`)i~^Mn?nwx6;C&G z2X3lZxQ&e`;AQn}Jn=#`>)$SP9$V?E@cr%WDHgM0JWA+%yE@A&=g8a09C;o((6+}J z9dGOS-|bYLBXO#2OO~sseCM=@i4i}i8S;8Tm4$_vQHK9r&-vKwiu9ZP2Iuz^j`u6> z?T0A7+umW)R_$*L0nG9Dj21cH2fswoKQ~;P3x0`m*=jBj5k_07B3Fow)#oV^(UzPK zarNJ8D3Y1{L&ws|zfzPTfuet|jw;C@p7+G357_p>qEBG-0owp(EQaar0BHSP#n|^^ zR1b2|6(V(NJGgE5umz&Lr444SZM3X0rn^n?j@GOTg|aV=wsvD$f*|3T&~#m0(I3F?bXok$1Vbi#+ zffKg>JGk$3;B8H6Kbk(P_hanq6Yxe3bxIJRIy3)-W*NQ4WC~Ofm8jeq&6PDEe*(^=X5+lPSLN{H}xYKkxWOr?}B#u?@_sdk=79vXEA?5PBR$%L}GZqE%&mUSaEIJ~;cRd)hhFYF1p zoFVZ2@g>L|HjGh5uT`r}gC$?CVfIe8xyV(s)maNZ!#PN15KCDMGjAj@EKF|w$pj}@ z4hmT|Est-5D-5MaFQ7n^BD+8WY?l01W^;-JIHTzRK$1G=zQLwjLUtxiRS_Z0)VV%K zEx`#2CRo3Bst} zo>ZqAytk{*NG+V-$#m2?%<=o3i!PrN-QhIiE_~s2w5fr2TW=cs=<|oRK6uC5 zVZ+1lx*dWZg3J9`y~}$u@DGLQ55l^yI3y|~zVW0y`QyG@-L4xbzZ0`wop$>DTJ;vY zx5j1VKCpvxg&RQ%QrT^k?|$w0A(;Jp5!MH9^(!ts45MGs^Jw$$%jQ22o<9hazuI{4 zX6^1FmRnBe9Z*Y5zvQZ0TGBeKy?h9+fq%m10k{Y2-T=M9j_}%cIB&i&yw*L{id)N< z7b?xS-VJ3-SZkku-@T{L_xAkze|NurzPIyu|L=V~Zs@N{RI7u%Vtd(k_>$SZ zo_EzMh&KVyBcfTszUBS2br_{BxW}7mbza`8uKeA&__TT%y7IL8+H%>c?$q!8pQBS3 zsPi@i)%n~4)=!e_-D~N8ahe>y(ejk0sP(2p`?0NJ`-PI_*S4zd7fF_1TgmOZJkl-D z4WR47$VRC)P_NwcZv1ebw!>={t$?n06Ji@RHh=adV)Krc`Nf=}ck@dT?GaVgc_h6M zP~b%V;72H@8C`_#Gi)#1_iEJMcVmKE+!cB23m?5)`?8%qZ;lNVZD&lP%eUY6S^wI7 z)2A)nyyMgT(sjG%+Ap2-RO-1klS?julhhvhHEwkcTnPIbE`n=o)WAD<7{=}&^pnk4 zKEO6hdgID2XS7IT%Hr8#%y1f;^#9di!@-JI%_S(G4HJ%|l`Bf?JmEL0Cz0N$BA5S6 z5kU+mHv%xr(ajJ)y&1X!UL)+D9{uomA7I~`c$DwAzMB9cnp1Lt?)xIZ=CMf6V{3Kx zR=BXLd7qbL-B2r{VL4gpeYMxpsm@Jr`Fg(6rDUrASEBijN&Mhn3|5z`-^m46ONNz} z_lwZ|Q#6wb<*h1ki@U8J;^kFMH}3J;s^v~`$8qi!&v;!|RekvE*BQadM9xMQ6=aF; zpz#)zivvdm!(@7q%urg$fpjt@w0wR19wmk3lAjrY)Te^}=b;{U$M187^udsNj;SG_ zIbu+<0GyE8M*!ZT#lAu^D%U!tvmgh2qpZqq)j%Lo97-uXws{)b3Y;M}hs0jR8z`WC z;KtY0oM!Z!qbf>;H)Lb!Dz$x;4ypEK0|nYX)Sc003FB=Bsy=P0(C`;sCUx*9kXo|nXJI&9qxnDQiZ(4_eeP$w<;7)$nwi`R7}jznS?J=x`cj5h*u65) zU8@LYF=@hD-!*~lSC9o==&(rVd54aa3*l^N5!c~bKmrf*2LIHy#6SL)$fF-PfE`-m+XHawP= zKeH1$p!8I-Gsl{)f1mE+2~ zuzi^(kcsi8i6b9VN3Jz>q#yKQZp5eug48_t&^^n2>h->Xxh0=vt8b&`Zc=x+CN`+; zwvE$kP5xrrRmEyO`N$@)tYHsn*8YflqY0?g6`I%W*S8b#hUp7#8*3d6Roy|1tGAoY z?DPQ`>?#<+1EM6&`$pNYL*1&z+qYUw`e#f9~x*fByAj z{O5f`BMa@jI+h2i$H09t|6S?8I3rgs?cK+sn{=TLDzR*8ZBDB*t<_98Im{FqBtwDyRkn4nZ<%)6* zbc%9BQVu8qzbFC#M?zpS<^SBYhhn{ry`0gsNCbD!$4YsgP&VIAX;Mh!YA9}B?N(LI zK6Sy+&`gA8VxB$8A%k-y5aUDbar2>~V$i7q>MRS!m5*e7n=;+*d;?oRdhozZ&9=-T zpe=wzQsqJ~cZh@YIHO_?O|!I0t!3lyUvs2+-&i796~ajN*_nk^UT;*&&PKV=>_&;) z0J<|{uvp|r-6MB_rQtBHfl)ljsf)F-ig)sguZ>wvJRRKnf>2$2|0U1?w7Yv>k9I~o zqumHZyD$DS+I#+NWd0kiM3H%UMCTBb)!IVSi(*WWI6OHzU#X^<$vU-fFB_Ia^(l^h z|5B=v1d8g-8gRI=VfL=WHz859H9wj_=&dLuVT6XntGn(WatiiYpKkwehNrU_UO}AM zT;4Zv0hjrI&z?W4`+v`NAJ6~Y%hSXE!#tkBSkPDv)9Rqz0&zfS{hZzQSAZc=Fv9h) z2GIiXNUAwIi^3d8xyly6NLGfnbmZCp{ontmtQ-*vz7eqbnHA&N4{Q3I9P2i32+#Zby~*^`YZbtv;jC6rdS2tA1$&MjeA&U6`cB zcMDw4a59qvTd0-a6pR;OO3N^40J%9R)-|AA%y%(i`a_K_w{gS+l0W; z{C~}%n8~@gE%djL39wTKrc41!fd>*4415r0O&8()aC@ zJDC)8<_K9R&hzDjXse4Td>97C$ zMVdC0{_~yJxVFJB>w#Z0Ivvtn@X;K;A5L(Fz*oR~$ML_>ui%;%St?yWn5NqI%ORIu zpto{bU!1bE4(&a75||=GAyAj*29YQXum_ru>%|vFh^rNUI@f3rUr$I`7lkv z2t1c;TsFS-LuGv#qZ`2V^18+5j3v`OgjPX#E^&LFD^uIa* z4n>(oO3rasFagW-|L)iI^`EEDo;~jW-OJNouY`oaodU?;L*xE*y8qGv)i6}TkTSwb zX@Y_)eW^k_opm6c&7wRtv6&zyY$A~EP^?bs@5@1#*p@oznj5B2ZO=mcwVfH>x;U`o%LH&RTY-IlZkbcWp?0Zoz+i)YD7_;_`++ngcsU;R(Lu#f}69kFLb=+LBEWY*8W2r(+yw&A{}$U^_BBp z8$N;w#95;$Rq%t?ypEQ+M1rV6WfW*?w`8>RP&Y3c?Qnz3S|MU zO40^J?eQ*-;#5&GUo@=5>bkl%f|ri2Ub4~$dq3If!wfsl4do`WcV$=nrbLHox_aow z7DR7Iwm2bg&rhPkz{9P2_$_%y=rsWq+xD%s^7<}J9we#Gwb$h90Bo+F?ZKU12g!AM zS60w#u?|`=E(CC)SjSMsU)d9^fn$VI{J-lQW!CiXEt2#GMijUIAX#rPhh$W2fX0q(m*od0V z7$0NRuf~IEow9HzFkO~c0Ug~Zd{;#Md+nO#>hsovql)6Ki61Ir!uoA#}4#h#jDZfz2yg+R}rMkZbJB!f&^pA=8AfEi5?B=%cMc$K>+ z7#Z7t0<=m)U^2H%cDZhj_4+9|pWVEpm!Y}a?!6<)ulidZrBlUpc%9A5y&iR0vZOI* zA1Zrgv0^f1i1Wh?a^8*El(ea29Yy3E@B7d^&v#?pViAgr_dC*19|01H$T;9LwZ*An zC=+c!uV5vD#N3Gl)+gt^+>;W&?^jZ$+)CfmQjiDcL(|bUpRLPSXX;wD3e6X>-WpmO zk_^n1%CX)EQ87DRamu>1X7?{<`^mxaxsnektV2zYL(ZVq3QTO6L=}&!nquCXn5F)T zwT;uuqLWEqJ5p>8v_$f_K=ZgUJk2CwxR`>As+bo&ix6EKy=9T7QUeG1y( zlz!m|>zV3a4J1=+zU!oPaWYY;!fvuZ`!EL^L&)5O57u= z)*mjP-uGH0FqwjJMw550091bA|6iR`@`$VtlMgpKUl6=PIAjm{X?;8a z6Izh8xB9P8{|?Q-jTNoC?7PfH%xeasCl1Gy+{TvNo91t7{WWXg)phDEwd%U`>RoEq z4&A!sI?!9_&4%XeR;4%4g~6?d)fA(9#ro~_;)Kz;IVQ0}M1W7j?@&SBUVB8E2hn{v zjcvGA@)+xKy1KSnk70LZXMOfPAjhki{4i0Eg5BFD-i?HQyPkFWp9 z6qEOM{MK%yOWZqRhDH?j!N-0^!>w%P$dB}Y(Gi#}$tulCb&n^QA*h0NH_>Q10zX__ zUJRsyQTkwj^H^W1Rq!M*wEmaKZokQS1L6o}T(2i@2N;tvmr(qW_;iZ{+{ld%FAR z|KH1Fbk~z7Prz$DHMa!r?v8f%MteJZJNw`q&FK{aVum@Gp)5yC>ou5V01FNZX*oE6 z6M>j6mC*Sd3&0ReW+(;cGwCSOH75N+ttWLZ zB6+u+>-_QxFb7QPIgYPTwouKRVCe@#l7iISJdF`|j#g-l{fx`3?%|zP%#}XF{+jV zwfa_Tbq+zjHcMTyBqZrR_$A`AXbVKcMEM0hE64#qrR1Dav9E8qsouZki0y-&>c#hr7P)?-5xk}a(U<)XopTQjegL2@ zx+STYcTp|vQfy(^+{>O8(EssO`vkd8H z&!0ZuSN}c^>H8hhD0vrWm=y2hcXHf&99KkYRm67tWRMp4(xBtLTFa5t_zW@5OO#kof z*6;t?*?YYI@qV7p*xTBEb(yW87Kap7*KXhm*nEdcdcr|u(nv{_W02C5|+c7+oJ zCcmYm&s3mBQgcS2o(FfEi_IsT*xPQS<)<=&w=51SpWGyHIcAKqKmF-XpwAtRD#eQ- z0{kQTlK&$DITQjhB6~x3m)9wOin=4pZ*o(l&(6n*#Qi?K^nZQDZmscOdwWlxHT3`2 zdyny7_wuZu|CcS@>O@qPW!T->*-`&=n1nN$UijNE)qP-GE$W}qR4ZnavrM8=dw5eu zi@cYW=u7x;4cEWFmE<*I9n?ON^E$Bged6IJ;GsIQ+w8}N!-tS8~-HCx%()D>(a4^RN z&taxEu(6zAm*=bm)lm@(4M@xoI4>#GSB}t$dM)Lw4D`Mbkfe~My`<*Wd|J2Uz$~NJ zXAEEA3{BB7Phh6vTB~@T1mg6^?qebtQx^r18Zm9g$ZwnDjb?lQI z)UgV(U2uWs+DG6>g`TaE>k=W52!TWhDuhZ)=}Z#3;>GbRiCdXKD6rD_i(6W>o~>QG zYC|x6L$ZZbZQqqhOB=M@^TlUBAsMuMB(H%wFOd|&+iB0e~JeR3M=wIze&4n_a2l~-HW-jzP<*Rpl1 zw$I+7?dIDR%2>_@rSr8?->&Scz%28|f|`WpDD8Ev*g6~ez%1CZ`T4=CSD#%nVo1^$ zrfCGKfM|UIM z>0w3#++^dwZH@Xrz#`w-_RGWFUb!WQeusYF&DNDaca+t`efq`!nG>#elmGMSi|38} zZ@bSP946hA&4O%R9|FVU8@SdFc~pTQI!C4L`>%HpenYBK`)#dz=7RIL3R)} z>~TmG-2SwF5^Ip{hx9dxCL^jY(|}RW{jsM(gkiKMhrZK0gB2dPd-h-I1F=qYyG>(( z-sg~TOo_@{7t_fklij!f((H~^5Y2H_=dhtHrROh6MvHW4@3tSpe-{izjn@k(TiSeo z#q${oRk%vh$iL%f2Dwi{ODwxdDfrrCN%iidS<>{E$`CF)N9$bX&<)N#B^*nRaU}fS ziOkT@dfU6G++=GhK@W9u!N>q_#`!yBg&C7#p3p1A79+Ez?2T!k+32^MK`Doc&-Om8 z_mf#-J~Z%$CU zK282rO@qpTULr3m>nev?!3AP|&C@rRMro$o`U?IU96V8}Au9#8GicmoMH$#sr&T($ z3xTMekZ3Rfo3a(R0ilU#6PnhFgXX<`>lc>p%c$z}BAYkCwNoxB1GNh9cR0@)hdJ-S z{x9?Y2o{uKGL5GUP9TAC3i)hI<>aNeBiQZ#?e2cv$p8LqXXn|Y|92lx5C5;x^RZGY zDuINYPpTwq)HxFPSGBH_V)i%iOHwd~i2C))tA4(A1$=?0rB?XqM`0 zLwE%Srs=K^ec;e<{NEgk*_N?SrJ+;HD5LK)IyN^1d7O#1zZ(6ESGl(=4cNIPMqkw! zARTf62DMx>R%Uh08WG67n9;$eTI@_Xy4XGlaHy820Z8gpW(J=+o2{#C8ADU_{#;i* z$g)>hASV5YfBZu+<%`H%QyE1afMh$p7Y;S@|AvwKX4H5St2f;Uc!m?y#M6T@`gY7^ z@T;3>hTchHCohCoXJDL>fU@V9OfyvJv1Tep54r7pM|1{lQ-|A$xyZ9Sk#dn|U6Nk5 z`@xI6$g@X@e^?U#1uw=&nDKHsqrT+fj)jI0whsoY^nD&lU>Az-tpmcZV5xfvV4h3L z%QvR~9FRN8h&4^^-k|KW?WoC!^|V%v{fFOJzqJ~?CeNb!2nvFK8|dxb+q>G1h#C*Y zU<%OY{@rJPu9K9`&9yEczM4>O8wA73!`{BRi`{-f$L$?zJOP_MR|7w)WyC0FbdJOf z6?~XTg)*@F`RnWPsFRZc;as)<+tlI+L{$gtW#UMy#0Y#*ehE(Xh`Or<0u8#7lg@5$ z($QlIRjtb0Y9dv$zTRxAF;xmJ$ya`YH`(#&`%V|~Yx+)#t^L&0{_w%W>=gcLyMmE3 zn!_m+D3vg(Y%{~^!!W8fv!(SfL95H)LzBM3r%xNh;cx@y*rWz_vSO;W=iQBWn56sQ zdo8$SLypRJY|N2>DHL#j1DIsL={G93c$nIrSxZZSF^h23K=oJ08zU-`)7yxSIbv6O zhkD`H;O}r=6(1&)m@L(@=3r1yWz2Jw$fjul(b+bQuz2YGz1^Aga|6WO9 zc?}D8U`Nxhc43y!pzT!I6i4zt0)ZHTYyl#(KKsT6byk)3sk6$&(Lf@l=Nc z*pEJ)2^45b*`l4>cPD5a?5l)^GCajnU^K`MvALmG1TwYuUjMCzO3YL;r~Y98%hrEi zH17X-`r!6fO zZ}m=2%M$8>1cm~QYhO0r^XWWSwZeW?m!hjg>8=tm&vh01|F2nI`dicMN6b8$fp*tw0pauAzJ zDN5Dtrs=t@Ey0YKAFbuK$tgc_LqeRwUbxKWRO*QKdQqTfqk)MT8TeouF)2Rzk(3-q zVmH6!0~HYT=5%uaHaF++{iejmH5$d%KfRQOg)&_RjyWQtUT6^28z2!4noV{(Cip6( z9sS?!%F^r0GUmn8v#acXY)X6fQRLa+aT}y_8>Bw9uO@k;gfc|crEt|E%~`4WWKRVa zX2O%@(l^xwDp&QkQ)zwSOr?+OKXfi=?Y}q zs#$G$R1&p(_hZ$??>Zg9iSCW(v(D1Jvs0i7bZ>z)$@=}xGgseDp}CxsQ#M>-Tjgeg z6m|5>k}Z|hgi1g*MIz?Pm5c?RBU*@cZp(%>tL>a@y9o_%8_hKh-|KIb+_}_bZ%!v94F+5r5t5iA4 z)aA&43kC(6E=HZILkTm1nYk=giHrpiTM(7rC~v=zy1h(OFkx~;k~DI4a0DWoh$fmt zE)a`=?*{3~ruBjg@{Z7JQqm3n5q){}k4W9N6b?)oc_H?W9zQqQ)n|ybZP%2&%P34a zNRjfRDCx;4k26c}FI>khvX&2?9{m_FO+EXtqRGD70+gsT&i#8Zf@Coy`n1tC&eB9} z=`A_Rrd)ql-DdtOapmXwRo8n6uJtrf18eO}eqiZYVu=&kb>)e-8_)#2lUeF4kL`4? z8)^$V9=Rjg85dT@KYiM8f9u&>M|n48g}3Hvmhyw_51jV;<|0Q4p5Ww^rs$9sM0~qA zrx^;cN=mR8s4?QxDyN+^MMFk2pf@l$kSTrCgSpVo?8L=ovhU>mNm2gCbE=Q^qd%ecr!SAWC$!$ZG{iwW_a3 z`{(>5B1W@eo#vGq!?~QH34U*SH`>yd+s)|} z>QP>Hd^BpTVfi}D=8P(JSeuI)n7Du_*d%~?nQ$lyZApwz!lwTh7x-n4;_r|5D znxR(~OkmDql-XybY zrM)czK@RPpO|4O9ZA?h54uiy%YmLqjE^B*Ul#0n2HSeGww1lbkbde9GyH=_vvB*)~ z0j`!V1<+Yv^riD!17PJ?sBB1bYCcWz(DS@>CLNRCs)0HI{4TW9);Wmw6_iK5Y!aJG9tuT}07~8_P<0>ln6h8Ik5KC~N>=BLMb6bo?H3!J|f% z&QeDxL$U#fkfb<;0==1-6GjJxpge(DMe!y57OCD#~RIGFcA1V zSLg`ZjX}DNx&;)&>pSW2%7m<~C!PI|W&Id--yd2pmJPa-9$5QGa(0=zxgVN-JT%^i zWPhV|4z!RSjJLQwtj@_Do}T=fE3Kxc(K(~<7eG+$x`BjXXBt>8`lz*d<*2}koIxBO zz6K=%?YZCe0(}o(COk!}vBLLl{7nt7Xq#n4lL<=1K8Q}~g<(%rIShgMZR?C`v}Dn% zr9p(o3CCsihMivpQi|egk$BYN9t0&!`FvGfN5Z&SC`=d@IDwffP)dx*kRg~Zj9#G3 z{811&byH_(3f7qLu0f!Dl5Z?fHLleJ%J+Z?6}qtmthL$}Y zB_*If<#L?%DOM1MU^0^v*{+5naZTB~VL{*(#F;AHSCpFD6{|>7XaI^NxS(!KK1?YW(a^bkH>lewD_8XT9rcIF^l1<5+QHIGF3w7`#qg)Js zD<~`GZAB)OC1{B05HUtsTPb}8YA7erLwbeS3JPSX)R@!P{=NdC;PB$4TD9K-*E5{V zz%}H+(bKvgjTYZhr@v?L3d!mT$BNq^W%vp)z|a&+)vN6p7I&g!RI6^GT(okpdd``u zUP|Yva}M0JX_^OOIHpuc=I8l}rmb&(*4D4uJ&>xGRd-@;R*$?MJv@AU2ffotvT}vs3;5;K|?>cIW4+l?>e^p@p<@9cl>`&(>TSP6)Iap zJTB5H5-XnrSaSc%&a<7J`u#6YcfWp&|G$r?hX&2lahyUlr-UOB%=~A!oX(EE1#|cw zIIdpzsUsh9FhFYL-lMX5VALGGe_K(r4|aFHyz7+uzpH1-_-B~v8^>;T^OJs-jsF)r z_5A<4J3D)il+Vyy&JG6oG%t~}F%oA7C9O5eenIcyJkQWSH)lfRe1Ch} zn~mp;rbQz7b~T3^%3(O6Y`&e+q>w}^NZVJt+h%`w8*`y|)163}nV4r!svAvhc$wO2 zAC@hwT0Ur8|8d;fS@kPG%5JqwOe*!)^PSf?Y6N#yVZLtEOaO$dc8A_dwu{>9ua@I; z!H(8xHepup@mfDud5{KvQ@q=-d@3T#r)c0evP$sNRzasX83Y>K4PsPwO;mEyt{g@o z?zZ!=_1>vo=?zxmb-Sjx3tf;i#1ZL^J6ehSR^?DmSpEWEwsZ>QD`VNnX5?1zzJU!wF@Yn~WADRmt)u zy%lYe7jZ$b;IW|byaZR*v~DX0%V{2f9ephA#k0O#)?IFST&4e;N1^5}V{gqEb@N+q z&h_jZUHE1J(9w{;ay9G%XQq28!A#N^48croiV+kxML{&S3a@3&u z7NS(LyOO4;+2mD3siwg?w4z@>fdWLw=jU(E_kj&hEiF#PMs*wgK4|bwY15B7%yn1` z3U2x$p)iw`or0?z#ym$!Ic?A>hXBm6Nt*w+^4&X3((0F-YFEF?oALkF;ZO9LU0mf* z9fE0dBp^wF!O74BF+`H?h0P_+c2gZGum@)+CLLe<)WRe^IygH4hD-IVNvgn)n50JD z_G^xG9h52>f}vI@8Zh=kFixA7UT7GHwsU}gfkgmD*^i+sG#ZmH2imhgkgKJ+c8(_M zMgu23qe=)>mlDOF>b?c1>(!SkF{9I=_32nZ5e$r9_36`w&Rcp$Gn_0WN&5F|r9>p) zfO^%Mqzr1a&8=XR9j#trqz)MltJkMBvMU)fifIntALxSw`$hvMdC^9Xaw*_@;KxKO zFiK6gw!>Hv_`Y6qj>(GXmBqag_GZoUm{_V8Gxx!{vHk}nRW^s$jyiVrVf1D)iEfuA zaW$v!uhNNDUjmc78Om~`6D;YgQ&jrEvIYzK zde5}bbklBmx3pWT65yj|f0*xP`su)ay%hHA4%kby`_ub%^Vz^w2;c_X4rAW?} zB01}WWGSBGrFf2e;#rF7N4yl5Q>2D#@7WTmS`Pem7x2D#zF&^#`<{6EqP$#=@^W>Q zeX*Y{$9{Gz?0pG2UQWpIx(Ml{NhMEzt!^ZU{@-7AhJX3z|L=<+Y0K?aXwqpcf^?dp z*moM_%q?3?S`UjRXHU{SkJ%U|u{xaKuE++WQXISGP7U8!?gr;g>m)0=Z5pE zj$TMS`uxR4)9L&WOn6*^>1I+Rn|a-%Ru1jn&V0l5RQI00#;f*MPlA?s8^-;=dEfOS z<2F607`D4J-)Ljj=icG%e!BPnd5)5JLRmb+(^+i7sPr9nD?|Y-+5g|&+j&vH|84KZ zWBkv(JUvEoPSY3?fy+eDS|VVTr`)@lK_D*lZ3QjMZaGbX1*MnWIwOHvu{)beYmNv~ zVD&8&jUXTcRGU##MqMID(*q*#fo+)jRHY7X{Neaq@ z5-}uc%ut@;1ae2d+Ag>*VO575AW4CRpyXi&-SbGSAVkhvDFGU{IiA!d`8Oi@=7y}V zXpZXXS*whbH&4-NTVi@|bzq-9ZCKVwsTGSH?Spfg$zkwTEqiSMz}(;ac17``U{Y*$ z{Jp$ewrbB=vh)Iw_uQ=?Cu2+_P`M*tOC#0iy0Vb2&<%Y+e}+z3p~ZLJMIJ-<*29W0 z4IkRlLRR>9?+*W(wX)81vP9F>T#qU>Q2A#p+t}%dIC@-XCRB*;U#ItKaHqFpKiXSW z57^X8oByGn95(X0zJxA^7^>&vY(n;3qK#Vr>(vhx9p}}G+{ChtjI}TQ(xdW26TzLD zH_!75s-D{?qza(H}talAep-Rr-5FLs~S&VTPd z+j(67y`Kjxdk%7#%n*2m6GS-L=qPDp)AmL?Ti|~|Qb4uuND!#44UN4W&UtfTO(<5;B=Je>~^5o6w1^DjGIe2?YeA$PkDT*QiYd+ZKQfIpJ(zyx5Yhk#BXXuY}I$l-L+M zT~J#`1xF(sf$u2O*X$N7ryTK$t7V7VK8g&KNHv9nO+3(5=`~_oAjJ$N0;r_y`fqhh zNCFFvWH|#xe^MM^ZfrH30A3_BL)aF$o*~uz@j_D%6>OI?*H}suWndHIf$knY!?}bs z!4t6nIbw+fz4?6SOLd+5oHAq>Zvhp83rJGg2Yd#ZbQ&~7JOE=v&;%zKW&Z^7* zPKyX^Ql|c5(ZKC>NEA=5a9T)M47l=P0HF7XC74S>+{+=9oasTJx}_m%7itGKQZWN{ zNy}BJHPfkq=K3lC_)dkM(Ns<;bdDX{!P=*hpT~tz-kS>5N2$7^f=6t6NmI7M5) zi|jREbb&29{+$@&iMp=4B>m0!Unmhx(jn0y5P%_GWNLiqs~S);g9Ing4iLd0;W}8t z2$Xu0nV%B?fo6{ax8*m`z^FA-y0KX0WK_hNW=;`N={8fp!LIhwaed{D9(wG~QHr7R z{hVh1Oxe4JQn;q(MwxECG|3iF{LQM zQZHETMyS9ED%G2DZ&Qg|sudxmZBZPu)EW?*kN^`JuFnaoJW8p(1w`KogWv1xGzJwH zju69FP~a;BBtv*qmjH<)%mdSU14;A15?&rZkV7$*IHdG5I@h{NqEg+Z?!;0lvMObO zQk6*;W`Ln9th!Q4F`>eY6M!-}rp*4L%yzRonhY>$<ho-tgZ7b@mzV>11DeYI3dKE5zErtjt}fwQwC3sg)IaDeq%6gV_YoNxSpX&)S2iY*SQ*+e>?2s%1I>YRj8Zf|D)T z3uBlmIl5-D9#PImK@9hS90e}ZQN<|9C&8=1qWI6ZI!#fjyIdTUI5dGd#ByG(u1err zuE{E0A)WP?yDA)WULa{g5@jPz3ElnD@@a3S^tD{3Z#injlLCj^l5Z*INx_wsR~Y9? zUm7>^XQlBf(}dnzCirc&@|aM;o+$UAxFOz zh=@$0O(@H$HrLYOb%vi-M0+Ffy>zQ2+QYK>*0TZ^g*L86R)donXPCKK8Np-*90mcY zLdFa2#VNnw@3a6=dU!b!1+gvxl)WynKZeR?UbER8!2R(H<=nP7w`v>TqO_TB#a-go)GAb1)1*uG{ML z-Wc^ztl0Y937|6Y1wsz)_ZMnSb#-r%dRMDfNJHl#N%Y)QoMJy3b7Yk;Mx0 zJd@K+O0tFKveaFMv?POg&P`dTapQ#s;&Qjtn*=3@bI7n7ofC%1)Xr!Swzkh5_?vtH zV5V-!G!wx27?aYgSJmnjY=iU+*H~LYjg#jSXHZ{p<)kAsTrDTBLGE7eB~3= zv|^WnQ#L^p&dpT2f|FF8v#`yw{V`Xbs+b||iusagT_!sjH6z7NG^#OXOrG_sv~`d} zRQ8^g709i_mO@bqU&&p})jToPs^HlOoFjK-Xrxfj;iA%PbuE<899!SU(?=a%p3PD% zXKclMOUn_}w-?gVBKy;3ZNghL*;X|bQVgj?YmSiaG=jj3C^+3((Smue- za)edZamz^emQ;%=l77>c<4h}ZH}01rn(+N zjW{MM!v%~7UvH?-v~(bqR@wh%;wQU0I(>6_a(Em8wJ9d~Er*@K0rW9f`?#aaQ2~LG z(%`J>F9(#JdP2YuOw}B-5;_!Qt36%dvfn5*r5SWXR1>!P@YMkm=5Ua&O4y(P8G>9+ zr`%O~Q>7ZM>P9KPZ^?v~#){V!hdim`oizNntIRzibH}y600r=*(lgRzPAd!8giqO4 zlgrThdd@1BnHvPSG^vdg?e=|AXaV64$Vo5g~>N66yH1|19 zJ<(C~DM%zzMABkz-BwRxtYXpgKii#kou(MgKvzk{C(Acf=tZ?adCv>h|X(>nEvP?Nssm%nu4tcI7J7y<9&s8fg zkdMGyk|EAjr=jFS?3F6k zk4`DlH#aDk+u0G4j5ix<+I_Lx7;uibIw`bzY5}e_t1zmKqiSQ}^5pW>@fJ9J zb2>aZ{qFqa^!wx2$ETND;PvtO;SUF=mj~aTygIr3yAq-APA*T6FLe08fq`*$aDI7m z`1aMoIXHWJe)i_#SX*1YTbQ9t&eeEM3CC*JLG8@wS)ng3FwYsy8J6z3YVZUUYOPes zU!`%K^-{gs$ayhWa}}$uF;|+M(*&2(WUbWAKC4=dbvIg@lUXf$-;BVklBKfJD{Rki zf)i=ZLZ*}_?Bu{&9UGT|;65H!v36p;iCw#wb^EpI8eT=`il z@0;3D;2=dA9xI1V5jSO&^K#$9q7(oof~(!}@c7W$(=#8GfwAqf3@gNDML=~eoWrTV zhA(T|kbo*UK;4;NEwW*f;8Z%cdMiOXFnUcF!^{G;8akOl$s)u6WO~nBTJh3W^CA=V ziLT;iQRKP!t3%wBSw6%rQ}fE8(mw7Gcd}sSat6iEGwHhf!?nIy*ty+iz0hPvsa^$DE1lkE zyIOMv5KNHLCtCn23L#05ZbYt^L5=oXD7iy(f<-wV%iY_|(hlf2GpkU_Z`qb=M|!?` zs|9mWo^z3s${B!_U?NbpgBy)B{N}tO6h^I9s>N{uRd zw`_#1E?#N$YNf~#1G6S9=S+B_^|KuCdb}&%OiJFQXo5(pYs_et2A0ktn=4IZo$Hc| z)o?Btt9HxG$}!{|F*$t9BI;Ii-FUn(&PLUsh2%iR)6$v0cBIj98%tWW03V+oNxK>f zgHzuR&d!cck52w)Uv{BdEy(k1VZs;O&^`H0k#b$`B>?~~dsf&oVHEyqf_1AY&JfeF z%z8GrRn6ol7-cC3h$I>1TIr7&Ox_{ELG;T%qiT|p!Ngj-g%u{HRn4r=nMsep<`E@- zF5?B9F=~PTX#mu0T}@N@j22lcUHp;`Gdpn1wzD^@BE}a)!1v`wsG8Jk0!H9x1YpJ~ zU`UrX>!enTtFl^d>;(~O+NIpg+!|xMwK_&s$cow_wUlsKF;W>uQ3`UE(HrvG&Zh}} zkz^vsak(FFIA`}=%jKDBkpr@1hOexqtTq!R*N(5$3hnWGsfwhj(qRp%q>+!*nJ)Q~ zv*pHuf=yYV47VKow*+CQB2ouv%Vk#1tp|^ZnSm;;SPE0=-8d6|I#vs1&|9suG7N>4 z^kvbeChU2*H`-BE`Z(upZifjs+i({sJ$_m8F!u_yIp4SRysgiz*mi-CM}n1aZcgfe z43cR9rwB~x6=I|w)Mpk-Dp#F1+ckQO=>MHOf%yOP~i@B_-`Aj4w@b@DE7WJLm)Ot%{wPo98FrS|BgTK(Sm*n9~-ZhRaL zhaWdSg0H?h$Z=^14oIr<4S)5OECPNhPaeI)B#n`}kMf_JPeQK;wHi|-VwmID1klCf zg(~gVZYiQ6ef8C`UJ5$m;P?uwsY`>boT-^{obni%GI146>Eh}xS)sh*DuV5d(f5l6 zIz+DY2{DzM$_4L#vEY8d(-}B$@-a0a`e(shi2j#4cdL_NZk_6_rQpY6jKKLACM_+h zr`t+koFHZ#5|5tlk`~`x1ni?Xdu4Ow*$&V{}_r43o* zmX$c8QyxpVB}VTBg8}4A>(qOuwVs?7smi^ul%;Ep{oZV#fkSopLS^=DYp>mGi}gW` zSbDet@w%6H=6C}Bu+Gsr1Z6N!^~$4JBSV*sdn z-$Io7784pzAkNzA+V3qej0Cr#cR%-l&_PV7SZBLZJVjizHcNl*0bwbF{9^S^(_#qJ z4Tr6rXJl1Pi$i$a?=1)}2+ngPmg8^_@5Ef!>cSZ+-=F~tP$s2srvM8l?_@Hg*CZx% zOw&btMYAF}D3(`h>%vgQ%Bx=uVZB~ooG5u0XP6Z4<9CP=6qKQ5b!xoU8R*)0tP{@# z(C7~}SZ6Be7aY+!#7$Sv)YP%UajFUhAba0y2rf`ks2f2K^-2C#95j`Ruh3VU>asB_ zNDO&g@G=!p2z$SpEgiPBwwAP;;MFy6hgUm2*9;43zv4-#V{Z!Trj(UK;U=_{m2ZuG zEv#vW)@rNlIPD2H=R9VJ3x*RBbXIN&@uqxal!lakuX-!u6nKu}6ir}}t%B06wt8<6 zDq@&r^g7NNzQP%rq8RZ6W{qj;ZDHL&QV>k_Y(sK0R&lfHRFalLj+FLaQC51vTqCV( zB5!qy%05_eG*$Ih$7y;Sr7>Ud7^ZVfZcIlvtlRKoU7ghj;yR^1fw^9+!HCBxqq!Vy z>k|JU@H?koWjKQAstW&RNLFvn8fm^a%6fT&Yl3|D@^LG_;8wWm9=f^xxO0ALm&nxo z(^OJ9x_LW(jaX&cPWI+i!5G)2H>d2cT66M-0knVCitnxfdInlIDOd29ZC zD%9KQm2#nZAL_LP%@Au28|SsD5trYRj23B3Sv*^e8BXnb=|4B0EG<*VUTdg>wGUrH z#T(Q@vd|pxTiRWq0p%KJX(9(z8ljnA*Z?rzcdP0mUfjHWb64*>2M?*|FA{+~Rg$4jSDU+%=T;tCHh53oj3%#-NYcS8TLr#_C zIQpx6Jyci3N9xvxUk$7SHdSFBd}BkF-QO63RdO%O;_7N`6~~)XrmWFMjYTaXM-IDG zNI4PC<=%j++ibV0#BWt1JP8=M6Wy&UC6hhqsX0ujP&oqRFRsrm=@SIflJ;R|3+bPc zwNIXaW{g&i;oWgp8ynytO*#10$kh-Qg7O4rh>iT~rF785Fy%uF=2s_gp3Y+Fq!hTT-0Ay9oo%(G&ENf)bHsxk&Z(XWY>qw zK65Ir*2l$)BSLmc!`9aN#gE`~RE)lBEm|NRNyTi%2XipP(^8s7+h z4L5#i80*Fcc)s%**Ob331b)rvbVzf-M|1dosE+@91-y40{~P@ZuI=G1n5O!)O%A#A zn2ai@#=9kEXovw4fH~qi)wwKyq6%n|n(N%+VOD31UN$Ef3sX zCr26jtw3B#elISn6POCqtvFXUWvynjBak`F<4}CEJHJuq?BthI<(^u1!KQYxQ?CMYD!ZYH1McTnbkOFGVflM#Jm&Q~<);mQ+>O@`bF&J_E_rB9kSKMhKY>U( zc9GO@>M!2U*@}OSICh4(Y+7=)`=qxlKuhD1dXv4BVba#SUx{LCw zo|CQ8JN*#eOV0(O;+=-d8MAf`^#oikVr1N&64n(=G9evxe6wB-P;pP49uC@fd_x_6 zui^hCoT&d>zEzk$4lc@=a`LF61;h&9GL z^jGDt3*AwAfy6ekqw5_4y|t%$4{L}kbISo=G3Xwc5@bVVmeiS%Fk7(xRI)<-Ub*$E zmTP}Y$IC2>f#|QgsQ$faFEbsiofYK)1lx%AX4}IW;pXEBn9zcxEAzpE?c&GhX|;QL zCfsP1uuvE4u;6XYci}(m)^~S(fD1Q~h7iJs#}@baQiC1(_3A@F*Zcix1pgFQ8#`{U z8hrQF*Qv3KpgapOk3fA6Ol5CNJ1(Z&vn77bDZxlOJ$ znj8!}J)j7RKv~f-8!e?uT3;5p4_|PGFFjYQ7kC)`Z{QR)xyR^vl?OD(Uli&N7j(z?8WU%L~fjxj{x`zuHx6D}+G^9)$~ z!GwKC?~4jtEI_^ZPJGsB%1%$0r_YzqPoAH=fJ?r~ZZ)~i4Nr4Ul<>G)D5?^+CGxPv z*Og%pR2{mgpx~4->oQw^xyn-EuMR1loI}y7F)CJqK5co+(;b#Ul)dK|idy^CAm9FKD|7;^EDF=#3q28L#g=3WT+n#k~go_9&EXr)Y=wG)rq z33GsKZh4_;pJO4X4afVG_MkZX8~kbc*X8M7W|*IJnEjsm0uO?W%O{o?we2 z`rzyTC2|UWwE`c&a%(fNxCf?j!NDGY7g=INYsqeToA&81GEK#&mkyL1L&dtyCt*5H zJBRk^$;k=+Cp66eQZ}f;ECTpDnxXzU5K|g;4#TtoCL>vsmcmEsH}h)ip;6gkVZ)nB zI!B((5_hmSn6tOlPJs#qp*kxZ($ zBhk5Uk14XO3gR0lK^wbL>z<(T=^GA1rsQkY{2(tfJg;#{LSK{25IFfd-k|_fFJK?h;4wSsVID#lz4FBiB3Hy`d;MgRVk?Pf+DZw8loNP_lMYjv1hSWoCc5qm z(+`{C0--T%$PoyQp;8Hn)7%a=#-0b*VRaK@3;;iQ$iQ5rULl|g$<4XxIme1l(|$8` zV3=aU4=I!%)>H%b8rjC2l&)3Pz0{B~oPk@D#{aW_p1*y|fL;Cj>f^=x%U7#6;TNIb zs)DlXCJa;SdY&4++iLqAR?gr8_#EE%)JrZ%6W;UJvsd4PUr3*g;pyPoeE3U2N8vKZ z!H^n#R(C!35@NNY0kklb<%QZ#La(BNRltl7gUF&TBv;^qBo?T>=)SvUScNHJ9c;~b4)GvTjja8 z4ZS$KGgo)v7kE&q;5EY{pem|&11k?ucaF8?3QdzoSD)wm?tus&80Y!Tk~<;3TO|9j z86-&bF-!itEi2Fu=wRR~19}mY#22@53X>rkRKSBy-w6)fdo14?CM4*xspqJ1LpER> zMfA8>z|R!lj|cM1eK7Zz&{_km3Bous6H4a`Q1$#vR(wgbI4L!~2N2JV6&wmABl9U_ z5Mb>JO9$lShuy=L5xq}VQlgQw4wartm zhO(ND)lF2R$kP|IZDnG#Bf|_B{BYW*xa0?*-HmYskm`xQcOYC#7rb0zM%pB?1=8a} zE2%@j2od&Fd3!;JocBMkz+y{N&3ltXE12NxPyvAutv#le-v77Yq2omv56JZ{<2Gl1%c1 zyj@6RPMG#xKa)I6fZ9?L)9&gAI1ZynDP>z~pHkgys{7PZc53zsmhc1vFq5SpTKfWg zCyHg#|DbWODi)(@SbEI$%Iq_^i}p2yxvu*NV22gfXGNQMHiQiUYeI|a(|-)fE%9?T zy3`%8#=sG|PyE$S=YOQw1myT3dY>wz-UHTBfITZ-B7Z}&O^K%tI|7OuS*_lIEkGyd zo^9Bhh)<24w+|onkQ+UHd`hwy)`h6V8bh`$90-Dp3LYmr-B_|h|AX=&WA^23!Gpq4 zS9T&CO%mZgL|UH|lR&Y4lN6s4Ffx|Jhu0qTcQ)MX(+>10B^<(JW56@1!CfaE64}8| zsnN~t+4dW zO^_7gma94ZXQ2jaa#~RZZi;BnM2v=rpZk!*cOvsn^GmoBPs8;_-$OO*R;zbGaTwIj zC)VoZvu;jVn;BdlyA_L)-n7+g-6o_NJPh6CJ3bajgZEs_%1Asm3^_!uIQ879d_a1#0D;!3WE@ z1%|;(^L&;2m&<;>c;z^J>xO*bU*k=lhGB=%lrPUjSvBK#Tl6rh2^3kHr?KRbHQNF7 z1e?H{UaMPk`w86VVX9v99UtBVx|L%Ct)SP_f1@=#rV0(Ud{?F2<}uP#_tlm94yKhq zDEKumxEx6kIH-yBRxLWtg${~6v)8@|nYh1@4Z;3pw~5aNs(SyD4+(p9ac)C$y~{|! z?EU?d*~oa`(7|nSe8X+1Cv(7JVPfbt@8T^la6NmSZvmgjm>&E5;Z3JU0R}H^QD@yJ zHOJ>A=(UEPb_N-e$^&ibES$khznC6l1~VYk_4ivNEz34u9A&^@fpcw-uJ`9+Xw{WD z9ecFJBMJ}t&Dft!qWS&UlPALelFSk?cd1_=^*!7pcqn8(_|dm_akaiNaq+m7nkBDf zCCr`<-E!M1%{DVR!9|T-z`W}wSZd@mZ=gOmu|KOfr?4{#RF:`. + type: string + deleted: + description: Deleted is when the release was deleted. + format: date-time + type: string + digest: + description: |- + Digest is the checksum of the release object in storage. + It has the format of `:`. + type: string + firstDeployed: + description: FirstDeployed is when the release was first deployed. + format: date-time + type: string + lastDeployed: + description: LastDeployed is when the release was last deployed. + format: date-time + type: string + name: + description: Name is the name of the release. + type: string + namespace: + description: Namespace is the namespace the release is deployed + to. + type: string + ociDigest: + description: OCIDigest is the digest of the OCI artifact associated + with the release. + type: string + status: + description: Status is the current state of the release. + type: string + testHooks: + additionalProperties: + description: |- + TestHookStatus holds the status information for a test hook as observed + to be run by the controller. + properties: + lastCompleted: + description: LastCompleted is the time the test hook last + completed. + format: date-time + type: string + lastStarted: + description: LastStarted is the time the test hook was + last started. + format: date-time + type: string + phase: + description: Phase the test hook was observed to be in. + type: string + type: object + description: |- + TestHooks is the list of test hooks for the release as observed to be + run by the controller. + type: object + version: + description: Version is the version of the release object in + storage. + type: integer + required: + - chartName + - chartVersion + - configDigest + - digest + - firstDeployed + - lastDeployed + - name + - namespace + - status + - version + type: object + type: array + installFailures: + description: |- + InstallFailures is the install failure count against the latest desired + state. It is reset after a successful reconciliation. + format: int64 + type: integer + lastAttemptedConfigDigest: + description: |- + LastAttemptedConfigDigest is the digest for the config (better known as + "values") of the last reconciliation attempt. + type: string + lastAttemptedGeneration: + description: |- + LastAttemptedGeneration is the last generation the controller attempted + to reconcile. + format: int64 + type: integer + lastAttemptedReleaseAction: + description: |- + LastAttemptedReleaseAction is the last release action performed for this + HelmRelease. It is used to determine the active retry or remediation + strategy. + enum: + - install + - upgrade + type: string + lastAttemptedReleaseActionDuration: + description: |- + LastAttemptedReleaseActionDuration is the duration of the last + release action performed for this HelmRelease. + type: string + lastAttemptedRevision: + description: |- + LastAttemptedRevision is the Source revision of the last reconciliation + attempt. For OCIRepository sources, the 12 first characters of the digest are + appended to the chart version e.g. "1.2.3+1234567890ab". + type: string + lastAttemptedRevisionDigest: + description: |- + LastAttemptedRevisionDigest is the digest of the last reconciliation attempt. + This is only set for OCIRepository sources. + type: string + lastAttemptedValuesChecksum: + description: |- + LastAttemptedValuesChecksum is the SHA1 checksum for the values of the last + reconciliation attempt. + + Deprecated: Use LastAttemptedConfigDigest instead. + type: string + lastHandledForceAt: + description: |- + LastHandledForceAt holds the value of the most recent + force request value, so a change of the annotation value + can be detected. + type: string + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + lastHandledResetAt: + description: |- + LastHandledResetAt holds the value of the most recent reset request + value, so a change of the annotation value can be detected. + type: string + lastReleaseRevision: + description: |- + LastReleaseRevision is the revision of the last successful Helm release. + + Deprecated: Use History instead. + type: integer + observedCommonMetadataDigest: + description: |- + ObservedCommonMetadataDigest is the digest for the common metadata of + the last successful reconciliation attempt. + type: string + observedGeneration: + description: ObservedGeneration is the last observed generation. + format: int64 + type: integer + observedPostRenderersDigest: + description: |- + ObservedPostRenderersDigest is the digest for the post-renderers of + the last successful reconciliation attempt. + type: string + storageNamespace: + description: |- + StorageNamespace is the namespace of the Helm release storage for the + current release. + maxLength: 63 + minLength: 1 + type: string + upgradeFailures: + description: |- + UpgradeFailures is the upgrade failure count against the latest desired + state. It is reset after a successful reconciliation. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + deprecated: true + deprecationWarning: v2beta2 HelmRelease is deprecated, upgrade to v2 + name: v2beta2 + schema: + openAPIV3Schema: + description: HelmRelease is the Schema for the helmreleases API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: HelmReleaseSpec defines the desired state of a Helm release. + properties: + chart: + description: |- + Chart defines the template of the v1beta2.HelmChart that should be created + for this HelmRelease. + properties: + metadata: + description: ObjectMeta holds the template for metadata like labels + and annotations. + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations is an unstructured key value map stored with a resource that may be + set by external tools to store and retrieve arbitrary metadata. They are not + queryable and should be preserved when modifying objects. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + type: object + labels: + additionalProperties: + type: string + description: |- + Map of string keys and values that can be used to organize and categorize + (scope and select) objects. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + type: object + type: object + spec: + description: Spec holds the template for the v1beta2.HelmChartSpec + for this HelmRelease. + properties: + chart: + description: The name or path the Helm chart is available + at in the SourceRef. + maxLength: 2048 + minLength: 1 + type: string + ignoreMissingValuesFiles: + description: IgnoreMissingValuesFiles controls whether to + silently ignore missing values files rather than failing. + type: boolean + interval: + description: |- + Interval at which to check the v1.Source for updates. Defaults to + 'HelmReleaseSpec.Interval'. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + reconcileStrategy: + default: ChartVersion + description: |- + Determines what enables the creation of a new artifact. Valid values are + ('ChartVersion', 'Revision'). + See the documentation of the values for an explanation on their behavior. + Defaults to ChartVersion when omitted. + enum: + - ChartVersion + - Revision + type: string + sourceRef: + description: The name and namespace of the v1.Source the chart + is available at. + properties: + apiVersion: + description: APIVersion of the referent. + type: string + kind: + description: Kind of the referent. + enum: + - InternalNelmOperatorHelmRepository + - InternalNelmOperatorGitRepository + - InternalNelmOperatorBucket + type: string + name: + description: Name of the referent. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: Namespace of the referent. + maxLength: 63 + minLength: 1 + type: string + required: + - kind + - name + type: object + valuesFile: + description: |- + Alternative values file to use as the default chart values, expected to + be a relative path in the SourceRef. Deprecated in favor of ValuesFiles, + for backwards compatibility the file defined here is merged before the + ValuesFiles items. Ignored when omitted. + type: string + valuesFiles: + description: |- + Alternative list of values files to use as the chart values (values.yaml + is not included by default), expected to be a relative path in the SourceRef. + Values files are merged in the order of this list with the last file overriding + the first. Ignored when omitted. + items: + type: string + type: array + verify: + description: |- + Verify contains the secret name containing the trusted public keys + used to verify the signature and specifies which provider to use to check + whether OCI image is authentic. + This field is only supported for OCI sources. + Chart dependencies, which are not bundled in the umbrella chart artifact, + are not verified. + properties: + provider: + default: cosign + description: Provider specifies the technology used to + sign the OCI Helm chart. + enum: + - cosign + - notation + type: string + secretRef: + description: |- + SecretRef specifies the Kubernetes Secret containing the + trusted public keys. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - provider + type: object + version: + default: '*' + description: |- + Version semver expression, ignored for charts from v1beta2.GitRepository and + v1beta2.Bucket sources. Defaults to latest when omitted. + type: string + required: + - chart + - sourceRef + type: object + required: + - spec + type: object + chartRef: + description: |- + ChartRef holds a reference to a source controller resource containing the + Helm chart artifact. + + Note: this field is provisional to the v2 API, and not actively used + by v2beta2 HelmReleases. + properties: + apiVersion: + description: APIVersion of the referent. + type: string + kind: + description: Kind of the referent. + enum: + - InternalNelmOperatorOCIRepository + - InternalNelmOperatorHelmChart + type: string + name: + description: Name of the referent. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace of the referent, defaults to the namespace of the Kubernetes + resource object that contains the reference. + maxLength: 63 + minLength: 1 + type: string + required: + - kind + - name + type: object + dependsOn: + description: |- + DependsOn may contain a meta.NamespacedObjectReference slice with + references to HelmRelease resources that must be ready before this HelmRelease + can be reconciled. + items: + description: |- + NamespacedObjectReference contains enough information to locate the referenced Kubernetes resource object in any + namespace. + properties: + name: + description: Name of the referent. + type: string + namespace: + description: Namespace of the referent, when not specified it + acts as LocalObjectReference. + type: string + required: + - name + type: object + type: array + driftDetection: + description: |- + DriftDetection holds the configuration for detecting and handling + differences between the manifest in the Helm storage and the resources + currently existing in the cluster. + properties: + ignore: + description: |- + Ignore contains a list of rules for specifying which changes to ignore + during diffing. + items: + description: |- + IgnoreRule defines a rule to selectively disregard specific changes during + the drift detection process. + properties: + paths: + description: |- + Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from + consideration in a Kubernetes object. + items: + type: string + type: array + target: + description: |- + Target is a selector for specifying Kubernetes objects to which this + rule applies. + If Target is not set, the Paths will be ignored for all Kubernetes + objects within the manifest of the Helm release. + properties: + annotationSelector: + description: |- + AnnotationSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource annotations. + type: string + group: + description: |- + Group is the API group to select resources from. + Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + kind: + description: |- + Kind of the API Group to select resources from. + Together with Group and Version it is capable of unambiguously + identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + labelSelector: + description: |- + LabelSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource labels. + type: string + name: + description: Name to match resources with. + type: string + namespace: + description: Namespace to select resources from. + type: string + version: + description: |- + Version of the API Group to select resources from. + Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + type: object + required: + - paths + type: object + type: array + mode: + description: |- + Mode defines how differences should be handled between the Helm manifest + and the manifest currently applied to the cluster. + If not explicitly set, it defaults to DiffModeDisabled. + enum: + - enabled + - warn + - disabled + type: string + type: object + install: + description: Install holds the configuration for Helm install actions + for this HelmRelease. + properties: + crds: + description: |- + CRDs upgrade CRDs from the Helm Chart's crds directory according + to the CRD upgrade policy provided here. Valid values are `Skip`, + `Create` or `CreateReplace`. Default is `Create` and if omitted + CRDs are installed but not updated. + + Skip: do neither install nor replace (update) any CRDs. + + Create: new CRDs are created, existing CRDs are neither updated nor deleted. + + CreateReplace: new CRDs are created, existing CRDs are updated (replaced) + but not deleted. + + By default, CRDs are applied (installed) during Helm install action. + With this option users can opt in to CRD replace existing CRDs on Helm + install actions, which is not (yet) natively supported by Helm. + https://helm.sh/docs/chart_best_practices/custom_resource_definitions. + enum: + - Skip + - Create + - CreateReplace + type: string + createNamespace: + description: |- + CreateNamespace tells the Helm install action to create the + HelmReleaseSpec.TargetNamespace if it does not exist yet. + On uninstall, the namespace will not be garbage collected. + type: boolean + disableHooks: + description: DisableHooks prevents hooks from running during the + Helm install action. + type: boolean + disableOpenAPIValidation: + description: |- + DisableOpenAPIValidation prevents the Helm install action from validating + rendered templates against the Kubernetes OpenAPI Schema. + type: boolean + disableWait: + description: |- + DisableWait disables the waiting for resources to be ready after a Helm + install has been performed. + type: boolean + disableWaitForJobs: + description: |- + DisableWaitForJobs disables waiting for jobs to complete after a Helm + install has been performed. + type: boolean + remediation: + description: |- + Remediation holds the remediation configuration for when the Helm install + action for the HelmRelease fails. The default is to not perform any action. + properties: + ignoreTestFailures: + description: |- + IgnoreTestFailures tells the controller to skip remediation when the Helm + tests are run after an install action but fail. Defaults to + 'Test.IgnoreFailures'. + type: boolean + remediateLastFailure: + description: |- + RemediateLastFailure tells the controller to remediate the last failure, when + no retries remain. Defaults to 'false'. + type: boolean + retries: + description: |- + Retries is the number of retries that should be attempted on failures before + bailing. Remediation, using an uninstall, is performed between each attempt. + Defaults to '0', a negative integer equals to unlimited retries. + type: integer + type: object + replace: + description: |- + Replace tells the Helm install action to re-use the 'ReleaseName', but only + if that name is a deleted release which remains in the history. + type: boolean + skipCRDs: + description: |- + SkipCRDs tells the Helm install action to not install any CRDs. By default, + CRDs are installed if not already present. + + Deprecated use CRD policy (`crds`) attribute with value `Skip` instead. + type: boolean + timeout: + description: |- + Timeout is the time to wait for any individual Kubernetes operation (like + Jobs for hooks) during the performance of a Helm install action. Defaults to + 'HelmReleaseSpec.Timeout'. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + type: object + interval: + description: Interval at which to reconcile the Helm release. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + kubeConfig: + description: |- + KubeConfig for reconciling the HelmRelease on a remote cluster. + When used in combination with HelmReleaseSpec.ServiceAccountName, + forces the controller to act on behalf of that Service Account at the + target cluster. + If the --default-service-account flag is set, its value will be used as + a controller level fallback for when HelmReleaseSpec.ServiceAccountName + is empty. + properties: + configMapRef: + description: |- + ConfigMapRef holds an optional name of a ConfigMap that contains + the following keys: + + - `provider`: the provider to use. One of `aws`, `azure`, `gcp`, or + `generic`. Required. + - `cluster`: the fully qualified resource name of the Kubernetes + cluster in the cloud provider API. Not used by the `generic` + provider. Required when one of `address` or `ca.crt` is not set. + - `address`: the address of the Kubernetes API server. Required + for `generic`. For the other providers, if not specified, the + first address in the cluster resource will be used, and if + specified, it must match one of the addresses in the cluster + resource. + If audiences is not set, will be used as the audience for the + `generic` provider. + - `ca.crt`: the optional PEM-encoded CA certificate for the + Kubernetes API server. If not set, the controller will use the + CA certificate from the cluster resource. + - `audiences`: the optional audiences as a list of + line-break-separated strings for the Kubernetes ServiceAccount + token. Defaults to the `address` for the `generic` provider, or + to specific values for the other providers depending on the + provider. + - `serviceAccountName`: the optional name of the Kubernetes + ServiceAccount in the same namespace that should be used + for authentication. If not specified, the controller + ServiceAccount will be used. + + Mutually exclusive with SecretRef. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + secretRef: + description: |- + SecretRef holds an optional name of a secret that contains a key with + the kubeconfig file as the value. If no key is set, the key will default + to 'value'. Mutually exclusive with ConfigMapRef. + It is recommended that the kubeconfig is self-contained, and the secret + is regularly updated if credentials such as a cloud-access-token expire. + Cloud specific `cmd-path` auth helpers will not function without adding + binaries and credentials to the Pod that is responsible for reconciling + Kubernetes resources. Supported only for the generic provider. + properties: + key: + description: Key in the Secret, when not specified an implementation-specific + default key is used. + type: string + name: + description: Name of the Secret. + type: string + required: + - name + type: object + type: object + x-kubernetes-validations: + - message: exactly one of spec.kubeConfig.configMapRef or spec.kubeConfig.secretRef + must be specified + rule: has(self.configMapRef) || has(self.secretRef) + - message: exactly one of spec.kubeConfig.configMapRef or spec.kubeConfig.secretRef + must be specified + rule: '!has(self.configMapRef) || !has(self.secretRef)' + maxHistory: + description: |- + MaxHistory is the number of revisions saved by Helm for this HelmRelease. + Use '0' for an unlimited number of revisions; defaults to '5'. + type: integer + persistentClient: + description: |- + PersistentClient tells the controller to use a persistent Kubernetes + client for this release. When enabled, the client will be reused for the + duration of the reconciliation, instead of being created and destroyed + for each (step of a) Helm action. + + This can improve performance, but may cause issues with some Helm charts + that for example do create Custom Resource Definitions during installation + outside Helm's CRD lifecycle hooks, which are then not observed to be + available by e.g. post-install hooks. + + If not set, it defaults to true. + type: boolean + postRenderers: + description: |- + PostRenderers holds an array of Helm PostRenderers, which will be applied in order + of their definition. + items: + description: PostRenderer contains a Helm PostRenderer specification. + properties: + kustomize: + description: Kustomization to apply as PostRenderer. + properties: + images: + description: |- + Images is a list of (image name, new name, new tag or digest) + for changing image names, tags or digests. This can also be achieved with a + patch, but this operator is simpler to specify. + items: + description: Image contains an image name, a new name, + a new tag or digest, which will replace the original + name and tag. + properties: + digest: + description: |- + Digest is the value used to replace the original image tag. + If digest is present NewTag value is ignored. + type: string + name: + description: Name is a tag-less image name. + type: string + newName: + description: NewName is the value used to replace + the original name. + type: string + newTag: + description: NewTag is the value used to replace the + original tag. + type: string + required: + - name + type: object + type: array + patches: + description: |- + Strategic merge and JSON patches, defined as inline YAML objects, + capable of targeting objects based on kind, label and annotation selectors. + items: + description: |- + Patch contains an inline StrategicMerge or JSON6902 patch, and the target the patch should + be applied to. + properties: + patch: + description: |- + Patch contains an inline StrategicMerge patch or an inline JSON6902 patch with + an array of operation objects. + type: string + target: + description: Target points to the resources that the + patch document should be applied to. + properties: + annotationSelector: + description: |- + AnnotationSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource annotations. + type: string + group: + description: |- + Group is the API group to select resources from. + Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + kind: + description: |- + Kind of the API Group to select resources from. + Together with Group and Version it is capable of unambiguously + identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + labelSelector: + description: |- + LabelSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource labels. + type: string + name: + description: Name to match resources with. + type: string + namespace: + description: Namespace to select resources from. + type: string + version: + description: |- + Version of the API Group to select resources from. + Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + type: object + required: + - patch + type: object + type: array + patchesJson6902: + description: |- + JSON 6902 patches, defined as inline YAML objects. + + Deprecated: use Patches instead. + items: + description: JSON6902Patch contains a JSON6902 patch and + the target the patch should be applied to. + properties: + patch: + description: Patch contains the JSON6902 patch document + with an array of operation objects. + items: + description: |- + JSON6902 is a JSON6902 operation object. + https://datatracker.ietf.org/doc/html/rfc6902#section-4 + properties: + from: + description: |- + From contains a JSON-pointer value that references a location within the target document where the operation is + performed. The meaning of the value depends on the value of Op, and is NOT taken into account by all operations. + type: string + op: + description: |- + Op indicates the operation to perform. Its value MUST be one of "add", "remove", "replace", "move", "copy", or + "test". + https://datatracker.ietf.org/doc/html/rfc6902#section-4 + enum: + - test + - remove + - add + - replace + - move + - copy + type: string + path: + description: |- + Path contains the JSON-pointer value that references a location within the target document where the operation + is performed. The meaning of the value depends on the value of Op. + type: string + value: + description: |- + Value contains a valid JSON structure. The meaning of the value depends on the value of Op, and is NOT taken into + account by all operations. + x-kubernetes-preserve-unknown-fields: true + required: + - op + - path + type: object + type: array + target: + description: Target points to the resources that the + patch document should be applied to. + properties: + annotationSelector: + description: |- + AnnotationSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource annotations. + type: string + group: + description: |- + Group is the API group to select resources from. + Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + kind: + description: |- + Kind of the API Group to select resources from. + Together with Group and Version it is capable of unambiguously + identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + labelSelector: + description: |- + LabelSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource labels. + type: string + name: + description: Name to match resources with. + type: string + namespace: + description: Namespace to select resources from. + type: string + version: + description: |- + Version of the API Group to select resources from. + Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + type: object + required: + - patch + - target + type: object + type: array + patchesStrategicMerge: + description: |- + Strategic merge patches, defined as inline YAML objects. + + Deprecated: use Patches instead. + items: + x-kubernetes-preserve-unknown-fields: true + type: array + type: object + type: object + type: array + releaseName: + description: |- + ReleaseName used for the Helm release. Defaults to a composition of + '[TargetNamespace-]Name'. + maxLength: 53 + minLength: 1 + type: string + rollback: + description: Rollback holds the configuration for Helm rollback actions + for this HelmRelease. + properties: + cleanupOnFail: + description: |- + CleanupOnFail allows deletion of new resources created during the Helm + rollback action when it fails. + type: boolean + disableHooks: + description: DisableHooks prevents hooks from running during the + Helm rollback action. + type: boolean + disableWait: + description: |- + DisableWait disables the waiting for resources to be ready after a Helm + rollback has been performed. + type: boolean + disableWaitForJobs: + description: |- + DisableWaitForJobs disables waiting for jobs to complete after a Helm + rollback has been performed. + type: boolean + force: + description: Force forces resource updates through a replacement + strategy. + type: boolean + recreate: + description: Recreate performs pod restarts for the resource if + applicable. + type: boolean + timeout: + description: |- + Timeout is the time to wait for any individual Kubernetes operation (like + Jobs for hooks) during the performance of a Helm rollback action. Defaults to + 'HelmReleaseSpec.Timeout'. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + type: object + serviceAccountName: + description: |- + The name of the Kubernetes service account to impersonate + when reconciling this HelmRelease. + maxLength: 253 + minLength: 1 + type: string + storageNamespace: + description: |- + StorageNamespace used for the Helm storage. + Defaults to the namespace of the HelmRelease. + maxLength: 63 + minLength: 1 + type: string + suspend: + description: |- + Suspend tells the controller to suspend reconciliation for this HelmRelease, + it does not apply to already started reconciliations. Defaults to false. + type: boolean + targetNamespace: + description: |- + TargetNamespace to target when performing operations for the HelmRelease. + Defaults to the namespace of the HelmRelease. + maxLength: 63 + minLength: 1 + type: string + test: + description: Test holds the configuration for Helm test actions for + this HelmRelease. + properties: + enable: + description: |- + Enable enables Helm test actions for this HelmRelease after an Helm install + or upgrade action has been performed. + type: boolean + filters: + description: Filters is a list of tests to run or exclude from + running. + items: + description: Filter holds the configuration for individual Helm + test filters. + properties: + exclude: + description: Exclude specifies whether the named test should + be excluded. + type: boolean + name: + description: Name is the name of the test. + maxLength: 253 + minLength: 1 + type: string + required: + - name + type: object + type: array + ignoreFailures: + description: |- + IgnoreFailures tells the controller to skip remediation when the Helm tests + are run but fail. Can be overwritten for tests run after install or upgrade + actions in 'Install.IgnoreTestFailures' and 'Upgrade.IgnoreTestFailures'. + type: boolean + timeout: + description: |- + Timeout is the time to wait for any individual Kubernetes operation during + the performance of a Helm test action. Defaults to 'HelmReleaseSpec.Timeout'. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + type: object + timeout: + description: |- + Timeout is the time to wait for any individual Kubernetes operation (like Jobs + for hooks) during the performance of a Helm action. Defaults to '5m0s'. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + uninstall: + description: Uninstall holds the configuration for Helm uninstall + actions for this HelmRelease. + properties: + deletionPropagation: + default: background + description: |- + DeletionPropagation specifies the deletion propagation policy when + a Helm uninstall is performed. + enum: + - background + - foreground + - orphan + type: string + disableHooks: + description: DisableHooks prevents hooks from running during the + Helm rollback action. + type: boolean + disableWait: + description: |- + DisableWait disables waiting for all the resources to be deleted after + a Helm uninstall is performed. + type: boolean + keepHistory: + description: |- + KeepHistory tells Helm to remove all associated resources and mark the + release as deleted, but retain the release history. + type: boolean + timeout: + description: |- + Timeout is the time to wait for any individual Kubernetes operation (like + Jobs for hooks) during the performance of a Helm uninstall action. Defaults + to 'HelmReleaseSpec.Timeout'. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + type: object + upgrade: + description: Upgrade holds the configuration for Helm upgrade actions + for this HelmRelease. + properties: + cleanupOnFail: + description: |- + CleanupOnFail allows deletion of new resources created during the Helm + upgrade action when it fails. + type: boolean + crds: + description: |- + CRDs upgrade CRDs from the Helm Chart's crds directory according + to the CRD upgrade policy provided here. Valid values are `Skip`, + `Create` or `CreateReplace`. Default is `Skip` and if omitted + CRDs are neither installed nor upgraded. + + Skip: do neither install nor replace (update) any CRDs. + + Create: new CRDs are created, existing CRDs are neither updated nor deleted. + + CreateReplace: new CRDs are created, existing CRDs are updated (replaced) + but not deleted. + + By default, CRDs are not applied during Helm upgrade action. With this + option users can opt-in to CRD upgrade, which is not (yet) natively supported by Helm. + https://helm.sh/docs/chart_best_practices/custom_resource_definitions. + enum: + - Skip + - Create + - CreateReplace + type: string + disableHooks: + description: DisableHooks prevents hooks from running during the + Helm upgrade action. + type: boolean + disableOpenAPIValidation: + description: |- + DisableOpenAPIValidation prevents the Helm upgrade action from validating + rendered templates against the Kubernetes OpenAPI Schema. + type: boolean + disableWait: + description: |- + DisableWait disables the waiting for resources to be ready after a Helm + upgrade has been performed. + type: boolean + disableWaitForJobs: + description: |- + DisableWaitForJobs disables waiting for jobs to complete after a Helm + upgrade has been performed. + type: boolean + force: + description: Force forces resource updates through a replacement + strategy. + type: boolean + preserveValues: + description: |- + PreserveValues will make Helm reuse the last release's values and merge in + overrides from 'Values'. Setting this flag makes the HelmRelease + non-declarative. + type: boolean + remediation: + description: |- + Remediation holds the remediation configuration for when the Helm upgrade + action for the HelmRelease fails. The default is to not perform any action. + properties: + ignoreTestFailures: + description: |- + IgnoreTestFailures tells the controller to skip remediation when the Helm + tests are run after an upgrade action but fail. + Defaults to 'Test.IgnoreFailures'. + type: boolean + remediateLastFailure: + description: |- + RemediateLastFailure tells the controller to remediate the last failure, when + no retries remain. Defaults to 'false' unless 'Retries' is greater than 0. + type: boolean + retries: + description: |- + Retries is the number of retries that should be attempted on failures before + bailing. Remediation, using 'Strategy', is performed between each attempt. + Defaults to '0', a negative integer equals to unlimited retries. + type: integer + strategy: + description: Strategy to use for failure remediation. Defaults + to 'rollback'. + enum: + - rollback + - uninstall + type: string + type: object + timeout: + description: |- + Timeout is the time to wait for any individual Kubernetes operation (like + Jobs for hooks) during the performance of a Helm upgrade action. Defaults to + 'HelmReleaseSpec.Timeout'. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + type: object + values: + description: Values holds the values for this Helm release. + x-kubernetes-preserve-unknown-fields: true + valuesFrom: + description: |- + ValuesFrom holds references to resources containing Helm values for this HelmRelease, + and information about how they should be merged. + items: + description: |- + ValuesReference contains a reference to a resource containing Helm values, + and optionally the key they can be found at. + properties: + kind: + description: Kind of the values referent, valid values are ('Secret', + 'ConfigMap'). + enum: + - Secret + - ConfigMap + type: string + name: + description: |- + Name of the values referent. Should reside in the same namespace as the + referring resource. + maxLength: 253 + minLength: 1 + type: string + optional: + description: |- + Optional marks this ValuesReference as optional. When set, a not found error + for the values reference is ignored, but any ValuesKey, TargetPath or + transient error will still result in a reconciliation failure. + type: boolean + targetPath: + description: |- + TargetPath is the YAML dot notation path the value should be merged at. When + set, the ValuesKey is expected to be a single flat value. Defaults to 'None', + which results in the values getting merged at the root. + maxLength: 250 + pattern: ^([a-zA-Z0-9_\-.\\\/]|\[[0-9]{1,5}\])+$ + type: string + valuesKey: + description: |- + ValuesKey is the data key where the values.yaml or a specific value can be + found at. Defaults to 'values.yaml'. + maxLength: 253 + pattern: ^[\-._a-zA-Z0-9]+$ + type: string + required: + - kind + - name + type: object + type: array + required: + - interval + type: object + x-kubernetes-validations: + - message: either chart or chartRef must be set + rule: (has(self.chart) && !has(self.chartRef)) || (!has(self.chart) + && has(self.chartRef)) + status: + default: + observedGeneration: -1 + description: HelmReleaseStatus defines the observed state of a HelmRelease. + properties: + conditions: + description: Conditions holds the conditions for the HelmRelease. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + failures: + description: |- + Failures is the reconciliation failure count against the latest desired + state. It is reset after a successful reconciliation. + format: int64 + type: integer + helmChart: + description: |- + HelmChart is the namespaced name of the HelmChart resource created by + the controller for the HelmRelease. + type: string + history: + description: |- + History holds the history of Helm releases performed for this HelmRelease + up to the last successfully completed release. + items: + description: |- + Snapshot captures a point-in-time copy of the status information for a Helm release, + as managed by the controller. + properties: + apiVersion: + description: |- + APIVersion is the API version of the Snapshot. + Provisional: when the calculation method of the Digest field is changed, + this field will be used to distinguish between the old and new methods. + type: string + appVersion: + description: AppVersion is the chart app version of the release + object in storage. + type: string + chartName: + description: ChartName is the chart name of the release object + in storage. + type: string + chartVersion: + description: |- + ChartVersion is the chart version of the release object in + storage. + type: string + configDigest: + description: |- + ConfigDigest is the checksum of the config (better known as + "values") of the release object in storage. + It has the format of `:`. + type: string + deleted: + description: Deleted is when the release was deleted. + format: date-time + type: string + digest: + description: |- + Digest is the checksum of the release object in storage. + It has the format of `:`. + type: string + firstDeployed: + description: FirstDeployed is when the release was first deployed. + format: date-time + type: string + lastDeployed: + description: LastDeployed is when the release was last deployed. + format: date-time + type: string + name: + description: Name is the name of the release. + type: string + namespace: + description: Namespace is the namespace the release is deployed + to. + type: string + ociDigest: + description: OCIDigest is the digest of the OCI artifact associated + with the release. + type: string + status: + description: Status is the current state of the release. + type: string + testHooks: + additionalProperties: + description: |- + TestHookStatus holds the status information for a test hook as observed + to be run by the controller. + properties: + lastCompleted: + description: LastCompleted is the time the test hook last + completed. + format: date-time + type: string + lastStarted: + description: LastStarted is the time the test hook was + last started. + format: date-time + type: string + phase: + description: Phase the test hook was observed to be in. + type: string + type: object + description: |- + TestHooks is the list of test hooks for the release as observed to be + run by the controller. + type: object + version: + description: Version is the version of the release object in + storage. + type: integer + required: + - chartName + - chartVersion + - configDigest + - digest + - firstDeployed + - lastDeployed + - name + - namespace + - status + - version + type: object + type: array + installFailures: + description: |- + InstallFailures is the install failure count against the latest desired + state. It is reset after a successful reconciliation. + format: int64 + type: integer + lastAppliedRevision: + description: |- + LastAppliedRevision is the revision of the last successfully applied + source. + + Deprecated: the revision can now be found in the History. + type: string + lastAttemptedConfigDigest: + description: |- + LastAttemptedConfigDigest is the digest for the config (better known as + "values") of the last reconciliation attempt. + type: string + lastAttemptedGeneration: + description: |- + LastAttemptedGeneration is the last generation the controller attempted + to reconcile. + format: int64 + type: integer + lastAttemptedReleaseAction: + description: |- + LastAttemptedReleaseAction is the last release action performed for this + HelmRelease. It is used to determine the active remediation strategy. + enum: + - install + - upgrade + type: string + lastAttemptedRevision: + description: |- + LastAttemptedRevision is the Source revision of the last reconciliation + attempt. For OCIRepository sources, the 12 first characters of the digest are + appended to the chart version e.g. "1.2.3+1234567890ab". + type: string + lastAttemptedRevisionDigest: + description: |- + LastAttemptedRevisionDigest is the digest of the last reconciliation attempt. + This is only set for OCIRepository sources. + type: string + lastAttemptedValuesChecksum: + description: |- + LastAttemptedValuesChecksum is the SHA1 checksum for the values of the last + reconciliation attempt. + + Deprecated: Use LastAttemptedConfigDigest instead. + type: string + lastHandledForceAt: + description: |- + LastHandledForceAt holds the value of the most recent force request + value, so a change of the annotation value can be detected. + type: string + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + lastHandledResetAt: + description: |- + LastHandledResetAt holds the value of the most recent reset request + value, so a change of the annotation value can be detected. + type: string + lastReleaseRevision: + description: |- + LastReleaseRevision is the revision of the last successful Helm release. + + Deprecated: Use History instead. + type: integer + observedGeneration: + description: ObservedGeneration is the last observed generation. + format: int64 + type: integer + observedPostRenderersDigest: + description: |- + ObservedPostRenderersDigest is the digest for the post-renderers of + the last successful reconciliation attempt. + type: string + storageNamespace: + description: |- + StorageNamespace is the namespace of the Helm release storage for the + current release. + maxLength: 63 + minLength: 1 + type: string + upgradeFailures: + description: |- + UpgradeFailures is the upgrade failure count against the latest desired + state. It is reset after a successful reconciliation. + format: int64 + type: integer + type: object + type: object + served: true + storage: false + subresources: + status: {} diff --git a/crds/embedded/nelm-source-controller.yaml b/crds/embedded/nelm-source-controller.yaml new file mode 100644 index 0000000..482118d --- /dev/null +++ b/crds/embedded/nelm-source-controller.yaml @@ -0,0 +1,4102 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + labels: + backup.deckhouse.io/cluster-config: "true" + heritage: deckhouse + module: operator-helm + name: internalnelmoperatorbuckets.source.internal.operator-helm.deckhouse.io +spec: + group: source.internal.operator-helm.deckhouse.io + names: + kind: InternalNelmOperatorBucket + listKind: InternalNelmOperatorBucketList + plural: internalnelmoperatorbuckets + singular: internalnelmoperatorbucket + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.endpoint + name: Endpoint + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + name: v1 + schema: + openAPIV3Schema: + description: Bucket is the Schema for the buckets API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BucketSpec specifies the required configuration to produce an Artifact for + an object storage bucket. + properties: + bucketName: + description: BucketName is the name of the object storage bucket. + type: string + certSecretRef: + description: |- + CertSecretRef can be given the name of a Secret containing + either or both of + + - a PEM-encoded client certificate (`tls.crt`) and private + key (`tls.key`); + - a PEM-encoded CA certificate (`ca.crt`) + + and whichever are supplied, will be used for connecting to the + bucket. The client cert and key are useful if you are + authenticating with a certificate; the CA cert is useful if + you are using a self-signed server certificate. The Secret must + be of type `Opaque` or `kubernetes.io/tls`. + + This field is only supported for the `generic` provider. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + endpoint: + description: Endpoint is the object storage address the BucketName + is located at. + type: string + ignore: + description: |- + Ignore overrides the set of excluded patterns in the .sourceignore format + (which is the same as .gitignore). If not provided, a default will be used, + consult the documentation for your version to find out what those are. + type: string + insecure: + description: Insecure allows connecting to a non-TLS HTTP Endpoint. + type: boolean + interval: + description: |- + Interval at which the Bucket Endpoint is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + prefix: + description: Prefix to use for server-side filtering of files in the + Bucket. + type: string + provider: + default: generic + description: |- + Provider of the object storage bucket. + Defaults to 'generic', which expects an S3 (API) compatible object + storage. + enum: + - generic + - aws + - gcp + - azure + type: string + proxySecretRef: + description: |- + ProxySecretRef specifies the Secret containing the proxy configuration + to use while communicating with the Bucket server. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + region: + description: Region of the Endpoint where the BucketName is located + in. + type: string + secretRef: + description: |- + SecretRef specifies the Secret containing authentication credentials + for the Bucket. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + serviceAccountName: + description: |- + ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate + the bucket. This field is only supported for the 'gcp' and 'aws' providers. + For more information about workload identity: + https://fluxcd.io/flux/components/source/buckets/#workload-identity + type: string + sts: + description: |- + STS specifies the required configuration to use a Security Token + Service for fetching temporary credentials to authenticate in a + Bucket provider. + + This field is only supported for the `aws` and `generic` providers. + properties: + certSecretRef: + description: |- + CertSecretRef can be given the name of a Secret containing + either or both of + + - a PEM-encoded client certificate (`tls.crt`) and private + key (`tls.key`); + - a PEM-encoded CA certificate (`ca.crt`) + + and whichever are supplied, will be used for connecting to the + STS endpoint. The client cert and key are useful if you are + authenticating with a certificate; the CA cert is useful if + you are using a self-signed server certificate. The Secret must + be of type `Opaque` or `kubernetes.io/tls`. + + This field is only supported for the `ldap` provider. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + endpoint: + description: |- + Endpoint is the HTTP/S endpoint of the Security Token Service from + where temporary credentials will be fetched. + pattern: ^(http|https)://.*$ + type: string + provider: + description: Provider of the Security Token Service. + enum: + - aws + - ldap + type: string + secretRef: + description: |- + SecretRef specifies the Secret containing authentication credentials + for the STS endpoint. This Secret must contain the fields `username` + and `password` and is supported only for the `ldap` provider. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - endpoint + - provider + type: object + suspend: + description: |- + Suspend tells the controller to suspend the reconciliation of this + Bucket. + type: boolean + timeout: + default: 60s + description: Timeout for fetch operations, defaults to 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + required: + - bucketName + - endpoint + - interval + type: object + x-kubernetes-validations: + - message: STS configuration is only supported for the 'aws' and 'generic' + Bucket providers + rule: self.provider == 'aws' || self.provider == 'generic' || !has(self.sts) + - message: '''aws'' is the only supported STS provider for the ''aws'' + Bucket provider' + rule: self.provider != 'aws' || !has(self.sts) || self.sts.provider + == 'aws' + - message: '''ldap'' is the only supported STS provider for the ''generic'' + Bucket provider' + rule: self.provider != 'generic' || !has(self.sts) || self.sts.provider + == 'ldap' + - message: spec.sts.secretRef is not required for the 'aws' STS provider + rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.secretRef)' + - message: spec.sts.certSecretRef is not required for the 'aws' STS provider + rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.certSecretRef)' + - message: ServiceAccountName is not supported for the 'generic' Bucket + provider + rule: self.provider != 'generic' || !has(self.serviceAccountName) + - message: cannot set both .spec.secretRef and .spec.serviceAccountName + rule: '!has(self.secretRef) || !has(self.serviceAccountName)' + status: + default: + observedGeneration: -1 + description: BucketStatus records the observed state of a Bucket. + properties: + artifact: + description: Artifact represents the last successful Bucket reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the Bucket. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedGeneration: + description: ObservedGeneration is the last observed generation of + the Bucket object. + format: int64 + type: integer + observedIgnore: + description: |- + ObservedIgnore is the observed exclusion patterns used for constructing + the source artifact. + type: string + url: + description: |- + URL is the dynamic fetch link for the latest Artifact. + It is provided on a "best effort" basis, and using the precise + BucketStatus.Artifact data is recommended. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.endpoint + name: Endpoint + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + deprecated: true + deprecationWarning: v1beta2 Bucket is deprecated, upgrade to v1 + name: v1beta2 + schema: + openAPIV3Schema: + description: Bucket is the Schema for the buckets API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BucketSpec specifies the required configuration to produce an Artifact for + an object storage bucket. + properties: + accessFrom: + description: |- + AccessFrom specifies an Access Control List for allowing cross-namespace + references to this object. + NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092 + properties: + namespaceSelectors: + description: |- + NamespaceSelectors is the list of namespace selectors to which this ACL applies. + Items in this list are evaluated using a logical OR operation. + items: + description: |- + NamespaceSelector selects the namespaces to which this ACL applies. + An empty map of MatchLabels matches all namespaces in a cluster. + properties: + matchLabels: + additionalProperties: + type: string + description: |- + MatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + type: array + required: + - namespaceSelectors + type: object + bucketName: + description: BucketName is the name of the object storage bucket. + type: string + certSecretRef: + description: |- + CertSecretRef can be given the name of a Secret containing + either or both of + + - a PEM-encoded client certificate (`tls.crt`) and private + key (`tls.key`); + - a PEM-encoded CA certificate (`ca.crt`) + + and whichever are supplied, will be used for connecting to the + bucket. The client cert and key are useful if you are + authenticating with a certificate; the CA cert is useful if + you are using a self-signed server certificate. The Secret must + be of type `Opaque` or `kubernetes.io/tls`. + + This field is only supported for the `generic` provider. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + endpoint: + description: Endpoint is the object storage address the BucketName + is located at. + type: string + ignore: + description: |- + Ignore overrides the set of excluded patterns in the .sourceignore format + (which is the same as .gitignore). If not provided, a default will be used, + consult the documentation for your version to find out what those are. + type: string + insecure: + description: Insecure allows connecting to a non-TLS HTTP Endpoint. + type: boolean + interval: + description: |- + Interval at which the Bucket Endpoint is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + prefix: + description: Prefix to use for server-side filtering of files in the + Bucket. + type: string + provider: + default: generic + description: |- + Provider of the object storage bucket. + Defaults to 'generic', which expects an S3 (API) compatible object + storage. + enum: + - generic + - aws + - gcp + - azure + type: string + proxySecretRef: + description: |- + ProxySecretRef specifies the Secret containing the proxy configuration + to use while communicating with the Bucket server. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + region: + description: Region of the Endpoint where the BucketName is located + in. + type: string + secretRef: + description: |- + SecretRef specifies the Secret containing authentication credentials + for the Bucket. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + sts: + description: |- + STS specifies the required configuration to use a Security Token + Service for fetching temporary credentials to authenticate in a + Bucket provider. + + This field is only supported for the `aws` and `generic` providers. + properties: + certSecretRef: + description: |- + CertSecretRef can be given the name of a Secret containing + either or both of + + - a PEM-encoded client certificate (`tls.crt`) and private + key (`tls.key`); + - a PEM-encoded CA certificate (`ca.crt`) + + and whichever are supplied, will be used for connecting to the + STS endpoint. The client cert and key are useful if you are + authenticating with a certificate; the CA cert is useful if + you are using a self-signed server certificate. The Secret must + be of type `Opaque` or `kubernetes.io/tls`. + + This field is only supported for the `ldap` provider. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + endpoint: + description: |- + Endpoint is the HTTP/S endpoint of the Security Token Service from + where temporary credentials will be fetched. + pattern: ^(http|https)://.*$ + type: string + provider: + description: Provider of the Security Token Service. + enum: + - aws + - ldap + type: string + secretRef: + description: |- + SecretRef specifies the Secret containing authentication credentials + for the STS endpoint. This Secret must contain the fields `username` + and `password` and is supported only for the `ldap` provider. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - endpoint + - provider + type: object + suspend: + description: |- + Suspend tells the controller to suspend the reconciliation of this + Bucket. + type: boolean + timeout: + default: 60s + description: Timeout for fetch operations, defaults to 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + required: + - bucketName + - endpoint + - interval + type: object + x-kubernetes-validations: + - message: STS configuration is only supported for the 'aws' and 'generic' + Bucket providers + rule: self.provider == 'aws' || self.provider == 'generic' || !has(self.sts) + - message: '''aws'' is the only supported STS provider for the ''aws'' + Bucket provider' + rule: self.provider != 'aws' || !has(self.sts) || self.sts.provider + == 'aws' + - message: '''ldap'' is the only supported STS provider for the ''generic'' + Bucket provider' + rule: self.provider != 'generic' || !has(self.sts) || self.sts.provider + == 'ldap' + - message: spec.sts.secretRef is not required for the 'aws' STS provider + rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.secretRef)' + - message: spec.sts.certSecretRef is not required for the 'aws' STS provider + rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.certSecretRef)' + status: + default: + observedGeneration: -1 + description: BucketStatus records the observed state of a Bucket. + properties: + artifact: + description: Artifact represents the last successful Bucket reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the Bucket. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedGeneration: + description: ObservedGeneration is the last observed generation of + the Bucket object. + format: int64 + type: integer + observedIgnore: + description: |- + ObservedIgnore is the observed exclusion patterns used for constructing + the source artifact. + type: string + url: + description: |- + URL is the dynamic fetch link for the latest Artifact. + It is provided on a "best effort" basis, and using the precise + BucketStatus.Artifact data is recommended. + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + labels: + backup.deckhouse.io/cluster-config: "true" + heritage: deckhouse + module: operator-helm + name: internalnelmoperatorexternalartifacts.source.internal.operator-helm.deckhouse.io +spec: + group: source.internal.operator-helm.deckhouse.io + names: + kind: InternalNelmOperatorExternalArtifact + listKind: InternalNelmOperatorExternalArtifactList + plural: internalnelmoperatorexternalartifacts + singular: internalnelmoperatorexternalartifact + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + - jsonPath: .spec.sourceRef.name + name: Source + type: string + name: v1 + schema: + openAPIV3Schema: + description: ExternalArtifact is the Schema for the external artifacts API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ExternalArtifactSpec defines the desired state of ExternalArtifact + properties: + sourceRef: + description: |- + SourceRef points to the Kubernetes custom resource for + which the artifact is generated. + properties: + apiVersion: + description: API version of the referent, if not specified the + Kubernetes preferred version will be used. + type: string + kind: + description: Kind of the referent. + type: string + name: + description: Name of the referent. + type: string + namespace: + description: Namespace of the referent, when not specified it + acts as LocalObjectReference. + type: string + required: + - kind + - name + type: object + type: object + status: + description: ExternalArtifactStatus defines the observed state of ExternalArtifact + properties: + artifact: + description: Artifact represents the output of an ExternalArtifact + reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the ExternalArtifact. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + labels: + backup.deckhouse.io/cluster-config: "true" + heritage: deckhouse + module: operator-helm + name: internalnelmoperatorgitrepositories.source.internal.operator-helm.deckhouse.io +spec: + group: source.internal.operator-helm.deckhouse.io + names: + kind: InternalNelmOperatorGitRepository + listKind: InternalNelmOperatorGitRepositoryList + plural: internalnelmoperatorgitrepositories + shortNames: + - intnelmopgitrepo + singular: internalnelmoperatorgitrepository + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + name: v1 + schema: + openAPIV3Schema: + description: GitRepository is the Schema for the gitrepositories API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + GitRepositorySpec specifies the required configuration to produce an + Artifact for a Git repository. + properties: + ignore: + description: |- + Ignore overrides the set of excluded patterns in the .sourceignore format + (which is the same as .gitignore). If not provided, a default will be used, + consult the documentation for your version to find out what those are. + type: string + include: + description: |- + Include specifies a list of GitRepository resources which Artifacts + should be included in the Artifact produced for this GitRepository. + items: + description: |- + GitRepositoryInclude specifies a local reference to a GitRepository which + Artifact (sub-)contents must be included, and where they should be placed. + properties: + fromPath: + description: |- + FromPath specifies the path to copy contents from, defaults to the root + of the Artifact. + type: string + repository: + description: |- + GitRepositoryRef specifies the GitRepository which Artifact contents + must be included. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + toPath: + description: |- + ToPath specifies the path to copy contents to, defaults to the name of + the GitRepositoryRef. + type: string + required: + - repository + type: object + type: array + interval: + description: |- + Interval at which the GitRepository URL is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + provider: + description: |- + Provider used for authentication, can be 'azure', 'github', 'generic'. + When not specified, defaults to 'generic'. + enum: + - generic + - azure + - github + type: string + proxySecretRef: + description: |- + ProxySecretRef specifies the Secret containing the proxy configuration + to use while communicating with the Git server. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + recurseSubmodules: + description: |- + RecurseSubmodules enables the initialization of all submodules within + the GitRepository as cloned from the URL, using their default settings. + type: boolean + ref: + description: |- + Reference specifies the Git reference to resolve and monitor for + changes, defaults to the 'master' branch. + properties: + branch: + description: Branch to check out, defaults to 'master' if no other + field is defined. + type: string + commit: + description: |- + Commit SHA to check out, takes precedence over all reference fields. + + This can be combined with Branch to shallow clone the branch, in which + the commit is expected to exist. + type: string + name: + description: |- + Name of the reference to check out; takes precedence over Branch, Tag and SemVer. + + It must be a valid Git reference: https://git-scm.com/docs/git-check-ref-format#_description + Examples: "refs/heads/main", "refs/tags/v0.1.0", "refs/pull/420/head", "refs/merge-requests/1/head" + type: string + semver: + description: SemVer tag expression to check out, takes precedence + over Tag. + type: string + tag: + description: Tag to check out, takes precedence over Branch. + type: string + type: object + secretRef: + description: |- + SecretRef specifies the Secret containing authentication credentials for + the GitRepository. + For HTTPS repositories the Secret must contain 'username' and 'password' + fields for basic auth or 'bearerToken' field for token auth. + For SSH repositories the Secret must contain 'identity' + and 'known_hosts' fields. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + serviceAccountName: + description: |- + ServiceAccountName is the name of the Kubernetes ServiceAccount used to + authenticate to the GitRepository. This field is only supported for 'azure' provider. + type: string + sparseCheckout: + description: |- + SparseCheckout specifies a list of directories to checkout when cloning + the repository. If specified, only these directories are included in the + Artifact produced for this GitRepository. + items: + type: string + type: array + suspend: + description: |- + Suspend tells the controller to suspend the reconciliation of this + GitRepository. + type: boolean + timeout: + default: 60s + description: Timeout for Git operations like cloning, defaults to + 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + url: + description: URL specifies the Git repository URL, it can be an HTTP/S + or SSH address. + pattern: ^(http|https|ssh)://.*$ + type: string + verify: + description: |- + Verification specifies the configuration to verify the Git commit + signature(s). + properties: + mode: + default: HEAD + description: |- + Mode specifies which Git object(s) should be verified. + + The variants "head" and "HEAD" both imply the same thing, i.e. verify + the commit that the HEAD of the Git repository points to. The variant + "head" solely exists to ensure backwards compatibility. + enum: + - head + - HEAD + - Tag + - TagAndHEAD + type: string + secretRef: + description: |- + SecretRef specifies the Secret containing the public keys of trusted Git + authors. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - secretRef + type: object + required: + - interval + - url + type: object + x-kubernetes-validations: + - message: serviceAccountName can only be set when provider is 'azure' + rule: '!has(self.serviceAccountName) || (has(self.provider) && self.provider + == ''azure'')' + status: + default: + observedGeneration: -1 + description: GitRepositoryStatus records the observed state of a Git repository. + properties: + artifact: + description: Artifact represents the last successful GitRepository + reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the GitRepository. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + includedArtifacts: + description: |- + IncludedArtifacts contains a list of the last successfully included + Artifacts as instructed by GitRepositorySpec.Include. + items: + description: Artifact represents the output of a Source reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of + ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI + annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedGeneration: + description: |- + ObservedGeneration is the last observed generation of the GitRepository + object. + format: int64 + type: integer + observedIgnore: + description: |- + ObservedIgnore is the observed exclusion patterns used for constructing + the source artifact. + type: string + observedInclude: + description: |- + ObservedInclude is the observed list of GitRepository resources used to + produce the current Artifact. + items: + description: |- + GitRepositoryInclude specifies a local reference to a GitRepository which + Artifact (sub-)contents must be included, and where they should be placed. + properties: + fromPath: + description: |- + FromPath specifies the path to copy contents from, defaults to the root + of the Artifact. + type: string + repository: + description: |- + GitRepositoryRef specifies the GitRepository which Artifact contents + must be included. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + toPath: + description: |- + ToPath specifies the path to copy contents to, defaults to the name of + the GitRepositoryRef. + type: string + required: + - repository + type: object + type: array + observedRecurseSubmodules: + description: |- + ObservedRecurseSubmodules is the observed resource submodules + configuration used to produce the current Artifact. + type: boolean + observedSparseCheckout: + description: |- + ObservedSparseCheckout is the observed list of directories used to + produce the current Artifact. + items: + type: string + type: array + sourceVerificationMode: + description: |- + SourceVerificationMode is the last used verification mode indicating + which Git object(s) have been verified. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + deprecated: true + deprecationWarning: v1beta2 GitRepository is deprecated, upgrade to v1 + name: v1beta2 + schema: + openAPIV3Schema: + description: GitRepository is the Schema for the gitrepositories API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + GitRepositorySpec specifies the required configuration to produce an + Artifact for a Git repository. + properties: + accessFrom: + description: |- + AccessFrom specifies an Access Control List for allowing cross-namespace + references to this object. + NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092 + properties: + namespaceSelectors: + description: |- + NamespaceSelectors is the list of namespace selectors to which this ACL applies. + Items in this list are evaluated using a logical OR operation. + items: + description: |- + NamespaceSelector selects the namespaces to which this ACL applies. + An empty map of MatchLabels matches all namespaces in a cluster. + properties: + matchLabels: + additionalProperties: + type: string + description: |- + MatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + type: array + required: + - namespaceSelectors + type: object + gitImplementation: + default: go-git + description: |- + GitImplementation specifies which Git client library implementation to + use. Defaults to 'go-git', valid values are ('go-git', 'libgit2'). + Deprecated: gitImplementation is deprecated now that 'go-git' is the + only supported implementation. + enum: + - go-git + - libgit2 + type: string + ignore: + description: |- + Ignore overrides the set of excluded patterns in the .sourceignore format + (which is the same as .gitignore). If not provided, a default will be used, + consult the documentation for your version to find out what those are. + type: string + include: + description: |- + Include specifies a list of GitRepository resources which Artifacts + should be included in the Artifact produced for this GitRepository. + items: + description: |- + GitRepositoryInclude specifies a local reference to a GitRepository which + Artifact (sub-)contents must be included, and where they should be placed. + properties: + fromPath: + description: |- + FromPath specifies the path to copy contents from, defaults to the root + of the Artifact. + type: string + repository: + description: |- + GitRepositoryRef specifies the GitRepository which Artifact contents + must be included. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + toPath: + description: |- + ToPath specifies the path to copy contents to, defaults to the name of + the GitRepositoryRef. + type: string + required: + - repository + type: object + type: array + interval: + description: Interval at which to check the GitRepository for updates. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + recurseSubmodules: + description: |- + RecurseSubmodules enables the initialization of all submodules within + the GitRepository as cloned from the URL, using their default settings. + type: boolean + ref: + description: |- + Reference specifies the Git reference to resolve and monitor for + changes, defaults to the 'master' branch. + properties: + branch: + description: Branch to check out, defaults to 'master' if no other + field is defined. + type: string + commit: + description: |- + Commit SHA to check out, takes precedence over all reference fields. + + This can be combined with Branch to shallow clone the branch, in which + the commit is expected to exist. + type: string + name: + description: |- + Name of the reference to check out; takes precedence over Branch, Tag and SemVer. + + It must be a valid Git reference: https://git-scm.com/docs/git-check-ref-format#_description + Examples: "refs/heads/main", "refs/tags/v0.1.0", "refs/pull/420/head", "refs/merge-requests/1/head" + type: string + semver: + description: SemVer tag expression to check out, takes precedence + over Tag. + type: string + tag: + description: Tag to check out, takes precedence over Branch. + type: string + type: object + secretRef: + description: |- + SecretRef specifies the Secret containing authentication credentials for + the GitRepository. + For HTTPS repositories the Secret must contain 'username' and 'password' + fields for basic auth or 'bearerToken' field for token auth. + For SSH repositories the Secret must contain 'identity' + and 'known_hosts' fields. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + suspend: + description: |- + Suspend tells the controller to suspend the reconciliation of this + GitRepository. + type: boolean + timeout: + default: 60s + description: Timeout for Git operations like cloning, defaults to + 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + url: + description: URL specifies the Git repository URL, it can be an HTTP/S + or SSH address. + pattern: ^(http|https|ssh)://.*$ + type: string + verify: + description: |- + Verification specifies the configuration to verify the Git commit + signature(s). + properties: + mode: + description: Mode specifies what Git object should be verified, + currently ('head'). + enum: + - head + type: string + secretRef: + description: |- + SecretRef specifies the Secret containing the public keys of trusted Git + authors. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - mode + - secretRef + type: object + required: + - interval + - url + type: object + status: + default: + observedGeneration: -1 + description: GitRepositoryStatus records the observed state of a Git repository. + properties: + artifact: + description: Artifact represents the last successful GitRepository + reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the GitRepository. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + contentConfigChecksum: + description: |- + ContentConfigChecksum is a checksum of all the configurations related to + the content of the source artifact: + - .spec.ignore + - .spec.recurseSubmodules + - .spec.included and the checksum of the included artifacts + observed in .status.observedGeneration version of the object. This can + be used to determine if the content of the included repository has + changed. + It has the format of `:`, for example: `sha256:`. + + Deprecated: Replaced with explicit fields for observed artifact content + config in the status. + type: string + includedArtifacts: + description: |- + IncludedArtifacts contains a list of the last successfully included + Artifacts as instructed by GitRepositorySpec.Include. + items: + description: Artifact represents the output of a Source reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of + ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI + annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedGeneration: + description: |- + ObservedGeneration is the last observed generation of the GitRepository + object. + format: int64 + type: integer + observedIgnore: + description: |- + ObservedIgnore is the observed exclusion patterns used for constructing + the source artifact. + type: string + observedInclude: + description: |- + ObservedInclude is the observed list of GitRepository resources used to + to produce the current Artifact. + items: + description: |- + GitRepositoryInclude specifies a local reference to a GitRepository which + Artifact (sub-)contents must be included, and where they should be placed. + properties: + fromPath: + description: |- + FromPath specifies the path to copy contents from, defaults to the root + of the Artifact. + type: string + repository: + description: |- + GitRepositoryRef specifies the GitRepository which Artifact contents + must be included. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + toPath: + description: |- + ToPath specifies the path to copy contents to, defaults to the name of + the GitRepositoryRef. + type: string + required: + - repository + type: object + type: array + observedRecurseSubmodules: + description: |- + ObservedRecurseSubmodules is the observed resource submodules + configuration used to produce the current Artifact. + type: boolean + url: + description: |- + URL is the dynamic fetch link for the latest Artifact. + It is provided on a "best effort" basis, and using the precise + GitRepositoryStatus.Artifact data is recommended. + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + labels: + backup.deckhouse.io/cluster-config: "true" + heritage: deckhouse + module: operator-helm + name: internalnelmoperatorhelmcharts.source.internal.operator-helm.deckhouse.io +spec: + group: source.internal.operator-helm.deckhouse.io + names: + kind: InternalNelmOperatorHelmChart + listKind: InternalNelmOperatorHelmChartList + plural: internalnelmoperatorhelmcharts + shortNames: + - intnelmophc + singular: internalnelmoperatorhelmchart + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.chart + name: Chart + type: string + - jsonPath: .spec.version + name: Version + type: string + - jsonPath: .spec.sourceRef.kind + name: Source Kind + type: string + - jsonPath: .spec.sourceRef.name + name: Source Name + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + name: v1 + schema: + openAPIV3Schema: + description: HelmChart is the Schema for the helmcharts API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: HelmChartSpec specifies the desired state of a Helm chart. + properties: + chart: + description: |- + Chart is the name or path the Helm chart is available at in the + SourceRef. + type: string + ignoreMissingValuesFiles: + description: |- + IgnoreMissingValuesFiles controls whether to silently ignore missing values + files rather than failing. + type: boolean + interval: + description: |- + Interval at which the HelmChart SourceRef is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + reconcileStrategy: + default: ChartVersion + description: |- + ReconcileStrategy determines what enables the creation of a new artifact. + Valid values are ('ChartVersion', 'Revision'). + See the documentation of the values for an explanation on their behavior. + Defaults to ChartVersion when omitted. + enum: + - ChartVersion + - Revision + type: string + sourceRef: + description: SourceRef is the reference to the Source the chart is + available at. + properties: + apiVersion: + description: APIVersion of the referent. + type: string + kind: + description: |- + Kind of the referent, valid values are ('HelmRepository', 'GitRepository', + 'Bucket'). + enum: + - InternalNelmOperatorHelmRepository + - InternalNelmOperatorGitRepository + - InternalNelmOperatorBucket + type: string + name: + description: Name of the referent. + type: string + required: + - kind + - name + type: object + suspend: + description: |- + Suspend tells the controller to suspend the reconciliation of this + source. + type: boolean + valuesFiles: + description: |- + ValuesFiles is an alternative list of values files to use as the chart + values (values.yaml is not included by default), expected to be a + relative path in the SourceRef. + Values files are merged in the order of this list with the last file + overriding the first. Ignored when omitted. + items: + type: string + type: array + verify: + description: |- + Verify contains the secret name containing the trusted public keys + used to verify the signature and specifies which provider to use to check + whether OCI image is authentic. + This field is only supported when using HelmRepository source with spec.type 'oci'. + Chart dependencies, which are not bundled in the umbrella chart artifact, are not verified. + properties: + matchOIDCIdentity: + description: |- + MatchOIDCIdentity specifies the identity matching criteria to use + while verifying an OCI artifact which was signed using Cosign keyless + signing. The artifact's identity is deemed to be verified if any of the + specified matchers match against the identity. + items: + description: |- + OIDCIdentityMatch specifies options for verifying the certificate identity, + i.e. the issuer and the subject of the certificate. + properties: + issuer: + description: |- + Issuer specifies the regex pattern to match against to verify + the OIDC issuer in the Fulcio certificate. The pattern must be a + valid Go regular expression. + type: string + subject: + description: |- + Subject specifies the regex pattern to match against to verify + the identity subject in the Fulcio certificate. The pattern must + be a valid Go regular expression. + type: string + required: + - issuer + - subject + type: object + type: array + provider: + default: cosign + description: Provider specifies the technology used to sign the + OCI Artifact. + enum: + - cosign + - notation + type: string + secretRef: + description: |- + SecretRef specifies the Kubernetes Secret containing the + trusted public keys. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - provider + type: object + version: + default: '*' + description: |- + Version is the chart version semver expression, ignored for charts from + GitRepository and Bucket sources. Defaults to latest when omitted. + type: string + required: + - chart + - interval + - sourceRef + type: object + x-kubernetes-validations: + - message: spec.verify is only supported when spec.sourceRef.kind is 'HelmRepository' + rule: '!has(self.verify) || self.sourceRef.kind == ''HelmRepository''' + status: + default: + observedGeneration: -1 + description: HelmChartStatus records the observed state of the HelmChart. + properties: + artifact: + description: Artifact represents the output of the last successful + reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the HelmChart. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedChartName: + description: |- + ObservedChartName is the last observed chart name as specified by the + resolved chart reference. + type: string + observedGeneration: + description: |- + ObservedGeneration is the last observed generation of the HelmChart + object. + format: int64 + type: integer + observedSourceArtifactRevision: + description: |- + ObservedSourceArtifactRevision is the last observed Artifact.Revision + of the HelmChartSpec.SourceRef. + type: string + observedValuesFiles: + description: |- + ObservedValuesFiles are the observed value files of the last successful + reconciliation. + It matches the chart in the last successfully reconciled artifact. + items: + type: string + type: array + url: + description: |- + URL is the dynamic fetch link for the latest Artifact. + It is provided on a "best effort" basis, and using the precise + BucketStatus.Artifact data is recommended. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.chart + name: Chart + type: string + - jsonPath: .spec.version + name: Version + type: string + - jsonPath: .spec.sourceRef.kind + name: Source Kind + type: string + - jsonPath: .spec.sourceRef.name + name: Source Name + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + deprecated: true + deprecationWarning: v1beta2 HelmChart is deprecated, upgrade to v1 + name: v1beta2 + schema: + openAPIV3Schema: + description: HelmChart is the Schema for the helmcharts API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: HelmChartSpec specifies the desired state of a Helm chart. + properties: + accessFrom: + description: |- + AccessFrom specifies an Access Control List for allowing cross-namespace + references to this object. + NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092 + properties: + namespaceSelectors: + description: |- + NamespaceSelectors is the list of namespace selectors to which this ACL applies. + Items in this list are evaluated using a logical OR operation. + items: + description: |- + NamespaceSelector selects the namespaces to which this ACL applies. + An empty map of MatchLabels matches all namespaces in a cluster. + properties: + matchLabels: + additionalProperties: + type: string + description: |- + MatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + type: array + required: + - namespaceSelectors + type: object + chart: + description: |- + Chart is the name or path the Helm chart is available at in the + SourceRef. + type: string + ignoreMissingValuesFiles: + description: |- + IgnoreMissingValuesFiles controls whether to silently ignore missing values + files rather than failing. + type: boolean + interval: + description: |- + Interval at which the HelmChart SourceRef is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + reconcileStrategy: + default: ChartVersion + description: |- + ReconcileStrategy determines what enables the creation of a new artifact. + Valid values are ('ChartVersion', 'Revision'). + See the documentation of the values for an explanation on their behavior. + Defaults to ChartVersion when omitted. + enum: + - ChartVersion + - Revision + type: string + sourceRef: + description: SourceRef is the reference to the Source the chart is + available at. + properties: + apiVersion: + description: APIVersion of the referent. + type: string + kind: + description: |- + Kind of the referent, valid values are ('HelmRepository', 'GitRepository', + 'Bucket'). + enum: + - InternalNelmOperatorHelmRepository + - InternalNelmOperatorGitRepository + - InternalNelmOperatorBucket + type: string + name: + description: Name of the referent. + type: string + required: + - kind + - name + type: object + suspend: + description: |- + Suspend tells the controller to suspend the reconciliation of this + source. + type: boolean + valuesFile: + description: |- + ValuesFile is an alternative values file to use as the default chart + values, expected to be a relative path in the SourceRef. Deprecated in + favor of ValuesFiles, for backwards compatibility the file specified here + is merged before the ValuesFiles items. Ignored when omitted. + type: string + valuesFiles: + description: |- + ValuesFiles is an alternative list of values files to use as the chart + values (values.yaml is not included by default), expected to be a + relative path in the SourceRef. + Values files are merged in the order of this list with the last file + overriding the first. Ignored when omitted. + items: + type: string + type: array + verify: + description: |- + Verify contains the secret name containing the trusted public keys + used to verify the signature and specifies which provider to use to check + whether OCI image is authentic. + This field is only supported when using HelmRepository source with spec.type 'oci'. + Chart dependencies, which are not bundled in the umbrella chart artifact, are not verified. + properties: + matchOIDCIdentity: + description: |- + MatchOIDCIdentity specifies the identity matching criteria to use + while verifying an OCI artifact which was signed using Cosign keyless + signing. The artifact's identity is deemed to be verified if any of the + specified matchers match against the identity. + items: + description: |- + OIDCIdentityMatch specifies options for verifying the certificate identity, + i.e. the issuer and the subject of the certificate. + properties: + issuer: + description: |- + Issuer specifies the regex pattern to match against to verify + the OIDC issuer in the Fulcio certificate. The pattern must be a + valid Go regular expression. + type: string + subject: + description: |- + Subject specifies the regex pattern to match against to verify + the identity subject in the Fulcio certificate. The pattern must + be a valid Go regular expression. + type: string + required: + - issuer + - subject + type: object + type: array + provider: + default: cosign + description: Provider specifies the technology used to sign the + OCI Artifact. + enum: + - cosign + - notation + type: string + secretRef: + description: |- + SecretRef specifies the Kubernetes Secret containing the + trusted public keys. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - provider + type: object + version: + default: '*' + description: |- + Version is the chart version semver expression, ignored for charts from + GitRepository and Bucket sources. Defaults to latest when omitted. + type: string + required: + - chart + - interval + - sourceRef + type: object + status: + default: + observedGeneration: -1 + description: HelmChartStatus records the observed state of the HelmChart. + properties: + artifact: + description: Artifact represents the output of the last successful + reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the HelmChart. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedChartName: + description: |- + ObservedChartName is the last observed chart name as specified by the + resolved chart reference. + type: string + observedGeneration: + description: |- + ObservedGeneration is the last observed generation of the HelmChart + object. + format: int64 + type: integer + observedSourceArtifactRevision: + description: |- + ObservedSourceArtifactRevision is the last observed Artifact.Revision + of the HelmChartSpec.SourceRef. + type: string + observedValuesFiles: + description: |- + ObservedValuesFiles are the observed value files of the last successful + reconciliation. + It matches the chart in the last successfully reconciled artifact. + items: + type: string + type: array + url: + description: |- + URL is the dynamic fetch link for the latest Artifact. + It is provided on a "best effort" basis, and using the precise + BucketStatus.Artifact data is recommended. + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + labels: + backup.deckhouse.io/cluster-config: "true" + heritage: deckhouse + module: operator-helm + name: internalnelmoperatorhelmrepositories.source.internal.operator-helm.deckhouse.io +spec: + group: source.internal.operator-helm.deckhouse.io + names: + kind: InternalNelmOperatorHelmRepository + listKind: InternalNelmOperatorHelmRepositoryList + plural: internalnelmoperatorhelmrepositories + shortNames: + - intnelmophelmrepo + singular: internalnelmoperatorhelmrepository + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + name: v1 + schema: + openAPIV3Schema: + description: HelmRepository is the Schema for the helmrepositories API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + HelmRepositorySpec specifies the required configuration to produce an + Artifact for a Helm repository index YAML. + properties: + accessFrom: + description: |- + AccessFrom specifies an Access Control List for allowing cross-namespace + references to this object. + NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092 + properties: + namespaceSelectors: + description: |- + NamespaceSelectors is the list of namespace selectors to which this ACL applies. + Items in this list are evaluated using a logical OR operation. + items: + description: |- + NamespaceSelector selects the namespaces to which this ACL applies. + An empty map of MatchLabels matches all namespaces in a cluster. + properties: + matchLabels: + additionalProperties: + type: string + description: |- + MatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + type: array + required: + - namespaceSelectors + type: object + certSecretRef: + description: |- + CertSecretRef can be given the name of a Secret containing + either or both of + + - a PEM-encoded client certificate (`tls.crt`) and private + key (`tls.key`); + - a PEM-encoded CA certificate (`ca.crt`) + + and whichever are supplied, will be used for connecting to the + registry. The client cert and key are useful if you are + authenticating with a certificate; the CA cert is useful if + you are using a self-signed server certificate. The Secret must + be of type `Opaque` or `kubernetes.io/tls`. + + It takes precedence over the values specified in the Secret referred + to by `.spec.secretRef`. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + insecure: + description: |- + Insecure allows connecting to a non-TLS HTTP container registry. + This field is only taken into account if the .spec.type field is set to 'oci'. + type: boolean + interval: + description: |- + Interval at which the HelmRepository URL is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + passCredentials: + description: |- + PassCredentials allows the credentials from the SecretRef to be passed + on to a host that does not match the host as defined in URL. + This may be required if the host of the advertised chart URLs in the + index differ from the defined URL. + Enabling this should be done with caution, as it can potentially result + in credentials getting stolen in a MITM-attack. + type: boolean + provider: + default: generic + description: |- + Provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'. + This field is optional, and only taken into account if the .spec.type field is set to 'oci'. + When not specified, defaults to 'generic'. + enum: + - generic + - aws + - azure + - gcp + type: string + secretRef: + description: |- + SecretRef specifies the Secret containing authentication credentials + for the HelmRepository. + For HTTP/S basic auth the secret must contain 'username' and 'password' + fields. + Support for TLS auth using the 'certFile' and 'keyFile', and/or 'caFile' + keys is deprecated. Please use `.spec.certSecretRef` instead. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + suspend: + description: |- + Suspend tells the controller to suspend the reconciliation of this + HelmRepository. + type: boolean + timeout: + description: |- + Timeout is used for the index fetch operation for an HTTPS helm repository, + and for remote OCI Repository operations like pulling for an OCI helm + chart by the associated HelmChart. + Its default value is 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + type: + description: |- + Type of the HelmRepository. + When this field is set to "oci", the URL field value must be prefixed with "oci://". + enum: + - default + - oci + type: string + url: + description: |- + URL of the Helm repository, a valid URL contains at least a protocol and + host. + pattern: ^(http|https|oci)://.*$ + type: string + required: + - url + type: object + status: + default: + observedGeneration: -1 + description: HelmRepositoryStatus records the observed state of the HelmRepository. + properties: + artifact: + description: Artifact represents the last successful HelmRepository + reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the HelmRepository. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedGeneration: + description: |- + ObservedGeneration is the last observed generation of the HelmRepository + object. + format: int64 + type: integer + url: + description: |- + URL is the dynamic fetch link for the latest Artifact. + It is provided on a "best effort" basis, and using the precise + HelmRepositoryStatus.Artifact data is recommended. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + deprecated: true + deprecationWarning: v1beta2 HelmRepository is deprecated, upgrade to v1 + name: v1beta2 + schema: + openAPIV3Schema: + description: HelmRepository is the Schema for the helmrepositories API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + HelmRepositorySpec specifies the required configuration to produce an + Artifact for a Helm repository index YAML. + properties: + accessFrom: + description: |- + AccessFrom specifies an Access Control List for allowing cross-namespace + references to this object. + NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092 + properties: + namespaceSelectors: + description: |- + NamespaceSelectors is the list of namespace selectors to which this ACL applies. + Items in this list are evaluated using a logical OR operation. + items: + description: |- + NamespaceSelector selects the namespaces to which this ACL applies. + An empty map of MatchLabels matches all namespaces in a cluster. + properties: + matchLabels: + additionalProperties: + type: string + description: |- + MatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + type: array + required: + - namespaceSelectors + type: object + certSecretRef: + description: |- + CertSecretRef can be given the name of a Secret containing + either or both of + + - a PEM-encoded client certificate (`tls.crt`) and private + key (`tls.key`); + - a PEM-encoded CA certificate (`ca.crt`) + + and whichever are supplied, will be used for connecting to the + registry. The client cert and key are useful if you are + authenticating with a certificate; the CA cert is useful if + you are using a self-signed server certificate. The Secret must + be of type `Opaque` or `kubernetes.io/tls`. + + It takes precedence over the values specified in the Secret referred + to by `.spec.secretRef`. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + insecure: + description: |- + Insecure allows connecting to a non-TLS HTTP container registry. + This field is only taken into account if the .spec.type field is set to 'oci'. + type: boolean + interval: + description: |- + Interval at which the HelmRepository URL is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + passCredentials: + description: |- + PassCredentials allows the credentials from the SecretRef to be passed + on to a host that does not match the host as defined in URL. + This may be required if the host of the advertised chart URLs in the + index differ from the defined URL. + Enabling this should be done with caution, as it can potentially result + in credentials getting stolen in a MITM-attack. + type: boolean + provider: + default: generic + description: |- + Provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'. + This field is optional, and only taken into account if the .spec.type field is set to 'oci'. + When not specified, defaults to 'generic'. + enum: + - generic + - aws + - azure + - gcp + type: string + secretRef: + description: |- + SecretRef specifies the Secret containing authentication credentials + for the HelmRepository. + For HTTP/S basic auth the secret must contain 'username' and 'password' + fields. + Support for TLS auth using the 'certFile' and 'keyFile', and/or 'caFile' + keys is deprecated. Please use `.spec.certSecretRef` instead. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + suspend: + description: |- + Suspend tells the controller to suspend the reconciliation of this + HelmRepository. + type: boolean + timeout: + description: |- + Timeout is used for the index fetch operation for an HTTPS helm repository, + and for remote OCI Repository operations like pulling for an OCI helm + chart by the associated HelmChart. + Its default value is 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + type: + description: |- + Type of the HelmRepository. + When this field is set to "oci", the URL field value must be prefixed with "oci://". + enum: + - default + - oci + type: string + url: + description: |- + URL of the Helm repository, a valid URL contains at least a protocol and + host. + pattern: ^(http|https|oci)://.*$ + type: string + required: + - url + type: object + status: + default: + observedGeneration: -1 + description: HelmRepositoryStatus records the observed state of the HelmRepository. + properties: + artifact: + description: Artifact represents the last successful HelmRepository + reconciliation. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the HelmRepository. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedGeneration: + description: |- + ObservedGeneration is the last observed generation of the HelmRepository + object. + format: int64 + type: integer + url: + description: |- + URL is the dynamic fetch link for the latest Artifact. + It is provided on a "best effort" basis, and using the precise + HelmRepositoryStatus.Artifact data is recommended. + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + labels: + backup.deckhouse.io/cluster-config: "true" + heritage: deckhouse + module: operator-helm + name: internalnelmoperatorocirepositories.source.internal.operator-helm.deckhouse.io +spec: + group: source.internal.operator-helm.deckhouse.io + names: + kind: InternalNelmOperatorOCIRepository + listKind: InternalNelmOperatorOCIRepositoryList + plural: internalnelmoperatorocirepositories + shortNames: + - intnelmopocirepo + singular: internalnelmoperatorocirepository + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.url + name: URL + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: OCIRepository is the Schema for the ocirepositories API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: OCIRepositorySpec defines the desired state of OCIRepository + properties: + certSecretRef: + description: |- + CertSecretRef can be given the name of a Secret containing + either or both of + + - a PEM-encoded client certificate (`tls.crt`) and private + key (`tls.key`); + - a PEM-encoded CA certificate (`ca.crt`) + + and whichever are supplied, will be used for connecting to the + registry. The client cert and key are useful if you are + authenticating with a certificate; the CA cert is useful if + you are using a self-signed server certificate. The Secret must + be of type `Opaque` or `kubernetes.io/tls`. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + ignore: + description: |- + Ignore overrides the set of excluded patterns in the .sourceignore format + (which is the same as .gitignore). If not provided, a default will be used, + consult the documentation for your version to find out what those are. + type: string + insecure: + description: Insecure allows connecting to a non-TLS HTTP container + registry. + type: boolean + interval: + description: |- + Interval at which the OCIRepository URL is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + layerSelector: + description: |- + LayerSelector specifies which layer should be extracted from the OCI artifact. + When not specified, the first layer found in the artifact is selected. + properties: + mediaType: + description: |- + MediaType specifies the OCI media type of the layer + which should be extracted from the OCI Artifact. The + first layer matching this type is selected. + type: string + operation: + description: |- + Operation specifies how the selected layer should be processed. + By default, the layer compressed content is extracted to storage. + When the operation is set to 'copy', the layer compressed content + is persisted to storage as it is. + enum: + - extract + - copy + type: string + type: object + provider: + default: generic + description: |- + The provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'. + When not specified, defaults to 'generic'. + enum: + - generic + - aws + - azure + - gcp + type: string + proxySecretRef: + description: |- + ProxySecretRef specifies the Secret containing the proxy configuration + to use while communicating with the container registry. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + ref: + description: |- + The OCI reference to pull and monitor for changes, + defaults to the latest tag. + properties: + digest: + description: |- + Digest is the image digest to pull, takes precedence over SemVer. + The value should be in the format 'sha256:'. + type: string + semver: + description: |- + SemVer is the range of tags to pull selecting the latest within + the range, takes precedence over Tag. + type: string + semverFilter: + description: SemverFilter is a regex pattern to filter the tags + within the SemVer range. + type: string + tag: + description: Tag is the image tag to pull, defaults to latest. + type: string + type: object + secretRef: + description: |- + SecretRef contains the secret name containing the registry login + credentials to resolve image metadata. + The secret must be of type kubernetes.io/dockerconfigjson. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + serviceAccountName: + description: |- + ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate + the image pull if the service account has attached pull secrets. For more information: + https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#add-imagepullsecrets-to-a-service-account + type: string + suspend: + description: This flag tells the controller to suspend the reconciliation + of this source. + type: boolean + timeout: + default: 60s + description: The timeout for remote OCI Repository operations like + pulling, defaults to 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + url: + description: |- + URL is a reference to an OCI artifact repository hosted + on a remote container registry. + pattern: ^oci://.*$ + type: string + verify: + description: |- + Verify contains the secret name containing the trusted public keys + used to verify the signature and specifies which provider to use to check + whether OCI image is authentic. + properties: + matchOIDCIdentity: + description: |- + MatchOIDCIdentity specifies the identity matching criteria to use + while verifying an OCI artifact which was signed using Cosign keyless + signing. The artifact's identity is deemed to be verified if any of the + specified matchers match against the identity. + items: + description: |- + OIDCIdentityMatch specifies options for verifying the certificate identity, + i.e. the issuer and the subject of the certificate. + properties: + issuer: + description: |- + Issuer specifies the regex pattern to match against to verify + the OIDC issuer in the Fulcio certificate. The pattern must be a + valid Go regular expression. + type: string + subject: + description: |- + Subject specifies the regex pattern to match against to verify + the identity subject in the Fulcio certificate. The pattern must + be a valid Go regular expression. + type: string + required: + - issuer + - subject + type: object + type: array + provider: + default: cosign + description: Provider specifies the technology used to sign the + OCI Artifact. + enum: + - cosign + - notation + type: string + secretRef: + description: |- + SecretRef specifies the Kubernetes Secret containing the + trusted public keys. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - provider + type: object + required: + - interval + - url + type: object + status: + default: + observedGeneration: -1 + description: OCIRepositoryStatus defines the observed state of OCIRepository + properties: + artifact: + description: Artifact represents the output of the last successful + OCI Repository sync. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the OCIRepository. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedGeneration: + description: ObservedGeneration is the last observed generation. + format: int64 + type: integer + observedIgnore: + description: |- + ObservedIgnore is the observed exclusion patterns used for constructing + the source artifact. + type: string + observedLayerSelector: + description: |- + ObservedLayerSelector is the observed layer selector used for constructing + the source artifact. + properties: + mediaType: + description: |- + MediaType specifies the OCI media type of the layer + which should be extracted from the OCI Artifact. The + first layer matching this type is selected. + type: string + operation: + description: |- + Operation specifies how the selected layer should be processed. + By default, the layer compressed content is extracted to storage. + When the operation is set to 'copy', the layer compressed content + is persisted to storage as it is. + enum: + - extract + - copy + type: string + type: object + url: + description: URL is the download link for the artifact output of the + last OCI Repository sync. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.url + name: URL + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + deprecated: true + deprecationWarning: v1beta2 OCIRepository is deprecated, upgrade to v1 + name: v1beta2 + schema: + openAPIV3Schema: + description: OCIRepository is the Schema for the ocirepositories API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: OCIRepositorySpec defines the desired state of OCIRepository + properties: + certSecretRef: + description: |- + CertSecretRef can be given the name of a Secret containing + either or both of + + - a PEM-encoded client certificate (`tls.crt`) and private + key (`tls.key`); + - a PEM-encoded CA certificate (`ca.crt`) + + and whichever are supplied, will be used for connecting to the + registry. The client cert and key are useful if you are + authenticating with a certificate; the CA cert is useful if + you are using a self-signed server certificate. The Secret must + be of type `Opaque` or `kubernetes.io/tls`. + + Note: Support for the `caFile`, `certFile` and `keyFile` keys have + been deprecated. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + ignore: + description: |- + Ignore overrides the set of excluded patterns in the .sourceignore format + (which is the same as .gitignore). If not provided, a default will be used, + consult the documentation for your version to find out what those are. + type: string + insecure: + description: Insecure allows connecting to a non-TLS HTTP container + registry. + type: boolean + interval: + description: |- + Interval at which the OCIRepository URL is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + layerSelector: + description: |- + LayerSelector specifies which layer should be extracted from the OCI artifact. + When not specified, the first layer found in the artifact is selected. + properties: + mediaType: + description: |- + MediaType specifies the OCI media type of the layer + which should be extracted from the OCI Artifact. The + first layer matching this type is selected. + type: string + operation: + description: |- + Operation specifies how the selected layer should be processed. + By default, the layer compressed content is extracted to storage. + When the operation is set to 'copy', the layer compressed content + is persisted to storage as it is. + enum: + - extract + - copy + type: string + type: object + provider: + default: generic + description: |- + The provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'. + When not specified, defaults to 'generic'. + enum: + - generic + - aws + - azure + - gcp + type: string + proxySecretRef: + description: |- + ProxySecretRef specifies the Secret containing the proxy configuration + to use while communicating with the container registry. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + ref: + description: |- + The OCI reference to pull and monitor for changes, + defaults to the latest tag. + properties: + digest: + description: |- + Digest is the image digest to pull, takes precedence over SemVer. + The value should be in the format 'sha256:'. + type: string + semver: + description: |- + SemVer is the range of tags to pull selecting the latest within + the range, takes precedence over Tag. + type: string + semverFilter: + description: SemverFilter is a regex pattern to filter the tags + within the SemVer range. + type: string + tag: + description: Tag is the image tag to pull, defaults to latest. + type: string + type: object + secretRef: + description: |- + SecretRef contains the secret name containing the registry login + credentials to resolve image metadata. + The secret must be of type kubernetes.io/dockerconfigjson. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + serviceAccountName: + description: |- + ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate + the image pull if the service account has attached pull secrets. For more information: + https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#add-imagepullsecrets-to-a-service-account + type: string + suspend: + description: This flag tells the controller to suspend the reconciliation + of this source. + type: boolean + timeout: + default: 60s + description: The timeout for remote OCI Repository operations like + pulling, defaults to 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + url: + description: |- + URL is a reference to an OCI artifact repository hosted + on a remote container registry. + pattern: ^oci://.*$ + type: string + verify: + description: |- + Verify contains the secret name containing the trusted public keys + used to verify the signature and specifies which provider to use to check + whether OCI image is authentic. + properties: + matchOIDCIdentity: + description: |- + MatchOIDCIdentity specifies the identity matching criteria to use + while verifying an OCI artifact which was signed using Cosign keyless + signing. The artifact's identity is deemed to be verified if any of the + specified matchers match against the identity. + items: + description: |- + OIDCIdentityMatch specifies options for verifying the certificate identity, + i.e. the issuer and the subject of the certificate. + properties: + issuer: + description: |- + Issuer specifies the regex pattern to match against to verify + the OIDC issuer in the Fulcio certificate. The pattern must be a + valid Go regular expression. + type: string + subject: + description: |- + Subject specifies the regex pattern to match against to verify + the identity subject in the Fulcio certificate. The pattern must + be a valid Go regular expression. + type: string + required: + - issuer + - subject + type: object + type: array + provider: + default: cosign + description: Provider specifies the technology used to sign the + OCI Artifact. + enum: + - cosign + - notation + type: string + secretRef: + description: |- + SecretRef specifies the Kubernetes Secret containing the + trusted public keys. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - provider + type: object + required: + - interval + - url + type: object + status: + default: + observedGeneration: -1 + description: OCIRepositoryStatus defines the observed state of OCIRepository + properties: + artifact: + description: Artifact represents the output of the last successful + OCI Repository sync. + properties: + digest: + description: Digest is the digest of the file in the form of ':'. + pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$ + type: string + lastUpdateTime: + description: |- + LastUpdateTime is the timestamp corresponding to the last update of the + Artifact. + format: date-time + type: string + metadata: + additionalProperties: + type: string + description: Metadata holds upstream information such as OCI annotations. + type: object + path: + description: |- + Path is the relative file path of the Artifact. It can be used to locate + the file in the root of the Artifact storage on the local file system of + the controller managing the Source. + type: string + revision: + description: |- + Revision is a human-readable identifier traceable in the origin source + system. It can be a Git commit SHA, Git tag, a Helm chart version, etc. + type: string + size: + description: Size is the number of bytes in the file. + format: int64 + type: integer + url: + description: |- + URL is the HTTP address of the Artifact as exposed by the controller + managing the Source. It can be used to retrieve the Artifact for + consumption, e.g. by another controller applying the Artifact contents. + type: string + required: + - digest + - lastUpdateTime + - path + - revision + - url + type: object + conditions: + description: Conditions holds the conditions for the OCIRepository. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + contentConfigChecksum: + description: |- + ContentConfigChecksum is a checksum of all the configurations related to + the content of the source artifact: + - .spec.ignore + - .spec.layerSelector + observed in .status.observedGeneration version of the object. This can + be used to determine if the content configuration has changed and the + artifact needs to be rebuilt. + It has the format of `:`, for example: `sha256:`. + + Deprecated: Replaced with explicit fields for observed artifact content + config in the status. + type: string + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + observedGeneration: + description: ObservedGeneration is the last observed generation. + format: int64 + type: integer + observedIgnore: + description: |- + ObservedIgnore is the observed exclusion patterns used for constructing + the source artifact. + type: string + observedLayerSelector: + description: |- + ObservedLayerSelector is the observed layer selector used for constructing + the source artifact. + properties: + mediaType: + description: |- + MediaType specifies the OCI media type of the layer + which should be extracted from the OCI Artifact. The + first layer matching this type is selected. + type: string + operation: + description: |- + Operation specifies how the selected layer should be processed. + By default, the layer compressed content is extracted to storage. + When the operation is set to 'copy', the layer compressed content + is persisted to storage as it is. + enum: + - extract + - copy + type: string + type: object + url: + description: URL is the download link for the artifact output of the + last OCI Repository sync. + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} diff --git a/crds/helmclusteraddoncharts.yaml b/crds/helmclusteraddoncharts.yaml new file mode 100644 index 0000000..490de0a --- /dev/null +++ b/crds/helmclusteraddoncharts.yaml @@ -0,0 +1,133 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + heritage: deckhouse + module: operator-helm + name: helmclusteraddoncharts.helm.deckhouse.io +spec: + group: helm.deckhouse.io + names: + categories: + - all + - operator-helm + kind: HelmClusterAddonChart + listKind: HelmClusterAddonChartList + plural: helmclusteraddoncharts + singular: helmclusteraddonchart + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: + HelmClusterAddonChart represents a Helm chart and its versions + from specific repository. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + status: + properties: + conditions: + description: + Conditions represent the latest available observations + of the repository state. + items: + description: + Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + versions: + description: Available helm chart versions + items: + properties: + digest: + description: Helm chart digest + type: string + pulled: + description: Chart pulled from repository + type: boolean + version: + description: Helm chart version + minLength: 1 + type: string + required: + - pulled + - version + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/crds/helmclusteraddonrepositories.yaml b/crds/helmclusteraddonrepositories.yaml new file mode 100644 index 0000000..7a7b19e --- /dev/null +++ b/crds/helmclusteraddonrepositories.yaml @@ -0,0 +1,166 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + heritage: deckhouse + module: operator-helm + name: helmclusteraddonrepositories.helm.deckhouse.io +spec: + group: helm.deckhouse.io + names: + categories: + - all + - operator-helm + kind: HelmClusterAddonRepository + listKind: HelmClusterAddonRepositoryList + plural: helmclusteraddonrepositories + singular: helmclusteraddonrepository + scope: Cluster + versions: + - additionalPrinterColumns: + - description: The readiness status of the repository + jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Status + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: + HelmClusterAddonRepository represents a Helm or an OCI compliant + repository with Helm charts. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + auth: + description: Auth contains authentication credentials for the repository. + properties: + password: + description: Repository authentication password. + minLength: 1 + type: string + username: + description: Repository authentication username. + minLength: 1 + type: string + required: + - password + - username + type: object + caCertificate: + description: + CACertificate is the PEM encoded CA certificate for TLS + verification. + type: string + tlsVerify: + default: true + description: TLSVerify enables or disables TLS certificate verification. + type: boolean + url: + description: + URL of the Helm repository. Supports http(s):// and oci:// + protocols. + type: string + x-kubernetes-validations: + - message: + URL must have a valid protocol (http, https, oci) and a + non-empty path + rule: self.matches('^(https?|oci)://.+$') + required: + - url + type: object + status: + properties: + conditions: + description: + Conditions represent the latest available observations + of the repository state. + items: + description: + Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + description: + Generating a resource that was last processed by the + controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/crds/helmclusteraddons.yaml b/crds/helmclusteraddons.yaml new file mode 100644 index 0000000..46bfe43 --- /dev/null +++ b/crds/helmclusteraddons.yaml @@ -0,0 +1,208 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + heritage: deckhouse + module: operator-helm + name: helmclusteraddons.helm.deckhouse.io +spec: + group: helm.deckhouse.io + names: + categories: + - all + - operator-helm + kind: HelmClusterAddon + listKind: HelmClusterAddonList + plural: helmclusteraddons + singular: helmclusteraddon + scope: Cluster + versions: + - additionalPrinterColumns: + - description: Helm release chart name. + jsonPath: .spec.chart.helmClusterAddonChart + name: Chart Name + type: string + - description: Helm release chart version. + jsonPath: .spec.chart.version + name: Chart Version + type: string + - description: The readiness status of the repository + jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Status + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: + HelmClusterAddon represents a Helm addon that is installed across + the whole cluster. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + chart: + properties: + helmClusterAddonChart: + description: |- + Specifies the name of the Helm chart to be installed + from the defined repository (e.g., "ingress-nginx" or "redis"). + minLength: 1 + type: string + helmClusterAddonRepository: + description: |- + Specifies the name of the HelmClusterAddonRepository custom resource that contains + the connection details and credentials for the repository where + the chart is located. + maxLength: 63 + minLength: 3 + type: string + version: + description: Versions holds the HelmClusterAddon chart version. + type: string + required: + - helmClusterAddonChart + - helmClusterAddonRepository + type: object + maintenance: + description: |- + Maintenance specifies the reconciliation strategy for the resource. + When set to "NoResourceReconciliation", the controller will stop updating the + underlying resources, allowing for manual intervention or maintenance + without the operator overwriting changes. + When empty (""), standard reconciliation is active. + enum: + - "" + - NoResourceReconciliation + type: string + namespace: + default: default + description: Namespace to deploy cluster addon release + maxLength: 63 + minLength: 3 + type: string + values: + description: Values holds the values for this HelmClusterAddon release. + x-kubernetes-preserve-unknown-fields: true + required: + - chart + type: object + status: + properties: + conditions: + description: + Conditions represent the latest available observations + of the repository state. + items: + description: + Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastAppliedChart: + description: + LastAppliedChart represents the latest chart that triggered + addon install or update. + properties: + helmClusterAddonChart: + description: |- + Specifies the name of the Helm chart to be installed + from the defined repository (e.g., "ingress-nginx" or "redis"). + type: string + helmClusterAddonRepository: + description: |- + Specifies the name of the HelmClusterAddonRepository custom resource that contains + the connection details and credentials for the repository where + the chart is located. + type: string + version: + description: Versions holds the HelmClusterAddon chart version. + type: string + type: object + lastAppliedValues: + description: + LastAppliedValues represents the latest values that triggered + addon install or update. + x-kubernetes-preserve-unknown-fields: true + observedGeneration: + description: + Generating a resource that was last processed by the + controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..6700ff7 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,4 @@ +--- +title: "The operator-helm module: configuration" +weight: 30 +--- diff --git a/docs/CONFIGURATION_RU.md b/docs/CONFIGURATION_RU.md new file mode 100644 index 0000000..fe053c1 --- /dev/null +++ b/docs/CONFIGURATION_RU.md @@ -0,0 +1,4 @@ +--- +title: "Модуль operator-helm: настройки" +weight: 30 +--- diff --git a/docs/CR.md b/docs/CR.md new file mode 100644 index 0000000..4f1f169 --- /dev/null +++ b/docs/CR.md @@ -0,0 +1,4 @@ +--- +title: "Custom Resources" +weight: 60 +--- diff --git a/docs/CR_RU.md b/docs/CR_RU.md new file mode 100644 index 0000000..4f1f169 --- /dev/null +++ b/docs/CR_RU.md @@ -0,0 +1,4 @@ +--- +title: "Custom Resources" +weight: 60 +--- diff --git a/docs/EXAMPLE.md b/docs/EXAMPLE.md new file mode 100644 index 0000000..3d6418a --- /dev/null +++ b/docs/EXAMPLE.md @@ -0,0 +1,86 @@ +--- +title: "The operator-helm module: usage examples" +description: "Deckhouse Kubernetes Platform — usage examples for the operator-helm module." +--- + +## Adding a Helm Repository + +{{< alert level="warning" >}} +In the MVP stage, only Helm repositories (using schema "http(s)://") are supported as chart sources. Support for OCI registries (using schema "oci://") will be added in the alpha version. +{{< /alert >}} + +To add a repository, you need to create a HelmClusterAddonRepository resource: + +```yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmClusterAddonRepository +metadata: + name: podinfo +spec: + url: https://stefanprodan.github.io/podinfo +``` + +After creating the repository, you can view the Helm charts available in it using the command below: + +```shell +kubectl get helmclusteraddoncharts.helm.deckhouse.io -l helm.deckhouse.io/cluster-addon-repository=podinfo +NAME AGE +podinfo-podinfo 56s +``` + +To view the list of versions available for a specific chart, run the following command: + +```shell +kubectl get helmclusteraddoncharts.helm.deckhouse.io podinfo-podinfo -o yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmClusterAddonChart +metadata: + creationTimestamp: "2026-03-11T05:31:14Z" + generation: 1 + labels: + helm.deckhouse.io/cluster-addon-repository: podinfo + helm.deckhouse.io/managed-by: operator-helm + name: podinfo-podinfo + ownerReferences: + - apiVersion: helm.deckhouse.io/v1alpha1 + blockOwnerDeletion: true + controller: true + kind: HelmClusterAddonRepository + name: podinfo + uid: 073d6efc-aa19-4ccd-9d8e-d3b1253f94cf + resourceVersion: "27054847" + uid: cef0e7aa-6d36-4ade-bc6d-9e66b853badf +status: + versions: + - digest: a5c4b7381a0907128243354ab100d2eecc480d7dcac5014ff7272b0acef03780 + pulled: false + version: 6.11.0 + - digest: 9f1cdb52fc5a57848f377b146919f8eb2c4a2c0ab8815bd019ec41c1d1895c0c + pulled: false + version: 6.10.2 +``` + +## Deploying an Application + +To deploy an application, you must create a `HelmClusterAddon` resource, specifying the name of the previously created repository, the chart name and version, and the namespace where the application will be deployed. + +```yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmClusterAddon +metadata: + name: podinfo +spec: + namespace: test + chart: + helmClusterAddonChart: podinfo + helmClusterAddonRepository: podinfo + version: 6.10.2 +``` + +{{< alert level="warning" >}} +Only one instance of `HelmClusterAddon` using a specific Helm chart from a specific repository can be deployed at a time. However, different Helm charts from the same repository can be deployed simultaneously. +{{< /alert >}} + +{{< alert level="info" >}} +It is permissible to omit a specific chart version in the .spec.chart.version parameter. In this case, the latest version of the application will be installed. +{{< /alert >}} \ No newline at end of file diff --git a/docs/EXAMPLE_RU.md b/docs/EXAMPLE_RU.md new file mode 100644 index 0000000..ef6c9c4 --- /dev/null +++ b/docs/EXAMPLE_RU.md @@ -0,0 +1,85 @@ +--- +title: "Модуль operator-helm: примеры использования" +description: "Deckhouse Kubernetes Platform — примеры использования модуля operator-helm." +--- + +## Добавление Helm репозитория + +{{< alert level="warning" >}} +На стадии MVP в качестве источников чартов поддерживаются только Helm репозитории (http(s)://). Поддержка OCI репозиториев (oci://) будет добавлена в alpha версии. +{{< /alert >}} + +Для добавления репозитория необходимо добавить ресурс `HelmClusterAddonRepository`: + +```yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmClusterAddonRepository +metadata: + name: podinfo +spec: + url: https://stefanprodan.github.io/podinfo +``` + +После создания репозитория, можно просмотреть доступные в нем Helm-чарты с помощью команды ниже: + +```shell +kubectl get helmclusteraddoncharts.helm.deckhouse.io -l helm.deckhouse.io/cluster-addon-repository=podinfo +NAME AGE +podinfo-podinfo 56s +``` + +Для просмотра списка версий, доступных для заданного чарта, необходимо выполнить команду ниже: +```shell +kubectl get helmclusteraddoncharts.helm.deckhouse.io podinfo-podinfo -o yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmClusterAddonChart +metadata: + creationTimestamp: "2026-03-11T05:31:14Z" + generation: 1 + labels: + helm.deckhouse.io/cluster-addon-repository: podinfo + helm.deckhouse.io/managed-by: operator-helm + name: podinfo-podinfo + ownerReferences: + - apiVersion: helm.deckhouse.io/v1alpha1 + blockOwnerDeletion: true + controller: true + kind: HelmClusterAddonRepository + name: podinfo + uid: 073d6efc-aa19-4ccd-9d8e-d3b1253f94cf + resourceVersion: "27054847" + uid: cef0e7aa-6d36-4ade-bc6d-9e66b853badf +status: + versions: + - digest: a5c4b7381a0907128243354ab100d2eecc480d7dcac5014ff7272b0acef03780 + pulled: false + version: 6.11.0 + - digest: 9f1cdb52fc5a57848f377b146919f8eb2c4a2c0ab8815bd019ec41c1d1895c0c + pulled: false + version: 6.10.2 +``` + +## Деплой приложения + +Для деплоя приложения необходимо создать ресурс `HelmClusterAddon` указав имя ранее созданного репозитория, имя и версию чарта, и namespace в который будет развернуто приложение. + +```yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmClusterAddon +metadata: + name: podinfo +spec: + namespace: test + chart: + helmClusterAddonChart: podinfo + helmClusterAddonRepository: podinfo + version: 6.10.2 +``` + +{{< alert level="warning" >}} +Одновременного допускается развертывание только одного экземпляра `HelmClusterAddon` использующего заданный Helm чарт из заданного репозитория. При этом из одноного репозитория одновременно могут быть развернуты разные Helm чарты. +{{< /alert >}} + +{{< alert level="info" >}} +Допустимо не указывать конкретную версию чарта в параметре `.Spec.chart.version`. В этом случае будет установлена последняя версия приложения. +{{< /alert >}} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..5d43ec5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,20 @@ +--- +title: "operator-helm" +menuTitle: "operator-helm" +moduleStatus: Experimental +weight: 10 +--- + +The operator-helm module is designed for declarative management of Helm charts. It enables application deployment via Custom Resources (CRs), minimizing the amount of required input data. + +## Supported Sources +The module provides flexibility in choosing application sources, supporting: +* Helm repositories (classic HTTP/HTTPS repositories); +* OCI registries that support Helm chart storage. + +## Management Methods +Management of the module's resources is unified and accessible via: +* Command Line Interface (CLI): using the `d8` or `kubectl` utility. +* Web Interface: through the Deckhouse Kubernetes Platform graphical console. + +See module usage examples in [Usage examples](examples.html) section. diff --git a/docs/README_RU.md b/docs/README_RU.md new file mode 100644 index 0000000..157eb6d --- /dev/null +++ b/docs/README_RU.md @@ -0,0 +1,20 @@ +--- +title: "operator-helm" +menuTitle: "operator-helm" +moduleStatus: General Availability +weight: 10 +--- + +Модуль `operator-helm` предназначен для декларативного управления Helm-чартами. Он позволяет развертывать приложения через кастомные ресурсы (CR), сводя к минимуму объем необходимых входных данных. + +## Поддерживаемые источники +Модуль обеспечивает гибкость выбора источников приложений, работая с: +* Helm-репозиториями (классические HTTP/HTTPS репозитории); +* OCI-репозиториями, поддерживающими хранение Helm-чартов. + +## Способы управления +Управление ресурсами модуля унифицировано и доступно через: +* Интерфейс командной строки: с помощью утилиты `d8`, либо kubectl. +* Web-интерфейс: через графическую консоль управления Deckhouse Kubernetes Platform. + +Примеры использования модуля приведены в разделе [Примеры использования](examples.html). diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..2851b6a --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,52 @@ +--- +title: "Usage" +description: Usage of the operator-helm Deckhouse module. +--- + +## Enabling the module + +You can enable the module in one of the following ways: + +- **Using the [Deckhouse web interface](/products/kubernetes-platform/documentation/v1/user/web/ui.html).** + + In the "System" section → "System Management" → "Deckhouse" → "Modules", open the `operator-helm` module, enable the "Module enabled" switch. Save the changes. + +- **Using [Deckhouse CLI](/products/kubernetes-platform/documentation/v1/cli/d8/).** + + Execute the following command to enable the module: + + ```shell + d8 system module enable operator-helm + ``` + +- **Using ModuleConfig `operator-helm`.** + + Set `spec.enabled` to `true` or `false` in ModuleConfig `operator-helm` (create it if necessary). + + Example manifest to enable the module: + + ```yaml + apiVersion: deckhouse.io/v1alpha1 + kind: ModuleConfig + metadata: + name: operator-helm + spec: + enabled: true + ``` + +## Disabling the module + +You can disable the module using one of the following methods: + +- **Using the [Deckhouse web interface](/products/kubernetes-platform/documentation/v1/user/web/ui.html).** + + In the "System" → "System Management" → "Deckhouse" → "Modules" section, open the `operator-helm` module and turn off the "Module Enabled" switch. Save the changes. + +- **Using [Deckhouse CLI](/products/kubernetes-platform/documentation/v1/cli/d8/).** + + Execute the following commands to disable the module: + + ```shell + d8 k annotate mc operator-helm modules.deckhouse.io/allow-disabling=true + d8 system module disable operator-helm + ``` diff --git a/docs/USAGE_RU.md b/docs/USAGE_RU.md new file mode 100644 index 0000000..7c70efa --- /dev/null +++ b/docs/USAGE_RU.md @@ -0,0 +1,50 @@ +--- +title: "Использование" +description: Использование модуля operator-helm. +--- + +## Включение модуля + +Включить модуль можно одним из следующих способов: +- **С помощью [веб-интерфейса Deckhouse](/products/kubernetes-platform/documentation/v1/user/web/ui.html).** + + В разделе «Система» → «Управление системой» → «Deckhouse» → «Модули», откройте модуль `operator-helm`, включите переключатель «Модуль включен». Сохраните изменения. + +- **С помощью [Deckhouse CLI](/products/kubernetes-platform/documentation/v1/cli/d8/).** + + Выполните следующую команду для включения модуля: + + ```shell + d8 system module enable operator-helm + ``` + +- **С помощью ModuleConfig `operator-helm`.** + + Установите `spec.enabled` в `true` или `false` в ModuleConfig `operator-helm` (создайте его, при необходимости). + + Пример манифеста для включения модуля: + + ```yaml + apiVersion: deckhouse.io/v1alpha1 + kind: ModuleConfig + metadata: + name: operator-helm + spec: + enabled + ``` + +## Выключение модуля + +Выключить модуль можно одним из следующих способов: +- **С помощью [веб-интерфейса Deckhouse](/products/kubernetes-platform/documentation/v1/user/web/ui.html).** + + В разделе «Система» → «Управление системой» → «Deckhouse» → «Модули», откройте модуль `operator-helm`, выключите переключатель «Модуль включен». Сохраните изменения. + +- **С помощью [Deckhouse CLI](/products/kubernetes-platform/documentation/v1/cli/d8/).** + + Выполните следующие команды для выключения модуля: + + ```shell + d8 k annotate mc operator-helm modules.deckhouse.io/allow-disabling=true + d8 system module disable operator-helm + ``` diff --git a/docs/images/.keep b/docs/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docs/internal/components_placement.md b/docs/internal/components_placement.md new file mode 100644 index 0000000..17bc1d2 --- /dev/null +++ b/docs/internal/components_placement.md @@ -0,0 +1,3 @@ +## Placement strategies + + diff --git a/enabled b/enabled new file mode 100755 index 0000000..b3012f9 --- /dev/null +++ b/enabled @@ -0,0 +1,11 @@ +#!/bin/bash + +source /deckhouse/shell_lib.sh + +function __main__() { + enabled::disable_module_if_cluster_is_not_bootstraped + enabled::disable_module_in_kubernetes_versions_less_than 1.23.0 + echo "true" > $MODULE_ENABLED_RESULT +} + +enabled::run $@ diff --git a/hooks/.keep b/hooks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/images/distroless/werf.inc.yaml b/images/distroless/werf.inc.yaml new file mode 100644 index 0000000..99f6e11 --- /dev/null +++ b/images/distroless/werf.inc.yaml @@ -0,0 +1,53 @@ +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact +fromImage: builder/alt +final: false +shell: + beforeInstall: + {{- include "alt packages proxy" . | nindent 2 }} + - | + apt-get install ca-certificates tzdata -y + {{- include "alt packages clean" . | nindent 2 }} + install: + - | + mkdir -p /relocate/etc/{pki,ssl} /relocate/usr/{bin,sbin,share,lib,lib64} + + cd /relocate + for dir in {bin,sbin,lib,lib64};do + ln -s usr/$dir $dir + done + # /var/run -> ../run symlink to prevent making /var/run a directory during the build. + # It is needed for better compatibility with containerd default top layer. + mkdir -p run + mkdir -p var + ln -s var/run ../run + cd / + + cp -pr /tmp /relocate + cp -pr /etc/passwd /etc/group /etc/hostname /etc/hosts /etc/shadow /etc/protocols /etc/services /etc/nsswitch.conf /relocate/etc + cp -pr /usr/share/ca-certificates /relocate/usr/share + cp -pr /usr/share/zoneinfo /relocate/usr/share + cp -pr /etc/pki/tls/cert.pem /relocate/etc/ssl + cp -pr /etc/pki/tls/certs /relocate/etc/ssl + cp -pr /etc/pki/ca-trust/ /relocate/etc/ + # Create 'deckhouse' user to run without root. + echo "deckhouse:x:64535:64535:deckhouse:/:/sbin/nologin" >> /relocate/etc/passwd + echo "deckhouse:x:64535:" >> /relocate/etc/group + echo "deckhouse:!::0:::::" >> /relocate/etc/shadow +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }} +final: false +fromImage: builder/scratch +import: + - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact + add: /relocate + to: / + before: setup +imageSpec: + config: + env: + PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + LANG: "" + LC_ALL: POSIX + user: 64535 + diff --git a/images/helm-controller/werf.inc.yaml b/images/helm-controller/werf.inc.yaml new file mode 100644 index 0000000..265b860 --- /dev/null +++ b/images/helm-controller/werf.inc.yaml @@ -0,0 +1,3 @@ +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }} +from: registry.werf.io/nelm/helm-controller:v0.1.3 diff --git a/images/hooks/cmd/operator-helm-module-hooks/main.go b/images/hooks/cmd/operator-helm-module-hooks/main.go new file mode 100644 index 0000000..e0fcf75 --- /dev/null +++ b/images/hooks/cmd/operator-helm-module-hooks/main.go @@ -0,0 +1,25 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/deckhouse/module-sdk/pkg/app" +) + +func main() { + app.Run() +} diff --git a/images/hooks/cmd/operator-helm-module-hooks/register.go b/images/hooks/cmd/operator-helm-module-hooks/register.go new file mode 100644 index 0000000..8c21530 --- /dev/null +++ b/images/hooks/cmd/operator-helm-module-hooks/register.go @@ -0,0 +1,21 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + _ "hooks/pkg/hooks/tls-certificates-controller" +) diff --git a/images/hooks/go.mod b/images/hooks/go.mod new file mode 100644 index 0000000..6404416 --- /dev/null +++ b/images/hooks/go.mod @@ -0,0 +1,97 @@ +module hooks + +go 1.25.0 + +require github.com/deckhouse/module-sdk v0.10.0 + +require ( + github.com/DataDog/gostackparse v0.7.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/caarlos0/env/v11 v11.3.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudflare/cfssl v1.6.5 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/deckhouse/deckhouse/pkg/log v0.2.0 // indirect + github.com/docker/cli v28.2.2+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/ettle/strcase v0.2.0 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/gojuno/minimock/v3 v3.4.7 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/certificate-transparency-go v1.1.7 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-containerregistry v0.20.6 // indirect + github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/onsi/gomega v1.38.3 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml v1.9.3 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/sylabs/oci-tools v0.7.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect + github.com/weppos/publicsuffix-go v0.30.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300 // indirect + github.com/zmap/zlint/v3 v3.5.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.12.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.35.1 // indirect + k8s.io/apiextensions-apiserver v0.35.1 // indirect + k8s.io/apimachinery v0.35.1 // indirect + k8s.io/client-go v0.35.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/controller-runtime v0.23.1 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/images/hooks/go.sum b/images/hooks/go.sum new file mode 100644 index 0000000..dbc33f6 --- /dev/null +++ b/images/hooks/go.sum @@ -0,0 +1,273 @@ +github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= +github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/cfssl v1.6.5 h1:46zpNkm6dlNkMZH/wMW22ejih6gIaJbzL2du6vD7ZeI= +github.com/cloudflare/cfssl v1.6.5/go.mod h1:Bk1si7sq8h2+yVEDrFJiz3d7Aw+pfjjJSZVaD+Taky4= +github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckhouse/deckhouse/pkg/log v0.2.0 h1:6tmZQLwNb1o/hP1gzJQBjcwfA/bubbgObovXzxq+Exo= +github.com/deckhouse/deckhouse/pkg/log v0.2.0/go.mod h1:pbAxTSDcPmwyl3wwKDcEB3qdxHnRxqTV+J0K+sha8bw= +github.com/deckhouse/module-sdk v0.10.0 h1:VPhYvMVQ3pT32I2WL1ITtQyrYdpiUR0RocLw7S4TfNg= +github.com/deckhouse/module-sdk v0.10.0/go.mod h1:Z1jfmd0fICoYww0daMijWAU+OZTxeJUXfMciKKuYAYA= +github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= +github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= +github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gojuno/minimock/v3 v3.4.7 h1:vhE5zpniyPDRT0DXd5s3DbtZJVlcbmC5k80izYtj9lY= +github.com/gojuno/minimock/v3 v3.4.7/go.mod h1:QxJk4mdPrVyYUmEZGc2yD2NONpqM/j4dWhsy9twjFHg= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.1.7 h1:IASD+NtgSTJLPdzkthwvAG1ZVbF2WtFg4IvoA68XGSw= +github.com/google/certificate-transparency-go v1.1.7/go.mod h1:FSSBo8fyMVgqptbfF6j5p/XNdgQftAhSmXcIxV9iphE= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= +github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= +github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= +github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= +github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/sylabs/oci-tools v0.7.0 h1:SIisUvcEL+Vpa9/kmQDy1W3AwV2XVGad83sgZmXLlb0= +github.com/sylabs/oci-tools v0.7.0/go.mod h1:Ry6ngChflh20WPq6mLvCKSw2OTd9iDB5aR8OQzeq4hM= +github.com/sylabs/sif/v2 v2.15.0 h1:Nv0tzksFnoQiQ2eUwpAis9nVqEu4c3RcNSxX8P3Cecw= +github.com/sylabs/sif/v2 v2.15.0/go.mod h1:X1H7eaPz6BAxA84POMESXoXfTqgAnLQkujyF/CQFWTc= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/weppos/publicsuffix-go v0.12.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/weppos/publicsuffix-go v0.30.0 h1:QHPZ2GRu/YE7cvejH9iyavPOkVCB4dNxp2ZvtT+vQLY= +github.com/weppos/publicsuffix-go v0.30.0/go.mod h1:kBi8zwYnR0zrbm8RcuN1o9Fzgpnnn+btVN8uWPMyXAY= +github.com/weppos/publicsuffix-go/publicsuffix/generator v0.0.0-20220927085643-dc0d00c92642/go.mod h1:GHfoeIdZLdZmLjMlzBftbTDntahTttUMWjxZwQJhULE= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= +github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= +github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is= +github.com/zmap/zcertificate v0.0.1/go.mod h1:q0dlN54Jm4NVSSuzisusQY0hqDWvu92C+TWveAxiVWk= +github.com/zmap/zcrypto v0.0.0-20201128221613-3719af1573cf/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= +github.com/zmap/zcrypto v0.0.0-20201211161100-e54a5822fb7e/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= +github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300 h1:DZH5n7L3L8RxKdSyJHZt7WePgwdhHnPhQFdQSJaHF+o= +github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300/go.mod h1:mOd4yUMgn2fe2nV9KXsa9AyQBFZGzygVPovsZR+Rl5w= +github.com/zmap/zlint/v3 v3.0.0/go.mod h1:paGwFySdHIBEMJ61YjoqT4h7Ge+fdYG4sUQhnTb1lJ8= +github.com/zmap/zlint/v3 v3.5.0 h1:Eh2B5t6VKgVH0DFmTwOqE50POvyDhUaU9T2mJOe1vfQ= +github.com/zmap/zlint/v3 v3.5.0/go.mod h1:JkNSrsDJ8F4VRtBZcYUQSvnWFL7utcjDIn+FE64mlBI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= +gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/images/hooks/pkg/hooks/tls-certificates-controller/hook.go b/images/hooks/pkg/hooks/tls-certificates-controller/hook.go new file mode 100644 index 0000000..0265681 --- /dev/null +++ b/images/hooks/pkg/hooks/tls-certificates-controller/hook.go @@ -0,0 +1,40 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls_certificates_controller + +import ( + "fmt" + "hooks/pkg/settings" + + tlscertificate "github.com/deckhouse/module-sdk/common-hooks/tls-certificate" +) + +var _ = tlscertificate.RegisterInternalTLSHookEM(tlscertificate.GenSelfSignedTLSHookConf{ + CN: settings.ControllerCertCN, + TLSSecretName: "operator-helm-controller-tls", + Namespace: settings.ModuleNamespace, + SANs: tlscertificate.DefaultSANs([]string{ + "localhost", + "127.0.0.1", + settings.ControllerCertCN, + fmt.Sprintf("%s.%s", settings.ControllerCertCN, settings.ModuleNamespace), + fmt.Sprintf("%s.%s.svc", settings.ControllerCertCN, settings.ModuleNamespace), + }), + + FullValuesPathPrefix: fmt.Sprintf("%s.internal.controller.cert", settings.ModuleName), + CommonCAValuesPath: fmt.Sprintf("%s.internal.rootCA", settings.ModuleName), +}) diff --git a/images/hooks/pkg/settings/certificate.go b/images/hooks/pkg/settings/certificate.go new file mode 100644 index 0000000..5d08591 --- /dev/null +++ b/images/hooks/pkg/settings/certificate.go @@ -0,0 +1,21 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package settings + +const ( + ControllerCertCN string = "operator-helm-controller" +) diff --git a/images/hooks/pkg/settings/module.go b/images/hooks/pkg/settings/module.go new file mode 100644 index 0000000..095d744 --- /dev/null +++ b/images/hooks/pkg/settings/module.go @@ -0,0 +1,23 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package settings + +// Essential module constants. +const ( + ModuleNamespace string = "d8-operator-helm" + ModuleName string = "operatorHelm" +) diff --git a/images/hooks/werf.inc.yaml b/images/hooks/werf.inc.yaml new file mode 100644 index 0000000..ade8485 --- /dev/null +++ b/images/hooks/werf.inc.yaml @@ -0,0 +1,52 @@ +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact +final: false +fromImage: builder/src +git: + - add: {{ .ModuleDir }}/images/{{ .ImageName }} + to: /src/images/hooks + stageDependencies: + install: + - "**/*" + - add: {{ .ModuleDir }}/images/operator-helm-artifact + to: /src/images/operator-helm-artifact + stageDependencies: + install: + - "**/*" + - add: {{ .ModuleDir }}/api + to: /src/api + stageDependencies: + install: + - "**/*" +shell: + install: + - cd /src +--- +image: {{ .ModuleNamePrefix }}go-hooks-artifact +final: false +fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-bookworm-1.25" "builder/golang-alt-svace-1.25" }} +import: + - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact + add: /src + to: /app + before: install +mount: + - fromPath: ~/go-pkg-cache + to: /go/pkg +secrets: + - id: GOPROXY + value: {{ .GOPROXY }} +shell: + install: + - export GOPROXY=$(cat /run/secrets/GOPROXY) + - cd /app/images/hooks + - go mod download + setup: + - cd /app/images/hooks + - | + export GOOS=linux + export GOARCH=amd64 + export CGO_ENABLED=0 + export TAGS="{{ printf "-tags %s" .MODULE_EDITION }}" + {{- $_ := set $ "ProjectName" (list $.ImageName "hooks" | join "/") }} + {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" $TAGS -a -o /go-hooks/operator-helm-module-hooks ./cmd/operator-helm-module-hooks`) | nindent 6 }} diff --git a/images/kube-api-rewriter/.dockerignore b/images/kube-api-rewriter/.dockerignore new file mode 100644 index 0000000..e5a9ac0 --- /dev/null +++ b/images/kube-api-rewriter/.dockerignore @@ -0,0 +1,9 @@ +.git +*.log +*.swp + +templates +Chart.yaml + +golangci-lint +proxy diff --git a/images/kube-api-rewriter/.gitignore b/images/kube-api-rewriter/.gitignore new file mode 100644 index 0000000..eeb1ad6 --- /dev/null +++ b/images/kube-api-rewriter/.gitignore @@ -0,0 +1 @@ +!pkg/log diff --git a/images/kube-api-rewriter/METRICS.md b/images/kube-api-rewriter/METRICS.md new file mode 100644 index 0000000..f7e3679 --- /dev/null +++ b/images/kube-api-rewriter/METRICS.md @@ -0,0 +1,166 @@ +# Metrics + +## Custom metrics + +These metrics describe proxy instances performance. + +### kube_api_rewriter_client_requests_total + +Total number of received client requests. + +Type: counter + +Labels: +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- decision - proxy decision: pass request Body as-is or rewrite its content. + +### kube_api_rewriter_target_responses_total + +Total number of responses from the target. + +Type: counter + +Labels: +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- decision - proxy decision: pass request Body as-is or rewrite its content. +- status - HTTP status of the target response. +- error - 0 if no error, 1 if error occurred. + +### kube_api_rewriter_target_response_invalid_json_total + +Total target responses with invalid JSON. Can be used to catch accidental Protobuf responses. + +Type: counter + +Labels: +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- status - HTTP status of the target response. + +### kube_api_rewriter_requests_handled_total + +Total number of requests handled by the proxy instance. + +Type: counter + +Labels: +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- decision - proxy decision: `pass` target response Body as-is or `rewrite` its content. +- status - HTTP status of the target response. +- error - 0 if no error, 1 if error occurred. + + +### kube_api_rewriter_request_handling_duration_seconds + +Duration of request handling for non-watching and watch event handling for watch requests + +Type: histogram + +Buckets: 1, 2, 5 ms, 10, 20, 50 ms, 100, 200, 500 ms, 1, 2, 5 s + +Labels: +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- decision - proxy decision: `pass` target response Body as-is or `rewrite` its content. +- status - HTTP status of the target response. + +### kube_api_rewriter_rewrites_total + +Total rewrites executed by the proxy instance. + +Type: counter + +Labels: +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- side - What was rewritten: `client` request or `target` response. +- operation - Rewrite operation: restore or rename. +- error - 0 if no error, 1 if error occurred. + +### kube_api_rewriter_rewrite_duration_seconds + +Duration of rewrite operations. + +Type: histogram + +Buckets: 1, 2, 5 ms, 10, 20, 50 ms, 100, 200, 500 ms, 1, 2, 5 s + +Labels: +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- side - What was rewritten: `client` request or `target` response. +- operation - Rewrite operation: restore or rename. + +### kube_api_rewriter_from_client_bytes_total + +Total bytes received from the client. + +Type: counter + +Labels: + +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- decision - proxy decision: `pass` client request Body as-is or `rewrite` its content. + +### kube_api_rewriter_to_target_bytes_total + +Total bytes transferred to the target. + +Type: counter + +Labels: + +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- decision - proxy decision: `pass` client request Body as-is or `rewrite` its content. + +### kube_api_rewriter_from_target_bytes_total + +Total bytes received from the target. + +Type: counter + +Labels: + +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- decision - proxy decision: `pass` target response Body as-is or `rewrite` its content. + +### kube_api_rewriter_to_client_bytes_total + +Total bytes transferred back to the client. + +Type: counter + +Labels: + +- name - Proxy instance name (kube-api or webhook). +- resource - Kubernetes resource type from url path. +- method - HTTP method of the request. +- watch - Is watch stream requested? (watch=true in the url query). +- decision - proxy decision: `pass` target response Body as-is or `rewrite` its content. + diff --git a/images/kube-api-rewriter/STRUCTURE.md b/images/kube-api-rewriter/STRUCTURE.md new file mode 100644 index 0000000..a3c6033 --- /dev/null +++ b/images/kube-api-rewriter/STRUCTURE.md @@ -0,0 +1,3 @@ +# kube-api-rewriter structure + +_WIP_ diff --git a/images/kube-api-rewriter/Taskfile.dist.yaml b/images/kube-api-rewriter/Taskfile.dist.yaml new file mode 100644 index 0000000..8ba8a68 --- /dev/null +++ b/images/kube-api-rewriter/Taskfile.dist.yaml @@ -0,0 +1,111 @@ +version: "3" + +silent: true + +includes: + my: + taskfile: Taskfile.my.yaml + optional: true + +vars: + DevImage: "${DevImage:-localhost:5000/$USER/kube-api-rewriter:latest}" + +tasks: + default: + cmds: + - task: dev:status + dev:build: + desc: "build latest image with kube-api-rewriter and test-controller" + cmds: + - | + docker build . -t {{.DevImage}} -f local/Dockerfile + docker push {{.DevImage}} + + dev:deploy: + desc: "apply manifest with kube-api-rewriter and test-controller" + cmds: + - task: dev:__deploy + vars: + CTR_COMMAND: "['./kube-api-rewriter']" + + dev:__deploy: + internal: true + cmds: + - | + if ! kubectl get no 2>&1 >/dev/null ; then + echo Restart cluster connection + exit 1 + fi + - | + kubectl get ns kproxy &>/dev/null || kubectl create ns kproxy + kubectl apply -f - <&1 >/dev/null ; then + echo Restart cluster connection + exit 1 + fi + - | + kubectl -n kproxy scale deployment/kube-api-rewriter --replicas=0 + kubectl -n kproxy scale deployment/kube-api-rewriter --replicas=1 + + dev:redeploy: + desc: "build, deploy, restart" + cmds: + - | + if ! kubectl get no 2>&1 >/dev/null ; then + echo Restart cluster connection + exit 1 + fi + - task: dev:build + - task: dev:deploy + - task: dev:restart + - | + sleep 3 + kubectl -n kproxy get all + + dev:status: + cmds: + - | + kubectl -n kproxy get po,deploy + + dev:curl: + desc: "run curl in kube-api-rewriter deployment" + cmds: + - | + kubectl -n kproxy exec -t deploy/kube-api-rewriter -- curl {{.CLI_ARGS}} + + dev:kubectl: + desc: "run kubectl in kube-api-rewriter deployment" + cmds: + - | + kubectl -n kproxy exec deploy/kube-api-rewriter -c proxy -- kubectl -s 127.0.0.1:23915 {{.CLI_ARGS}} + #kubectl -n d8-virtualization exec deploy/virt-operator -- kubectl -s 127.0.0.1:23915 {{.CLI_ARGS}} + + logs:proxy: + desc: "Logs for proxy container" + cmds: + - | + kubectl -n kproxy logs deployments/kube-api-rewriter -c proxy -f + + logs:controller: + desc: "Logs for test-controller container" + cmds: + - | + kubectl -n kproxy logs deployments/kube-api-rewriter -c controller -f diff --git a/images/kube-api-rewriter/cmd/kube-api-rewriter/main.go b/images/kube-api-rewriter/cmd/kube-api-rewriter/main.go new file mode 100644 index 0000000..dc80460 --- /dev/null +++ b/images/kube-api-rewriter/cmd/kube-api-rewriter/main.go @@ -0,0 +1,224 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + log "log/slog" + "net/http" + "os" + + logutil "github.com/deckhouse/kube-api-rewriter/pkg/log" + "github.com/deckhouse/kube-api-rewriter/pkg/monitoring/healthz" + "github.com/deckhouse/kube-api-rewriter/pkg/monitoring/metrics" + "github.com/deckhouse/kube-api-rewriter/pkg/monitoring/profiler" + "github.com/deckhouse/kube-api-rewriter/pkg/operatornelm" + "github.com/deckhouse/kube-api-rewriter/pkg/proxy" + "github.com/deckhouse/kube-api-rewriter/pkg/rewriter" + "github.com/deckhouse/kube-api-rewriter/pkg/server" + "github.com/deckhouse/kube-api-rewriter/pkg/target" +) + +// This proxy is a proof-of-concept of proxying Kubernetes API requests +// with rewrites. +// +// It assumes presence of KUBERNETES_* environment variables and files +// in /var/run/secrets/kubernetes.io/serviceaccount (token and ca.crt). +// +// A client behind the proxy should connect to 127.0.0.1:$PROXY_PORT +// using plain http. Example of kubeconfig file: +// apiVersion: v1 +// kind: Config +// clusters: +// - cluster: +// server: http://127.0.0.1:23915 +// name: proxy.api.server +// contexts: +// - context: +// cluster: proxy.api.server +// name: proxy.api.server +// current-context: proxy.api.server + +const ( + loopbackAddr = "127.0.0.1" + anyAddr = "0.0.0.0" + defaultAPIClientProxyPort = "23915" + defaultWebhookProxyPort = "24192" +) + +const ( + logLevelEnv = "LOG_LEVEL" + logFormatEnv = "LOG_FORMAT" + logOutputEnv = "LOG_OUTPUT" +) + +const ( + MonitoringBindAddress = "MONITORING_BIND_ADDRESS" + DefaultMonitoringBindAddress = ":9090" + PprofBindAddressEnv = "PPROF_BIND_ADDRESS" +) + +func main() { + // Set options for the default logger: level, format and output. + logutil.SetupDefaultLoggerFromEnv(logutil.Options{ + Level: os.Getenv(logLevelEnv), + Format: os.Getenv(logFormatEnv), + Output: os.Getenv(logOutputEnv), + }) + + // Load rules from file or use default kubevirt rules. + rewriteRules := operatornelm.OperatorNelmRewriteRules + if os.Getenv("RULES_PATH") != "" { + rulesFromFile, err := rewriter.LoadRules(os.Getenv("RULES_PATH")) + if err != nil { + log.Error("Load rules from %s: %v", os.Getenv("RULES_PATH"), err) + os.Exit(1) + } + rewriteRules = rulesFromFile + } + rewriteRules.Init() + + // Init and register metrics. + metrics.Init() + proxy.RegisterMetrics() + + httpServers := make([]*server.HTTPServer, 0) + + // Now add proxy workers with rewriters. + hasRewriter := false + + // Register direct proxy from local Kubernetes API client to Kubernetes API server. + if os.Getenv("CLIENT_PROXY") == "no" { + log.Info("Will not start client proxy: CLIENT_PROXY=no") + } else { + config, err := target.NewKubernetesTarget() + if err != nil { + log.Error("Load Kubernetes REST", logutil.SlogErr(err)) + os.Exit(1) + } + lAddr := server.ConstructListenAddr( + os.Getenv("CLIENT_PROXY_ADDRESS"), os.Getenv("CLIENT_PROXY_PORT"), + loopbackAddr, defaultAPIClientProxyPort) + rwr := &rewriter.RuleBasedRewriter{ + Rules: rewriteRules, + } + proxyHandler := &proxy.Handler{ + Name: "kube-api", + TargetClient: config.Client, + TargetURL: config.APIServerURL, + ProxyMode: proxy.ToRenamed, + Rewriter: rwr, + } + proxyHandler.Init() + proxySrv := &server.HTTPServer{ + InstanceDesc: "API Client proxy", + ListenAddr: lAddr, + RootHandler: proxyHandler, + } + httpServers = append(httpServers, proxySrv) + hasRewriter = true + } + + // Register reverse proxy from Kubernetes API server to local webhook server. + if os.Getenv("WEBHOOK_ADDRESS") == "" { + log.Info("Will not start webhook proxy for empty WEBHOOK_ADDRESS") + } else { + config, err := target.NewWebhookTarget() + if err != nil { + log.Error("Configure webhook client", logutil.SlogErr(err)) + os.Exit(1) + } + lAddr := server.ConstructListenAddr( + os.Getenv("WEBHOOK_PROXY_ADDRESS"), os.Getenv("WEBHOOK_PROXY_PORT"), + anyAddr, defaultWebhookProxyPort) + rwr := &rewriter.RuleBasedRewriter{ + Rules: rewriteRules, + } + proxyHandler := &proxy.Handler{ + Name: "webhook", + TargetClient: config.Client, + TargetURL: config.URL, + ProxyMode: proxy.ToOriginal, + Rewriter: rwr, + } + proxyHandler.Init() + proxySrv := &server.HTTPServer{ + InstanceDesc: "Webhook proxy", + ListenAddr: lAddr, + RootHandler: proxyHandler, + CertManager: config.CertManager, + } + httpServers = append(httpServers, proxySrv) + hasRewriter = true + } + + if !hasRewriter { + log.Info("No proxy rewriters to start, exit. Check CLIENT_PROXY and WEBHOOK_ADDRESS environment variables.") + return + } + + // Always add monitoring server with metrics and healthz probes + { + lAddr := os.Getenv(MonitoringBindAddress) + if lAddr == "" { + lAddr = DefaultMonitoringBindAddress + } + + monMux := http.NewServeMux() + healthz.AddHealthzHandler(monMux) + metrics.AddMetricsHandler(monMux) + + monSrv := &server.HTTPServer{ + InstanceDesc: "Monitoring handlers", + ListenAddr: lAddr, + RootHandler: monMux, + CertManager: nil, + Err: nil, + } + httpServers = append(httpServers, monSrv) + } + + // Enable pprof server if bind address is specified. + pprofBindAddress := os.Getenv(PprofBindAddressEnv) + if pprofBindAddress != "" { + pprofHandler := profiler.NewPprofHandler() + + pprofSrv := &server.HTTPServer{ + InstanceDesc: "Pprof", + ListenAddr: pprofBindAddress, + RootHandler: pprofHandler, + } + httpServers = append(httpServers, pprofSrv) + } + + // Start all registered servers and block the main process until at least one server stops. + group := server.NewRunnableGroup() + for i := range httpServers { + group.Add(httpServers[i]) + } + // Block while servers are running. + group.Start() + + // Log errors for each instance and exit. + exitCode := 0 + for _, srv := range httpServers { + if srv.Err != nil { + log.Error(srv.InstanceDesc, logutil.SlogErr(srv.Err)) + exitCode = 1 + } + } + os.Exit(exitCode) +} diff --git a/images/kube-api-rewriter/go.mod b/images/kube-api-rewriter/go.mod new file mode 100644 index 0000000..1a6e730 --- /dev/null +++ b/images/kube-api-rewriter/go.mod @@ -0,0 +1,73 @@ +module github.com/deckhouse/kube-api-rewriter + +go 1.25.0 + +require ( + github.com/fsnotify/fsnotify v1.9.0 + github.com/josephburnett/jd v1.9.2 + github.com/kr/text v0.2.0 + github.com/prometheus/client_golang v1.23.2 + github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.18.0 + github.com/tidwall/sjson v1.2.5 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 + sigs.k8s.io/controller-runtime v0.23.1 + sigs.k8s.io/yaml v1.6.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect +) + +replace google.golang.org/protobuf => google.golang.org/protobuf v1.33.0 + +// CVE Replaces +replace ( + golang.org/x/net => golang.org/x/net v0.40.0 // CVE-2025-22870, CVE-2025-22872 + golang.org/x/oauth2 => golang.org/x/oauth2 v0.27.0 // CVE-2025-22868 +) diff --git a/images/kube-api-rewriter/go.sum b/images/kube-api-rewriter/go.sum new file mode 100644 index 0000000..6dd7074 --- /dev/null +++ b/images/kube-api-rewriter/go.sum @@ -0,0 +1,164 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y= +github.com/josephburnett/jd v1.9.2/go.mod h1:bImDr8QXpxMb3SD+w1cDRHp97xP6UwI88xUAuxwDQfM= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/images/kube-api-rewriter/local/Dockerfile b/images/kube-api-rewriter/local/Dockerfile new file mode 100644 index 0000000..d3b3ff3 --- /dev/null +++ b/images/kube-api-rewriter/local/Dockerfile @@ -0,0 +1,45 @@ +# Build kube-api-rewriter for local development purposes. +# Note: it is not a part of the production build! + +# Go builder. +FROM golang:1.22.7-alpine3.19 AS builder + +RUN go install github.com/go-delve/delve/cmd/dlv@latest + +# Cache-friendly download of go dependencies. +ADD go.mod go.sum /app/ +WORKDIR /app +RUN go mod download + +ADD . /app + +RUN GOOS=linux \ + go build -o kube-api-rewriter ./cmd/kube-api-rewriter + +# Go builder. +FROM golang:1.22.7-alpine3.19 AS builder-test-controller + +# Cache-friendly download of go dependencies. +ADD local/test-controller/go.mod local/test-controller/go.sum /app/ +WORKDIR /app +RUN go mod download + +ADD local/test-controller/main.go /app/ + +RUN GOOS=linux \ + go build -o test-controller . + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates bash sed tini curl && \ + kubectlArch=linux/amd64 && \ + echo "Download kubectl for ${kubectlArch}" && \ + wget https://storage.googleapis.com/kubernetes-release/release/v1.30.0/bin/${kubectlArch}/kubectl -O /bin/kubectl && \ + chmod +x /bin/kubectl +COPY --from=builder /go/bin/dlv / +COPY --from=builder /app/kube-api-rewriter / +COPY --from=builder-test-controller /app/test-controller / +ADD local/kube-api-rewriter.kubeconfig / + +# Use user nobody. +USER 65534:65534 +WORKDIR / diff --git a/images/kube-api-rewriter/local/kube-api-rewriter.kubeconfig b/images/kube-api-rewriter/local/kube-api-rewriter.kubeconfig new file mode 100644 index 0000000..11f4a32 --- /dev/null +++ b/images/kube-api-rewriter/local/kube-api-rewriter.kubeconfig @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Config +clusters: +- cluster: + server: http://127.0.0.1:23915 + name: kube-api-rewriter +contexts: +- context: + cluster: kube-api-rewriter + name: kube-api-rewriter +current-context: kube-api-rewriter diff --git a/images/kube-api-rewriter/local/proxy-gen-certs.sh b/images/kube-api-rewriter/local/proxy-gen-certs.sh new file mode 100755 index 0000000..9514d0d --- /dev/null +++ b/images/kube-api-rewriter/local/proxy-gen-certs.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +# Copyright 2024 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +NAMESPACE=kproxy +SERVICE_NAME=test-admission-webhook +CN="api proxying tests for validating webhook" +OUTDIR=proxy-certs + +COMMON_NAME=${SERVICE_NAME}.${NAMESPACE} + +set -eo pipefail + +echo ================================================================= +echo THIS SCRIPT IS NOT SECURE! USE IT ONLY FOR DEMONSTATION PURPOSES. +echo ================================================================= +echo + +mkdir -p ${OUTDIR} && cd ${OUTDIR} + +if [[ -e ca.csr ]] ; then + read -p "Regenerate certificates? (yes/no) [no]: " + if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]] + then + exit 0 + fi +fi + +RM_FILES="ca* cert*" +echo ">>> Remove ${RM_FILES}" +rm -f $RM_FILES + +echo ">>> Generate CA key and certificate" +cat <>> Generate cert.key and cert.crt" +cat < ./../../../../api + +// TODO: delete this replaces after fixing https://github.com/golang/go/issues/66403. +replace ( + github.com/cilium/proxy => github.com/cilium/proxy v0.0.0-20231202123106-38b645b854f3 + github.com/markbates/safe => github.com/markbates/safe v1.0.1 + k8s.io/api => k8s.io/api v0.29.2 + k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.29.2 + k8s.io/apimachinery => k8s.io/apimachinery v0.29.2 + k8s.io/apiserver => k8s.io/apiserver v0.29.2 + k8s.io/code-generator => k8s.io/code-generator v0.29.2 + k8s.io/component-base => k8s.io/component-base v0.29.2 + k8s.io/kms => k8s.io/kms v0.29.2 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch/v5 v5.8.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/openshift/api v0.0.0-20230503133300-8bbcb7ca7183 // indirect + github.com/openshift/custom-resource-status v1.1.2 // indirect + github.com/pborman/uuid v1.2.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.18.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/oauth2 v0.12.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/term v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.3.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/component-base v0.29.2 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + kubevirt.io/containerized-data-importer-api v1.57.0-alpha1 // indirect + kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/images/kube-api-rewriter/local/test-controller/go.sum b/images/kube-api-rewriter/local/test-controller/go.sum new file mode 100644 index 0000000..e0ca07b --- /dev/null +++ b/images/kube-api-rewriter/local/test-controller/go.sum @@ -0,0 +1,484 @@ +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckhouse/virtualization/api v0.0.0-20240417135227-efb465e54575 h1:FdSicGvp9Gz1dvrzV7vVkMAlEMYUWMKq/QLKeZxZOtw= +github.com/deckhouse/virtualization/api v0.0.0-20240417135227-efb465e54575/go.mod h1:1tfoFeZmlKqq6jEuSfIpdrxsBpOcMajYaCbO94pVQLs= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful v2.15.0+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.8.0 h1:lRj6N9Nci7MvzrXuX6HFzU8XjmhPiXPlsKEy1u0KQro= +github.com/evanphx/json-patch/v5 v5.8.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= +github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= +github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= +github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= +github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= +github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= +github.com/onsi/ginkgo/v2 v2.8.1/go.mod h1:N1/NbDngAFcSLdyZ+/aYTYGSlq9qMCS/cNKGJjy+csc= +github.com/onsi/ginkgo/v2 v2.9.0/go.mod h1:4xkjoL/tZv4SMWeww56BU5kAt19mVB47gTWxmrTcxyk= +github.com/onsi/ginkgo/v2 v2.9.1/go.mod h1:FEcmzVcCHl+4o9bQZVab+4dC9+j+91t2FHSzmGAPfuo= +github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= +github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= +github.com/onsi/ginkgo/v2 v2.9.7/go.mod h1:cxrmXWykAwTwhQsJOPfdIDiJ+l2RYq7U8hFU+M/1uw0= +github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/ginkgo/v2 v2.14.0 h1:vSmGj2Z5YPb9JwCWT6z6ihcUvDhuXLc3sJiqd3jMKAY= +github.com/onsi/ginkgo/v2 v2.14.0/go.mod h1:JkUdW7JkN0V6rFvsHcJ478egV3XH9NxpD27Hal/PhZw= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= +github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= +github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= +github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= +github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= +github.com/onsi/gomega v1.26.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= +github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= +github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw= +github.com/onsi/gomega v1.27.4/go.mod h1:riYq/GJKh8hhoM01HN6Vmuy93AarCXCBGpvFDK3q3fQ= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4= +github.com/onsi/gomega v1.27.8/go.mod h1:2J8vzI/s+2shY9XHRApDkdgPo1TKT7P2u6fXeJKFnNQ= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= +github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/openshift/api v0.0.0-20230503133300-8bbcb7ca7183 h1:t/CahSnpqY46sQR01SoS+Jt0jtjgmhgE6lFmRnO4q70= +github.com/openshift/api v0.0.0-20230503133300-8bbcb7ca7183/go.mod h1:4VWG+W22wrB4HfBL88P40DxLEpSOaiBVxUnfalfJo9k= +github.com/openshift/custom-resource-status v1.1.2 h1:C3DL44LEbvlbItfd8mT5jWrqPfHnSOQoQf/sypqA6A4= +github.com/openshift/custom-resource-status v1.1.2/go.mod h1:DB/Mf2oTeiAmVVX1gN+NEqweonAPY0TKUwADizj8+ZA= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= +golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A= +k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0= +k8s.io/apiextensions-apiserver v0.29.2 h1:UK3xB5lOWSnhaCk0RFZ0LUacPZz9RY4wi/yt2Iu+btg= +k8s.io/apiextensions-apiserver v0.29.2/go.mod h1:aLfYjpA5p3OwtqNXQFkhJ56TB+spV8Gc4wfMhUA3/b8= +k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= +k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= +k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg= +k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA= +k8s.io/code-generator v0.29.2/go.mod h1:FwFi3C9jCrmbPjekhaCYcYG1n07CYiW1+PAPCockaos= +k8s.io/component-base v0.29.2 h1:lpiLyuvPA9yV1aQwGLENYyK7n/8t6l3nn3zAtFTJYe8= +k8s.io/component-base v0.29.2/go.mod h1:BfB3SLrefbZXiBfbM+2H1dlat21Uewg/5qtKOl8degM= +k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= +k8s.io/gengo v0.0.0-20211129171323-c02415ce4185/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= +k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/klog/v2 v2.40.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/kube-openapi v0.0.0-20220124234850-424119656bbf/go.mod h1:sX9MT8g7NVZM5lVL/j8QyCCJe8YSMW30QvGZWaCIDIk= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +kubevirt.io/api v1.0.0 h1:RBdXP5CDhE0v5qL2OUQdrYyRrHe/F68Z91GWqBDF6nw= +kubevirt.io/api v1.0.0/go.mod h1:CJ4vZsaWhVN3jNbyc9y3lIZhw8nUHbWjap0xHABQiqc= +kubevirt.io/containerized-data-importer-api v1.57.0-alpha1 h1:IWo12+ei3jltSN5jQN1xjgakfvRSF3G3Rr4GXVOOy2I= +kubevirt.io/containerized-data-importer-api v1.57.0-alpha1/go.mod h1:Y/8ETgHS1GjO89bl682DPtQOYEU/1ctPFBz6Sjxm4DM= +kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 h1:QMrd0nKP0BGbnxTqakhDZAUhGKxPiPiN5gSDqKUmGGc= +kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90/go.mod h1:018lASpFYBsYN6XwmA2TIrPCx6e0gviTd/ZNtSitKgc= +sigs.k8s.io/controller-runtime v0.17.2 h1:FwHwD1CTUemg0pW2otk7/U5/i5m2ymzvOXdbeGOUvw0= +sigs.k8s.io/controller-runtime v0.17.2/go.mod h1:+MngTvIQQQhfXtwfdGw/UOQ/aIaqsYywfCINOtwMO/s= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/images/kube-api-rewriter/local/test-controller/main.go b/images/kube-api-rewriter/local/test-controller/main.go new file mode 100644 index 0000000..f602da2 --- /dev/null +++ b/images/kube-api-rewriter/local/test-controller/main.go @@ -0,0 +1,369 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "flag" + "fmt" + "os" + "runtime" + "strconv" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/go-logr/logr" + "go.uber.org/zap/zapcore" + corev1 "k8s.io/api/core/v1" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/manager/signals" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var ( + log = logf.Log.WithName("cmd") + resourcesSchemeFuncs = []func(*apiruntime.Scheme) error{ + clientgoscheme.AddToScheme, + extv1.AddToScheme, + virtv1.AddToScheme, + v1alpha2.AddToScheme, + } +) + +const ( + podNamespaceVar = "POD_NAMESPACE" + defaultVerbosity = "1" +) + +func setupLogger() { + verbose := defaultVerbosity + if verboseEnvVarVal := os.Getenv("VERBOSITY"); verboseEnvVarVal != "" { + verbose = verboseEnvVarVal + } + // visit actual flags passed in and if passed check -v and set verbose + if fv := flag.Lookup("v"); fv != nil { + verbose = fv.Value.String() + } + if verbose == defaultVerbosity { + log.V(1).Info(fmt.Sprintf("Note: increase the -v level in the controller deployment for more detailed logging, eg. -v=%d or -v=%d\n", 2, 3)) + } + verbosityLevel, err := strconv.Atoi(verbose) + debug := false + if err == nil && verbosityLevel > 1 { + debug = true + } + + // The logger instantiated here can be changed to any logger + // implementing the logr.Logger interface. This logger will + // be propagated through the whole operator, generating + // uniform and structured logs. + logf.SetLogger(zap.New(zap.Level(zapcore.Level(-1*verbosityLevel)), zap.UseDevMode(debug))) +} + +func printVersion() { + log.Info(fmt.Sprintf("Go Version: %s", runtime.Version())) + log.Info(fmt.Sprintf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)) +} + +func main() { + flag.Parse() + + setupLogger() + printVersion() + + // Get a config to talk to the apiserver + cfg, err := config.GetConfig() + if err != nil { + log.Error(err, "") + os.Exit(1) + } + + leaderElectionNS := os.Getenv(podNamespaceVar) + if leaderElectionNS == "" { + leaderElectionNS = "default" + } + + // Setup scheme for all resources + scheme := apiruntime.NewScheme() + for _, f := range resourcesSchemeFuncs { + err = f(scheme) + if err != nil { + log.Error(err, "Failed to add to scheme") + os.Exit(1) + } + } + + managerOpts := manager.Options{ + // This controller watches resources in all namespaces. + LeaderElection: false, + LeaderElectionNamespace: leaderElectionNS, + LeaderElectionID: "test-controller-leader-election-helper", + LeaderElectionResourceLock: "leases", + Scheme: scheme, + } + + // Create a new Manager to provide shared dependencies and start components + mgr, err := manager.New(cfg, managerOpts) + if err != nil { + log.Error(err, "") + os.Exit(1) + } + + log.Info("Bootstrapping the Manager.") + + // Setup context to gracefully handle termination. + ctx := signals.SetupSignalHandler() + + // Add initial lister to sync rules and routes at start. + initLister := &InitialLister{ + client: mgr.GetClient(), + log: log, + } + err = mgr.Add(initLister) + if err != nil { + log.Error(err, "add initial lister to the manager") + } + + // + if _, err := NewController(ctx, mgr, log); err != nil { + log.Error(err, "") + os.Exit(1) + } + + // Start the Manager. + if err := mgr.Start(ctx); err != nil { + log.Error(err, "manager exited non-zero") + os.Exit(1) + } +} + +// InitialLister is a Runnable implementatin to access existing objects +// before handling any event with Reconcile method. +type InitialLister struct { + log logr.Logger + client client.Client +} + +func (i *InitialLister) Start(ctx context.Context) error { + cl := i.client + + // List VMs, Pods, CRDs before starting manager. + vms := v1alpha2.VirtualMachineList{} + err := cl.List(ctx, &vms) + if err != nil { + i.log.Error(err, "list VMs") + return err + } + log.Info(fmt.Sprintf("List returns %d VMs", len(vms.Items))) + for _, vm := range vms.Items { + i.log.Info(fmt.Sprintf("observe VM %s/%s at start", vm.GetNamespace(), vm.GetName())) + } + + pods := corev1.PodList{} + err = cl.List(ctx, &pods, client.InNamespace("")) + if err != nil { + i.log.Error(err, "list Pods") + return err + } + log.Info(fmt.Sprintf("List returns %d Pods", len(pods.Items))) + for _, pod := range pods.Items { + i.log.Info(fmt.Sprintf("observe Pod %s/%s at start", pod.GetNamespace(), pod.GetName())) + } + + crds := extv1.CustomResourceDefinitionList{} + err = cl.List(ctx, &crds, client.InNamespace("")) + if err != nil { + i.log.Error(err, "list Pods") + return err + } + log.Info(fmt.Sprintf("List returns %d CRDs", len(crds.Items))) + for _, crd := range crds.Items { + i.log.Info(fmt.Sprintf("observe CRD %s/%s at start", crd.GetNamespace(), crd.GetName())) + } + + i.log.Info("Initial listing done, proceed to manager Start") + return nil +} + +const ( + controllerName = "test-controller" +) + +func NewController( + ctx context.Context, + mgr manager.Manager, + log logr.Logger, +) (controller.Controller, error) { + reconciler := &VMReconciler{ + Client: mgr.GetClient(), + Cache: mgr.GetCache(), + Recorder: mgr.GetEventRecorderFor(controllerName), + Scheme: mgr.GetScheme(), + Log: log, + } + + c, err := controller.New(controllerName, mgr, controller.Options{Reconciler: reconciler}) + if err != nil { + return nil, err + } + + if err = SetupWatches(ctx, mgr, c, log); err != nil { + return nil, err + } + + if err = SetupWebhooks(ctx, mgr, reconciler); err != nil { + return nil, err + } + + log.Info("Initialized controller with test watches") + return c, nil +} + +// SetupWatches subscripts controller to Pods, CRDs and DVP VMs. +func SetupWatches(ctx context.Context, mgr manager.Manager, ctr controller.Controller, log logr.Logger) error { + if err := ctr.Watch(source.Kind(mgr.GetCache(), &v1alpha2.VirtualMachine{}), &handler.EnqueueRequestForObject{}, + // if err := ctr.Watch(source.Kind(mgr.GetCache(), &corev1.Pod{}), &handler.EnqueueRequestForObject{}, + predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + log.Info(fmt.Sprintf("Got CREATE event for VM %s/%s gvk %v", e.Object.GetNamespace(), e.Object.GetName(), e.Object.GetObjectKind().GroupVersionKind())) + return true + }, + DeleteFunc: func(e event.DeleteEvent) bool { + log.Info(fmt.Sprintf("Got DELETE event for VM %s/%s gvk %v", e.Object.GetNamespace(), e.Object.GetName(), e.Object.GetObjectKind().GroupVersionKind())) + return true + }, + UpdateFunc: func(e event.UpdateEvent) bool { + log.Info(fmt.Sprintf("Got UPDATE event for VM %s/%s gvk %v", e.ObjectNew.GetNamespace(), e.ObjectNew.GetName(), e.ObjectNew.GetObjectKind().GroupVersionKind())) + return true + }, + }, + ); err != nil { + return fmt.Errorf("error setting watch on DVP VMs: %w", err) + } + + if err := ctr.Watch(source.Kind(mgr.GetCache(), &corev1.Pod{}), &handler.EnqueueRequestForObject{}, + predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + log.Info(fmt.Sprintf("Got CREATE event for Pod %s/%s gvk %v", e.Object.GetNamespace(), e.Object.GetName(), e.Object.GetObjectKind().GroupVersionKind())) + return true + }, + DeleteFunc: func(e event.DeleteEvent) bool { + log.Info(fmt.Sprintf("Got DELETE event for Pod %s/%s gvk %v", e.Object.GetNamespace(), e.Object.GetName(), e.Object.GetObjectKind().GroupVersionKind())) + return true + }, + UpdateFunc: func(e event.UpdateEvent) bool { + log.Info(fmt.Sprintf("Got UPDATE event for Pod %s/%s gvk %v", e.ObjectNew.GetNamespace(), e.ObjectNew.GetName(), e.ObjectNew.GetObjectKind().GroupVersionKind())) + return true + }, + }, + ); err != nil { + return fmt.Errorf("error setting watch on Pods: %w", err) + } + + if err := ctr.Watch(source.Kind(mgr.GetCache(), &extv1.CustomResourceDefinition{}), &handler.EnqueueRequestForObject{}, + predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + log.Info(fmt.Sprintf("Got CREATE event for CRD %s/%s gvk %v", e.Object.GetNamespace(), e.Object.GetName(), e.Object.GetObjectKind().GroupVersionKind())) + return true + }, + DeleteFunc: func(e event.DeleteEvent) bool { + log.Info(fmt.Sprintf("Got DELETE event for CRD %s/%s gvk %v", e.Object.GetNamespace(), e.Object.GetName(), e.Object.GetObjectKind().GroupVersionKind())) + return true + }, + UpdateFunc: func(e event.UpdateEvent) bool { + log.Info(fmt.Sprintf("Got UPDATE event for CRD %s/%s gvk %v", e.ObjectNew.GetNamespace(), e.ObjectNew.GetName(), e.ObjectNew.GetObjectKind().GroupVersionKind())) + return true + }, + }, + ); err != nil { + return fmt.Errorf("error setting watch on CRDs: %w", err) + } + + return nil +} + +func SetupWebhooks(ctx context.Context, mgr manager.Manager, validator admission.CustomValidator) error { + return builder.WebhookManagedBy(mgr). + For(&virtv1.VirtualMachine{}). + WithValidator(validator). + Complete() +} + +type VMReconciler struct { + Client client.Client + Cache cache.Cache + Recorder record.EventRecorder + Scheme *apiruntime.Scheme + Log logr.Logger +} + +func (r *VMReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + r.Log.Info(fmt.Sprintf("Got request for %s", req.String())) + return reconcile.Result{}, nil +} + +func (r *VMReconciler) ValidateCreate(ctx context.Context, obj apiruntime.Object) (admission.Warnings, error) { + vm, ok := obj.(*virtv1.VirtualMachine) + if !ok { + return nil, fmt.Errorf("expected a new VirtualMachine but got a %T", obj) + } + + warnings := admission.Warnings{ + fmt.Sprintf("Validate new VM %s is OK, got kind %s, apiVersion %s", vm.GetName(), vm.GetObjectKind(), vm.APIVersion), + } + return warnings, nil +} + +func (r *VMReconciler) ValidateUpdate(ctx context.Context, _, newObj apiruntime.Object) (admission.Warnings, error) { + vm, ok := newObj.(*virtv1.VirtualMachine) + if !ok { + return nil, fmt.Errorf("expected a new VirtualMachine but got a %T", newObj) + } + + warnings := admission.Warnings{ + fmt.Sprintf("Validate updated VM %s is OK, got kind %s, apiVersion %s", vm.GetName(), vm.GetObjectKind(), vm.APIVersion), + } + return warnings, nil +} + +func (v *VMReconciler) ValidateDelete(_ context.Context, obj apiruntime.Object) (admission.Warnings, error) { + vm, ok := obj.(*virtv1.VirtualMachine) + if !ok { + return nil, fmt.Errorf("expected a deleted VirtualMachine but got a %T", obj) + } + + warnings := admission.Warnings{ + fmt.Sprintf("Validate deleted VM %s is OK, got kind %s, apiVersion %s", vm.GetName(), vm.GetObjectKind(), vm.APIVersion), + } + return warnings, nil +} diff --git a/images/kube-api-rewriter/mount-points.yaml b/images/kube-api-rewriter/mount-points.yaml new file mode 100644 index 0000000..eefff43 --- /dev/null +++ b/images/kube-api-rewriter/mount-points.yaml @@ -0,0 +1 @@ +dirs: [] diff --git a/images/kube-api-rewriter/pkg/labels/context_values.go b/images/kube-api-rewriter/pkg/labels/context_values.go new file mode 100644 index 0000000..55e27ef --- /dev/null +++ b/images/kube-api-rewriter/pkg/labels/context_values.go @@ -0,0 +1,104 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package labels + +import ( + "context" + "strconv" +) + +func ContextWithCommon(ctx context.Context, name, resource, method, watch, toTargetAction, fromTargetAction string) context.Context { + ctx = context.WithValue(ctx, resourceKey{}, resource) + ctx = context.WithValue(ctx, methodKey{}, method) + ctx = context.WithValue(ctx, watchKey{}, watch) + ctx = context.WithValue(ctx, toTargetActionKey{}, toTargetAction) + ctx = context.WithValue(ctx, toTargetActionKey{}, fromTargetAction) + return context.WithValue(ctx, nameKey{}, name) +} + +func ContextWithDecision(ctx context.Context, decision string) context.Context { + return context.WithValue(ctx, decisionKey{}, decision) +} + +func ContextWithStatus(ctx context.Context, status int) context.Context { + return context.WithValue(ctx, statusKey{}, strconv.Itoa(status)) +} + +type nameKey struct{} +type resourceKey struct{} +type methodKey struct{} +type watchKey struct{} +type decisionKey struct{} +type toTargetActionKey struct{} +type fromTargetActionKey struct{} +type statusKey struct{} + +func NameFromContext(ctx context.Context) string { + if method, ok := ctx.Value(nameKey{}).(string); ok { + return method + } + return "" +} + +func ResourceFromContext(ctx context.Context) string { + if method, ok := ctx.Value(resourceKey{}).(string); ok { + return method + } + return "" +} + +func MethodFromContext(ctx context.Context) string { + if method, ok := ctx.Value(methodKey{}).(string); ok { + return method + } + return "" +} + +func WatchFromContext(ctx context.Context) string { + if value, ok := ctx.Value(watchKey{}).(string); ok { + return value + } + return "" +} + +func ToTargetActionFromContext(ctx context.Context) string { + if value, ok := ctx.Value(toTargetActionKey{}).(string); ok { + return value + } + return "" +} + +func FromTargetActionFromContext(ctx context.Context) string { + if value, ok := ctx.Value(fromTargetActionKey{}).(string); ok { + return value + } + return "" +} + +func DecisionFromContext(ctx context.Context) string { + if decision, ok := ctx.Value(decisionKey{}).(string); ok { + return decision + } + return "" +} + +func StatusFromContext(ctx context.Context) string { + if decision, ok := ctx.Value(statusKey{}).(string); ok { + return decision + } + return "" +} diff --git a/images/kube-api-rewriter/pkg/log/attrs.go b/images/kube-api-rewriter/pkg/log/attrs.go new file mode 100644 index 0000000..09c3ff0 --- /dev/null +++ b/images/kube-api-rewriter/pkg/log/attrs.go @@ -0,0 +1,31 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package log + +import "log/slog" + +func SlogErr(err error) slog.Attr { + return slog.Any("err", err) +} + +func BodyDiff(diff string) slog.Attr { + return slog.String(BodyDiffKey, diff) +} + +func BodyDump(dump string) slog.Attr { + return slog.String(BodyDumpKey, dump) +} diff --git a/images/kube-api-rewriter/pkg/log/body.go b/images/kube-api-rewriter/pkg/log/body.go new file mode 100644 index 0000000..6cf3d7d --- /dev/null +++ b/images/kube-api-rewriter/pkg/log/body.go @@ -0,0 +1,83 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package log + +import ( + "bytes" + "fmt" + "io" +) + +// ReaderLogger is ReadCloser implementation that catches content +// while underlying Reader is being read, e.g. with io.Copy. +// Content is copied into the buffer and may be used after copying +// for logging or other handling. +type ReaderLogger struct { + wrappedReader io.ReadCloser + buf bytes.Buffer +} + +func NewReaderLogger(r io.Reader) *ReaderLogger { + rdr := &ReaderLogger{} + rdr.wrappedReader = io.NopCloser(io.TeeReader(r, &rdr.buf)) + return rdr +} + +func (r *ReaderLogger) Read(p []byte) (n int, err error) { + return r.wrappedReader.Read(p) +} + +func (r *ReaderLogger) Close() error { + return r.wrappedReader.Close() +} + +func HeadString(obj interface{}, limit int) string { + readLog, ok := obj.(*ReaderLogger) + if !ok { + return "" + } + bufLen := readLog.buf.Len() + bufStr := readLog.buf.String() + if bufLen < limit { + return bufStr + } + return bufStr[0:limit] +} + +func HeadStringEx(obj interface{}, limit int) string { + s := HeadString(obj, limit) + if s == "" { + return "" + } + return fmt.Sprintf("[%d] %s", len(s), s) +} + +func HasData(obj interface{}) bool { + readLog, ok := obj.(*ReaderLogger) + if !ok { + return false + } + return readLog.buf.Len() > 0 +} + +func Bytes(obj interface{}) []byte { + readLog, ok := obj.(*ReaderLogger) + if !ok { + return nil + } + return readLog.buf.Bytes() +} diff --git a/images/kube-api-rewriter/pkg/log/differ.go b/images/kube-api-rewriter/pkg/log/differ.go new file mode 100644 index 0000000..e9a4c86 --- /dev/null +++ b/images/kube-api-rewriter/pkg/log/differ.go @@ -0,0 +1,133 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package log + +import ( + "bytes" + "fmt" + "log/slog" + + jd "github.com/josephburnett/jd/lib" + "github.com/tidwall/gjson" +) + +// DebugBodyChanges logs debug message with diff between 2 bodies. +func DebugBodyChanges(logger *slog.Logger, msg string, resourceType string, inBytes, rwrBytes []byte) { + if !logger.Enabled(nil, slog.LevelDebug) { + return + } + + // No changes were made to inBytes. + if rwrBytes == nil { + logger.Debug(fmt.Sprintf("%s: no changes after rewrite", msg)) + return + } + + if len(inBytes) == 0 && len(rwrBytes) == 0 { + logger.Debug(fmt.Sprintf("%s: empty body", msg)) + return + } + + if len(inBytes) == 0 && len(rwrBytes) != 0 { + logger.Debug(fmt.Sprintf("%s: possible bug: empty body produces %d bytes", msg, len(rwrBytes))) + DebugBodyHead(logger, msg, resourceType, rwrBytes) + return + } + + if len(inBytes) != 0 && len(rwrBytes) == 0 { + logger.Error(fmt.Sprintf("%s: possible bug: non-empty body [%d] produces empty rewrite", msg, len(inBytes))) + DebugBodyHead(logger, msg, resourceType, inBytes) + return + } + + // Print diff for non-empty non-equal JSONs. + diffContent, err := Diff(inBytes, rwrBytes) + if err != nil { + // Rollback to printing a limited part of the JSON. + logger.Error(fmt.Sprintf("Can't diff '%s' JSONs after rewrite", resourceType), SlogErr(err)) + DebugBodyHead(logger, msg, resourceType, rwrBytes) + return + } + + // TODO pass ns/name as arguments for patches. + apiVersion := gjson.GetBytes(inBytes, "apiVersion") + kind := gjson.GetBytes(inBytes, "kind") + ns := gjson.GetBytes(inBytes, "metadata.namespace") + name := gjson.GetBytes(inBytes, "metadata.name") + logger.Debug(fmt.Sprintf("%s: changes after rewrite for %s/%s/%s/%s", msg, ns, apiVersion, kind, name), BodyDiff(diffContent)) +} + +// DebugBodyHead logs head of input slice. +func DebugBodyHead(logger *slog.Logger, msg, resourceType string, obj []byte) { + limit := 1024 + switch resourceType { + case "virtualmachines", + "virtualmachines/status", + "virtualmachineinstances", + "virtualmachineinstances/status", + "clustervirtualimages", + "clustervirtualimages/status", + "clusterrolebindings", + "customresourcedefinitions": + limit = 32000 + } + if resourceType == "patch" { + limit = len(obj) + } + logger.Debug(fmt.Sprintf("%s: dump rewritten body", msg), BodyDump(headBytes(obj, limit))) +} + +func headBytes(msg []byte, limit int) string { + s := string(msg) + msgLen := len(s) + if msgLen == 0 { + return "" + } + // Lower the limit if message is shorter than the limit. + if msgLen < limit { + limit = msgLen + } + return fmt.Sprintf("[%d] %s", msgLen, s[0:limit]) +} + +// Diff returns a human-readable diff between 2 JSONs suitable for debugging. +// See: https://github.com/josephburnett/jd/blob/master/README.md +func Diff(json1, json2 []byte) (string, error) { + // Handle some edge cases. + switch { + case json1 == nil && json2 != nil: + return "", fmt.Errorf("got %d rewritten bytes without original", len(json2)) + case json1 != nil && json2 == nil: + return "", nil + case json1 == nil && json2 == nil: + return "", nil + case bytes.Equal(json1, json2): + return "", nil + } + + // Calculate diff between JSONs. + jd.Setkeys("name") + a, err := jd.ReadJsonString(string(json1)) + if err != nil { + return "", err + } + b, err := jd.ReadJsonString(string(json2)) + if err != nil { + return "", err + } + return a.Diff(b).Render(), nil +} diff --git a/images/kube-api-rewriter/pkg/log/pretty_handler.go b/images/kube-api-rewriter/pkg/log/pretty_handler.go new file mode 100644 index 0000000..39586fe --- /dev/null +++ b/images/kube-api-rewriter/pkg/log/pretty_handler.go @@ -0,0 +1,248 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package log + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "runtime" + "sort" + "sync" + + "github.com/kr/text" + "sigs.k8s.io/yaml" +) + +// PrettyHandler is a custom handler to print pretty debug logs: +// - Print attributes unquoted +// - Print body.dump and body.diff as sections +// +// Notes on implementation: record in the Handle method contains only attrs from Info/Debug calls, +// other Attrs are stored inside parent Handlers. There is no way to access those attributes +// in a simple manner, e.g. via slog exposed methods. +// Internal slog logic around Attrs includes grouping, preformatting, replacing. It is not simple +// to reimplement it, so lazy JsonHandler workaround is used to re-use this internal machinery +// in exchange to performance. This handler is meant to use for debugging purposes, so it is OK. +// +// For one who brave enough to optimize this Handler, please, please, read these sources thoroughly: +// - https://dusted.codes/creating-a-pretty-console-logger-using-gos-slog-package +// - https://betterstack.com/community/guides/logging/logging-in-go/ +// - https://github.com/golang/example/tree/master/slog-handler-guide + +const BodyDiffKey = "body.diff" +const BodyDumpKey = "body.dump" + +const dateTimeWithSecondsFrac = "2006-01-02 15:04:05.000" + +// PrettyHandler is a pretty print handler close to default slog handler. +type PrettyHandler struct { + jh slog.Handler + jhb *bytes.Buffer + jhmu *sync.Mutex + w io.Writer + wmu *sync.Mutex + opts *slog.HandlerOptions +} + +func NewPrettyHandler(w io.Writer, opts *slog.HandlerOptions) *PrettyHandler { + if opts == nil { + opts = &slog.HandlerOptions{} + } + b := &bytes.Buffer{} + return &PrettyHandler{ + jh: slog.NewJSONHandler(b, &slog.HandlerOptions{ + Level: opts.Level, + AddSource: opts.AddSource, + ReplaceAttr: suppressDefaultAttrs(opts.ReplaceAttr), + }), + jhb: b, + jhmu: &sync.Mutex{}, + w: w, + wmu: &sync.Mutex{}, + opts: opts, + } +} + +// Enabled returns if level is enabled for this handler. +func (h *PrettyHandler) Enabled(ctx context.Context, l slog.Level) bool { + return h.jh.Enabled(ctx, l) +} + +func (h *PrettyHandler) WithAttrs(as []slog.Attr) slog.Handler { + return &PrettyHandler{ + jh: h.jh.WithAttrs(as), + jhb: h.jhb, + jhmu: h.jhmu, + w: h.w, + wmu: h.wmu, + opts: h.opts, + } +} + +// WithGroup adds group +func (h *PrettyHandler) WithGroup(name string) slog.Handler { + return &PrettyHandler{ + jh: h.jh.WithGroup(name), + jhb: h.jhb, + jhmu: h.jhmu, + w: h.w, + wmu: h.wmu, + opts: h.opts, + } +} + +func (h *PrettyHandler) Handle(ctx context.Context, r slog.Record) error { + // Get all attributes set by parent Handlers via JsonHandler. + allAttrs, err := h.gatherAttrs(ctx, r) + if err != nil { + return err + } + + // Separate dumps and other attributes. + dumps := make(map[string]string) + groups := make(map[string]any) + attrs := make([]slog.Attr, 0) + for attrKey, attr := range allAttrs { + switch v := attr.(type) { + case map[string]any, []any: + groups[attrKey] = v + case string: + switch attrKey { + case BodyDumpKey, BodyDiffKey: + dumps[attrKey] = v + default: + attrs = append(attrs, slog.String(attrKey, v)) + } + default: + attrs = append(attrs, slog.Any(attrKey, attr)) + } + } + + var b bytes.Buffer + // Write main line: time, level, message and attributes. + b.WriteString(r.Time.Format(dateTimeWithSecondsFrac)) + b.WriteString(" ") + + b.WriteString(r.Level.String()) + b.WriteString(" ") + + b.WriteString(r.Message) + b.WriteString(" ") + + sort.Slice(attrs, func(i, j int) bool { + return attrs[i].Key < attrs[j].Key + }) + for i, attr := range attrs { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(attr.Key) + b.WriteString("=\"") + b.WriteString(attr.Value.String()) + b.WriteString("\"") + } + ensureEndingNewLine(&b) + + if h.opts != nil && h.opts.AddSource && r.PC != 0 { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + b.WriteString(fmt.Sprintf(" source=%s:%d %s\n", f.File, f.Line, f.Function)) + } + + // Add sectioned info: grouped attributes, a body diff and a body dump. + if len(groups) > 0 { + groupsBytes, err := yaml.Marshal(groups) + if err != nil { + return fmt.Errorf("error marshaling grouped attrs: %w", err) + } + //b.WriteString("Grouped attrs:\n") + b.Write(text.IndentBytes(groupsBytes, []byte(" "))) + ensureEndingNewLine(&b) + } + + for _, dumpName := range []string{BodyDumpKey, BodyDiffKey} { + if diff, ok := dumps[dumpName]; ok { + b.WriteString(fmt.Sprintf(" %s:\n", dumpName)) + b.WriteString(text.Indent(diff, " ")) + ensureEndingNewLine(&b) + } + } + + //if diff, ok := dumps[BodyDiffKey]; ok { + // b.WriteString(" body.diff:\n") + // b.WriteString(text.Indent(diff, " ")) + // ensureEndingNewLine(&b) + //} + // + //if dump, ok := dumps[BodyDumpKey]; ok { + // b.WriteString(" body.dump:\n") + // b.WriteString(text.Indent(dump, " ")) + // ensureEndingNewLine(&b) + //} + + // Use Mutex to sync access to the shared Writer. + h.wmu.Lock() + defer h.wmu.Unlock() + _, err = b.WriteTo(h.w) + return err +} + +func ensureEndingNewLine(buf *bytes.Buffer) { + last := string(buf.Bytes()[buf.Len()-1:]) + if last != "\n" { + buf.WriteString("\n") + } +} + +func (h *PrettyHandler) gatherAttrs(ctx context.Context, r slog.Record) (map[string]any, error) { + h.jhmu.Lock() + defer func() { + h.jhb.Reset() + h.jhmu.Unlock() + }() + if err := h.jh.Handle(ctx, r); err != nil { + return nil, fmt.Errorf("error when calling inner handler's Handle: %w", err) + } + + var attrs map[string]any + err := json.Unmarshal(h.jhb.Bytes(), &attrs) + if err != nil { + return nil, fmt.Errorf("error when unmarshaling inner handler's Handle result: %w", err) + } + return attrs, nil +} + +func suppressDefaultAttrs( + next func([]string, slog.Attr) slog.Attr, +) func([]string, slog.Attr) slog.Attr { + return func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey || + a.Key == slog.LevelKey || + a.Key == slog.MessageKey || + a.Key == slog.SourceKey { + return slog.Attr{} + } + if next == nil { + return a + } + return next(groups, a) + } +} diff --git a/images/kube-api-rewriter/pkg/log/pretty_handler_test.go b/images/kube-api-rewriter/pkg/log/pretty_handler_test.go new file mode 100644 index 0000000..a856cb8 --- /dev/null +++ b/images/kube-api-rewriter/pkg/log/pretty_handler_test.go @@ -0,0 +1,72 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package log + +import ( + "log/slog" + "os" + "testing" +) + +func TestDefaultCustomHandler(t *testing.T) { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + AddSource: true, + //Level: nil, + //ReplaceAttr: nil, + }))) + + logg := slog.With( + slog.Group("properties", + slog.Int("width", 4000), + slog.Int("height", 3000), + slog.String("format", "jpeg"), + slog.Group("nestedprops", + slog.String("arg", "val"), + ), + ), + slog.String("azaz", "foo"), + ) + logg.Info("message with group", + slog.Group("properties", + slog.Int("width", 6000), + ), + ) + + // set PrettyHandler as default + //dbgHandler := NewPrettyHandler(os.Stdout, nil) + dbgHandler := NewPrettyHandler(os.Stdout, &slog.HandlerOptions{AddSource: true}) + + slog.SetDefault(slog.New(dbgHandler)) + + logger := slog.With( + slog.String("arg1", "val1"), + slog.String("body.diff", "+-+-+-+\n++--++--\n + qwe\n - azaz"), + slog.Group("properties", + slog.Int("width", 6000), + ), + ) + + logger.Info("info message") + + logger = slog.With( + slog.String("arg1", "val1"), + slog.String("body.diff", "+-+-+-+"), + ) + logger.WithGroup("properties").Info("info message", + slog.Int("width", 6000), + ) +} diff --git a/images/kube-api-rewriter/pkg/log/setup.go b/images/kube-api-rewriter/pkg/log/setup.go new file mode 100644 index 0000000..2b1beec --- /dev/null +++ b/images/kube-api-rewriter/pkg/log/setup.go @@ -0,0 +1,120 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package log + +import ( + "io" + "log/slog" + "os" + "strings" +) + +type Format string + +const ( + JSONLog Format = "json" + TextLog Format = "text" + PrettyLog Format = "pretty" +) + +type Output string + +const ( + Stdout Output = "stdout" + Stderr Output = "stderr" + Discard Output = "discard" +) + +// Defaults +const ( + DefaultLogLevel = slog.LevelInfo + DefaultDebugLogFormat = PrettyLog + DefaultLogFormat = JSONLog +) + +var DefaultLogOutput = os.Stdout + +type Options struct { + Level string + Format string + Output string +} + +func SetupDefaultLoggerFromEnv(opts Options) { + handler := SetupHandler(opts) + if handler != nil { + slog.SetDefault(slog.New(handler)) + } +} + +func SetupHandler(opts Options) slog.Handler { + logLevel := detectLogLevel(opts.Level) + logOutput := detectLogOutput(opts.Output) + logFormat := detectLogFormat(opts.Format, logLevel) + + logHandlerOpts := &slog.HandlerOptions{Level: logLevel} + switch logFormat { + case TextLog: + return slog.NewTextHandler(logOutput, logHandlerOpts) + case JSONLog: + return slog.NewJSONHandler(logOutput, logHandlerOpts) + case PrettyLog: + return NewPrettyHandler(logOutput, logHandlerOpts) + } + return nil +} + +func detectLogLevel(level string) slog.Level { + switch strings.ToLower(level) { + case "error": + return slog.LevelError + case "warn": + return slog.LevelWarn + case "info": + return slog.LevelInfo + case "debug": + return slog.LevelDebug + } + return DefaultLogLevel +} + +func detectLogFormat(format string, level slog.Level) Format { + switch strings.ToLower(format) { + case string(TextLog): + return TextLog + case string(JSONLog): + return JSONLog + case string(PrettyLog): + return PrettyLog + } + if level == slog.LevelDebug { + return DefaultDebugLogFormat + } + return DefaultLogFormat +} + +func detectLogOutput(output string) io.Writer { + switch strings.ToLower(output) { + case string(Stdout): + return os.Stdout + case string(Stderr): + return os.Stderr + case string(Discard): + return io.Discard + } + return DefaultLogOutput +} diff --git a/images/kube-api-rewriter/pkg/monitoring/healthz/handler.go b/images/kube-api-rewriter/pkg/monitoring/healthz/handler.go new file mode 100644 index 0000000..d523b23 --- /dev/null +++ b/images/kube-api-rewriter/pkg/monitoring/healthz/handler.go @@ -0,0 +1,35 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package healthz + +import "net/http" + +// AddHealthzHandler adds endpoints for health and readiness probes. +func AddHealthzHandler(mux *http.ServeMux) { + if mux == nil { + return + } + mux.HandleFunc("/healthz", okStatusHandler) + mux.HandleFunc("/healthz/", okStatusHandler) + mux.HandleFunc("/readyz", okStatusHandler) + mux.HandleFunc("/readyz/", okStatusHandler) +} + +func okStatusHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} diff --git a/images/kube-api-rewriter/pkg/monitoring/metrics/handler.go b/images/kube-api-rewriter/pkg/monitoring/metrics/handler.go new file mode 100644 index 0000000..522a964 --- /dev/null +++ b/images/kube-api-rewriter/pkg/monitoring/metrics/handler.go @@ -0,0 +1,34 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package metrics + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func AddMetricsHandler(mux *http.ServeMux) { + if mux == nil { + return + } + + handler := promhttp.HandlerFor(Registry, promhttp.HandlerOpts{ + ErrorHandling: promhttp.HTTPErrorOnError, + }) + mux.Handle("/metrics", handler) +} diff --git a/images/kube-api-rewriter/pkg/monitoring/metrics/registry.go b/images/kube-api-rewriter/pkg/monitoring/metrics/registry.go new file mode 100644 index 0000000..363aa96 --- /dev/null +++ b/images/kube-api-rewriter/pkg/monitoring/metrics/registry.go @@ -0,0 +1,40 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" +) + +// RegistererGatherer combines both parts of the API of a Prometheus +// registry, both the Registerer and the Gatherer interfaces. +type RegistererGatherer interface { + prometheus.Registerer + prometheus.Gatherer +} + +// Registry is our instance of the prometheus registry for storing metrics. +var Registry RegistererGatherer = prometheus.NewRegistry() + +func Init() { + Registry.MustRegister( + collectors.NewBuildInfoCollector(), + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) +} diff --git a/images/kube-api-rewriter/pkg/monitoring/profiler/handler.go b/images/kube-api-rewriter/pkg/monitoring/profiler/handler.go new file mode 100644 index 0000000..01d4335 --- /dev/null +++ b/images/kube-api-rewriter/pkg/monitoring/profiler/handler.go @@ -0,0 +1,35 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package profiler + +import ( + "net/http" + "net/http/pprof" +) + +// NewPprofHandler returns http.ServeMux with pprof endpoints. +func NewPprofHandler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + + return mux +} diff --git a/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules.go b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules.go new file mode 100644 index 0000000..c527d40 --- /dev/null +++ b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules.go @@ -0,0 +1,158 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package operatornelm + +import ( + . "github.com/deckhouse/kube-api-rewriter/pkg/rewriter" +) + +const ( + internalPrefix = "internal.operator-helm.deckhouse.io" +) + +var OperatorNelmRewriteRules = &RewriteRules{ + KindPrefix: "InternalNelmOperator", + ResourceTypePrefix: "internalnelmoperator", + ShortNamePrefix: "intnelm", + Categories: []string{"intnelm"}, + Rules: OperatorNelmAPIGroupsRules, + Webhooks: OperatorNelmWebhooks, + Labels: MetadataReplace{ + Names: []MetadataReplaceRule{ + {Original: "source.werf.io", Renamed: "source." + internalPrefix}, + {Original: "helm.werf.io", Renamed: "helm." + internalPrefix}, + }, + Prefixes: []MetadataReplaceRule{ + {Original: "source.werf.io", Renamed: "source." + internalPrefix}, + {Original: "helm.werf.io", Renamed: "helm." + internalPrefix}, + }, + }, + Annotations: MetadataReplace{ + Prefixes: []MetadataReplaceRule{ + {Original: "source.werf.io", Renamed: "source." + internalPrefix}, + {Original: "helm.werf.io", Renamed: "helm." + internalPrefix}, + }, + }, + Finalizers: MetadataReplace{ + Names: []MetadataReplaceRule{ + {Original: "finalizers.werf.io", Renamed: "finalizers." + internalPrefix}, + }, + Prefixes: []MetadataReplaceRule{ + {Original: "werf.io", Renamed: "werf." + internalPrefix}, + }, + }, + Excludes: []ExcludeRule{}, + KindRefPaths: map[string][]string{ + "HelmChart": {"spec.sourceRef"}, + "HelmRelease": {"spec.chart.spec.sourceRef", "spec.chartRef"}, + }, +} + +var OperatorNelmAPIGroupsRules = map[string]APIGroupRule{ + "source.werf.io": { + GroupRule: GroupRule{ + Group: "source.werf.io", + Versions: []string{"v1beta1", "v1beta2", "v1"}, + PreferredVersion: "v1", + Renamed: "source." + internalPrefix, + }, + ResourceRules: map[string]ResourceRule{ + "buckets": { + Kind: "Bucket", + ListKind: "BucketList", + Plural: "buckets", + Singular: "bucket", + Versions: []string{"v1beta2", "v1"}, + PreferredVersion: "v1", + Categories: []string{}, + ShortNames: []string{}, + }, + "externalartifacts": { + Kind: "ExternalArtifact", + ListKind: "ExternalArtifactList", + Plural: "externalartifacts", + Singular: "externalartifact", + Versions: []string{"v1"}, + PreferredVersion: "v1", + Categories: []string{}, + ShortNames: []string{}, + }, + "gitrepositories": { + Kind: "GitRepository", + ListKind: "GitRepositoryList", + Plural: "gitrepositories", + Singular: "gitrepository", + Versions: []string{"v1beta2", "v1"}, + PreferredVersion: "v1", + Categories: []string{}, + ShortNames: []string{"gitrepo"}, + }, + "helmcharts": { + Kind: "HelmChart", + ListKind: "HelmChartList", + Plural: "helmcharts", + Singular: "helmchart", + Versions: []string{"v1beta2", "v1"}, + PreferredVersion: "v1", + Categories: []string{}, + ShortNames: []string{"hc"}, + }, + "helmrepositories": { + Kind: "HelmRepository", + ListKind: "HelmRepositoryList", + Plural: "helmrepositories", + Singular: "helmrepository", + Versions: []string{"v1beta2", "v1"}, + PreferredVersion: "v1", + Categories: []string{}, + ShortNames: []string{"helmrepo"}, + }, + "ocirepositories": { + Kind: "OCIRepository", + ListKind: "OCIRepositoryList", + Plural: "ocirepositories", + Singular: "ocirepository", + Versions: []string{"v1beta2", "v1"}, + PreferredVersion: "v1", + Categories: []string{}, + ShortNames: []string{"ocirepo"}, + }, + }, + }, + "helm.werf.io": { + GroupRule: GroupRule{ + Group: "helm.werf.io", + Versions: []string{"v2beta1", "v2beta2", "v2"}, + PreferredVersion: "v2", + Renamed: "helm." + internalPrefix, + }, + ResourceRules: map[string]ResourceRule{ + "helmreleases": { + Kind: "HelmRelease", + ListKind: "HelmReleaseList", + Plural: "helmreleases", + Singular: "helmrelease", + Versions: []string{"v2beta1", "v2beta2", "v2"}, + PreferredVersion: "v2", + Categories: []string{}, + ShortNames: []string{"hr"}, + }, + }, + }, +} + +var OperatorNelmWebhooks = map[string]WebhookRule{} diff --git a/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules_test.go b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules_test.go new file mode 100644 index 0000000..876ed3f --- /dev/null +++ b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules_test.go @@ -0,0 +1,33 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package operatornelm + +import ( + "fmt" + "testing" + + "sigs.k8s.io/yaml" +) + +func TestOperatorNelmRulesToYAML(t *testing.T) { + b, err := yaml.Marshal(OperatorNelmRewriteRules) + if err != nil { + t.Fatalf("should marshal operatornelm rules without error: %v", err) + } + + fmt.Printf("%s\n", string(b)) +} diff --git a/images/kube-api-rewriter/pkg/proxy/bytes_counter.go b/images/kube-api-rewriter/pkg/proxy/bytes_counter.go new file mode 100644 index 0000000..a03ced3 --- /dev/null +++ b/images/kube-api-rewriter/pkg/proxy/bytes_counter.go @@ -0,0 +1,76 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "io" + "sync/atomic" +) + +func BytesCounterReaderWrap(r io.Reader) io.ReadCloser { + return &bytesCounter{origReader: r} +} + +func BytesCounterWriterWrap(w io.Writer) io.Writer { + return &bytesCounter{origWriter: w} +} + +var _ io.ReadCloser = &bytesCounter{} +var _ io.Writer = &bytesCounter{} + +type bytesCounter struct { + origReader io.Reader + origWriter io.Writer + counter atomic.Int64 +} + +func (r *bytesCounter) Read(p []byte) (n int, err error) { + l, err := r.origReader.Read(p) + r.counter.Add(int64(l)) + return l, err +} + +func (r *bytesCounter) Write(p []byte) (n int, err error) { + l, err := r.origWriter.Write(p) + r.counter.Add(int64(l)) + return l, err +} + +func (r *bytesCounter) Close() error { + return nil +} + +func (r *bytesCounter) Reset() { + r.counter.Store(0) +} + +func (r *bytesCounter) Count() int { + return int(r.counter.Load()) +} + +func CounterReset(wrapped interface{}) { + if bytesCounter, ok := wrapped.(*bytesCounter); ok { + bytesCounter.Reset() + } +} + +func CounterValue(wrapped interface{}) int { + if bytesCounter, ok := wrapped.(*bytesCounter); ok { + return bytesCounter.Count() + } + return 0 +} diff --git a/images/kube-api-rewriter/pkg/proxy/doc.go b/images/kube-api-rewriter/pkg/proxy/doc.go new file mode 100644 index 0000000..f33937c --- /dev/null +++ b/images/kube-api-rewriter/pkg/proxy/doc.go @@ -0,0 +1,55 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +// Proxy handler implements 2 types of proxy: +// - proxy for client interaction with Kubernetes API Server +// - proxy to deliver AdmissionReview requests from Kubernetes API Server to webhook server +// +// Proxy for webhooks acts as follows: +// ServerHTTP method reads request from Kubernetes API Server, restores apiVersion, kind and +// ownerRefs, sends it to real webhook, renames apiVersion, kind, and ownerRefs +// and sends it back to Kubernetes API Server. +// +// +--------------------------------------------+ +// | Kubernetes API Server | +// +--------------------------------------------+ +// | ^ +// | | +// 1. AdmissionReview request 4. AdmissionReview response +// webhook.srv:443/webhook-endpoint | +// apiVersion: renamed-group.io | +// kind: PrefixedResource | +// | | +// v | +// +-----------------------------------------------------+ +// | Proxy | +// | 2. Restore 3. Rename | +// | apiVersion, kind field if Admission response | +// | in Admission request has patchType: JSONPatch | +// | in Admission request rename kind in ownerRef | +// +-----------------------------------------------------+ +// | ^ +// 127.0.0.1:9443/webhook-endpoint | +// apiVersion: original-group.io | +// kind: Resource | +// | | +// v | +// +-------------------------------------------------------+ +// | Webhook | +// | handles request ---> sends response | +// +-------------------------------------------------------+ diff --git a/images/kube-api-rewriter/pkg/proxy/handler.go b/images/kube-api-rewriter/pkg/proxy/handler.go new file mode 100644 index 0000000..72c1dc6 --- /dev/null +++ b/images/kube-api-rewriter/pkg/proxy/handler.go @@ -0,0 +1,573 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/tidwall/gjson" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + "github.com/deckhouse/kube-api-rewriter/pkg/labels" + logutil "github.com/deckhouse/kube-api-rewriter/pkg/log" + "github.com/deckhouse/kube-api-rewriter/pkg/rewriter" +) + +type ProxyMode string + +const ( + // ToOriginal mode indicates that resource should be restored when passed to target and renamed when passing back to client. + ToOriginal ProxyMode = "original" + // ToRenamed mode indicates that resource should be renamed when passed to target and restored when passing back to client. + ToRenamed ProxyMode = "renamed" +) + +func ToTargetAction(proxyMode ProxyMode) rewriter.Action { + if proxyMode == ToRenamed { + return rewriter.Rename + } + return rewriter.Restore +} + +func FromTargetAction(proxyMode ProxyMode) rewriter.Action { + if proxyMode == ToRenamed { + return rewriter.Restore + } + return rewriter.Rename +} + +type Handler struct { + Name string + // ProxyPass is a target http client to send requests to. + // An allusion to nginx proxy_pass directive. + TargetClient *http.Client + TargetURL *url.URL + ProxyMode ProxyMode + Rewriter *rewriter.RuleBasedRewriter + MetricsProvider MetricsProvider + streamHandler *StreamHandler + m sync.Mutex +} + +func (h *Handler) Init() { + if h.MetricsProvider == nil { + h.MetricsProvider = NewMetricsProvider() + } + h.streamHandler = &StreamHandler{ + Rewriter: h.Rewriter, + MetricsProvider: h.MetricsProvider, + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if req == nil { + slog.Error("req is nil. something wrong") + return + } + if req.URL == nil { + slog.Error(fmt.Sprintf("req.URL is nil. something wrong. method %s RequestURI '%s' Headers %+v", req.Method, req.RequestURI, req.Header)) + return + } + + requestHandleStart := time.Now() + + // Step 1. Parse request url, prepare path rewrite. + targetReq := rewriter.NewTargetRequest(h.Rewriter, req) + + resource := targetReq.ResourceForLog() + toTargetAction := string(ToTargetAction(h.ProxyMode)) + fromTargetAction := string(FromTargetAction(h.ProxyMode)) + ctx := labels.ContextWithCommon(req.Context(), h.Name, resource, req.Method, WatchLabel(targetReq.IsWatch()), toTargetAction, fromTargetAction) + + logger := LoggerWithCommonAttrs(ctx, + slog.String("url.path", req.URL.Path), + ) + + metrics := NewProxyMetrics(ctx, h.MetricsProvider) + metrics.GotClientRequest() + + // Set target address, cleanup RequestURI. + req.RequestURI = "" + req.URL.Scheme = h.TargetURL.Scheme + req.URL.Host = h.TargetURL.Host + + // Log request path. + rwrReq := " NO" + if targetReq.ShouldRewriteRequest() { + rwrReq = "REQ" + } + rwrResp := " NO" + if targetReq.ShouldRewriteResponse() { + rwrResp = "RESP" + } + if targetReq.Path() != req.URL.Path || targetReq.RawQuery() != req.URL.RawQuery { + logger.Info(fmt.Sprintf("%s [%s,%s] %s -> %s", req.Method, rwrReq, rwrResp, req.URL.RequestURI(), targetReq.RequestURI())) + } else { + logger.Info(fmt.Sprintf("%s [%s,%s] %s", req.Method, rwrReq, rwrResp, req.URL.String())) + } + + // TODO(development): Mute some logging for development: election, non-rewritable resources. + isMute := false + if !targetReq.ShouldRewriteRequest() && !targetReq.ShouldRewriteResponse() { + isMute = true + } + switch resource { + case "leases": + isMute = true + case "endpoints": + isMute = true + case "clusterrolebindings": + isMute = false + case "clustervirtualmachineimages": + isMute = false + case "virtualmachines": + isMute = false + case "virtualmachines/status": + isMute = false + } + if isMute { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + logger.Debug(fmt.Sprintf("Request: orig headers: %+v", req.Header)) + + // Step 2. Modify request endpoint, headers and body bytes before send it to the target. + origRequestBytes, rwrRequestBytes, err := h.transformRequest(targetReq, req) + if err != nil { + logger.Error(fmt.Sprintf("Error transforming request: %s", req.URL.String()), logutil.SlogErr(err)) + http.Error(w, "can't rewrite request", http.StatusBadRequest) + metrics.ClientRequestRewriteError() + return + } + + logger.Debug(fmt.Sprintf("Request: target headers: %+v", req.Header)) + + // Restore req.Body as this reader was read earlier by the transformRequest. + clientBodyDecision := decisionPass + if rwrRequestBytes != nil { + req.Body = BytesCounterReaderWrap(bytes.NewBuffer(rwrRequestBytes)) + metrics.ClientRequestRewriteSuccess() + clientBodyDecision = decisionRewrite + // metrics.ClientRequestRewriteDuration() + } else if origRequestBytes != nil { + // Fallback to origRequestBytes if body was not rewritten. + req.Body = BytesCounterReaderWrap(bytes.NewBuffer(origRequestBytes)) + } + + metrics.FromClientBytesAdd(clientBodyDecision, len(origRequestBytes)) + + // Step 3. Send request to the target. + resp, err := h.TargetClient.Do(req) + if err != nil { + logger.Error("Error passing request to the target", logutil.SlogErr(err)) + http.Error(w, k8serrors.NewInternalError(err).Error(), http.StatusInternalServerError) + metrics.TargetResponseError() + return + } + + ctx = labels.ContextWithStatus(ctx, resp.StatusCode) + metrics = NewProxyMetrics(ctx, h.MetricsProvider) + metrics.ToTargetBytesAdd(clientBodyDecision, CounterValue(req.Body)) + metrics.TargetResponseSuccess(clientBodyDecision) + + // Save original Body to close when handler finishes. + origRespBody := resp.Body + defer func() { + origRespBody.Close() + }() + + // TODO handle resp.Status 3xx, 4xx, 5xx, etc. + + if req.Method == http.MethodPatch { + logutil.DebugBodyHead(logger, "Request PATCH", "patch", origRequestBytes) + logutil.DebugBodyChanges(logger, "Request PATCH", "patch", origRequestBytes, rwrRequestBytes) + } else { + logutil.DebugBodyChanges(logger, "Request", resource, origRequestBytes, rwrRequestBytes) + } + + // Step 5. Handle response: pass through, transform resp.Body, or run stream transformer. + + if !targetReq.ShouldRewriteResponse() { + ctx = labels.ContextWithDecision(ctx, decisionPass) + metrics = NewProxyMetrics(ctx, h.MetricsProvider) + // Pass response as-is without rewriting. + if targetReq.IsWatch() { + logger.Debug(fmt.Sprintf("Response decision: PASS STREAM, Status %s, Headers %+v", resp.Status, resp.Header)) + } else { + logger.Debug(fmt.Sprintf("Response decision: PASS, Status %s, Headers %+v", resp.Status, resp.Header)) + } + h.passResponse(ctx, targetReq, w, resp, logger) + metrics.RequestDuration(time.Since(requestHandleStart)) + return + } + + ctx = labels.ContextWithDecision(ctx, decisionRewrite) + metrics = NewProxyMetrics(ctx, h.MetricsProvider) + + if targetReq.IsWatch() { + logger.Debug(fmt.Sprintf("Response decision: REWRITE STREAM, Status %s, Headers %+v", resp.Status, resp.Header)) + + h.transformStream(ctx, targetReq, w, resp, logger) + metrics.RequestDuration(time.Since(requestHandleStart)) + return + } + + // One-time rewrite is required for client or webhook requests. + logger.Debug(fmt.Sprintf("Response decision: REWRITE, Status %s, Headers %+v", resp.Status, resp.Header)) + + h.transformResponse(ctx, targetReq, w, resp, logger) + metrics.RequestDuration(time.Since(requestHandleStart)) + return +} + +func copyHeader(dst, src http.Header) { + for header, values := range src { + // Do not override dst header with the header from the src. + if len(dst.Values(header)) > 0 { + continue + } + for _, value := range values { + dst.Add(header, value) + } + } +} + +// resp.Header.Get("Content-Encoding") +func encodingAwareReaderWrap(bodyReader io.ReadCloser, encoding string) (io.ReadCloser, error) { + var reader io.ReadCloser + var err error + + switch encoding { + case "gzip": + reader, err = gzip.NewReader(bodyReader) + if err != nil { + return nil, fmt.Errorf("errorf making gzip reader: %v", err) + } + return io.NopCloser(reader), nil + case "deflate": + return flate.NewReader(bodyReader), nil + } + + return bodyReader, nil +} + +// transformRequest transforms request headers and rewrites request payload to use +// request as client to the target. +// TargetMode field defines either transformer should rename resources +// if request is from the client, or restore resources if it is a call +// from the API Server to the webhook. +func (h *Handler) transformRequest(targetReq *rewriter.TargetRequest, req *http.Request) ([]byte, []byte, error) { + if req == nil || req.URL == nil { + return nil, nil, fmt.Errorf("http request and URL should not be nil") + } + + var origBodyBytes []byte + var rwrBodyBytes []byte + var err error + + hasPayload := req.Body != nil + + if hasPayload { + origBodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return nil, nil, fmt.Errorf("read request body: %w", err) + } + } + + // Rewrite incoming payload, e.g. create, put, etc. + if targetReq.ShouldRewriteRequest() && hasPayload { + switch { + case req.Method == http.MethodPatch && isServerSideApply(req): + rwrBodyBytes, err = h.Rewriter.RewriteJSONPayload(targetReq, origBodyBytes, ToTargetAction(h.ProxyMode)) + case req.Method == http.MethodPatch: + rwrBodyBytes, err = h.Rewriter.RewritePatch(targetReq, origBodyBytes) + default: + rwrBodyBytes, err = h.Rewriter.RewriteJSONPayload(targetReq, origBodyBytes, ToTargetAction(h.ProxyMode)) + } + if err != nil { + return nil, nil, err + } + + // Put new Body reader to req and fix Content-Length header. + rwrBodyLen := len(rwrBodyBytes) + if rwrBodyLen > 0 { + // Fix content-length if needed. + req.ContentLength = int64(rwrBodyLen) + if req.Header.Get("Content-Length") != "" { + req.Header.Set("Content-Length", strconv.Itoa(rwrBodyLen)) + } + } + } + + // TODO Implement protobuf and table rewriting to remove these manipulations with Accept header. + // TODO Move out to a separate method forceApplicationJSONContent. + if targetReq.ShouldRewriteResponse() { + newAccept := make([]string, 0) + for _, hdr := range req.Header.Values("Accept") { + // Accept header may contain comma-separated media types + // (e.g. "application/vnd.kubernetes.protobuf;as=PartialObjectMetadata;...,application/json;as=PartialObjectMetadata;...,application/json"). + // Process each media type individually to avoid discarding + // non-protobuf alternatives when a protobuf entry is present. + mediaTypes := strings.Split(hdr, ",") + filteredTypes := make([]string, 0, len(mediaTypes)) + for _, mt := range mediaTypes { + mt = strings.TrimSpace(mt) + if mt == "" { + continue + } + + // Rewriter doesn't work with protobuf, drop protobuf media types. + if strings.Contains(mt, "application/vnd.kubernetes.protobuf") { + continue + } + + // TODO Add rewriting support for Table format. + // Quickly support kubectl with simple hack + if strings.Contains(mt, "application/json") && strings.Contains(mt, "as=Table") { + filteredTypes = append(filteredTypes, "application/json") + continue + } + + filteredTypes = append(filteredTypes, mt) + } + if len(filteredTypes) > 0 { + newAccept = append(newAccept, strings.Join(filteredTypes, ",")) + } + } + + // Ensure Accept is not empty: fall back to application/json. + if len(newAccept) == 0 { + newAccept = append(newAccept, "application/json") + } + + req.Header["Accept"] = newAccept + } + + // Set new endpoint path and query. + req.URL.Path = targetReq.Path() + req.URL.RawQuery = targetReq.RawQuery() + + return origBodyBytes, rwrBodyBytes, nil +} + +func (h *Handler) passResponse(ctx context.Context, targetReq *rewriter.TargetRequest, w http.ResponseWriter, resp *http.Response, logger *slog.Logger) { + copyHeader(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + + bodyReader := resp.Body + + dst := &immediateWriter{dst: w} + + if logger.Enabled(nil, slog.LevelDebug) { + if targetReq.IsWatch() { + dst.chunkFn = func(chunk []byte) { + logger.Debug(fmt.Sprintf("Pass through response chunk: %s", string(chunk))) + } + } else { + bodyReader = logutil.NewReaderLogger(bodyReader) + } + } + + metrics := NewProxyMetrics(ctx, h.MetricsProvider) + + // Wrap body reader with bytes counter to set to_client_bytes metric. + bytesCounterBody := BytesCounterReaderWrap(bodyReader) + + _, err := io.Copy(dst, bytesCounterBody) + if err != nil { + logger.Error(fmt.Sprintf("copy target response back to client: %v", err)) + metrics.RequestHandleError() + } else { + metrics.ToClientBytesAdd(CounterValue(bytesCounterBody)) + metrics.RequestHandleSuccess() + } + + if logger.Enabled(nil, slog.LevelDebug) && !targetReq.IsWatch() { + logutil.DebugBodyHead(logger, + fmt.Sprintf("Pass through response: status %d, content-length: '%s'", resp.StatusCode, resp.Header.Get("Content-Length")), + targetReq.ResourceForLog(), + logutil.Bytes(bodyReader), + ) + } + + return +} + +// transformResponse rewrites payloads in responses from the target. +// +// ProxyMode field defines either rewriter should restore, or rename resources. +func (h *Handler) transformResponse(ctx context.Context, targetReq *rewriter.TargetRequest, w http.ResponseWriter, resp *http.Response, logger *slog.Logger) { + metrics := NewProxyMetrics(ctx, h.MetricsProvider) + + var err error + bytesCounter := BytesCounterReaderWrap(resp.Body) + // Add gzip decoder if needed. + bodyReader, err := encodingAwareReaderWrap(bytesCounter, resp.Header.Get("Content-Encoding")) + if err != nil { + logger.Error("Error decoding response body", logutil.SlogErr(err)) + http.Error(w, "can't decode response body", http.StatusInternalServerError) + metrics.RequestHandleError() + return + } + // Close needed for gzip and flate readers. + defer bodyReader.Close() + + // Step 1. Read response body to buffer. + origBodyBytes, err := io.ReadAll(bodyReader) + if err != nil { + logger.Error("Error reading response payload", logutil.SlogErr(err)) + http.Error(w, "Error reading response payload", http.StatusBadGateway) + metrics.RequestHandleError() + return + } + + metrics.FromTargetBytesAdd(CounterValue(bytesCounter)) + + // Rewrite supports only json responses for now. Pass invalid JSON and non-JSON responses as-is. + if !gjson.ValidBytes(origBodyBytes) { + contentType := resp.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "application/json") { + logger.Warn(fmt.Sprintf("Will not transform invalid JSON response from target: Content-type=%s", contentType)) + } else { + logger.Warn(fmt.Sprintf("Will not transform non JSON response from target: Content-type=%s", contentType)) + } + + metrics.TargetResponseInvalidJSON(resp.StatusCode) + + h.passResponse(ctx, targetReq, w, resp, logger) + return + } + + // Step 2. Rewrite response JSON. + rewriteStart := time.Now() + statusCode := resp.StatusCode + rwrBodyBytes, err := h.Rewriter.RewriteJSONPayload(targetReq, origBodyBytes, FromTargetAction(h.ProxyMode)) + if err != nil { + if !errors.Is(err, rewriter.SkipItem) { + logger.Error("Error rewriting response", logutil.SlogErr(err)) + http.Error(w, "can't rewrite response", http.StatusInternalServerError) + metrics.RequestHandleError() + metrics.TargetResponseRewriteError() + return + } + // Return NotFound Status object if rewriter decides to skip resource. + rwrBodyBytes = notFoundJSON(targetReq.OrigResourceType(), origBodyBytes) + statusCode = http.StatusNotFound + } + metrics.TargetResponseRewriteSuccess() + metrics.TargetResponseRewriteDuration(time.Since(rewriteStart)) + + if targetReq.IsWebhook() { + logutil.DebugBodyHead(logger, "Response from webhook", targetReq.ResourceForLog(), origBodyBytes) + } + logutil.DebugBodyChanges(logger, "Response", targetReq.ResourceForLog(), origBodyBytes, rwrBodyBytes) + + // Step 3. Fix headers before sending response back to the client. + copyHeader(w.Header(), resp.Header) + // Fix Content headers. + // rwrBodyBytes are always decoded from gzip. Delete header to not break our client. + w.Header().Del("Content-Encoding") + if rwrBodyBytes != nil { + w.Header().Set("Content-Length", strconv.Itoa(len(rwrBodyBytes))) + } + w.WriteHeader(statusCode) + + // Step 4. Write non-empty rewritten body to the client. + if rwrBodyBytes != nil { + copied, err := w.Write(rwrBodyBytes) + if err != nil { + logger.Error(fmt.Sprintf("error writing response from target to the client: %v", err)) + metrics.RequestHandleError() + } else { + metrics.RequestHandleSuccess() + metrics.ToClientBytesAdd(copied) + } + } + + return +} + +func (h *Handler) transformStream(ctx context.Context, targetReq *rewriter.TargetRequest, w http.ResponseWriter, resp *http.Response, logger *slog.Logger) { + // Rewrite body as a stream. ServeHTTP will block until context cancel. + err := h.streamHandler.Handle(ctx, w, resp, targetReq) + if err != nil { + logger.Error("Error watching stream", logutil.SlogErr(err)) + http.Error(w, fmt.Sprintf("watch stream: %v", err), http.StatusInternalServerError) + } +} + +type immediateWriter struct { + dst io.Writer + chunkFn func([]byte) +} + +func (iw *immediateWriter) Write(p []byte) (n int, err error) { + n, err = iw.dst.Write(p) + + if iw.chunkFn != nil { + iw.chunkFn(p) + } + + if flusher, ok := iw.dst.(http.Flusher); ok { + flusher.Flush() + } + + return +} + +// isServerSideApply returns true if the request is a server-side apply patch. +// Server-side apply uses Content-Type "application/apply-patch+yaml" and sends +// a full resource manifest (including apiVersion and kind), unlike regular +// merge/JSON patches that only contain partial updates. +func isServerSideApply(req *http.Request) bool { + ct := req.Header.Get("Content-Type") + return strings.Contains(ct, "application/apply-patch") +} + +// notFoundJSON constructs Status response of type NotFound +// for resourceType and object name. +// Example: +// +// { +// "kind":"Status", +// "apiVersion":"v1", +// "metadata":{}, +// "status":"Failure", +// "message":"pods \"vmi-router-x9mqwdqwd\" not found", +// "reason":"NotFound", +// "details":{"name":"vmi-router-x9mqwdqwd","kind":"pods"}, +// "code":404} +func notFoundJSON(resourceType string, obj []byte) []byte { + objName := gjson.GetBytes(obj, "metadata.name").String() + details := fmt.Sprintf(`"details":{"name":"%s","kind":"%s"}`, objName, resourceType) + message := fmt.Sprintf(`"message":"%s %s not found"`, resourceType, objName) + notFoundTpl := `{"kind":"Status","apiVersion":"v1",%s,%s,"metadata":{},"status":"Failure","reason":"NotFound","code":404}` + return []byte(fmt.Sprintf(notFoundTpl, message, details)) +} diff --git a/images/kube-api-rewriter/pkg/proxy/logger.go b/images/kube-api-rewriter/pkg/proxy/logger.go new file mode 100644 index 0000000..f6f2022 --- /dev/null +++ b/images/kube-api-rewriter/pkg/proxy/logger.go @@ -0,0 +1,35 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "context" + "log/slog" + + "github.com/deckhouse/kube-api-rewriter/pkg/labels" +) + +func LoggerWithCommonAttrs(ctx context.Context, attrs ...any) *slog.Logger { + logger := slog.Default() + logger = logger.With( + slog.String("proxy.name", labels.NameFromContext(ctx)), + slog.String("resource", labels.ResourceFromContext(ctx)), + slog.String("method", labels.MethodFromContext(ctx)), + slog.String("watch", labels.WatchFromContext(ctx)), + ) + return logger.With(attrs...) +} diff --git a/images/kube-api-rewriter/pkg/proxy/metrics.go b/images/kube-api-rewriter/pkg/proxy/metrics.go new file mode 100644 index 0000000..90ca49c --- /dev/null +++ b/images/kube-api-rewriter/pkg/proxy/metrics.go @@ -0,0 +1,126 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "context" + "strconv" + "time" + + "github.com/deckhouse/kube-api-rewriter/pkg/labels" +) + +type ProxyMetrics struct { + provider MetricsProvider + name string + resource string + method string + watch string + decision string + side string + toTargetAction string + fromTargetAction string + status string +} + +func NewProxyMetrics(ctx context.Context, provider MetricsProvider) *ProxyMetrics { + return &ProxyMetrics{ + provider: provider, + name: labels.NameFromContext(ctx), + resource: labels.ResourceFromContext(ctx), + method: labels.MethodFromContext(ctx), + watch: labels.WatchFromContext(ctx), + decision: labels.DecisionFromContext(ctx), + toTargetAction: labels.ToTargetActionFromContext(ctx), + fromTargetAction: labels.FromTargetActionFromContext(ctx), + status: labels.StatusFromContext(ctx), + } +} + +func WatchLabel(isWatch bool) string { + if isWatch { + return watchRequest + } + return regularRequest +} + +func (p *ProxyMetrics) GotClientRequest() { + p.provider.NewClientRequestsTotal(p.name, p.resource, p.method, p.watch, p.decision).Inc() +} + +func (p *ProxyMetrics) TargetResponseSuccess(decision string) { + p.provider.NewTargetResponsesTotal(p.name, p.resource, p.method, p.watch, decision, p.status, noError).Inc() +} + +func (p *ProxyMetrics) TargetResponseError() { + p.provider.NewTargetResponsesTotal(p.name, p.resource, p.method, p.watch, "", p.status, errorOccurred).Inc() +} + +func (p *ProxyMetrics) TargetResponseInvalidJSON(status int) { + p.provider.NewTargetResponseInvalidJSONTotal(p.name, p.resource, p.method, p.watch, strconv.Itoa(status)) +} + +func (p *ProxyMetrics) RequestHandleSuccess() { + p.provider.NewRequestsHandledTotal(p.name, p.resource, p.method, p.watch, p.decision, p.status, noError).Inc() +} +func (p *ProxyMetrics) RequestHandleError() { + p.provider.NewRequestsHandledTotal(p.name, p.resource, p.method, p.watch, p.decision, p.status, errorOccurred).Inc() +} + +func (p *ProxyMetrics) RequestDuration(dur time.Duration) { + p.provider.NewRequestsHandlingSeconds(p.name, p.resource, p.method, p.watch, p.decision, p.status).Observe(dur.Seconds()) +} + +func (p *ProxyMetrics) TargetResponseRewriteError() { + p.provider.NewRewritesTotal(p.name, p.resource, p.method, p.watch, targetSide, p.fromTargetAction, errorOccurred).Inc() +} + +func (p *ProxyMetrics) TargetResponseRewriteSuccess() { + p.provider.NewRewritesTotal(p.name, p.resource, p.method, p.watch, targetSide, p.fromTargetAction, noError).Inc() +} + +func (p *ProxyMetrics) ClientRequestRewriteError() { + p.provider.NewRewritesTotal(p.name, p.resource, p.method, p.watch, clientSide, p.toTargetAction, errorOccurred).Inc() +} + +func (p *ProxyMetrics) ClientRequestRewriteSuccess() { + p.provider.NewRewritesTotal(p.name, p.resource, p.method, p.watch, clientSide, p.toTargetAction, noError).Inc() +} + +func (p *ProxyMetrics) ClientRequestRewriteDuration(dur time.Duration) { + p.provider.NewRewritesDurationSeconds(p.name, p.resource, p.method, p.watch, clientSide, p.toTargetAction).Observe(dur.Seconds()) +} + +func (p *ProxyMetrics) TargetResponseRewriteDuration(dur time.Duration) { + p.provider.NewRewritesDurationSeconds(p.name, p.resource, p.method, p.watch, targetSide, p.fromTargetAction).Observe(dur.Seconds()) +} + +func (p *ProxyMetrics) FromClientBytesAdd(decision string, count int) { + p.provider.NewFromClientBytesTotal(p.name, p.resource, p.method, p.watch, decision).Add(float64(count)) +} + +func (p *ProxyMetrics) ToTargetBytesAdd(decision string, count int) { + p.provider.NewToTargetBytesTotal(p.name, p.resource, p.method, p.watch, decision).Add(float64(count)) +} + +func (p *ProxyMetrics) FromTargetBytesAdd(count int) { + p.provider.NewFromTargetBytesTotal(p.name, p.resource, p.method, p.watch, p.decision).Add(float64(count)) +} + +func (p *ProxyMetrics) ToClientBytesAdd(count int) { + p.provider.NewToClientBytesTotal(p.name, p.resource, p.method, p.watch, p.decision).Add(float64(count)) +} diff --git a/images/kube-api-rewriter/pkg/proxy/metrics_provider.go b/images/kube-api-rewriter/pkg/proxy/metrics_provider.go new file mode 100644 index 0000000..8d48573 --- /dev/null +++ b/images/kube-api-rewriter/pkg/proxy/metrics_provider.go @@ -0,0 +1,276 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/deckhouse/kube-api-rewriter/pkg/monitoring/metrics" +) + +var Subsystem = defaultSubsystem + +const ( + defaultSubsystem = "kube_api_rewriter" + + clientRequestsTotalName = "client_requests_total" + targetResponsesTotalName = "target_responses_total" + targetResponseInvalidJSONTotalName = "target_response_invalid_json_total" + + requestsHandledTotalName = "requests_handled_total" + requestHandlingDurationSecondsName = "request_handling_duration_seconds" + + rewritesTotalName = "rewrites_total" + rewriteDurationSecondsName = "rewrite_duration_seconds" + + fromClientBytesName = "from_client_bytes_total" + toTargetBytesName = "to_target_bytes_total" + fromTargetBytesName = "from_target_bytes_total" + toClientBytesName = "to_client_bytes_total" + + nameLabel = "name" + resourceLabel = "resource" + methodLabel = "method" + watchLabel = "watch" + decisionLabel = "decision" + sideLabel = "side" + operationLabel = "operation" + statusLabel = "status" + errorLabel = "error" + + watchRequest = "1" + regularRequest = "0" + + decisionRewrite = "rewrite" + decisionPass = "pass" + + targetSide = "target" + clientSide = "client" + + operationRename = "rename" + operationRestore = "restore" + + errorOccurred = "1" + noError = "0" +) + +var ( + clientRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: Subsystem, + Name: clientRequestsTotalName, + Help: "Total number of received client requests", + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, decisionLabel}) + + targetResponsesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: Subsystem, + Name: targetResponsesTotalName, + Help: "Total number of responses from the target", + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, decisionLabel, statusLabel, errorLabel}) + + targetResponseInvalidJSONTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: Subsystem, + Name: targetResponseInvalidJSONTotalName, + Help: "Total target responses with invalid JSON", + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, statusLabel}) + + requestsHandledTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: Subsystem, + Name: requestsHandledTotalName, + Help: "Total number of requests handled by the proxy instance", + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, decisionLabel, statusLabel, errorLabel}) + + requestHandlingDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Subsystem: Subsystem, + Name: requestHandlingDurationSecondsName, + Help: "Duration of request handling for non-watching and watch event handling for watch requests", + Buckets: []float64{ + 0.0, + 0.001, 0.002, 0.005, // 1, 2, 5 milliseconds + 0.01, 0.02, 0.05, // 10, 20, 50 milliseconds + 0.1, 0.2, 0.5, // 100, 200, 500 milliseconds + 1, 2, 5, // 1, 2, 5 seconds + }, + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, decisionLabel, statusLabel}) + + rewritesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: Subsystem, + Name: rewritesTotalName, + Help: "Total rewrites executed by the proxy instance", + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, sideLabel, operationLabel, errorLabel}) + + rewritesDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Subsystem: Subsystem, + Name: rewriteDurationSecondsName, + Help: "Duration of rewrite operations", + Buckets: []float64{ + 0.0, + 0.001, 0.002, 0.005, // 1, 2, 5 milliseconds + 0.01, 0.02, 0.05, // 10, 20, 50 milliseconds + 0.1, 0.2, 0.5, // 100, 200, 500 milliseconds + 1, 2, 5, // 1, 2, 5 seconds + }, + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, sideLabel, operationLabel}) + + fromClientBytes = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: Subsystem, + Name: fromClientBytesName, + Help: "Total bytes received from the client", + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, decisionLabel}) + + toTargetBytes = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: Subsystem, + Name: toTargetBytesName, + Help: "Total bytes transferred to the target", + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, decisionLabel}) + + fromTargetBytes = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: Subsystem, + Name: fromTargetBytesName, + Help: "Total bytes received from the target", + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, decisionLabel}) + + toClientBytes = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: Subsystem, + Name: toClientBytesName, + Help: "Total bytes transferred back to the client", + }, []string{nameLabel, resourceLabel, methodLabel, watchLabel, decisionLabel}) +) + +func RegisterMetrics() { + metrics.Registry.MustRegister( + clientRequestsTotal, + targetResponsesTotal, + targetResponseInvalidJSONTotal, + requestsHandledTotal, + requestHandlingDurationSeconds, + fromClientBytes, + toTargetBytes, + fromTargetBytes, + toClientBytes, + rewritesTotal, + rewritesDurationSeconds, + ) +} + +type MetricsProvider interface { + NewClientRequestsTotal(name, resource, method, watch, decision string) prometheus.Counter + NewTargetResponsesTotal(name, resource, method, watch, decision, status, error string) prometheus.Counter + NewTargetResponseInvalidJSONTotal(name, resource, method, watch, status string) prometheus.Counter + NewRequestsHandledTotal(name, resource, method, watch, decision, status, error string) prometheus.Counter + NewRequestsHandlingSeconds(name, resource, method, watch, decision, status string) prometheus.Observer + NewRewritesTotal(name, resource, method, watch, side, operation, error string) prometheus.Counter + NewRewritesDurationSeconds(name, resource, method, watch, side, operation string) prometheus.Observer + NewFromClientBytesTotal(name, resource, method, watch, decision string) prometheus.Counter + NewToTargetBytesTotal(name, resource, method, watch, decision string) prometheus.Counter + NewFromTargetBytesTotal(name, resource, method, watch, decision string) prometheus.Counter + NewToClientBytesTotal(name, resource, method, watch, decision string) prometheus.Counter +} + +func NewMetricsProvider() MetricsProvider { + return &proxyMetricsProvider{} +} + +type proxyMetricsProvider struct{} + +func (p *proxyMetricsProvider) NewClientRequestsTotal(name, resource, method, watch, decision string) prometheus.Counter { + return clientRequestsTotal.WithLabelValues(name, resource, method, watch, decision) +} + +func (p *proxyMetricsProvider) NewTargetResponsesTotal(name, resource, method, watch, decision, status, error string) prometheus.Counter { + return targetResponsesTotal.WithLabelValues(name, resource, method, watch, decision, status, error) +} + +func (p *proxyMetricsProvider) NewTargetResponseInvalidJSONTotal(name, resource, method, watch, status string) prometheus.Counter { + return targetResponseInvalidJSONTotal.WithLabelValues(name, resource, method, watch, status) +} + +func (p *proxyMetricsProvider) NewRequestsHandledTotal(name, resource, method, watch, decision, status, error string) prometheus.Counter { + return requestsHandledTotal.WithLabelValues(name, resource, method, watch, decision, status, error) +} + +func (p *proxyMetricsProvider) NewRequestsHandlingSeconds(name, resource, method, watch, decision, status string) prometheus.Observer { + return requestHandlingDurationSeconds.WithLabelValues(name, resource, method, watch, decision, status) +} + +func (p *proxyMetricsProvider) NewRewritesTotal(name, resource, method, watch, side, operation, error string) prometheus.Counter { + return rewritesTotal.WithLabelValues(name, resource, method, watch, side, operation, error) +} + +func (p *proxyMetricsProvider) NewRewritesDurationSeconds(name, resource, method, watch, side, operation string) prometheus.Observer { + return rewritesDurationSeconds.WithLabelValues(name, resource, method, watch, side, operation) +} + +func (p *proxyMetricsProvider) NewFromClientBytesTotal(name, resource, method, watch, decision string) prometheus.Counter { + return fromClientBytes.WithLabelValues(name, resource, method, watch, decision) +} + +func (p *proxyMetricsProvider) NewToTargetBytesTotal(name, resource, method, watch, decision string) prometheus.Counter { + return toTargetBytes.WithLabelValues(name, resource, method, watch, decision) +} + +func (p *proxyMetricsProvider) NewFromTargetBytesTotal(name, resource, method, watch, decision string) prometheus.Counter { + return fromTargetBytes.WithLabelValues(name, resource, method, watch, decision) +} + +func (p *proxyMetricsProvider) NewToClientBytesTotal(name, resource, method, watch, decision string) prometheus.Counter { + return toClientBytes.WithLabelValues(name, resource, method, watch, decision) +} + +func NoopMetricsProvider() MetricsProvider { + return noopMetricsProvider{} +} + +type noopMetric struct { + prometheus.Counter + prometheus.Observer +} + +type noopMetricsProvider struct{} + +func (_ noopMetricsProvider) NewClientRequestsTotal(name, resource, method, watch, decision string) prometheus.Counter { + return noopMetric{} +} +func (_ noopMetricsProvider) NewTargetResponsesTotal(name, resource, method, watch, decision, status, error string) prometheus.Counter { + return noopMetric{} +} +func (_ noopMetricsProvider) NewTargetResponseInvalidJSONTotal(name, resource, method, watch, status string) prometheus.Counter { + return noopMetric{} +} +func (_ noopMetricsProvider) NewRequestsHandledTotal(name, resource, method, watch, decision, status, error string) prometheus.Counter { + return noopMetric{} +} +func (_ noopMetricsProvider) NewRequestsHandlingSeconds(name, resource, method, watch, decision, status string) prometheus.Observer { + return noopMetric{} +} +func (_ noopMetricsProvider) NewRewritesTotal(name, resource, method, watch, side, operation, error string) prometheus.Counter { + return noopMetric{} +} +func (_ noopMetricsProvider) NewRewritesDurationSeconds(name, resource, method, watch, side, operation string) prometheus.Observer { + return noopMetric{} +} +func (_ noopMetricsProvider) NewFromClientBytesTotal(name, resource, method, watch, decision string) prometheus.Counter { + return noopMetric{} +} +func (_ noopMetricsProvider) NewToTargetBytesTotal(name, resource, method, watch, decision string) prometheus.Counter { + return noopMetric{} +} +func (_ noopMetricsProvider) NewFromTargetBytesTotal(name, resource, method, watch, decision string) prometheus.Counter { + return noopMetric{} +} +func (_ noopMetricsProvider) NewToClientBytesTotal(name, resource, method, watch, decision string) prometheus.Counter { + return noopMetric{} +} diff --git a/images/kube-api-rewriter/pkg/proxy/stream_handler.go b/images/kube-api-rewriter/pkg/proxy/stream_handler.go new file mode 100644 index 0000000..f599ce6 --- /dev/null +++ b/images/kube-api-rewriter/pkg/proxy/stream_handler.go @@ -0,0 +1,311 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "time" + + "github.com/tidwall/gjson" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/streaming" + apiutilnet "k8s.io/apimachinery/pkg/util/net" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes/scheme" + + logutil "github.com/deckhouse/kube-api-rewriter/pkg/log" + "github.com/deckhouse/kube-api-rewriter/pkg/rewriter" +) + +// StreamHandler reads a stream from the target, transforms events +// and sends them to the client. +type StreamHandler struct { + Rewriter *rewriter.RuleBasedRewriter + MetricsProvider MetricsProvider +} + +// streamRewriter reads a stream from the src reader, transforms events +// and sends them to the dst writer. +type streamRewriter struct { + dst io.Writer + bytesCounter io.ReadCloser + src io.ReadCloser + rewriter *rewriter.RuleBasedRewriter + targetReq *rewriter.TargetRequest + decoder streaming.Decoder + done chan struct{} + log *slog.Logger + metrics *ProxyMetrics +} + +// Handle starts a go routine to pass rewritten Watch Events +// from server to client. +// Sources: +// k8s.io/apimachinery@v0.26.1/pkg/watch/streamwatcher.go:100 receive method +// k8s.io/kubernetes@v1.13.0/staging/src/k8s.io/client-go/rest/request.go:537 wrapperFn, create framer. +// k8s.io/kubernetes@v1.13.0/staging/src/k8s.io/client-go/rest/request.go:598 instantiate watch NewDecoder +func (s *StreamHandler) Handle(ctx context.Context, w http.ResponseWriter, resp *http.Response, targetReq *rewriter.TargetRequest) error { + rewriterInstance := &streamRewriter{ + dst: w, + targetReq: targetReq, + rewriter: s.Rewriter, + done: make(chan struct{}), + log: LoggerWithCommonAttrs(ctx), + metrics: NewProxyMetrics(ctx, s.MetricsProvider), + } + err := rewriterInstance.init(resp) + if err != nil { + return err + } + + rewriterInstance.copyHeaders(w, resp) + + // Start rewriting stream. + go rewriterInstance.start(ctx) + + <-rewriterInstance.DoneChan() + return nil +} + +func (s *streamRewriter) init(resp *http.Response) (err error) { + s.bytesCounter = BytesCounterReaderWrap(resp.Body) + s.src = s.bytesCounter + + if s.log.Enabled(nil, slog.LevelDebug) { + s.src = logutil.NewReaderLogger(s.bytesCounter) + } + + contentType := resp.Header.Get("Content-Type") + s.decoder, err = createWatchDecoder(s.src, contentType) + return err +} + +func (s *streamRewriter) copyHeaders(w http.ResponseWriter, resp *http.Response) { + copyHeader(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) +} + +// proxy reads result from the decoder in a loop, rewrites and writes to a client. +// Sources +// k8s.io/apimachinery@v0.26.1/pkg/watch/streamwatcher.go:100 receive method +func (s *streamRewriter) start(ctx context.Context) { + defer utilruntime.HandleCrash() + defer s.Stop() + + for { + // Read event from the server. + var got metav1.WatchEvent + s.log.Debug("Start decode from stream") + res, _, err := s.decoder.Decode(nil, &got) + s.metrics.FromTargetBytesAdd(CounterValue(s.bytesCounter)) + if s.log.Enabled(ctx, slog.LevelDebug) { + s.log.Debug(fmt.Sprintf("Got decoded WatchEvent from stream: %d bytes received", CounterValue(s.bytesCounter))) + } + CounterReset(s.bytesCounter) + + // Check if context was canceled. + select { + case <-ctx.Done(): + s.log.Debug("Context canceled, stop stream rewriter") + return + default: + } + + if err != nil { + switch err { + case io.EOF: + // Watch closed normally. + s.log.Debug("Catch EOF from target, stop proxying the stream") + case io.ErrUnexpectedEOF: + s.log.Error("Unexpected EOF during watch stream event decoding", logutil.SlogErr(err)) + default: + if apiutilnet.IsProbableEOF(err) || apiutilnet.IsTimeout(err) { + s.log.Error("Unable to decode an event from the watch stream", logutil.SlogErr(err)) + } else { + s.log.Error("Unable to decode an event from the watch stream", logutil.SlogErr(err)) + } + } + return + } + + watchEventHandleStart := time.Now() + + var rwrEvent *metav1.WatchEvent + if res != &got { + s.log.Warn(fmt.Sprintf("unable to decode to metav1.Event: res=%#v, got=%#v", res, got)) + s.metrics.TargetResponseInvalidJSON(200) + s.metrics.RequestHandleError() + // There is nothing to send to the client: no event decoded. + } else { + rwrEvent, err = s.transformWatchEvent(&got) + if err != nil && errors.Is(err, rewriter.SkipItem) { + s.log.Warn(fmt.Sprintf("Watch event '%s': skipped by rewriter", got.Type), logutil.SlogErr(err)) + logutil.DebugBodyHead(s.log, fmt.Sprintf("Watch event '%s' skipped", got.Type), s.targetReq.ResourceForLog(), got.Object.Raw) + s.metrics.RequestHandleSuccess() + } else { + if err != nil { + s.log.Error(fmt.Sprintf("Watch event '%s': transform error", got.Type), logutil.SlogErr(err)) + logutil.DebugBodyHead(s.log, fmt.Sprintf("Watch event '%s'", got.Type), s.targetReq.ResourceForLog(), got.Object.Raw) + } + if rwrEvent == nil { + // No rewrite, pass original event as-is. + rwrEvent = &got + } else { + // Log changes after rewrite. + logutil.DebugBodyChanges(s.log, "Watch event", s.targetReq.ResourceForLog(), got.Object.Raw, rwrEvent.Object.Raw) + } + // Pass event to the client. + logutil.DebugBodyHead(s.log, fmt.Sprintf("WatchEvent type '%s' send back to client %d bytes", rwrEvent.Type, len(rwrEvent.Object.Raw)), s.targetReq.ResourceForLog(), rwrEvent.Object.Raw) + s.writeEvent(rwrEvent) + } + } + + s.metrics.RequestDuration(time.Since(watchEventHandleStart)) + + // Check if application is stopped before waiting for the next event. + select { + case <-s.done: + return + default: + } + } +} + +func (s *streamRewriter) Stop() { + select { + case <-s.done: + default: + close(s.done) + } +} + +func (s *streamRewriter) DoneChan() chan struct{} { + return s.done +} + +// createSerializers +// Source +// k8s.io/client-go@v0.26.1/rest/request.go:765 newStreamWatcher +// k8s.io/apimachinery@v0.26.1/pkg/runtime/negotiate.go:70 StreamDecoder +func createWatchDecoder(r io.Reader, contentType string) (streaming.Decoder, error) { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return nil, fmt.Errorf("unexpected media type from the server: %q: %w", contentType, err) + } + + negotiatedSerializer := scheme.Codecs.WithoutConversion() + mediaTypes := negotiatedSerializer.SupportedMediaTypes() + info, ok := runtime.SerializerInfoForMediaType(mediaTypes, mediaType) + if !ok { + if len(contentType) != 0 || len(mediaTypes) == 0 { + return nil, fmt.Errorf("no matching serializer for media type '%s'", contentType) + } + info = mediaTypes[0] + } + if info.StreamSerializer == nil { + return nil, fmt.Errorf("no serializer for content type %s", contentType) + } + + // A chain of the framer and the serializer will split body stream into JSON objects. + frameReader := info.StreamSerializer.Framer.NewFrameReader(io.NopCloser(r)) + streamingDecoder := streaming.NewDecoder(frameReader, info.StreamSerializer.Serializer) + return streamingDecoder, nil +} + +func (s *streamRewriter) transformWatchEvent(ev *metav1.WatchEvent) (*metav1.WatchEvent, error) { + switch ev.Type { + case string(watch.Added), string(watch.Modified), string(watch.Deleted), string(watch.Error), string(watch.Bookmark): + default: + return nil, fmt.Errorf("got unknown type in WatchEvent: %v", ev.Type) + } + + group := gjson.GetBytes(ev.Object.Raw, "apiVersion").String() + kind := gjson.GetBytes(ev.Object.Raw, "kind").String() + name := gjson.GetBytes(ev.Object.Raw, "metadata.name").String() + ns := gjson.GetBytes(ev.Object.Raw, "metadata.namespace").String() + + // TODO add pass-as-is for non rewritable objects. + if group == "" && kind == "" { + // Object in event is undetectable, pass this event as-is. + return nil, fmt.Errorf("object has no apiVersion and kind") + } + s.log.Debug(fmt.Sprintf("Receive '%s' watch event with %s/%s %s/%s object", ev.Type, group, kind, ns, name)) + + var rwrObjBytes []byte + var err error + rewriteStart := time.Now() + defer func() { + s.metrics.TargetResponseRewriteDuration(time.Since(rewriteStart)) + }() + + if ev.Type == string(watch.Bookmark) { + // Temporarily print original BOOKMARK WatchEvent. + logutil.DebugBodyHead(s.log, fmt.Sprintf("Watch event '%s' from target", ev.Type), s.targetReq.OrigResourceType(), ev.Object.Raw) + rwrObjBytes, err = s.rewriter.RestoreBookmark(s.targetReq, ev.Object.Raw) + } else { + // Restore object in the event. Watch responses are always from the Kubernetes API server, so rename is not needed. + rwrObjBytes, err = s.rewriter.RewriteJSONPayload(s.targetReq, ev.Object.Raw, rewriter.Restore) + } + if err != nil { + if errors.Is(err, rewriter.SkipItem) { + s.metrics.TargetResponseRewriteSuccess() + return nil, err + } + s.metrics.TargetResponseRewriteError() + return nil, fmt.Errorf("rewrite object in WatchEvent '%s': %w", ev.Type, err) + } + + s.metrics.TargetResponseRewriteSuccess() + // Prepare rewritten event bytes. + return &metav1.WatchEvent{ + Type: ev.Type, + Object: runtime.RawExtension{ + Raw: rwrObjBytes, + }, + }, nil +} + +func (s *streamRewriter) writeEvent(ev *metav1.WatchEvent) { + rwrEventBytes, err := json.Marshal(ev) + if err != nil { + s.log.Error("encode restored event to bytes", logutil.SlogErr(err)) + return + } + + // Send rewritten event to the client. + copied, err := s.dst.Write(rwrEventBytes) + if err != nil { + s.log.Error("Watch event: error writing event to the client", logutil.SlogErr(err)) + s.metrics.RequestHandleSuccess() + s.metrics.ToClientBytesAdd(copied) + } else { + s.metrics.RequestHandleError() + } + // Flush writer to immediately send any buffered content to the client. + if wr, ok := s.dst.(http.Flusher); ok { + wr.Flush() + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/3rdparty.go b/images/kube-api-rewriter/pkg/rewriter/3rdparty.go new file mode 100644 index 0000000..915d73e --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/3rdparty.go @@ -0,0 +1,32 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +// Rewrite routines for 3rd party resources, i.e. ServiceMonitor. + +const ( + PrometheusRuleKind = "PrometheusRule" + PrometheusRuleListKind = "PrometheusRuleList" + ServiceMonitorKind = "ServiceMonitor" + ServiceMonitorListKind = "ServiceMonitorList" +) + +func RewriteServiceMonitorOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return TransformObject(obj, "spec.selector", func(obj []byte) ([]byte, error) { + return rewriteLabelSelector(rules, obj, action) + }) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/admission_configuration.go b/images/kube-api-rewriter/pkg/rewriter/admission_configuration.go new file mode 100644 index 0000000..6f881c8 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/admission_configuration.go @@ -0,0 +1,89 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import "github.com/tidwall/gjson" + +const ( + ValidatingWebhookConfigurationKind = "ValidatingWebhookConfiguration" + ValidatingWebhookConfigurationListKind = "ValidatingWebhookConfigurationList" + MutatingWebhookConfigurationKind = "MutatingWebhookConfiguration" + MutatingWebhookConfigurationListKind = "MutatingWebhookConfigurationList" +) + +func RewriteValidatingOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + if action == Rename { + return RewriteResourceOrList(obj, ValidatingWebhookConfigurationListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "webhooks", func(webhook []byte) ([]byte, error) { + return RewriteArray(webhook, "rules", func(item []byte) ([]byte, error) { + return RenameResourceRule(rules, item) + }) + }) + }) + } + return RewriteResourceOrList(obj, ValidatingWebhookConfigurationListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "webhooks", func(webhook []byte) ([]byte, error) { + return RewriteArray(webhook, "rules", func(item []byte) ([]byte, error) { + return RestoreResourceRule(rules, item) + }) + }) + }) +} + +func RewriteMutatingOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + if action == Rename { + return RewriteResourceOrList(obj, MutatingWebhookConfigurationListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "webhooks", func(webhook []byte) ([]byte, error) { + return RewriteArray(webhook, "rules", func(item []byte) ([]byte, error) { + return RenameResourceRule(rules, item) + }) + }) + }) + } + return RewriteResourceOrList(obj, MutatingWebhookConfigurationListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "webhooks", func(webhook []byte) ([]byte, error) { + return RewriteArray(webhook, "rules", func(item []byte) ([]byte, error) { + return RestoreResourceRule(rules, item) + }) + }) + }) +} + +func RenameWebhookConfigurationPatch(rules *RewriteRules, obj []byte) ([]byte, error) { + obj, err := RenameMetadataPatch(rules, obj) + if err != nil { + return nil, err + } + + return TransformPatch(obj, func(mergePatch []byte) ([]byte, error) { + return RewriteArray(mergePatch, "webhooks", func(webhook []byte) ([]byte, error) { + return RewriteArray(webhook, "rules", func(item []byte) ([]byte, error) { + return RestoreResourceRule(rules, item) + }) + }) + }, func(jsonPatch []byte) ([]byte, error) { + path := gjson.GetBytes(jsonPatch, "path").String() + if path == "/webhooks" { + return RewriteArray(jsonPatch, "value", func(webhook []byte) ([]byte, error) { + return RewriteArray(webhook, "rules", func(item []byte) ([]byte, error) { + return RenameResourceRule(rules, item) + }) + }) + } + return jsonPatch, nil + }) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/admission_configuration_test.go b/images/kube-api-rewriter/pkg/rewriter/admission_configuration_test.go new file mode 100644 index 0000000..42c7f63 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/admission_configuration_test.go @@ -0,0 +1,85 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidatingRename(t *testing.T) { + tests := []struct { + name string + manifest string + expect string + }{ + { + "mixed resources", + `{"webhooks":[{"rules":[{"apiGroups":[""],"resources":["pods"]},{"apiGroups": ["original.group.io"], "resources": ["someresources"]}]}]}`, + `{"webhooks":[{"rules":[{"apiGroups":[""],"resources":["pods"]},{"apiGroups": ["prefixed.resources.group.io"], "resources": ["prefixedsomeresources"]}]}]}`, + }, + { + "empty object", + `{}`, + `{}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rwr := createTestRewriter() + + resBytes, err := RewriteValidatingOrList(rwr.Rules, []byte(tt.manifest), Rename) + require.NoError(t, err, "should rename validating webhook configuration") + + actual := string(resBytes) + require.Equal(t, tt.expect, actual) + }) + } +} + +func TestValidatingRestore(t *testing.T) { + tests := []struct { + name string + manifest string + expect string + }{ + { + "mixed resources", + `{"webhooks":[{"rules":[{"apiGroups":[""],"resources":["pods"]},{"apiGroups": ["prefixed.resources.group.io"], "resources": ["prefixedsomeresources"]}]}]}`, + `{"webhooks":[{"rules":[{"apiGroups":[""],"resources":["pods"]},{"apiGroups": ["original.group.io"], "resources": ["someresources"]}]}]}`, + }, + { + "empty object", + `{}`, + `{}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rwr := createTestRewriter() + + resBytes, err := RewriteValidatingOrList(rwr.Rules, []byte(tt.manifest), Restore) + require.NoError(t, err, "should rename validating webhook configuration") + + actual := string(resBytes) + require.Equal(t, tt.expect, actual) + }) + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/admission_policy.go b/images/kube-api-rewriter/pkg/rewriter/admission_policy.go new file mode 100644 index 0000000..f2f7265 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/admission_policy.go @@ -0,0 +1,67 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +const ( + ValidatingAdmissionPolicyKind = "ValidatingAdmissionPolicy" + ValidatingAdmissionPolicyListKind = "ValidatingAdmissionPolicyList" + ValidatingAdmissionPolicyBindingKind = "ValidatingAdmissionPolicyBinding" + ValidatingAdmissionPolicyBindingListKind = "ValidatingAdmissionPolicyBindingList" +) + +// renames apiGroups and resources in a single resourceRule. +// Rule examples: +// resourceRules: +// - apiGroups: +// - "" +// apiVersions: +// - '*' +// operations: +// - '*' +// resources: +// - nodes +// scope: '*' + +func RewriteValidatingAdmissionPolicyOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + if action == Rename { + return RewriteResourceOrList(obj, ValidatingAdmissionPolicyListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "spec.matchConstraints.resourceRules", func(item []byte) ([]byte, error) { + return RenameResourceRule(rules, item) + }) + }) + } + return RewriteResourceOrList(obj, ValidatingAdmissionPolicyListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "spec.matchConstraints.resourceRules", func(item []byte) ([]byte, error) { + return RestoreResourceRule(rules, item) + }) + }) +} + +func RewriteValidatingAdmissionPolicyBindingOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + if action == Rename { + return RewriteResourceOrList(obj, ValidatingAdmissionPolicyBindingListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "spec.matchResources.resourceRules", func(item []byte) ([]byte, error) { + return RenameResourceRule(rules, item) + }) + }) + } + return RewriteResourceOrList(obj, ValidatingAdmissionPolicyBindingListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "spec.matchResources.resourceRules", func(item []byte) ([]byte, error) { + return RestoreResourceRule(rules, item) + }) + }) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/admission_review.go b/images/kube-api-rewriter/pkg/rewriter/admission_review.go new file mode 100644 index 0000000..613e3d9 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/admission_review.go @@ -0,0 +1,238 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "encoding/base64" + "fmt" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// RewriteAdmissionReview rewrites AdmissionReview request and response. +// NOTE: only one rewrite direction is supported for now: +// - Restore object in AdmissionReview request. +// - Do nothing for AdmissionReview response. +func RewriteAdmissionReview(rules *RewriteRules, obj []byte) ([]byte, error) { + if gjson.GetBytes(obj, "response").Exists() { + return TransformObject(obj, "response", func(responseObj []byte) ([]byte, error) { + return RenameAdmissionReviewResponse(rules, responseObj) + }) + } + + request := gjson.GetBytes(obj, "request") + if request.Exists() { + newRequest, err := RestoreAdmissionReviewRequest(rules, []byte(request.Raw)) + if err != nil { + return nil, err + } + if len(newRequest) > 0 { + obj, err = sjson.SetRawBytes(obj, "request", newRequest) + if err != nil { + return nil, err + } + } + } + + return obj, nil +} + +// RenameAdmissionReviewResponse renames metadata in AdmissionReview response patch. +// AdmissionReview response example: +// +// "response": { +// "uid": "", +// "allowed": true, +// "patchType": "JSONPatch", +// "patch": "W3sib3AiOiAiYWRkIiwgInBhdGgiOiAiL3NwZWMvcmVwbGljYXMiLCAidmFsdWUiOiAzfV0=" +// } +// +// TODO rename annotations in AuditAnnotations field. (Ignore for now, as not used by the kubevirt). +func RenameAdmissionReviewResponse(rules *RewriteRules, obj []byte) ([]byte, error) { + // Description for the AdmissionResponse.PatchType field: The type of Patch. Currently, we only allow "JSONPatch". + patchType := gjson.GetBytes(obj, "patchType").String() + if patchType != "JSONPatch" { + return obj, nil + } + + // Get decoded patch. + b64Patch := gjson.GetBytes(obj, "patch").String() + if b64Patch == "" { + return obj, nil + } + + patch, err := base64.StdEncoding.DecodeString(b64Patch) + if err != nil { + return nil, fmt.Errorf("decode base64 patch: %w", err) + } + + rwrPatch, err := RenameMetadataPatch(rules, patch) + if err != nil { + return nil, fmt.Errorf("rename metadata patch: %w", err) + } + + // Update patch field to base64 encoded rewritten patch. + return sjson.SetBytes(obj, "patch", base64.StdEncoding.EncodeToString(rwrPatch)) +} + +// RestoreAdmissionReviewRequest restores apiVersion, kind and other fields in an AdmissionReview request. +// Only restoring is required, as AdmissionReview request only comes from API Server. +// Fields for AdmissionReview request: +// +// kind, requestKind: - Fully-qualified group/version/kind of the incoming object +// kind - restore +// version +// group - restore +// resource, requestResource - Fully-qualified group/version/kind of the resource being modified +// group - restore +// version +// resource - restore +// object, oldObject - new and old objects being admitted, should be restored. +// +// non-rewritable: +// uid - review uid, no rewrite +// subResource, requestSubResource - scale or status, no rewrite +// name +// namespace +// operation +// userInfo +// options +// dryRun +func RestoreAdmissionReviewRequest(rules *RewriteRules, obj []byte) ([]byte, error) { + var err error + + // Rewrite "resource" field and find rules. + { + resourceObj := gjson.GetBytes(obj, "resource") + group := resourceObj.Get("group") + resource := resourceObj.Get("resource") + // Ignore reviews for unknown renamed group. + if !rules.IsRenamedGroup(group.String()) { + return nil, nil + } + restoredResourceType := rules.RestoreResource(resource.String()) + obj, err = sjson.SetBytes(obj, "resource.resource", restoredResourceType) + if err != nil { + return nil, err + } + restoredGroup := rules.RestoreApiVersion(group.String()) + obj, err = sjson.SetBytes(obj, "resource.group", restoredGroup) + if err != nil { + return nil, err + } + } + + // Rewrite "requestResource" field. + { + fieldObj := gjson.GetBytes(obj, "requestResource") + group := fieldObj.Get("group") + resource := fieldObj.Get("resource") + // Ignore reviews for unknown renamed group. + if !rules.IsRenamedGroup(group.String()) { + return nil, nil + } + restoredResourceType := rules.RestoreResource(resource.String()) + obj, err = sjson.SetBytes(obj, "requestResource.resource", restoredResourceType) + if err != nil { + return nil, err + } + restoredGroup := rules.RestoreApiVersion(group.String()) + obj, err = sjson.SetBytes(obj, "requestResource.group", restoredGroup) + if err != nil { + return nil, err + } + } + + // Check "subresource" field. No need to rewrite kind, requestKind, object and oldObject fields if subresource is set. + { + fieldObj := gjson.GetBytes(obj, "subresource") + if fieldObj.Exists() && fieldObj.String() != "" { + return obj, err + } + } + + // Rewrite "kind" field. + { + fieldObj := gjson.GetBytes(obj, "kind") + kind := fieldObj.Get("kind") + restoredKind := rules.RestoreKind(kind.String()) + obj, err = sjson.SetBytes(obj, "kind.kind", restoredKind) + if err != nil { + return nil, err + } + group := fieldObj.Get("group") + restoredGroup := rules.RestoreApiVersion(group.String()) + obj, err = sjson.SetBytes(obj, "kind.group", restoredGroup) + if err != nil { + return nil, err + } + } + + // Rewrite "requestKind" field. + { + fieldObj := gjson.GetBytes(obj, "requestKind") + kind := fieldObj.Get("kind") + restoredKind := rules.RestoreKind(kind.String()) + obj, err = sjson.SetBytes(obj, "requestKind.kind", restoredKind) + if err != nil { + return nil, err + } + group := fieldObj.Get("group") + restoredGroup := rules.RestoreApiVersion(group.String()) + obj, err = sjson.SetBytes(obj, "requestKind.group", restoredGroup) + if err != nil { + return nil, err + } + } + + // Rewrite "object" field. + obj, err = TransformObject(obj, "object", func(objectObj []byte) ([]byte, error) { + return RestoreAdmissionReviewObject(rules, objectObj) + }) + if err != nil { + return nil, fmt.Errorf("restore 'object': %w", err) + } + // Rewrite "object" field. + obj, err = TransformObject(obj, "oldObject", func(objectObj []byte) ([]byte, error) { + return RestoreAdmissionReviewObject(rules, objectObj) + }) + if err != nil { + return nil, fmt.Errorf("restore 'oldObject': %w", err) + } + + return obj, nil +} + +// RestoreAdmissionReviewObject fully restores object of known resource. +// TODO deduplicate with code in RewriteJSONPayload. +func RestoreAdmissionReviewObject(rules *RewriteRules, obj []byte) ([]byte, error) { + var err error + obj, err = RestoreResource(rules, obj) + if err != nil { + return nil, fmt.Errorf("restore resource group, kind: %w", err) + } + + obj, err = TransformObject(obj, "metadata", func(metadataObj []byte) ([]byte, error) { + return RewriteMetadata(rules, metadataObj, Restore) + }) + if err != nil { + return nil, fmt.Errorf("restore resource metadata: %w", err) + } + + return obj, nil +} diff --git a/images/kube-api-rewriter/pkg/rewriter/admission_review_test.go b/images/kube-api-rewriter/pkg/rewriter/admission_review_test.go new file mode 100644 index 0000000..cde5f76 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/admission_review_test.go @@ -0,0 +1,225 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestRewriteAdmissionReviewRequestForResource(t *testing.T) { + admissionReview := `{ + "kind":"AdmissionReview", + "apiVersion":"admission.k8s.io/v1", + "request":{ + "uid":"389cfe15-34a1-4829-ad4d-de2576385711", + "kind":{"group":"prefixed.resources.group.io","version":"v1","kind":"PrefixedSomeResource"}, + "resource":{"group":"prefixed.resources.group.io","version":"v1","resource":"prefixedsomeresources"}, + "requestKind":{"group":"prefixed.resources.group.io","version":"v1","kind":"PrefixedSomeResource"}, + "requestResource":{"group":"prefixed.resources.group.io","version":"v1","resource":"prefixedsomeresources"}, + "name":"some-resource-name", + "namespace":"nsname", + "operation":"UPDATE", + "userInfo":{"username":"kubernetes-admin","groups":["system:masters","system:authenticated"]}, + "object":{ + "apiVersion":"prefixed.resources.group.io/v1", + "kind":"PrefixedSomeResource", + "metadata":{ + "annotations":{ + "anno":"value", + }, + "creationTimestamp":"2024-02-05T12:42:32Z", + "finalizers":["group.io/protection","other.group.io/protection"], + "name":"some-resource-name", + "namespace":"nsname", + "ownerReferences":[ + {"apiVersion":"controller.group.io/v2", + "blockOwnerDeletion":true, + "controller":true, + "kind":"SomeKind","name":"some-controller-name","uid":"904cfea9-c9d6-4d3a-82f7-5790b1a1b3e0"} + ], + "resourceVersion":"265111919","uid":"4c74c3ff-2199-4f20-a71c-3b0e5fb505ca" + }, + "spec":{"field1":"value1", "field2":"value2"}, + "status":{ + "conditions":[ + {"lastProbeTime":null,"lastTransitionTime":"2024-03-06T14:38:39Z","status":"True","type":"Ready"}, + {"lastProbeTime":"2024-02-29T14:11:05Z","lastTransitionTime":null,"status":"True","type":"Healthy"}], + "printableStatus":"Ready" + } + }, + + "oldObject":{ + "apiVersion":"prefixed.resources.group.io/v1", + "kind":"PrefixedSomeResource", + "metadata":{ + "annotations":{ + "anno":"value", + }, + "creationTimestamp":"2024-02-05T12:42:32Z", + "finalizers":["group.io/protection","other.group.io/protection"], + "name":"some-resource-name", + "namespace":"nsname", + "ownerReferences":[ + {"apiVersion":"controller.group.io/v2", + "blockOwnerDeletion":true, + "controller":true, + "kind":"SomeKind","name":"some-controller-name","uid":"904cfea9-c9d6-4d3a-82f7-5790b1a1b3e0"} + ], + "resourceVersion":"265111919","uid":"4c74c3ff-2199-4f20-a71c-3b0e5fb505ca" + }, + "spec":{"field1":"value1", "field2":"value2"}, + "status":{ + "conditions":[ + {"lastProbeTime":null,"lastTransitionTime":"2024-03-06T14:38:39Z","status":"True","type":"Ready"}, + {"lastProbeTime":"2024-02-29T14:11:05Z","lastTransitionTime":null,"status":"True","type":"Healthy"}], + "printableStatus":"Ready" + } + } + } +} +` + admissionReviewRequest := `POST /validate-prefixed-resources-group-io-v1-prefixedsomeresource HTTP/1.1 +Host: 127.0.0.1 +Content-Type: application/json +Content-Length: ` + strconv.Itoa(len(admissionReview)) + ` + +` + admissionReview + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(admissionReviewRequest))) + require.NoError(t, err, "should read hardcoded AdmissionReview request") + + rwr := createTestRewriter() + + // Check getting TargetRequest from the webhook request. + var targetReq *TargetRequest + targetReq = NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request in TargetRequest") + + // Check payload rewriting. + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(admissionReview), Restore) + require.NoError(t, err, "should rewrite request") + if err != nil { + t.Fatalf("should rewrite request: %v", err) + } + + require.Greater(t, len(resultBytes), 0, "result bytes from RewriteJSONPayload should not be empty") + + groupRule, resRule := rwr.Rules.ResourceRules("original.group.io", "someresources") + require.NotNil(t, resRule, "should get resourceRule for hardcoded group and resourceType") + + tests := []struct { + path string + expected string + }{ + {"request.kind.group", groupRule.Group}, + {"request.kind.kind", resRule.Kind}, + {"request.requestKind.group", groupRule.Group}, + {"request.requestKind.kind", resRule.Kind}, + {"request.resource.group", groupRule.Group}, + {"request.resource.resource", resRule.Plural}, + {"request.requestResource.group", groupRule.Group}, + {"request.requestResource.resource", resRule.Plural}, + {"request.object.apiVersion", groupRule.Group + "/v1"}, + {"request.object.kind", resRule.Kind}, + {"request.oldObject.apiVersion", groupRule.Group + "/v1"}, + {"request.oldObject.kind", resRule.Kind}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } +} + +func TestRewriteAdmissionReviewResponse(t *testing.T) { + admissionReviewResponseTpl := `{ + "kind":"AdmissionReview", + "apiVersion":"admission.k8s.io/v1", + "response":{ + "uid":"389cfe15-34a1-4829-ad4d-de2576385711", + "allowed": true, + "patchType": "JSONPatch", + "patch": "%s" + } +} +` + admissionReviewRequest := `POST /validate-prefixed-resources-group-io-v1-prefixedsomeresource HTTP/1.1 +Host: 127.0.0.1 +Content-Type: application/json + +` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(admissionReviewRequest))) + require.NoError(t, err, "should read hardcoded AdmissionReview request") + + rwr := createTestRewriter() + + // Check getting TargetRequest from the webhook request. + var targetReq *TargetRequest + targetReq = NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request in TargetRequest") + + // Check patches rewriting. + + tests := []struct { + name string + patch string + expected string + }{ + { + "rename label in replace op", + `[{"op":"replace","path":"/metadata/labels","value":{"labelgroup.io":"labelValue"}}]`, + `[{"op":"replace","path":"/metadata/labels","value":{"replacedlabelgroup.io":"labelValue"}}]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b64Patch := base64.StdEncoding.EncodeToString([]byte(tt.patch)) + payload := fmt.Sprintf(admissionReviewResponseTpl, b64Patch) + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(payload), Rename) + require.NoError(t, err, "should rewrite AdmissionRequest response") + if err != nil { + t.Fatalf("should rewrite AdmissionRequest response: %v", err) + } + + require.Greater(t, len(resultBytes), 0, "result bytes from RewriteJSONPayload should not be empty") + + b64Actual := gjson.GetBytes(resultBytes, "response.patch").String() + actual, err := base64.StdEncoding.DecodeString(b64Actual) + require.NoError(t, err, "should decode result patch: '%s'", b64Actual) + + require.NotEqual(t, tt.expected, actual, "%s value should be %s, got %s", tt.name, tt.expected, actual) + }) + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/affinity.go b/images/kube-api-rewriter/pkg/rewriter/affinity.go new file mode 100644 index 0000000..a729a57 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/affinity.go @@ -0,0 +1,187 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// RewriteAffinity renames or restores labels in labelSelector of affinity structure. +// See https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity +func RewriteAffinity(rules *RewriteRules, obj []byte, path string, action Action) ([]byte, error) { + return TransformObject(obj, path, func(affinity []byte) ([]byte, error) { + rwrAffinity, err := TransformObject(affinity, "nodeAffinity", func(item []byte) ([]byte, error) { + return rewriteNodeAffinity(rules, item, action) + }) + if err != nil { + return nil, err + } + + rwrAffinity, err = TransformObject(rwrAffinity, "podAffinity", func(item []byte) ([]byte, error) { + return rewritePodAffinity(rules, item, action) + }) + if err != nil { + return nil, err + } + + return TransformObject(rwrAffinity, "podAntiAffinity", func(item []byte) ([]byte, error) { + return rewritePodAffinity(rules, item, action) + }) + + }) +} + +// rewriteNodeAffinity rewrites labels in nodeAffinity structure. +// nodeAffinity: +// +// requiredDuringSchedulingIgnoredDuringExecution: +// nodeSelectorTerms []NodeSelector -> rewrite each item: key in each matchExpressions and matchFields +// preferredDuringSchedulingIgnoredDuringExecution: -> array of PreferredSchedulingTerm: +// preference NodeSelector -> rewrite key in each matchExpressions and matchFields +// weight: +func rewriteNodeAffinity(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + // Rewrite an array of nodeSelectorTerms in requiredDuringSchedulingIgnoredDuringExecution field. + var err error + obj, err = TransformObject(obj, "requiredDuringSchedulingIgnoredDuringExecution", func(affinityTerm []byte) ([]byte, error) { + return RewriteArray(affinityTerm, "nodeSelectorTerms", func(item []byte) ([]byte, error) { + return rewriteNodeSelectorTerm(rules, item, action) + }) + }) + if err != nil { + return nil, err + } + + // Rewrite an array of weightedNodeSelectorTerms in preferredDuringSchedulingIgnoredDuringExecution field. + return RewriteArray(obj, "preferredDuringSchedulingIgnoredDuringExecution", func(item []byte) ([]byte, error) { + return TransformObject(item, "preference", func(preference []byte) ([]byte, error) { + return rewriteNodeSelectorTerm(rules, preference, action) + }) + }) +} + +// rewriteNodeSelectorTerm renames or restores selector requirements arrays in matchLabels or matchExpressions of NodeSelectorTerm. +// See [v1.NodeSelectorTerm](https://pkg.go.dev/k8s.io/api/core/v1#NodeSelectorTerm) +func rewriteNodeSelectorTerm(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + obj, err := RewriteArray(obj, "matchLabels", func(item []byte) ([]byte, error) { + return rewriteSelectorRequirement(rules, item, action) + }) + if err != nil { + return nil, err + } + return RewriteArray(obj, "matchExpressions", func(labelSelectorObj []byte) ([]byte, error) { + return rewriteSelectorRequirement(rules, labelSelectorObj, action) + }) +} + +// rewriteSelectorRequirement rewrites key and values in the selector requirement. +// Selector requirement example: +// {"key":"app.kubernetes.io/managed-by", "operator": "In", "values": ["Helm"]} +func rewriteSelectorRequirement(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + key := gjson.GetBytes(obj, "key").String() + valuesArr := gjson.GetBytes(obj, "values").Array() + values := make([]string, len(valuesArr)) + for i, value := range valuesArr { + values[i] = value.String() + } + rwrKey, rwrValues := rules.LabelsRewriter().RewriteNameValues(key, values, action) + + obj, err := sjson.SetBytes(obj, "key", rwrKey) + if err != nil { + return nil, err + } + + return sjson.SetBytes(obj, "values", rwrValues) +} + +// rewritePodAffinity rewrites PodAffinity and PodAntiAffinity structures. +// PodAffinity and PodAntiAffinity structures are the same: +// +// requiredDuringSchedulingIgnoredDuringExecution -> array of PodAffinityTerm structures: +// labelSelector: +// matchLabels -> rewrite map +// matchExpressions -> rewrite key in each item +// topologyKey -> rewrite as label name +// namespaceSelector -> rewrite as labelSelector +// matchLabelKeys -> rewrite array of label keys +// mismatchLabelKeys -> rewrite array of label keys +// preferredDuringSchedulingIgnoredDuringExecution -> array of WeightedPodAffinityTerm: +// weight +// podAffinityTerm PodAffinityTerm -> rewrite as described above +func rewritePodAffinity(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + // Rewrite an array of PodAffinityTerms in requiredDuringSchedulingIgnoredDuringExecution field. + obj, err := RewriteArray(obj, "requiredDuringSchedulingIgnoredDuringExecution", func(affinityTerm []byte) ([]byte, error) { + return rewritePodAffinityTerm(rules, affinityTerm, action) + }) + if err != nil { + return nil, err + } + + // Rewrite an array of WeightedPodAffinityTerms in requiredDuringSchedulingIgnoredDuringExecution field. + return RewriteArray(obj, "preferredDuringSchedulingIgnoredDuringExecution", func(affinityTerm []byte) ([]byte, error) { + return TransformObject(affinityTerm, "podAffinityTerm", func(podAffinityTerm []byte) ([]byte, error) { + return rewritePodAffinityTerm(rules, podAffinityTerm, action) + }) + }) +} + +func rewritePodAffinityTerm(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + obj, err := TransformObject(obj, "labelSelector", func(labelSelector []byte) ([]byte, error) { + return rewriteLabelSelector(rules, labelSelector, action) + }) + if err != nil { + return nil, err + } + + obj, err = TransformString(obj, "topologyKey", func(topologyKey string) string { + return rules.LabelsRewriter().Rewrite(topologyKey, action) + }) + if err != nil { + return nil, err + } + + obj, err = TransformObject(obj, "namespaceSelector", func(selector []byte) ([]byte, error) { + return rewriteLabelSelector(rules, selector, action) + }) + if err != nil { + return nil, err + } + + obj, err = TransformArrayOfStrings(obj, "matchLabelKeys", func(labelKey string) string { + return rules.LabelsRewriter().Rewrite(labelKey, action) + }) + if err != nil { + return nil, err + } + + return TransformArrayOfStrings(obj, "mismatchLabelKeys", func(labelKey string) string { + return rules.LabelsRewriter().Rewrite(labelKey, action) + }) +} + +// rewriteLabelSelector rewrites matchLabels and matchExpressions. It is similar to rewriteNodeSelectorTerm +// but matchLabels is a map here, not an array of requirements. +func rewriteLabelSelector(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + obj, err := RewriteLabelsMap(rules, obj, "matchLabels", action) + if err != nil { + return nil, err + } + + return RewriteArray(obj, "matchExpressions", func(item []byte) ([]byte, error) { + return rewriteSelectorRequirement(rules, item, action) + }) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/api_endpoint.go b/images/kube-api-rewriter/pkg/rewriter/api_endpoint.go new file mode 100644 index 0000000..830ea6a --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/api_endpoint.go @@ -0,0 +1,313 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "net/url" + "strings" +) + +type APIEndpoint struct { + // IsUknown indicates that path is unknown for rewriter and should be passed as is. + IsUnknown bool + RawPath string + + IsRoot bool + + Prefix string + IsCore bool + + Group string + Version string + Namespace string + ResourceType string + Name string + Subresource string + Remainder []string + + IsCRD bool + CRDResourceType string + CRDGroup string + + IsWatch bool + RawQuery string +} + +// Core resources: +// - /api/VERSION/RESOURCETYPE +// - /api/VERSION/RESOURCETYPE/NAME +// - /api/VERSION/RESOURCETYPE/NAME/SUBRESOURCE +// - /api/VERSION/namespaces/NAMESPACE/RESOURCETYPE +// - /api/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME +// - /api/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME/SUBRESOURCE +// - /api/VERSION/namespaces/NAME/SUBRESOURCE - RESOURCETYPE=namespaces +// +// Cluster scoped custom resource: +// - /apis/GROUP/VERSION/RESOURCETYPE/NAME/SUBRESOURCE +// | | | | +// PrefixIdx | | | +// GroupIDx -+ | | +// VersionIDx -----+ | +// ClusterResourceIdx -----+ +// +// Namespaced custom resource: +// - /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME/SUBRESOURCE +// - /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME/SUBRESOURCE +// +// CRD (CRD is itself a cluster scoped custom resource): +// - /apis/apiextensions.k8s.io/v1/customresourcedefinitions +// - /apis/apiextensions.k8s.io/v1/customresourcedefinitions/RESOURCETYPE.GROUP + +const ( + CorePrefix = "api" + APIsPrefix = "apis" + + NamespacesPart = "namespaces" + + CRDGroup = "apiextensions.k8s.io" + CRDResourceType = "customresourcedefinitions" + + WatchClause = "watch=true" +) + +// ParseAPIEndpoint breaks url path by parts. +func ParseAPIEndpoint(apiURL *url.URL) *APIEndpoint { + rawPath := apiURL.Path + rawQuery := apiURL.RawQuery + isWatch := strings.Contains(rawQuery, WatchClause) + + cleanedPath := strings.Trim(apiURL.Path, "/") + pathItems := strings.Split(cleanedPath, "/") + + if cleanedPath == "" || len(pathItems) == 0 { + return &APIEndpoint{ + IsRoot: true, + IsWatch: isWatch, + RawPath: rawPath, + RawQuery: rawQuery, + } + } + + var ae *APIEndpoint + // PREFIX is the first item in path. + prefix := pathItems[0] + switch prefix { + case CorePrefix: + ae = parseCoreEndpoint(pathItems) + case APIsPrefix: + ae = parseAPIsEndpoint(pathItems) + } + + if ae == nil { + return &APIEndpoint{ + IsUnknown: true, + RawPath: rawPath, + RawQuery: rawQuery, + } + } + + ae.IsWatch = isWatch + ae.RawPath = rawPath + ae.RawQuery = rawQuery + return ae +} + +func parseCoreEndpoint(pathItems []string) *APIEndpoint { + var isLast bool + var ae APIEndpoint + ae.IsCore = true + + // /api + ae.Prefix, isLast = Shift(&pathItems) + if isLast { + return &ae + } + + // /api/VERSION + ae.Version, isLast = Shift(&pathItems) + if isLast { + return &ae + } + + // /api/VERSION/RESOURCETYPE + ae.ResourceType, isLast = Shift(&pathItems) + if isLast { + return &ae + } + + // /api/VERSION/RESOURCETYPE/NAME + ae.Name, isLast = Shift(&pathItems) + if isLast { + return &ae + } + + // /api/VERSION/RESOURCETYPE/NAME/SUBRESOURCE + // /api/VERSION/namespaces/NAMESPACE/status + // /api/VERSION/namespaces/NAMESPACE/RESOURCETYPE + ae.Subresource, isLast = Shift(&pathItems) + if ae.ResourceType == NamespacesPart && ae.Subresource != "status" { + // It is a namespaced resource, we got ns name and resourcetype in name and subresource. + ae.Namespace = ae.Name + ae.ResourceType = ae.Subresource + ae.Name = "" + ae.Subresource = "" + } + // Stop if no items available. + if isLast { + return &ae + } + + // /api/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME + ae.Name, isLast = Shift(&pathItems) + if isLast { + return &ae + } + // /api/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME/SUBRESOURCE + ae.Subresource, isLast = Shift(&pathItems) + if isLast { + return &ae + } + + // Save remaining items if any. + ae.Remainder = pathItems + return &ae +} + +func parseAPIsEndpoint(pathItems []string) *APIEndpoint { + var ae APIEndpoint + var isLast bool + + // /apis + ae.Prefix, isLast = Shift(&pathItems) + if isLast { + return &ae + } + + // /apis/GROUP + ae.Group, isLast = Shift(&pathItems) + if isLast { + return &ae + } + + // /apis/GROUP/VERSION + ae.Version, isLast = Shift(&pathItems) + if isLast { + return &ae + } + + // /apis/GROUP/VERSION/RESOURCETYPE + ae.ResourceType, isLast = Shift(&pathItems) + // /apis/apiextensions.k8s.io/VERSION/customresourcedefinitions + if ae.Group == CRDGroup && ae.ResourceType == CRDResourceType { + ae.IsCRD = true + } + if isLast { + return &ae + } + + // /apis/GROUP/VERSION/RESOURCETYPE/NAME + ae.Name, isLast = Shift(&pathItems) + if ae.IsCRD { + ae.CRDResourceType, ae.CRDGroup, _ = strings.Cut(ae.Name, ".") + } + if isLast { + return &ae + } + + // /apis/GROUP/VERSION/RESOURCETYPE/NAME/SUBRESOURCE + // /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE + ae.Subresource, isLast = Shift(&pathItems) + if ae.ResourceType == NamespacesPart { + // It is a namespaced resource, we got ns name and resourcetype in name and subresource. + ae.Namespace = ae.Name + ae.ResourceType = ae.Subresource + ae.Name = "" + ae.Subresource = "" + } + // Stop if no items available. + if isLast { + return &ae + } + + // /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME + ae.Name, isLast = Shift(&pathItems) + if isLast { + return &ae + } + // /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME/SUBRESOURCE + ae.Subresource, isLast = Shift(&pathItems) + if isLast { + return &ae + } + + // Save remaining items if any. + ae.Remainder = pathItems + return &ae +} + +func (a *APIEndpoint) Clone() *APIEndpoint { + clone := *a + return &clone +} + +func (a *APIEndpoint) Path() string { + if a.IsRoot || a.IsCore || a.IsUnknown { + return a.RawPath + } + + ns := "" + if a.Namespace != "" { + ns = NamespacesPart + "/" + a.Namespace + } + var parts []string + parts = []string{ + a.Prefix, + a.Group, + a.Version, + ns, + a.ResourceType, + a.Name, + a.Subresource, + } + if len(a.Remainder) > 0 { + parts = append(parts, a.Remainder...) + } + + nonEmptyParts := make([]string, 0) + for _, part := range parts { + if part != "" { + nonEmptyParts = append(nonEmptyParts, part) + } + } + + return "/" + strings.Join(nonEmptyParts, "/") +} + +// Shift deletes the first item from the array and returns it. +func Shift(items *[]string) (string, bool) { + if len(*items) == 0 { + return "", true + } + + first := (*items)[0] + if len(*items) == 1 { + *items = []string{} + } else { + *items = (*items)[1:] + } + return first, len(*items) == 0 +} diff --git a/images/kube-api-rewriter/pkg/rewriter/api_endpoint_test.go b/images/kube-api-rewriter/pkg/rewriter/api_endpoint_test.go new file mode 100644 index 0000000..234bbcf --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/api_endpoint_test.go @@ -0,0 +1,292 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseAPIEndpoint(t *testing.T) { + + tests := []struct { + name string + path string + expect *APIEndpoint + }{ + { + "root", + "/", + &APIEndpoint{ + IsRoot: true, + }, + }, + + // Core resources. + { + "core apiversions", + "/api", + &APIEndpoint{ + IsCore: true, + Prefix: CorePrefix, + }, + }, + { + "core apiresourcelist", + "/api/v1", + &APIEndpoint{ + IsCore: true, + Prefix: CorePrefix, + Version: "v1", + }, + }, + { + "core deploymentlist", + "/api/v1/deployments", + &APIEndpoint{ + IsCore: true, + Prefix: CorePrefix, + Version: "v1", + ResourceType: "deployments", + }, + }, + { + "core deployment dy name", + "/api/v1/deployments/deployname", + &APIEndpoint{ + IsCore: true, + Prefix: CorePrefix, + Version: "v1", + ResourceType: "deployments", + Name: "deployname", + }, + }, + { + "core deployment status", + "/api/v1/deployments/deployname/status", + &APIEndpoint{ + IsCore: true, + Prefix: CorePrefix, + Version: "v1", + ResourceType: "deployments", + Name: "deployname", + Subresource: "status", + }, + }, + { + "core deployments in nsname", + "/api/v1/namespaces/nsname/deployments", + &APIEndpoint{ + IsCore: true, + Prefix: CorePrefix, + Version: "v1", + ResourceType: "deployments", + Namespace: "nsname", + }, + }, + { + "core deployment in nsname by name", + "/api/v1/namespaces/nsname/deployments/deployname", + &APIEndpoint{ + IsCore: true, + Prefix: CorePrefix, + Version: "v1", + ResourceType: "deployments", + Namespace: "nsname", + Name: "deployname", + }, + }, + { + "core deployment status in nsname", + "/api/v1/namespaces/nsname/deployments/deployname/status", + &APIEndpoint{ + IsCore: true, + Prefix: CorePrefix, + Version: "v1", + ResourceType: "deployments", + Namespace: "nsname", + Name: "deployname", + Subresource: "status", + }, + }, + + // Custom resources. + { + "apigrouplist", + "/apis", + &APIEndpoint{ + Prefix: APIsPrefix, + }, + }, + { + "apigroup", + "/apis/group.io", + &APIEndpoint{ + Prefix: APIsPrefix, + Group: "group.io", + }, + }, + { + "apiresourcelist", + "/apis/group.io/v1", + &APIEndpoint{ + Prefix: APIsPrefix, + Group: "group.io", + Version: "v1", + }, + }, + { + "someresourceslist", + "/apis/group.io/v1/someresources", + &APIEndpoint{ + Prefix: APIsPrefix, + Group: "group.io", + Version: "v1", + ResourceType: "someresources", + }, + }, + { + "someresource by name", + "/apis/group.io/v1/someresources/srname", + &APIEndpoint{ + Prefix: APIsPrefix, + Group: "group.io", + Version: "v1", + ResourceType: "someresources", + Name: "srname", + }, + }, + { + "someresource status", + "/apis/group.io/v1/someresources/srname/status", + &APIEndpoint{ + Prefix: APIsPrefix, + Group: "group.io", + Version: "v1", + ResourceType: "someresources", + Name: "srname", + Subresource: "status", + }, + }, + { + "someresources in nsname", + "/apis/group.io/v1/namespaces/nsname/someresources", + &APIEndpoint{ + Prefix: APIsPrefix, + Group: "group.io", + Version: "v1", + Namespace: "nsname", + ResourceType: "someresources", + }, + }, + { + "someresource in nsname by name", + "/apis/group.io/v1/namespaces/nsname/someresources/srname", + &APIEndpoint{ + Prefix: APIsPrefix, + Group: "group.io", + Version: "v1", + Namespace: "nsname", + ResourceType: "someresources", + Name: "srname", + }, + }, + { + "someresource status in nsname", + "/apis/group.io/v1/namespaces/nsname/someresources/srname/status", + &APIEndpoint{ + Prefix: APIsPrefix, + Group: "group.io", + Version: "v1", + Namespace: "nsname", + ResourceType: "someresources", + Name: "srname", + Subresource: "status", + }, + }, + + // CRDs + { + "crd list", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions", + &APIEndpoint{ + IsCRD: true, + Prefix: APIsPrefix, + Group: "apiextensions.k8s.io", + Version: "v1", + ResourceType: "customresourcedefinitions", + }, + }, + { + "crd by name", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/crname", + &APIEndpoint{ + IsCRD: true, + Prefix: APIsPrefix, + Group: "apiextensions.k8s.io", + Version: "v1", + ResourceType: "customresourcedefinitions", + Name: "crname", + }, + }, + { + "crd status", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/crname/status", + &APIEndpoint{ + IsCRD: true, + Prefix: APIsPrefix, + Group: "apiextensions.k8s.io", + Version: "v1", + ResourceType: "customresourcedefinitions", + Name: "crname", + Subresource: "status", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.path) + require.NoError(t, err, "should parse path '%s'", tt.path) + + actual := ParseAPIEndpoint(u) + if tt.expect == nil { + require.Nil(t, actual, "expect not parse path '%s', got non-empty %+v", tt.path, actual) + } + + if tt.expect != nil { + require.NotNil(t, actual, "expect parse path '%s' to %+v, got nil", tt.path, tt.expect) + + // Flags. + require.Equal(t, tt.expect.IsRoot, actual.IsRoot, "IsRoot") + require.Equal(t, tt.expect.IsCore, actual.IsCore, "IsCore") + require.Equal(t, tt.expect.IsCRD, actual.IsCRD, "IsCRD") + + // Parts. + require.Equal(t, tt.expect.Prefix, actual.Prefix, "Prefix") + require.Equal(t, tt.expect.Group, actual.Group, "Group") + require.Equal(t, tt.expect.Version, actual.Version, "Version") + require.Equal(t, tt.expect.ResourceType, actual.ResourceType, "ResourceType") + require.Equal(t, tt.expect.Name, actual.Name, "Name") + require.Equal(t, tt.expect.Subresource, actual.Subresource, "Subresource") + require.Equal(t, tt.expect.Namespace, actual.Namespace, "Namespace") + } + }) + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/app.go b/images/kube-api-rewriter/pkg/rewriter/app.go new file mode 100644 index 0000000..23a1ae2 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/app.go @@ -0,0 +1,91 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import "github.com/tidwall/gjson" + +const ( + DeploymentKind = "Deployment" + DeploymentListKind = "DeploymentList" + DaemonSetKind = "DaemonSet" + DaemonSetListKind = "DaemonSetList" + StatefulSetKind = "StatefulSet" + StatefulSetListKind = "StatefulSetList" +) + +func RewriteDeploymentOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteResourceOrList(obj, DeploymentListKind, func(singleObj []byte) ([]byte, error) { + return RewriteSpecTemplateLabelsAnno(rules, singleObj, "spec", action) + }) +} + +func RewriteDaemonSetOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteResourceOrList(obj, DaemonSetListKind, func(singleObj []byte) ([]byte, error) { + return RewriteSpecTemplateLabelsAnno(rules, singleObj, "spec", action) + }) +} + +func RewriteStatefulSetOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteResourceOrList(obj, StatefulSetListKind, func(singleObj []byte) ([]byte, error) { + return RewriteSpecTemplateLabelsAnno(rules, singleObj, "spec", action) + }) +} + +func RenameSpecTemplatePatch(rules *RewriteRules, obj []byte) ([]byte, error) { + obj, err := RenameMetadataPatch(rules, obj) + if err != nil { + return nil, err + } + + return TransformPatch(obj, func(mergePatch []byte) ([]byte, error) { + return RewriteSpecTemplateLabelsAnno(rules, mergePatch, "spec", Rename) + }, func(jsonPatch []byte) ([]byte, error) { + path := gjson.GetBytes(jsonPatch, "path").String() + if path == "/spec" { + return RewriteSpecTemplateLabelsAnno(rules, jsonPatch, "value", Rename) + } + return jsonPatch, nil + }) +} + +// RewriteSpecTemplateLabelsAnno transforms labels and annotations in spec fields: +// - selector as LabelSelector +// - template.metadata.labels as labels map +// - template.metadata.annotations as annotations map +// - template.affinity as Affinity +// - template.nodeSelector as labels map. +func RewriteSpecTemplateLabelsAnno(rules *RewriteRules, obj []byte, path string, action Action) ([]byte, error) { + return TransformObject(obj, path, func(obj []byte) ([]byte, error) { + obj, err := RewriteLabelsMap(rules, obj, "template.metadata.labels", action) + if err != nil { + return nil, err + } + obj, err = RewriteLabelsMap(rules, obj, "selector.matchLabels", action) + if err != nil { + return nil, err + } + obj, err = RewriteLabelsMap(rules, obj, "template.spec.nodeSelector", action) + if err != nil { + return nil, err + } + obj, err = RewriteAffinity(rules, obj, "template.spec.affinity", action) + if err != nil { + return nil, err + } + return RewriteAnnotationsMap(rules, obj, "template.metadata.annotations", action) + }) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/app_test.go b/images/kube-api-rewriter/pkg/rewriter/app_test.go new file mode 100644 index 0000000..2d453ab --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/app_test.go @@ -0,0 +1,253 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bufio" + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func createTestRewriterForApp() *RuleBasedRewriter { + apiGroupRules := map[string]APIGroupRule{ + "original.group.io": { + GroupRule: GroupRule{ + Group: "original.group.io", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Renamed: "prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "someresources": { + Kind: "SomeResource", + ListKind: "SomeResourceList", + Plural: "someresources", + Singular: "someresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Categories: []string{"all"}, + ShortNames: []string{"sr", "srs"}, + }, + "anotherresources": { + Kind: "AnotherResource", + ListKind: "AnotherResourceList", + Plural: "anotherresources", + Singular: "anotherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"ar"}, + }, + }, + }, + "other.group.io": { + GroupRule: GroupRule{ + Group: "other.group.io", + Versions: []string{"v2alpha3"}, + PreferredVersion: "v2alpha3", + Renamed: "other.prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "otherresources": { + Kind: "OtherResource", + ListKind: "OtherResourceList", + Plural: "otherresources", + Singular: "otherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"or"}, + }, + }, + }, + } + + rules := &RewriteRules{ + KindPrefix: "Prefixed", // KV + ResourceTypePrefix: "prefixed", // kv + ShortNamePrefix: "p", + Categories: []string{"prefixed"}, + Rules: apiGroupRules, + Labels: MetadataReplace{ + Prefixes: []MetadataReplaceRule{ + {Original: "labelgroup.io", Renamed: "replacedlabelgroup.io"}, + {Original: "component.labelgroup.io", Renamed: "component.replacedlabelgroup.io"}, + }, + Names: []MetadataReplaceRule{ + {Original: "labelgroup.io", Renamed: "replacedlabelgroup.io"}, + { + Original: "labelgroup.io", OriginalValue: "some-value", + Renamed: "replacedlabelgroup.io", RenamedValue: "some-value-renamed", + }, + }, + }, + Annotations: MetadataReplace{ + Prefixes: []MetadataReplaceRule{ + {Original: "annogroup.io", Renamed: "replacedannogroup.io"}, + {Original: "component.annogroup.io", Renamed: "component.replacedannogroup.io"}, + }, + Names: []MetadataReplaceRule{ + {Original: "annogroup.io", Renamed: "replacedannogroup.io"}, + }, + }, + } + rules.Init() + return &RuleBasedRewriter{ + Rules: rules, + } +} + +func TestRenameDeploymentLabels(t *testing.T) { + deploymentReq := `POST /apis/apps/v1/deployments/testdeployment HTTP/1.1 +Host: 127.0.0.1 + +` + deploymentBody := `{ +"apiVersion": "apiextensions.k8s.io/v1", +"kind": "Deployment", +"metadata": { + "name":"testdeployment", + "labels":{ + "labelgroup.io": "labelValue", + "labelgroup.io/labelName": "labelValue", + "component.labelgroup.io/labelName": "labelValue" + }, + "annotations": { + "annogroup.io": "annoValue", + "annogroup.io/annoName": "annoValue", + "component.annogroup.io/annoName": "annoValue" + } +}, +"spec": { + "replicas": 1, + "selector": { + "matchLabels": { + "labelgroup.io": "labelValue", + "labelgroup.io/labelName": "labelValue", + "component.labelgroup.io/labelName": "labelValue" + } + }, + "template": { + "metadata": { + "name":"testdeployment", + "labels":{ + "labelgroup.io": "labelValue", + "labelgroup.io/labelName": "labelValue", + "component.labelgroup.io/labelName": "labelValue" + }, + "annotations": { + "annogroup.io": "annoValue", + "annogroup.io/annoName": "annoValue", + "component.annogroup.io/annoName": "annoValue" + } + }, + "spec": { + "nodeSelector": { + "labelgroup.io": "labelValue", + "labelgroup.io/labelName": "labelValue", + "component.labelgroup.io/labelName": "labelValue" + }, + "affinity": { + "podAntiAffinity": { + "preferredDuringSchedulingIgnoredDuringExecution": [ + { + "podAffinityTerm": { + "labelSelector": { + "matchExpressions":[{ + "key": "labelgroup.io", + "operator":"In", + "values": ["some-value"] + }] + }, + "topologyKey": "kubernetes.io/hostname" + }, + "weight": 1 + } + ] + }, + "nodeAffinity": { + "preferredDuringSchedulingIgnoredDuringExecution": [ + { + "preference": { + "matchExpressions":[{ + "key": "labelgroup.io", + "operator":"In", + "values": ["some-value"] + }] + }, + "weight": 1 + } + ] + } + }, + "containers": [] + } + } +} +}` + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(deploymentReq + deploymentBody))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriterForApp() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + // require.Equal(t, origGroup, targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(deploymentBody), Rename) + if err != nil { + t.Fatalf("should rename Deployment without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should rename Deployment: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`metadata.labels.replacedlabelgroup\.io`, "labelValue"}, + {`metadata.labels.labelgroup\.io`, ""}, + {`metadata.labels.replacedlabelgroup\.io/labelName`, "labelValue"}, + {`metadata.labels.labelgroup\.io/labelName`, ""}, + {`metadata.labels.component\.replacedlabelgroup\.io/labelName`, "labelValue"}, + {`metadata.labels.component\.labelgroup\.io/labelName`, ""}, + {`metadata.annotations.replacedannogroup\.io`, "annoValue"}, + {`metadata.annotations.annogroup\.io`, ""}, + {`metadata.annotations.replacedannogroup\.io/annoName`, "annoValue"}, + {`metadata.annotations.annogroup\.io/annoName`, ""}, + {`metadata.annotations.component\.replacedannogroup\.io/annoName`, "annoValue"}, + {`metadata.annotations.component\.annogroup\.io/annoName`, ""}, + {`spec.template.spec.affinity.podAntiAffinity.preferredDuringSchedulingIgnoredDuringExecution.0.podAffinityTerm.labelSelector.matchExpressions.0.key`, "replacedlabelgroup.io"}, + {`spec.template.spec.affinity.podAntiAffinity.preferredDuringSchedulingIgnoredDuringExecution.0.podAffinityTerm.labelSelector.matchExpressions.0.values`, `["some-value-renamed"]`}, + {`spec.template.spec.affinity.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution.0.preference.matchExpressions.0.key`, "replacedlabelgroup.io"}, + {`spec.template.spec.affinity.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution.0.preference.matchExpressions.0.values`, `["some-value-renamed"]`}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/core.go b/images/kube-api-rewriter/pkg/rewriter/core.go new file mode 100644 index 0000000..e61cb03 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/core.go @@ -0,0 +1,87 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "github.com/tidwall/gjson" +) + +const ( + PodKind = "Pod" + PodListKind = "PodList" + ServiceKind = "Service" + ServiceListKind = "ServiceList" + JobKind = "Job" + JobListKind = "JobList" + PersistentVolumeClaimKind = "PersistentVolumeClaim" + PersistentVolumeClaimListKind = "PersistentVolumeClaimList" +) + +func RewritePodOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteResourceOrList(obj, PodListKind, func(singleObj []byte) ([]byte, error) { + singleObj, err := RewriteLabelsMap(rules, singleObj, "spec.nodeSelector", action) + if err != nil { + return nil, err + } + return RewriteAffinity(rules, singleObj, "spec.affinity", action) + }) +} + +func RewriteServiceOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteResourceOrList(obj, ServiceListKind, func(singleObj []byte) ([]byte, error) { + return RewriteLabelsMap(rules, singleObj, "spec.selector", action) + }) +} + +// RewriteJobOrList transforms known fields in the Job manifest. +func RewriteJobOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteResourceOrList(obj, JobListKind, func(singleObj []byte) ([]byte, error) { + return RewriteSpecTemplateLabelsAnno(rules, singleObj, "spec", action) + }) +} + +// RewritePVCOrList transforms known fields in the PersistentVolumeClaim manifest. +func RewritePVCOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteResourceOrList(obj, PersistentVolumeClaimListKind, func(singleObj []byte) ([]byte, error) { + singleObj, err := TransformObject(singleObj, "spec.dataSource", func(specDataSource []byte) ([]byte, error) { + return RewriteAPIGroupAndKind(rules, specDataSource, action) + }) + if err != nil { + return nil, err + } + return TransformObject(singleObj, "spec.dataSourceRef", func(specDataSourceRef []byte) ([]byte, error) { + return RewriteAPIGroupAndKind(rules, specDataSourceRef, action) + }) + }) +} + +func RenameServicePatch(rules *RewriteRules, obj []byte) ([]byte, error) { + obj, err := RenameMetadataPatch(rules, obj) + if err != nil { + return nil, err + } + + // Also rename patch on spec field. + return TransformPatch(obj, nil, func(jsonPatch []byte) ([]byte, error) { + path := gjson.GetBytes(jsonPatch, "path").String() + switch path { + case "/spec": + return RewriteLabelsMap(rules, jsonPatch, "value.selector", Rename) + } + return jsonPatch, nil + }) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/core_test.go b/images/kube-api-rewriter/pkg/rewriter/core_test.go new file mode 100644 index 0000000..62de244 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/core_test.go @@ -0,0 +1,379 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bufio" + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func createTestRewriterForCore() *RuleBasedRewriter { + apiGroupRules := map[string]APIGroupRule{ + "original.group.io": { + GroupRule: GroupRule{ + Group: "original.group.io", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Renamed: "prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "someresources": { + Kind: "SomeResource", + ListKind: "SomeResourceList", + Plural: "someresources", + Singular: "someresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Categories: []string{"all"}, + ShortNames: []string{"sr", "srs"}, + }, + "anotherresources": { + Kind: "AnotherResource", + ListKind: "AnotherResourceList", + Plural: "anotherresources", + Singular: "anotherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"ar"}, + }, + }, + }, + "other.group.io": { + GroupRule: GroupRule{ + Group: "other.group.io", + Versions: []string{"v2alpha3"}, + PreferredVersion: "v2alpha3", + Renamed: "other.prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "otherresources": { + Kind: "OtherResource", + ListKind: "OtherResourceList", + Plural: "otherresources", + Singular: "otherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"or"}, + }, + }, + }, + } + + rules := &RewriteRules{ + KindPrefix: "Prefixed", // KV + ResourceTypePrefix: "prefixed", // kv + ShortNamePrefix: "p", + Categories: []string{"prefixed"}, + Rules: apiGroupRules, + Labels: MetadataReplace{ + Prefixes: []MetadataReplaceRule{ + {Original: "labelgroup.io", Renamed: "replacedlabelgroup.io"}, + {Original: "component.labelgroup.io", Renamed: "component.replacedlabelgroup.io"}, + }, + Names: []MetadataReplaceRule{ + {Original: "labelgroup.io", Renamed: "replacedlabelgroup.io"}, + }, + }, + Annotations: MetadataReplace{ + Prefixes: []MetadataReplaceRule{ + {Original: "annogroup.io", Renamed: "replacedannogroup.io"}, + {Original: "component.annogroup.io", Renamed: "component.replacedannogroup.io"}, + }, + Names: []MetadataReplaceRule{ + {Original: "annogroup.io", Renamed: "replacedannogroup.io"}, + }, + }, + } + rules.Init() + return &RuleBasedRewriter{ + Rules: rules, + } +} + +func TestRewriteServicePatch(t *testing.T) { + serviceReq := `PATCH /api/v1/namespaces/default/services/testservice HTTP/1.1 +Host: 127.0.0.1 + +` + servicePatch := `[{ + "op":"replace", + "path":"/spec", + "value": { + "selector":{ "labelgroup.io":"true" } + } +}]` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(serviceReq + servicePatch))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriterForCore() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + // require.Equal(t, origGroup, targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewritePatch(targetReq, []byte(servicePatch)) + if err != nil { + t.Fatalf("should rename Service patch without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should rename Service patch: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`0.value.selector.labelgroup\.io`, ""}, + {`0.value.selector.replacedlabelgroup\.io`, "true"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } + +} + +func TestRewriteMetadataPatch(t *testing.T) { + serviceReq := `PATCH /apis/admissionregistration.k8s.io/v1/validatingwebhookconfigurations/test-validator HTTP/1.1 +Host: 127.0.0.1 + +` + servicePatch := `[{ + "op":"replace", + "path":"/metadata/labels", + "value": {"labelgroup.io":"true" } +}]` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(serviceReq + servicePatch))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriterForCore() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + // require.Equal(t, origGroup, targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewritePatch(targetReq, []byte(servicePatch)) + if err != nil { + t.Fatalf("should rename Service patch without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should rename Service patch: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`0.value.labelgroup\.io`, ""}, + {`0.value.replacedlabelgroup\.io`, "true"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } + +} + +// TestRewriteMetadataPatchWithPreservedPrefixes +// RewritePatch should remove prefix from preserved names. +func TestRewriteMetadataPatchWithPreservedPrefixes(t *testing.T) { + nodeReq := `PATCH /api/v1/nodes/master-node-0 HTTP/1.1 +Host: 127.0.0.1 + +` + nodePatch := `[{ + "op":"test", + "path":"/metadata/labels", + "value": { + "preserved-original-labelgroup.io": "original-label-value", + "labelgroup.io": "value-for-overriden-label" + } +},{ + "op":"replace", + "path":"/metadata/labels", + "value": { + "preserved-original-labelgroup.io": "original-label-value", + "labelgroup.io": "new-value-for-overriden-label" + } +}]` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(nodeReq + nodePatch))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriterForCore() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + // require.Equal(t, origGroup, targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewritePatch(targetReq, []byte(nodePatch)) + if err != nil { + t.Fatalf("should rename Node patch without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should rename Node patch: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`0.value.labelgroup\.io`, "original-label-value"}, + {`0.value.replacedlabelgroup\.io`, "value-for-overriden-label"}, + {`1.value.labelgroup\.io`, "original-label-value"}, + {`1.value.replacedlabelgroup\.io`, "new-value-for-overriden-label"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s: %s", tt.path, tt.expected, actual, string(resultBytes)) + } + }) + } + +} + +func TestRewritePVC(t *testing.T) { + pvcReq := `POST /api/v1/namespaces/vm/persistentvolumeclaims HTTP/1.1 +Host: 127.0.0.1 + +` + pvcPayload := `{ + "kind": "PersistentVolumeClaim", + "apiVersion": "v1", + "metadata": { + "name": "some-pvc-name", + "namespace": "vm", + "labels":{ + "labelgroup.io": "labelValue", + "labelgroup.io/labelName": "labelValue", + "component.labelgroup.io/labelName": "labelValue" + }, + "annotations": { + "annogroup.io": "annoValue", + "annogroup.io/annoName": "annoValue", + "component.annogroup.io/annoName": "annoValue" + } + }, + "spec": { + "accessModes": [ + "ReadWriteMany" + ], + "resources": { + "requests": { + "storage": "40Gi" + } + }, + "storageClassName": "some-storage-class-name", + "volumeMode": "Block", + "dataSourceRef": { + "apiGroup": "original.group.io", + "kind": "SomeResource", + "name": "some-name" + } + } +}` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(pvcReq + pvcPayload))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriterForCore() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + // require.Equal(t, origGroup, targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(pvcPayload), Rename) + if err != nil { + t.Fatalf("should rename PVC without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should rename PVC: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`spec.dataSourceRef.kind`, "PrefixedSomeResource"}, + {`spec.dataSourceRef.apiGroup`, "prefixed.resources.group.io"}, + {`spec.dataSource`, ""}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } + + // Restore. + resultBytes, err = rwr.RewriteJSONPayload(targetReq, []byte(pvcPayload), Restore) + if err != nil { + t.Fatalf("should restore PVC without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should restore PVC: %v", err) + } + + tests = []struct { + path string + expected string + }{ + {`spec.dataSourceRef.kind`, "SomeResource"}, + {`spec.dataSourceRef.apiGroup`, "original.group.io"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } + +} diff --git a/images/kube-api-rewriter/pkg/rewriter/crd.go b/images/kube-api-rewriter/pkg/rewriter/crd.go new file mode 100644 index 0000000..a0c2be0 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/crd.go @@ -0,0 +1,257 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "fmt" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + CRDKind = "CustomResourceDefinition" + CRDListKind = "CustomResourceDefinitionList" +) + +func RewriteCRDOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + // CREATE, UPDATE, or PATCH requests. + if action == Rename { + return RewriteResourceOrList(obj, CRDListKind, func(singleObj []byte) ([]byte, error) { + return RenameCRD(rules, singleObj) + }) + } + + // Responses of GET, LIST, DELETE requests. Also, rewrite in watch events. + return RewriteResourceOrList(obj, CRDListKind, func(singleObj []byte) ([]byte, error) { + return RestoreCRD(rules, singleObj) + }) +} + +// RestoreCRD restores fields in CRD to original. +// +// Example: +// .metadata.name prefixedvirtualmachines.x.virtualization.deckhouse.io -> virtualmachines.kubevirt.io +// .spec.group x.virtualization.deckhouse.io -> kubevirt.io +// .spec.names +// +// categories kubevirt -> all +// kind PrefixedVirtualMachines -> VirtualMachine +// listKind PrefixedVirtualMachineList -> VirtualMachineList +// plural prefixedvirtualmachines -> virtualmachines +// singular prefixedvirtualmachine -> virtualmachine +// shortNames [xvm xvms] -> [vm vms] +func RestoreCRD(rules *RewriteRules, obj []byte) ([]byte, error) { + crdName := gjson.GetBytes(obj, "metadata.name").String() + resource, group, found := strings.Cut(crdName, ".") + if !found { + return nil, fmt.Errorf("malformed CRD name: should be resourcetype.group, got %s", crdName) + } + + // Skip CRD with original group to avoid duplicates in restored List. + if rules.HasGroup(group) { + return nil, SkipItem + } + + // Do not restore CRDs from unknown groups. + if !rules.IsRenamedGroup(group) { + return nil, nil + } + + origResource := rules.RestoreResource(resource) + + groupRule, resourceRule := rules.GroupResourceRules(origResource) + if resourceRule == nil { + return nil, nil + } + + newName := resourceRule.Plural + "." + groupRule.Group + obj, err := sjson.SetBytes(obj, "metadata.name", newName) + if err != nil { + return nil, err + } + + obj, err = sjson.SetBytes(obj, "spec.group", groupRule.Group) + if err != nil { + return nil, err + } + + names := []byte(gjson.GetBytes(obj, "spec.names").Raw) + + names, err = sjson.SetBytes(names, "categories", rules.RestoreCategories(resourceRule)) + if err != nil { + return nil, err + } + names, err = sjson.SetBytes(names, "kind", rules.RestoreKind(resourceRule.Kind)) + if err != nil { + return nil, err + } + names, err = sjson.SetBytes(names, "listKind", rules.RestoreKind(resourceRule.ListKind)) + if err != nil { + return nil, err + } + names, err = sjson.SetBytes(names, "plural", rules.RestoreResource(resourceRule.Plural)) + if err != nil { + return nil, err + } + names, err = sjson.SetBytes(names, "singular", rules.RestoreResource(resourceRule.Singular)) + if err != nil { + return nil, err + } + names, err = sjson.SetBytes(names, "shortNames", rules.RestoreShortNames(resourceRule.ShortNames)) + if err != nil { + return nil, err + } + + obj, err = sjson.SetRawBytes(obj, "spec.names", names) + if err != nil { + return nil, err + } + + return obj, nil +} + +// RenameCRD renames fields in CRD. +// +// Example: +// .metadata.name virtualmachines.kubevirt.io -> prefixedvirtualmachines.x.virtualization.deckhouse.io +// .spec.group kubevirt.io -> x.virtualization.deckhouse.io +// .spec.names +// +// categories all -> kubevirt +// kind VirtualMachine -> PrefixedVirtualMachines +// listKind VirtualMachineList -> PrefixedVirtualMachineList +// plural virtualmachines -> prefixedvirtualmachines +// singular virtualmachine -> prefixedvirtualmachine +// shortNames [vm vms] -> [xvm xvms] +func RenameCRD(rules *RewriteRules, obj []byte) ([]byte, error) { + crdName := gjson.GetBytes(obj, "metadata.name").String() + resource, group, found := strings.Cut(crdName, ".") + if !found { + return nil, fmt.Errorf("malformed CRD name: should be resourcetype.group, got %s", crdName) + } + + _, resourceRule := rules.ResourceRules(group, resource) + if resourceRule == nil { + return nil, nil + } + + newName := rules.RenameResource(resource) + "." + rules.RenameApiVersion(group) + obj, err := sjson.SetBytes(obj, "metadata.name", newName) + if err != nil { + return nil, err + } + + spec := gjson.GetBytes(obj, "spec") + newSpec, err := renameCRDSpec(rules, resourceRule, []byte(spec.Raw)) + if err != nil { + return nil, err + } + return sjson.SetRawBytes(obj, "spec", newSpec) +} + +func renameCRDSpec(rules *RewriteRules, resourceRule *ResourceRule, spec []byte) ([]byte, error) { + var err error + + spec, err = TransformString(spec, "group", func(crdSpecGroup string) string { + return rules.RenameApiVersion(crdSpecGroup) + }) + if err != nil { + return nil, err + } + + // Rename fields in the 'names' object. + names := []byte(gjson.GetBytes(spec, "names").Raw) + + if gjson.GetBytes(names, "categories").Exists() { + names, err = sjson.SetBytes(names, "categories", rules.RenameCategories(resourceRule.Categories)) + if err != nil { + return nil, err + } + } + if gjson.GetBytes(names, "kind").Exists() { + names, err = sjson.SetBytes(names, "kind", rules.RenameKind(resourceRule.Kind)) + if err != nil { + return nil, err + } + } + if gjson.GetBytes(names, "listKind").Exists() { + names, err = sjson.SetBytes(names, "listKind", rules.RenameKind(resourceRule.ListKind)) + if err != nil { + return nil, err + } + } + if gjson.GetBytes(names, "plural").Exists() { + names, err = sjson.SetBytes(names, "plural", rules.RenameResource(resourceRule.Plural)) + if err != nil { + return nil, err + } + } + if gjson.GetBytes(names, "singular").Exists() { + names, err = sjson.SetBytes(names, "singular", rules.RenameResource(resourceRule.Singular)) + if err != nil { + return nil, err + } + } + if gjson.GetBytes(names, "shortNames").Exists() { + names, err = sjson.SetBytes(names, "shortNames", rules.RenameShortNames(resourceRule.ShortNames)) + if err != nil { + return nil, err + } + } + + spec, err = sjson.SetRawBytes(spec, "names", names) + if err != nil { + return nil, err + } + + return spec, nil +} + +func RenameCRDPatch(rules *RewriteRules, resourceRule *ResourceRule, obj []byte) ([]byte, error) { + var err error + + obj, err = RenameMetadataPatch(rules, obj) + if err != nil { + return nil, fmt.Errorf("rename metadata patches for CRD: %w", err) + } + + isRenamed := false + newPatches, err := RewriteArray(obj, Root, func(singlePatch []byte) ([]byte, error) { + op := gjson.GetBytes(singlePatch, "op").String() + path := gjson.GetBytes(singlePatch, "path").String() + + if (op == "replace" || op == "add") && path == "/spec" { + isRenamed = true + value := []byte(gjson.GetBytes(singlePatch, "value").Raw) + newValue, err := renameCRDSpec(rules, resourceRule, value) + if err != nil { + return nil, err + } + return sjson.SetRawBytes(singlePatch, "value", newValue) + } + + return nil, nil + }) + + if !isRenamed { + return obj, nil + } + + return newPatches, nil +} diff --git a/images/kube-api-rewriter/pkg/rewriter/crd_test.go b/images/kube-api-rewriter/pkg/rewriter/crd_test.go new file mode 100644 index 0000000..ffdf20c --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/crd_test.go @@ -0,0 +1,336 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bufio" + "bytes" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func createRewriterForCRDTest() *RuleBasedRewriter { + apiGroupRules := map[string]APIGroupRule{ + "original.group.io": { + GroupRule: GroupRule{ + Group: "original.group.io", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Renamed: "prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "someresources": { + Kind: "SomeResource", + ListKind: "SomeResourceList", + Plural: "someresources", + Singular: "someresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Categories: []string{"all"}, + ShortNames: []string{"sr", "srs"}, + }, + "anotherresources": { + Kind: "AnotherResource", + ListKind: "AnotherResourceList", + Plural: "anotherresources", + Singular: "anotherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"ar"}, + }, + }, + }, + "other.group.io": { + GroupRule: GroupRule{ + Group: "other.group.io", + Versions: []string{"v2alpha3"}, + PreferredVersion: "v2alpha3", + Renamed: "other.prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "otherresources": { + Kind: "OtherResource", + ListKind: "OtherResourceList", + Plural: "otherresources", + Singular: "otherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"or"}, + }, + }, + }, + } + + rwRules := &RewriteRules{ + KindPrefix: "Prefixed", // KV + ResourceTypePrefix: "prefixed", // kv + ShortNamePrefix: "p", + Categories: []string{"prefixed"}, + Rules: apiGroupRules, + } + + rwRules.Init() + return &RuleBasedRewriter{ + Rules: rwRules, + } +} + +// TestCRDRename - rename of a single CRD. +func TestCRDRename(t *testing.T) { + reqBody := `{ +"apiVersion": "apiextensions.k8s.io/v1", +"kind": "CustomResourceDefinition", +"metadata": { + "name":"someresources.original.group.io" +} +"spec": { + "group": "original.group.io", + "names": { + "kind": "SomeResource", + "listKind": "SomeResourceList", + "plural": "someresources", + "singular": "someresource", + "shortNames": ["sr"], + "categories": ["all"] + }, + "scope":"Namespaced", + "versions": {} +} +}` + rwr := createRewriterForCRDTest() + testCRDRules := rwr.Rules + + restored, err := RewriteCRDOrList(testCRDRules, []byte(reqBody), Rename) + if err != nil { + t.Fatalf("should rename CRD without error: %v", err) + } + if restored == nil { + t.Fatalf("should rename CRD: %v", err) + } + + groupRule, resRule := testCRDRules.KindRules("original.group.io", "SomeResource") + + tests := []struct { + path string + expected string + }{ + {"metadata.name", testCRDRules.RenameResource(resRule.Plural) + "." + groupRule.Renamed}, + {"spec.group", groupRule.Renamed}, + {"spec.names.kind", testCRDRules.RenameKind(resRule.Kind)}, + {"spec.names.listKind", testCRDRules.RenameKind(resRule.ListKind)}, + {"spec.names.plural", testCRDRules.RenameResource(resRule.Plural)}, + {"spec.names.singular", testCRDRules.RenameResource(resRule.Singular)}, + {"spec.names.shortNames", `["psr","psrs"]`}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(restored, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } +} + +// TestCRDPatch tests renaming /spec in a CRD patch. +func TestCRDPatch(t *testing.T) { + patches := `[{ "op": "add", "path": "/metadata/ownerReferences", "value": null }, +{ "op": "replace", "path": "/spec", "value": { +"group":"original.group.io", +"names":{"plural":"someresources","singular":"someresource","shortNames":["sr","srs"],"kind":"SomeResource","categories":["all"]}, +"scope":"Namespaced","versions":[{"name":"v1alpha1","schema":{}}] +} } +]` + patches = strings.ReplaceAll(patches, "\n", "") + + expect := `[{ "op": "add", "path": "/metadata/ownerReferences", "value": null }, +{ "op": "replace", "path": "/spec", "value": { +"group":"prefixed.resources.group.io", +"names":{"plural":"prefixedsomeresources","singular":"prefixedsomeresource","shortNames":["psr","psrs"],"kind":"PrefixedSomeResource","categories":["prefixed"]}, +"scope":"Namespaced","versions":[{"name":"v1alpha1","schema":{}}] +} } +]` + expect = strings.ReplaceAll(expect, "\n", "") + + rwr := createRewriterForCRDTest() + _, resRule := rwr.Rules.ResourceRules("original.group.io", "someresources") + require.NotNil(t, resRule, "should get resource rule for hardcoded group and resourceType") + + resBytes, err := RenameCRDPatch(rwr.Rules, resRule, []byte(patches)) + require.NoError(t, err, "should rename CRD patch") + + actual := string(resBytes) + require.Equal(t, expect, actual) +} + +// TestCRDRestore test restoring of a single CRD. +func TestCRDRestore(t *testing.T) { + crdHTTPRequest := `GET /apis/apiextensions.k8s.io/v1/customresourcedefinitions/someresources.original.group.io HTTP/1.1 +Host: 127.0.0.1 + +` + origGroup := "original.group.io" + crdPayload := `{ +"apiVersion": "apiextensions.k8s.io/v1", +"kind": "CustomResourceDefinition", +"metadata": { + "name":"prefixedsomeresources.prefixed.resources.group.io" +} +"spec": { + "group": "prefixed.resources.group.io", + "names": { + "kind": "PrefixedSomeResource", + "listKind": "PrefixedSomeResourceList", + "plural": "prefixedsomeresources", + "singular": "prefixedsomeresource", + "shortNames": ["psr"], + "categories": ["prefixed"] + }, + "scope":"Namespaced", + "versions": {} +} +}` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(crdHTTPRequest))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createRewriterForCRDTest() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.Equal(t, origGroup, targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(crdPayload), Restore) // RewriteCRDOrList(crdPayload, []byte(reqBody), Restore, origGroup) + if err != nil { + t.Fatalf("should restore CRD without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should restore CRD: %v", err) + } + + resRule := rwr.Rules.Rules[origGroup].ResourceRules["someresources"] + + tests := []struct { + path string + expected string + }{ + {"metadata.name", resRule.Plural + "." + origGroup}, + {"spec.group", origGroup}, + {"spec.names.kind", resRule.Kind}, + {"spec.names.listKind", resRule.ListKind}, + {"spec.names.plural", resRule.Plural}, + {"spec.names.singular", resRule.Singular}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } +} + +func TestCRDPathRewrite(t *testing.T) { + tests := []struct { + name string + urlPath string + expected string + origGroup string + origResourceType string + }{ + { + "crd with rule", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/someresources.original.group.io", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/prefixedsomeresources.prefixed.resources.group.io", + "original.group.io", + "someresources", + }, + { + "crd watch by name", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions?fieldSelector=metadata.name%3Dsomeresources.original.group.io&resourceVersion=0&watch=true", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions?fieldSelector=metadata.name%3Dprefixedsomeresources.prefixed.resources.group.io&resourceVersion=0&watch=true", + "", + "", + }, + { + "unknown crd watch by name", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions?fieldSelector=metadata.name%3Dresource.unknown.group.io&resourceVersion=0&watch=true", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions?fieldSelector=metadata.name%3Dresource.unknown.group.io&resourceVersion=0&watch=true", + "", + "", + }, + { + "crd without rule", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/unknown.group.io", + "", + "", + "", + }, + { + "crd list", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions", + "", + "", + "", + }, + { + "non crd apiextension", + "/apis/apiextensions.k8s.io/v1/unknown", + "", + "", + "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpReqHead := fmt.Sprintf(`GET %s HTTP/1.1`, tt.urlPath) + httpReq := httpReqHead + "\n" + "Host: 127.0.0.1\n\n" + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(httpReq))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createRewriterForCRDTest() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + + if tt.expected == "" { + require.Equal(t, tt.urlPath, targetReq.Path(), "should not rewrite api endpoint path") + return + } + + if tt.origGroup != "" { + require.Equal(t, tt.origGroup, targetReq.OrigGroup()) + } + + actual := targetReq.Path() + if targetReq.RawQuery() != "" { + actual += "?" + targetReq.RawQuery() + } + + require.Equal(t, tt.expected, actual, "should rewrite api endpoint path") + }) + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/discovery.go b/images/kube-api-rewriter/pkg/rewriter/discovery.go new file mode 100644 index 0000000..0f2f515 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/discovery.go @@ -0,0 +1,574 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bytes" + "fmt" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// RewriteAPIGroupList restores groups and kinds in "groups" array in /apis/ response. +// +// Response example: +// +// { +// "kind": "APIGroupList", +// "apiVersion": "v1", +// "groups": [ +// { +// "name": "prefixed.resources.group.io", +// "versions": [ +// {"groupVersion":"prefixed.resources.group.io/v1","version":"v1"}, +// {"groupVersion":"prefixed.resources.group.io/v1beta1","version":"v1beta1"}, +// {"groupVersion":"prefixed.resources.group.io/v1alpha3","version":"v1alpha3"} +// ], +// "preferredVersion": { +// "groupVersion":"prefixed.resources.group.io/v1", +// "version":"v1" +// } +// } +// ] +// } +func RewriteAPIGroupList(rules *RewriteRules, obj []byte) ([]byte, error) { + return RewriteArray(obj, "groups", func(groupObj []byte) ([]byte, error) { + // Remove original groups to prevent duplicates if cluster have CRDs with original names. + groupName := gjson.GetBytes(groupObj, "name").String() + if rules.HasGroup(groupName) { + return nil, SkipItem + } + + groupObj, err := TransformString(groupObj, "name", func(name string) string { + return rules.RestoreApiVersion(name) + }) + if err != nil { + return nil, err + } + + groupObj, err = TransformString(groupObj, "preferredVersion.groupVersion", func(groupVersion string) string { + return rules.RestoreApiVersion(groupVersion) + }) + if err != nil { + return nil, err + } + + return RewriteArray(groupObj, "versions", func(versionObj []byte) ([]byte, error) { + return TransformString(versionObj, "groupVersion", func(groupVersion string) string { + return rules.RestoreApiVersion(groupVersion) + }) + }) + }) +} + +// RewriteAPIGroup restores apiGroup, kinds and versions in responses from renamed APIGroup query: +// /apis/renamed.resource.group.io +// +// This call returns all versions for renamed.resource.group.io. +// Rewriter should reduce versions for only available in original group +// To reduce further requests with specific versions. +// +// Example response with renamed group: +// { "kind":"APIGroup", +// +// "apiVersion":"v1", +// "name":"renamed.resource.group.io", +// "versions":[ +// {"groupVersion":"renamed.resource.group.io/v1","version":"v1"}, +// {"groupVersion":"renamed.resource.group.io/v1alpha1","version":"v1alpha1"} +// ], +// "preferredVersion": { +// "groupVersion":"renamed.resource.group.io/v1", +// "version":"v1"} +// } +// +// Restored response should be: +// { "kind":"APIGroup", +// +// "apiVersion":"v1", +// "name":"original.group.io", +// "versions":[ +// {"groupVersion":"original.group.io/v1","version":"v1"}, +// {"groupVersion":"original.group.io/v1alpha1","version":"v1alpha1"} +// ], +// "preferredVersion": { +// "groupVersion":"original.group.io/v1", +// "version":"v1"} +// } +func RewriteAPIGroup(rules *RewriteRules, obj []byte) ([]byte, error) { + groupName := gjson.GetBytes(obj, "name").String() + // Return as-is for group without rules. + if !rules.IsRenamedGroup(groupName) { + return obj, nil + } + obj, err := sjson.SetBytes(obj, "name", rules.RestoreApiVersion(groupName)) + if err != nil { + return nil, err + } + + obj, err = RewriteArray(obj, "versions", func(versionObj []byte) ([]byte, error) { + return TransformString(versionObj, "groupVersion", func(groupVersion string) string { + return rules.RestoreApiVersion(groupVersion) + }) + }) + if err != nil { + return nil, err + } + + return TransformString(obj, "preferredVersion.groupVersion", func(preferredGroupVersion string) string { + return rules.RestoreApiVersion(preferredGroupVersion) + }) +} + +// RewriteAPIResourceList rewrites server responses from /apis/GROUP/VERSION discovery requests. +// +// Example: +// +// Path rewrite: https://10.222.0.1:443/apis/original.group.io/v1 -> https://10.222.0.1:443/apis/prefixed.resources.group.io/v1 +// 1. Restore "groupVersion" field. +// 2. Restore items in "resources": +// 2.1. If name is a resource type: restore "name", "singularName", "kind", "shortNames", and "categories". +// 2.2. If name contains "/status" suffix: restore "name" and "kind" fields +// 2.3. If name contains "/scale" suffix: restore "name" field as a resource type +// +// Rewrite of response from /apis/prefixed.resources.group.io/v1: +// +// { +// "kind":"APIResourceList", +// "apiVersion":"v1", +// "groupVersion":"prefixed.resources.group.io/v1", --> Restore apiGroup, keep version: original.group.io/v1 +// "resources":[ +// { +// "name":"prefixedsomeresources", --> Restore resource type: someresources +// "singularName":"prefixedsomeresource", --> Restore singular: someresource +// "namespaced":true, +// "kind":"PrefixedSomeResource", --> restore kind: SomeResource +// "verbs":["delete","deletecollection","get","list","patch","create","update","watch"], +// "shortNames":["psr","psrs"], --> Restore shortNames: ["sr", "srs"] +// "categories":["prefixed"], --> Restore categories: ["all"] +// "storageVersionHash":"QUMxLW9gfYs=" +// },{ +// "name":"prefixedsomeresources/status", --> Restore resource type, keep suffix: someresources/status +// "singularName":"", +// "namespaced":true, +// "kind":"PrefixedSomeResource", --> Restore kind: SomeResource +// "verbs":["get","patch","update"] +// },{ +// "name":"prefixedsomeresources/scale", --> Restore resource type, keep suffix: someresources/status +// "singularName":"", +// "namespaced":true, +// "group":"autoscaling", +// "version":"v1", +// "kind":"Scale", +// "verbs":["get","patch","update"] +// }] +// } +// } +func RewriteAPIResourceList(rules *RewriteRules, obj []byte) ([]byte, error) { + // Check if groupVersion is renamed and save restored group. + // No rewrite if groupVersion has no rules. + groupVersion := gjson.GetBytes(obj, "groupVersion").String() + if !rules.IsRenamedGroup(groupVersion) { + return obj, nil + } + origGroup := rules.RestoreApiVersion(groupVersion) + obj, err := sjson.SetBytes(obj, "groupVersion", origGroup) + if err != nil { + return nil, err + } + + // Rewrite "resources" array. + return RewriteArray(obj, "resources", func(resource []byte) ([]byte, error) { + name := gjson.GetBytes(resource, "name").String() + origResourceType := rules.RestoreResource(name) + + // No rewrite if resource has no rules. + _, resourceRule := rules.ResourceRules(origGroup, origResourceType) + if resourceRule == nil { + return resource, nil + } + + resource, err = TransformString(resource, "name", func(name string) string { + return origResourceType + }) + if err != nil { + return nil, err + } + + resource, err = TransformString(resource, "kind", func(kind string) string { + return rules.RestoreKind(kind) + }) + if err != nil { + return nil, err + } + + resource, err = TransformString(resource, "singularName", func(singularName string) string { + return rules.RestoreResource(singularName) + }) + if err != nil { + return nil, err + } + + resource, err = TransformArrayOfStrings(resource, "shortNames", func(shortName string) string { + return rules.RestoreShortName(shortName) + }) + if err != nil { + return nil, err + } + + categories := gjson.GetBytes(resource, "categories") + if categories.Exists() { + restoredCategories := rules.RestoreCategories(resourceRule) + resource, err = sjson.SetBytes(resource, "categories", restoredCategories) + if err != nil { + return nil, err + } + } + + return resource, nil + }) +} + +// RewriteAPIGroupDiscoveryList restores renamed groups and resources in the aggregated +// discovery response (APIGroupDiscoveryList kind). +// +// Example of APIGroupDiscoveryList structure: +// +// { +// "kind": "APIGroupDiscoveryList", +// "apiVersion": "apidiscovery.k8s.io/v2beta1", +// "metadata": {}, +// "items": [ +// An array of APIGroupDiscovery objects ... +// { +// "metadata": { +// "name": "internal.virtualization.deckhouse.io", <-- should be renamed group +// "creationTimestamp": null +// }, +// "versions": [ +// APIVersionDiscovery, .. , APIVersionDiscovery +// ] +// }, ... +// ] +// +// NOTE: Can't use RewriteArray here, because one APIGroupDiscovery with renamed +// resource produces many APIGroupDiscovery objects with restored resource. + +func newSliceBytesBuilder() *sliceBytesBuilder { + return &sliceBytesBuilder{ + buf: bytes.NewBuffer([]byte("[")), + } +} + +type sliceBytesBuilder struct { + buf *bytes.Buffer + begin bool +} + +func (b *sliceBytesBuilder) WriteString(s string) { + if s == "" { + return + } + if b.begin { + b.buf.WriteString(",") + } + b.buf.WriteString(s) + b.begin = true +} + +func (b *sliceBytesBuilder) Write(bytes []byte) { + if len(bytes) == 0 { + return + } + if b.begin { + b.buf.WriteString(",") + } + b.buf.Write(bytes) + b.begin = true +} + +func (b *sliceBytesBuilder) Complete() *sliceBytesBuilder { + b.buf.WriteString("]") + return b +} + +func (b *sliceBytesBuilder) Bytes() []byte { + return b.buf.Bytes() +} + +func RewriteAPIGroupDiscoveryList(rules *RewriteRules, obj []byte) ([]byte, error) { + items := gjson.GetBytes(obj, "items").Array() + if len(items) == 0 { + return obj, nil + } + + rwrItems := newSliceBytesBuilder() + + for _, item := range items { + + itemBytes := []byte(item.Raw) + var err error + + groupName := gjson.GetBytes(itemBytes, "metadata.name").String() + + if !rules.IsRenamedGroup(groupName) { + // Remove duplicates if cluster have CRDs with original group names. + if rules.HasGroup(groupName) { + continue + } + + // No transform for non-renamed groups, add as-is. + rwrItems.Write(itemBytes) + continue + } + + newItems, err := RestoreAggregatedGroupDiscovery(rules, itemBytes) + if err != nil { + return nil, err + } + if newItems == nil { + rwrItems.Write(itemBytes) + } else { + // Replace renamed group with restored groups. + for _, newItem := range newItems { + rwrItems.Write(newItem) + } + } + } + + return sjson.SetRawBytes(obj, "items", rwrItems.Complete().Bytes()) +} + +// RestoreAggregatedGroupDiscovery returns an array of APIGroupDiscovery objects with restored resources. +// +// obj is an APIGroupDiscovery object with renamed resources: +// +// { +// "metadata": { +// "name": "internal.virtualization.deckhouse.io", <-- renamed group +// "creationTimestamp": null +// }, +// "versions": [ +// { // APIVersionDiscovery +// "version": "v1", +// "resources": [ APIResourceDiscovery{}, ..., APIResourceDiscovery{}] , +// "freshness": "Current" +// }, ... , more APIVersionDiscovery objects. +// ] +// } +// +// Renamed resources in one version may belong to different original groups, +// so this method indexes and restores all resources in APIResourceDiscovery +// and then produces APIGroupDiscovery for each restored group. +func RestoreAggregatedGroupDiscovery(rules *RewriteRules, obj []byte) ([][]byte, error) { + // restoredResources holds restored resources indexed by group and version to construct final APIGroupDiscovery items later. + // A APIGroupDiscovery "metadata" object field and a version item "version" field are not stored and will be reconstructed. + restoredResources := make(map[string]map[string][][]byte) + + // versionFreshness stores freshness values for versions + versionFreshness := make(map[string]string) + + versions := gjson.GetBytes(obj, "versions").Array() + if len(versions) == 0 { + return nil, nil + } + + for _, version := range versions { + versionBytes := []byte(version.Raw) + + versionName := gjson.GetBytes(versionBytes, "version").String() + if versionName == "" { + continue + } + + // Save freshness. + freshness := gjson.GetBytes(versionBytes, "freshness").String() + versionFreshness[versionName] = freshness + + // Loop over resources. + resources := gjson.GetBytes(versionBytes, "resources").Array() + if len(resources) == 0 { + continue + } + + for _, resource := range resources { + restoredGroup, restoredResource, err := RestoreAggregatedDiscoveryResource(rules, []byte(resource.Raw)) + if err != nil { + return nil, nil + } + + if _, ok := restoredResources[restoredGroup]; !ok { + restoredResources[restoredGroup] = make(map[string][][]byte) + } + if _, ok := restoredResources[restoredGroup][versionName]; !ok { + restoredResources[restoredGroup][versionName] = make([][]byte, 0) + } + restoredResources[restoredGroup][versionName] = append(restoredResources[restoredGroup][versionName], restoredResource) + } + } + + // Produce restored APIGroupDiscovery items from indexed APIResourceDiscovery. + restoredGroupList := make([][]byte, 0, len(restoredResources)) + var err error + for groupName, groupVersions := range restoredResources { + // Restore metadata for APIGroupDiscovery. + restoredGroupObj := []byte(fmt.Sprintf(`{"metadata":{"name":"%s", "creationTimestamp":null}}`, groupName)) + + // Construct an array of APIVersionDiscovery objects. + restoredVersions := newSliceBytesBuilder() + for versionName, versionResources := range groupVersions { + // Init restored APIVersionDiscovery object. + restoredVersionObj := []byte(fmt.Sprintf(`{"version":"%s"}`, versionName)) + + // Construct an array of APIResourceDiscovery objects. + { + + restoredVersionResources := newSliceBytesBuilder() + for _, resource := range versionResources { + restoredVersionResources.Write(resource) + } + // Set resources field. + restoredVersionObj, err = sjson.SetRawBytes(restoredVersionObj, "resources", restoredVersionResources.Complete().Bytes()) + if err != nil { + return nil, err + } + } + + // Append restored APIVersionDiscovery object. + restoredVersions.Write(restoredVersionObj) + } + restoredGroupObj, err := sjson.SetRawBytes(restoredGroupObj, "versions", restoredVersions.Complete().Bytes()) + if err != nil { + return nil, err + } + + restoredGroupList = append(restoredGroupList, restoredGroupObj) + } + + return restoredGroupList, nil +} + +// RestoreAggregatedDiscoveryResource restores fields in a renamed APIResourceDiscovery object. +// +// Example of the APIResourceDiscovery object: +// +// { +// "resource": "internalvirtualizationkubevirts", +// "responseKind": { +// "group": "internal.virtualization.deckhouse.io", +// "version": "v1", +// "kind": "InternalVirtualizationKubeVirt" +// }, +// "scope": "Namespaced", +// "singularResource": "internalvirtualizationkubevirt", +// "verbs": [ "delete", "deletecollection", "get", ... ], // Optional +// "categories": [ "intvirt" ], // Optional +// "subresources": [ // Optional +// { +// "subresource": "status", +// "responseKind": { +// "group": "internal.virtualization.deckhouse.io", +// "version": "v1", +// "kind": "InternalVirtualizationKubeVirt" +// }, +// "verbs": [ "get", "patch", "update" ] +// } +// ] +// } +func RestoreAggregatedDiscoveryResource(rules *RewriteRules, obj []byte) (string, []byte, error) { + var err error + + // Get resource plural. + resource := gjson.GetBytes(obj, "resource").String() + origResource := rules.RestoreResource(resource) + + groupRule, resRule := rules.GroupResourceRules(origResource) + + // Ignore resource without rules. + if resRule == nil { + return "", nil, err + } + + origGroup := groupRule.Group + + obj, err = sjson.SetBytes(obj, "resource", origResource) + if err != nil { + return "", nil, err + } + + // Reconstruct group and kind in responseKind field. + responseKind := gjson.GetBytes(obj, "responseKind") + if responseKind.IsObject() { + obj, err = sjson.SetBytes(obj, "responseKind.group", origGroup) + if err != nil { + return "", nil, err + } + obj, err = sjson.SetBytes(obj, "responseKind.kind", resRule.Kind) + if err != nil { + return "", nil, err + } + } + + singular := gjson.GetBytes(obj, "singularResource").String() + if singular != "" { + obj, err = sjson.SetBytes(obj, "singularResource", rules.RestoreResource(singular)) + if err != nil { + return "", nil, err + } + } + + shortNames := gjson.GetBytes(obj, "shortNames").Array() + if len(shortNames) > 0 { + strShortNames := make([]string, 0, len(shortNames)) + for _, shortName := range shortNames { + strShortNames = append(strShortNames, shortName.String()) + } + newShortNames := rules.RestoreShortNames(strShortNames) + obj, err = sjson.SetBytes(obj, "shortNames", newShortNames) + if err != nil { + return "", nil, err + } + } + + categories := gjson.GetBytes(obj, "categories") + if categories.Exists() { + restoredCategories := rules.RestoreCategories(resRule) + obj, err = sjson.SetBytes(obj, "categories", restoredCategories) + if err != nil { + return "", nil, err + } + } + + obj, err = RewriteArray(obj, "subresources", func(item []byte) ([]byte, error) { + // Reconstruct group and kind in responseKind field. + responseKind := gjson.GetBytes(item, "responseKind") + if responseKind.IsObject() { + item, err = sjson.SetBytes(item, "responseKind.group", origGroup) + if err != nil { + return nil, err + } + item, err = sjson.SetBytes(item, "responseKind.kind", resRule.Kind) + if err != nil { + return nil, err + } + } + return item, nil + }) + + return origGroup, obj, nil +} diff --git a/images/kube-api-rewriter/pkg/rewriter/discovery_test.go b/images/kube-api-rewriter/pkg/rewriter/discovery_test.go new file mode 100644 index 0000000..44063e6 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/discovery_test.go @@ -0,0 +1,606 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bufio" + "bytes" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func createRewriterForDiscoveryTest() *RuleBasedRewriter { + apiGroupRules := map[string]APIGroupRule{ + "original.group.io": { + GroupRule: GroupRule{ + Group: "original.group.io", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Renamed: "prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "someresources": { + Kind: "SomeResource", + ListKind: "SomeResourceList", + Plural: "someresources", + Singular: "someresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Categories: []string{"all"}, + ShortNames: []string{"sr", "srs"}, + }, + "anotherresources": { + Kind: "AnotherResource", + ListKind: "AnotherResourceList", + Plural: "anotherresources", + Singular: "anotherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"ar"}, + }, + }, + }, + "other.group.io": { + GroupRule: GroupRule{ + Group: "other.group.io", + Versions: []string{"v2alpha3"}, + PreferredVersion: "v2alpha3", + Renamed: "other.prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "otherresources": { + Kind: "OtherResource", + ListKind: "OtherResourceList", + Plural: "otherresources", + Singular: "otherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"or"}, + }, + }, + }, + } + + webhookRules := map[string]WebhookRule{ + "/validate-prefixed-resources-group-io-v1-prefixedsomeresource": { + Path: "/validate-original-group-io-v1-someresource", + Group: "original.group.io", + Resource: "someresources", + }, + } + + rwRules := &RewriteRules{ + KindPrefix: "Prefixed", // KV + ResourceTypePrefix: "prefixed", // kv + ShortNamePrefix: "p", + Categories: []string{"prefixed"}, + Rules: apiGroupRules, + Webhooks: webhookRules, + } + rwRules.Init() + + return &RuleBasedRewriter{ + Rules: rwRules, + } +} + +func TestRewriteRequestAPIGroupList(t *testing.T) { + // Request APIGroupList. + request := `GET /apis HTTP/1.1 +Host: 127.0.0.1 + +` + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(request))) + require.NoError(t, err, "should read hardcoded request") + + expectPath := "/apis" + + // Response body with renamed APIGroupList + apiGroupResponse := `{ + "kind": "APIGroupList", + "apiVersion": "v1", + "groups": [ + { + "name": "original.group.io", + "versions": [ + {"groupVersion":"original.group.io/v1", "version":"v1"}, + {"groupVersion":"original.group.io/v1alpha1", "version":"v1alpha1"} + ], + "preferredVersion": { + "groupVersion": "original.group.io/v1", + "version":"v1" + } + }, + { + "name": "prefixed.resources.group.io", + "versions": [ + {"groupVersion":"prefixed.resources.group.io/v1", "version":"v1"}, + {"groupVersion":"prefixed.resources.group.io/v1alpha1", "version":"v1alpha1"} + ], + "preferredVersion": { + "groupVersion": "prefixed.resources.group.io/v1", + "version":"v1" + } + }, + { + "name": "other.prefixed.resources.group.io", + "versions": [ + {"groupVersion":"other.prefixed.resources.group.io/v2alpha3", "version":"v2alpha3"} + ], + "preferredVersion": { + "groupVersion": "other.prefixed.resources.group.io/v2alpha3", + "version":"v2alpha3" + } + } + ] +}` + + // Client proxy mode. + rwr := createRewriterForDiscoveryTest() + + var targetReq *TargetRequest + + targetReq = NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.Equal(t, expectPath, targetReq.Path(), "should rewrite api endpoint path") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(apiGroupResponse), Restore) + if err != nil { + t.Fatalf("should rewrite body with renamed resources: %v", err) + } + + tests := []struct { + path string + expected string + }{ + // Check no prefixed groups left after rewrite. + {`groups.#(name=="prefixed.resource.group.io").name`, ""}, + // Should have only 1 group instance, no duplicates. + {`groups.#(name=="original.group.io")#|#`, "1"}, + {`groups.#(name=="original.group.io").name`, "original.group.io"}, + {`groups.#(name=="original.group.io").preferredVersion.groupVersion`, "original.group.io/v1"}, + // Should not add more versions than there are in response. + {`groups.#(name=="original.group.io").versions.#`, "2"}, + {`groups.#(name=="original.group.io").versions.#(version="v1").groupVersion`, "original.group.io/v1"}, + {`groups.#(name=="original.group.io").versions.#(version="v1alpha1").groupVersion`, "original.group.io/v1alpha1"}, + // Check other.group.io is restored. + {`groups.#(name=="other.group.io")#|#`, "1"}, + {`groups.#(name=="other.group.io").name`, "other.group.io"}, + {`groups.#(name=="other.group.io").preferredVersion.groupVersion`, "other.group.io/v2alpha3"}, + // Should not add more versions than there are in response. + {`groups.#(name=="other.group.io").versions.#`, "1"}, + {`groups.#(name=="other.group.io").versions.#(version="v2alpha3").groupVersion`, "other.group.io/v2alpha3"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got '%s', rewritten APIGroupList: %s", tt.path, tt.expected, actual, string(resultBytes)) + } + }) + } +} + +func TestRewriteRequestAPIGroup(t *testing.T) { + // Request APIResourcesList of original, non-renamed resources. + request := `GET /apis/original.group.io HTTP/1.1 +Host: 127.0.0.1 + +` + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(request))) + require.NoError(t, err, "should read hardcoded request") + + expectPath := "/apis/prefixed.resources.group.io" + + // Response body with renamed APIResourcesList + apiGroupResponse := `{ + "kind": "APIGroup", + "apiVersion": "v1", + "name": "prefixed.resources.group.io", + "versions": [ + {"groupVersion":"prefixed.resources.group.io/v1", "version":"v1"}, + {"groupVersion":"prefixed.resources.group.io/v1alpha1", "version":"v1alpha1"} + ], + "preferredVersion": { + "groupVersion": "prefixed.resources.group.io/v1", + "version":"v1" + } +}` + + // Client proxy mode. + rwr := createRewriterForDiscoveryTest() + + var targetReq *TargetRequest + + targetReq = NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.Equal(t, expectPath, targetReq.Path(), "should rewrite api endpoint path") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(apiGroupResponse), Restore) + if err != nil { + t.Fatalf("should rewrite body with renamed resources: %v", err) + } + + groupRule, _ := rwr.Rules.GroupResourceRules("someresources") + require.NotNil(t, groupRule, "should get rule for hard-coded resource type someresources") + + tests := []struct { + path string + expected string + }{ + {"name", groupRule.Group}, + {"versions.#(version==\"v1\").groupVersion", groupRule.Group + "/v1"}, + {"versions.#(version==\"v1alpha1\").groupVersion", groupRule.Group + "/v1alpha1"}, + {"preferredVersion.groupVersion", groupRule.Group + "/v1"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got '%s', rewritten APIGroup: %s", tt.path, tt.expected, actual, string(resultBytes)) + } + }) + } +} + +func TestRewriteRequestAPIGroupUnknownGroup(t *testing.T) { + // Request APIGroup discovery for unknown group. + request := `GET /apis/unknown.group.io HTTP/1.1 +Host: 127.0.0.1 + +` + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(request))) + require.NoError(t, err, "should read hardcoded request") + + apiGroupResponse := `{ + "kind": "APIGroup", + "apiVersion": "v1", + "name": "unknown.group.io", + "versions": [ + {"groupVersion":"unknown.group.io/v1beta1", "version":"v1beta1"}, + {"groupVersion":"unknown.group.io/v1alpha3", "version":"v1alpha3"} + ], + "preferredVersion": { + "groupVersion": "unknown.group.io/v1beta1", + "version":"v1beta1" + } +}` + + // Client proxy mode. + rwr := createRewriterForDiscoveryTest() + + var targetReq *TargetRequest + + targetReq = NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.Equal(t, req.URL.Path, targetReq.Path(), "should not rewrite api endpoint path") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(apiGroupResponse), Restore) + if err != nil { + t.Fatalf("should rewrite body with renamed resources: %v", err) + } + + require.Equal(t, apiGroupResponse, string(resultBytes), "should not rewrite ApiGroup for unknown group") +} + +func TestRewriteRequestAPIResourceList(t *testing.T) { + // Request APIResourcesList of original, non-renamed resources. + // Note: use non preferred version. + request := `GET /apis/original.group.io/v1alpha1 HTTP/1.1 +Host: 127.0.0.1 + +` + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(request))) + require.NoError(t, err, "should read hardcoded request") + + expectPath := "/apis/prefixed.resources.group.io/v1alpha1" + + // Response body with renamed APIResourcesList + resourceListPayload := `{ + "kind": "APIResourceList", + "apiVersion": "v1", + "groupVersion": "prefixed.resources.group.io/v1alpha1", + "resources": [ + {"name":"prefixedsomeresources", + "singularName":"prefixedsomeresource", + "namespaced":true, + "kind":"PrefixedSomeResource", + "verbs":["delete","deletecollection","get","list","patch","create","update","watch"], + "shortNames":["psr","psrs"], + "categories":["prefixed"], + "storageVersionHash":"1qIJ90Mhvd8="}, + + {"name":"prefixedsomeresources/status", + "singularName":"", + "namespaced":true, + "kind":"PrefixedSomeResource", + "verbs":["get","patch","update"]}, + + {"name":"norulesresources", + "singularName":"norulesresource", + "namespaced":true, + "kind":"NoRulesResource", + "verbs":["delete","deletecollection","get","list","patch","create","update","watch"], + "shortNames":["nrr"], + "categories":["prefixed"], + "storageVersionHash":"Nwlto9QquX0="}, + + {"name":"norulesresources/status", + "singularName":"", + "namespaced":true, + "kind":"NoRulesResource", + "verbs":["get","patch","update"]} +]}` + + // Client proxy mode. + rwr := createRewriterForDiscoveryTest() + + var targetReq *TargetRequest + + targetReq = NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.Equal(t, expectPath, targetReq.Path(), "should rewrite api endpoint path") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(resourceListPayload), Restore) + if err != nil { + t.Fatalf("should rewrite body with renamed resources: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {"groupVersion", "original.group.io/v1alpha1"}, + {"resources.#(name==\"someresources\").name", "someresources"}, + {"resources.#(name==\"someresources\").kind", "SomeResource"}, + {"resources.#(name==\"someresources\").singularName", "someresource"}, + {"resources.#(name==\"someresources\").categories.0", "all"}, + {"resources.#(name==\"someresources\").shortNames.0", "sr"}, + {"resources.#(name==\"someresources\").shortNames.1", "srs"}, + {"resources.#(name==\"someresources/status\").name", "someresources/status"}, + {"resources.#(name==\"someresources/status\").kind", "SomeResource"}, + {"resources.#(name==\"someresources/status\").singularName", ""}, + // norulesresources should not be restored. + {"resources.#(name==\"norulesresources\").name", "norulesresources"}, + {"resources.#(name==\"norulesresources\").kind", "NoRulesResource"}, + {"resources.#(name==\"norulesresources\").singularName", "norulesresource"}, + {"resources.#(name==\"norulesresources\").categories.0", "prefixed"}, + {"resources.#(name==\"norulesresources\").shortNames.0", "nrr"}, + {"resources.#(name==\"norulesresources/status\").name", "norulesresources/status"}, + {"resources.#(name==\"norulesresources/status\").kind", "NoRulesResource"}, + {"resources.#(name==\"norulesresources/status\").singularName", ""}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got '%s', rewritten APIGroupDiscovery: %s", tt.path, tt.expected, actual, string(resultBytes)) + } + }) + } +} + +func TestRewriteRequestAPIGroupDiscoveryList(t *testing.T) { + // Request aggregated discovery as APIGroupDiscoveryList kind. + request := `GET /apis HTTP/1.1 +Host: 127.0.0.1 +Accept: application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList + +` + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(request))) + require.NoError(t, err, "should read hardcoded request") + + // This group contains resources from 2 original groups: + // - someresources.original.group.io with v1 and v1alpha1 version + // - otherresources.other.group.io of v2alpha3 version + // Restored list should contain 2 APIGroupDiscovery. + renamedAPIGroupDiscovery := `{ + "metadata":{ + "name": "prefixed.resources.group.io", + "creationTimestamp": null + }, + "versions":[ + { "version": "v1", + "freshness": "Current", + "resources": [ + { "resource": "prefixedsomeresources", + "responseKind": {"group": "prefixed.resources.group.io", "version": "v1", "kind": "PrefixedSomeResource"}, + "scope": "Namespaced", + "singularResource": "prefixedsomeresource", + "shortNames": ["psr"], + "categories": ["prefixed"], + "verbs": ["create", "patch"], + "subresources": [ + { "subresource": "status", + "responseKind": {"group": "prefixed.resources.group.io", "version": "v1", "kind": "PrefixedSomeResource"}, + "verbs": ["get", "patch"] + } + ] + } + ] + }, + { "version": "v1alpha1", + "resources": [ + { "resource": "prefixedsomeresources", + "responseKind": {"group": "prefixed.resources.group.io", "version": "v1alpha1", "kind": "PrefixedSomeResource"}, + "scope": "Namespaced", + "singularResource": "prefixedsomeresource", + "verbs": ["create", "patch"], + "subresources": [ + { "subresource": "status", + "responseKind": {"group": "prefixed.resources.group.io", "version": "v1alpha1", "kind": "PrefixedSomeResource"}, + "verbs": ["get", "patch"] + } + ] + } + ] + } + ] +}` + renamedOtherAPIGroupDiscovery := `{ + "metadata":{ + "name": "other.prefixed.resources.group.io", + "creationTimestamp": null + }, + "versions":[ + { "version": "v2alpha3", + "resources": [ + { "resource": "prefixedotherresources", + "responseKind": {"group": "other.prefixed.resources.group.io", "version": "v1alpha1", "kind": "PrefixedOtherResource"}, + "scope": "Namespaced", + "singularResource": "prefixedotherresource", + "verbs": ["create", "patch"], + "subresources": [ + { "subresource": "status", + "responseKind": {"group": "other.prefixed.resources.group.io", "version": "v1alpha1", "kind": "PrefixedOtherResource"}, + "verbs": ["get", "patch"] + } + ] + } + ] + } + ] +}` + // This groups should not be rewritten. + appsAPIGroupDiscovery := `{ + "metadata": { + "name": "apps", + "creationTimestamp": null + }, + "versions": [ + {"version": "v1", + "freshness": "Current", + "resources": [ + {"resource": "deployments", + "responseKind": {"group": "", "version": "", "kind": "Deployment"}, + "scope": "Namespaced", + "singularResource": "deployment", + "verbs": ["create", "patch"] + } + ]} + ] +}` + // This groups should not be rewritten. + nonRewritableAPIGroupDiscovery := `{ + "metadata": { + "name": "custom.resources.io", + "creationTimestamp": null + }, + "versions": [ + {"version": "v1", + "freshness": "Current", + "resources": [ + {"resource": "somecustomresources", + "responseKind": {"group": "custom.resources.io", "version": "v1", "kind": "SomeCustomResource"}, + "scope": "Namespaced", + "singularResource": "somecustomresource", + "verbs": ["create", "patch"] + } + ]} + ] +}` + + // Response body with renamed APIGroupDiscoveryList + apiGroupDiscoveryListPayload := fmt.Sprintf(`{ + "kind": "APIGroupDiscoveryList", + "apiVersion": "apidiscovery.k8s.io/v2beta1", + "metadata": {}, + "items": [ %s ] +}`, strings.Join([]string{ + appsAPIGroupDiscovery, + renamedAPIGroupDiscovery, + renamedOtherAPIGroupDiscovery, + nonRewritableAPIGroupDiscovery, + }, ",")) + + // Initialize rewriter using hard-coded client http request. + rwr := createRewriterForDiscoveryTest() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(apiGroupDiscoveryListPayload), Restore) + if err != nil { + t.Fatalf("should rewrite body with renamed resources: %v", err) + } + + // Get rules for rewritable resource. + groupRule, resRule := rwr.Rules.GroupResourceRules("someresources") + require.NotNil(t, groupRule, "should get groupRule for hardcoded resourceType") + require.NotNil(t, resRule, "should get resourceRule for hardcoded resourceType") + + // Expect renamed groups present in the restored object. + { + expected := []string{ + "apps", + "original.group.io", + "other.group.io", + "custom.resources.io", + } + + groups := gjson.GetBytes(resultBytes, `items.#.metadata.name`).Array() + + actual := []string{} + for _, group := range groups { + actual = append(actual, group.String()) + } + + require.Equal(t, len(expected), len(groups), "restored object should have %d groups, got %d: %#v", len(expected), len(groups), actual) + for _, expect := range expected { + require.Contains(t, actual, expect, "restored object should have group %s, got %v", expect, actual) + } + } + + // Test renamed fields for someresources in original.group.io. + { + group := gjson.GetBytes(resultBytes, `items.#(metadata.name=="original.group.io")`) + groupRule, resRule := rwr.Rules.GroupResourceRules("someresources") + + require.NotNil(t, resRule, "should get rule for hard-coded resource type someresources") + + tests := []struct { + path string + expected string + }{ + {"versions.#(version==\"v1\").resources.0.resource", resRule.Plural}, + {"versions.#(version==\"v1\").resources.0.responseKind.group", groupRule.Group}, + {"versions.#(version==\"v1\").resources.0.responseKind.kind", resRule.Kind}, + {"versions.#(version==\"v1\").resources.0.singularResource", resRule.Singular}, + {"versions.#(version==\"v1\").resources.0.categories.0", resRule.Categories[0]}, + {"versions.#(version==\"v1\").resources.0.shortNames.0", resRule.ShortNames[0]}, + {"versions.#(version==\"v1\").resources.0.subresources.0.responseKind.group", groupRule.Group}, + {"versions.#(version==\"v1\").resources.0.subresources.0.responseKind.kind", resRule.Kind}, + } + + groupBytes := []byte(group.Raw) + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(groupBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got '%s', rewritten APIGroupDiscovery: %s", tt.path, tt.expected, actual, string(groupBytes)) + } + }) + } + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/events.go b/images/kube-api-rewriter/pkg/rewriter/events.go new file mode 100644 index 0000000..3de3894 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/events.go @@ -0,0 +1,52 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +const ( + EventKind = "Event" + EventListKind = "EventList" +) + +// RewriteEventOrList rewrites a single Event resource or a list of Events in EventList. +// The only field need to rewrite is involvedObject: +// +// { +// "metadata": { "name": "...", "namespace": "...", "managedFields": [...] }, +// "involvedObject": { +// "kind": "SomeResource", +// "namespace": "name", +// "name": "ns", +// "uid": "a260fe4f-103a-41c6-996c-d29edb01fbbd", +// "apiVersion": "group.io/v1" +// }, +// "type": "...", +// "reason": "...", +// "message": "...", +// "source": { +// "component": "...", +// "host": "..." +// }, +// "reportingComponent": "...", +// "reportingInstance": "..." +// }, +func RewriteEventOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteResourceOrList(obj, EventListKind, func(singleObj []byte) ([]byte, error) { + return TransformObject(singleObj, "involvedObject", func(involvedObj []byte) ([]byte, error) { + return RewriteAPIVersionAndKind(rules, involvedObj, action) + }) + }) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/events_test.go b/images/kube-api-rewriter/pkg/rewriter/events_test.go new file mode 100644 index 0000000..0574238 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/events_test.go @@ -0,0 +1,123 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bufio" + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestRewriteEvent(t *testing.T) { + eventReq := `POST /api/v1/namespaces/vm/events HTTP/1.1 +Host: 127.0.0.1 + +` + eventPayload := `{ + "kind": "Event", + "apiVersion": "v1", + "metadata": { + "name": "some-event-name", + "namespace": "vm", + }, + "involvedObject": { + "kind": "SomeResource", + "namespace": "vm", + "name": "some-vm-name", + "uid": "ad9f7357-f6b0-4679-8571-042c75ec53fb", + "apiVersion": "original.group.io/v1" + }, + "reason": "EventReason", + "message": "Event message for some-vm-name", + "source": { + "component": "some-component", + "host": "some-node" + }, + "count": 1000, + "type": "Warning", + "eventTime": null, + "reportingComponent": "some-component", + "reportingInstance": "some-node" +}` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(eventReq + eventPayload))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriterForCore() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + // require.Equal(t, origGroup, targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(eventPayload), Rename) + if err != nil { + t.Fatalf("should rename Error without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should rename Error: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`involvedObject.kind`, "PrefixedSomeResource"}, + {`involvedObject.apiVersion`, "prefixed.resources.group.io/v1"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } + + // Restore. + resultBytes, err = rwr.RewriteJSONPayload(targetReq, []byte(eventPayload), Restore) + if err != nil { + t.Fatalf("should restore PVC without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should restore PVC: %v", err) + } + + tests = []struct { + path string + expected string + }{ + {`involvedObject.kind`, "SomeResource"}, + {`involvedObject.apiVersion`, "original.group.io/v1"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } + +} diff --git a/images/kube-api-rewriter/pkg/rewriter/gvk.go b/images/kube-api-rewriter/pkg/rewriter/gvk.go new file mode 100644 index 0000000..a318d6c --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/gvk.go @@ -0,0 +1,69 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func RewriteAPIGroupAndKind(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteGVK(rules, obj, action, "apiGroup") +} + +func RewriteAPIVersionAndKind(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteGVK(rules, obj, action, "apiVersion") +} + +// RewriteGVK rewrites a "kind" field and a field with the group +// if there is the rule for these particular kind and group. +func RewriteGVK(rules *RewriteRules, obj []byte, action Action, gvFieldName string) ([]byte, error) { + kind := gjson.GetBytes(obj, "kind").String() + apiGroupVersion := gjson.GetBytes(obj, gvFieldName).String() + + rwrApiVersion := "" + rwrKind := "" + if action == Rename { + // Rename if there is a rule for kind and group + _, resourceRule := rules.KindRules(apiGroupVersion, kind) + if resourceRule == nil { + return obj, nil + } + rwrApiVersion = rules.RenameApiVersion(apiGroupVersion) + rwrKind = rules.RenameKind(kind) + } + if action == Restore { + // Restore if group is renamed and a rule can be found + // for restored kind and group. + if !rules.IsRenamedGroup(apiGroupVersion) { + return obj, nil + } + rwrApiVersion = rules.RestoreApiVersion(apiGroupVersion) + rwrKind = rules.RestoreKind(kind) + _, resourceRule := rules.KindRules(rwrApiVersion, rwrKind) + if resourceRule == nil { + return obj, nil + } + } + + obj, err := sjson.SetBytes(obj, "kind", rwrKind) + if err != nil { + return nil, err + } + + return sjson.SetBytes(obj, gvFieldName, rwrApiVersion) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/indexer/map_indexer.go b/images/kube-api-rewriter/pkg/rewriter/indexer/map_indexer.go new file mode 100644 index 0000000..6e2faaa --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/indexer/map_indexer.go @@ -0,0 +1,58 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package indexer + +type MapIndexer struct { + idx map[string]string + reverse map[string]string +} + +func NewMapIndexer() *MapIndexer { + return &MapIndexer{ + idx: make(map[string]string), + reverse: make(map[string]string), + } +} + +func (m *MapIndexer) AddPair(original, renamed string) { + m.idx[original] = renamed + m.reverse[renamed] = original +} + +func (m *MapIndexer) Rename(original string) string { + if renamed, ok := m.idx[original]; ok { + return renamed + } + return original +} + +func (m *MapIndexer) Restore(renamed string) string { + if original, ok := m.reverse[renamed]; ok { + return original + } + return renamed +} + +func (m *MapIndexer) IsOriginal(original string) bool { + _, ok := m.idx[original] + return ok +} + +func (m *MapIndexer) IsRenamed(original string) bool { + _, ok := m.reverse[original] + return ok +} diff --git a/images/kube-api-rewriter/pkg/rewriter/list.go b/images/kube-api-rewriter/pkg/rewriter/list.go new file mode 100644 index 0000000..129dab5 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/list.go @@ -0,0 +1,101 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bytes" + "errors" + "strings" + + "github.com/tidwall/gjson" +) + +// TODO merge this file into transformers.go + +// RewriteResourceOrList is a helper to transform a single resource or a list of resources. +func RewriteResourceOrList(payload []byte, listKind string, transformFn func(singleObj []byte) ([]byte, error)) ([]byte, error) { + kind := gjson.GetBytes(payload, "kind").String() + + // Not a list, transform a single resource. + if kind != listKind { + return transformFn(payload) + } + + return RewriteArray(payload, "items", transformFn) +} + +// RewriteResourceOrList2 is a helper to transform a single resource or a list of resources. +func RewriteResourceOrList2(payload []byte, transformFn func(singleObj []byte) ([]byte, error)) ([]byte, error) { + kind := gjson.GetBytes(payload, "kind").String() + if !strings.HasSuffix(kind, "List") { + return transformFn(payload) + } + return RewriteArray(payload, "items", transformFn) +} + +// SkipItem may be used by the transformFn to indicate that the item should be skipped from the result. +var SkipItem = errors.New("remove item from the result") + +// RewriteArray gets array by path and transforms each item using transformFn. +// Use Root path to transform object itself. +// transformFn contract: +// return obj, nil -> obj is considered a replacement for the element. +// return nil, nil -> no transformation, element is added as-is. +// return any, SkipItem -> no transformation and no adding to the result. +// return any, err -> stop transformation, return error. +func RewriteArray(obj []byte, arrayPath string, transformFn func(item []byte) ([]byte, error)) ([]byte, error) { + // Transform each item in list. Put back original items if transformFn returns nil bytes. + items := GetBytes(obj, arrayPath).Array() + if len(items) == 0 { + return obj, nil + } + + var rwrItems bytes.Buffer + rwrItems.Grow(len(obj)) + // Start array + rwrItems.WriteString(`[`) + + first := true + for _, item := range items { + + rwrItem, err := transformFn([]byte(item.Raw)) + if err != nil { + if errors.Is(err, SkipItem) { + continue + } + return nil, err + } + + // Prepend a comma for all elements except the first one. + if first { + first = false + } else { + rwrItems.WriteString(`,`) + } + + // Put original item back to allow transformFn returns nil. + if rwrItem == nil { + rwrItem = []byte(item.Raw) + } + + rwrItems.Write(rwrItem) + } + + // Close array + rwrItems.WriteString(`]`) + return SetRawBytes(obj, arrayPath, rwrItems.Bytes()) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/load.go b/images/kube-api-rewriter/pkg/rewriter/load.go new file mode 100644 index 0000000..f44514a --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/load.go @@ -0,0 +1,38 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "os" + + "sigs.k8s.io/yaml" +) + +func LoadRules(filename string) (*RewriteRules, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + var rules = new(RewriteRules) + err = yaml.Unmarshal(data, rules) + if err != nil { + return nil, err + } + + return rules, nil +} diff --git a/images/kube-api-rewriter/pkg/rewriter/map.go b/images/kube-api-rewriter/pkg/rewriter/map.go new file mode 100644 index 0000000..83d51db --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/map.go @@ -0,0 +1,39 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// TODO merge this file into transformers.go + +// RewriteMapStringString transforms map[string]string value addressed by path. +func RewriteMapStringString(obj []byte, mapPath string, transformFn func(k, v string) (string, string)) ([]byte, error) { + m := gjson.GetBytes(obj, mapPath).Map() + if len(m) == 0 { + return obj, nil + } + newMap := make(map[string]string, len(m)) + for k, v := range m { + newK, newV := transformFn(k, v.String()) + newMap[newK] = newV + } + + return sjson.SetBytes(obj, mapPath, newMap) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/metadata.go b/images/kube-api-rewriter/pkg/rewriter/metadata.go new file mode 100644 index 0000000..8f6fa59 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/metadata.go @@ -0,0 +1,144 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func RewriteMetadata(rules *RewriteRules, metadataObj []byte, action Action) ([]byte, error) { + metadataObj, err := RewriteLabelsMap(rules, metadataObj, "labels", action) + if err != nil { + return nil, err + } + metadataObj, err = RewriteAnnotationsMap(rules, metadataObj, "annotations", action) + if err != nil { + return nil, err + } + metadataObj, err = RewriteFinalizers(rules, metadataObj, "finalizers", action) + if err != nil { + return nil, err + } + return RewriteOwnerReferences(rules, metadataObj, "ownerReferences", action) +} + +// RenameMetadataPatch transforms known metadata fields in patches. +// Example: +// - merge patch on metadata: +// {"metadata": { "labels": {"kubevirt.io/schedulable": "false", "cpumanager": "false"}, "annotations": {"kubevirt.io/heartbeat": "2024-06-07T23:27:53Z"}}} +// - JSON patch on metadata: +// [{"op":"test", "path":"/metadata/labels", "value":{"label":"value"}}, +// +// {"op":"replace", "path":"/metadata/labels", "value":{"label":"newValue"}}] +func RenameMetadataPatch(rules *RewriteRules, patch []byte) ([]byte, error) { + return TransformPatch(patch, + func(mergePatch []byte) ([]byte, error) { + return TransformObject(mergePatch, "metadata", func(metadataObj []byte) ([]byte, error) { + return RewriteMetadata(rules, metadataObj, Rename) + }) + }, + func(jsonPatch []byte) ([]byte, error) { + path := gjson.GetBytes(jsonPatch, "path").String() + switch path { + case "/metadata/labels": + return RewriteLabelsMap(rules, jsonPatch, "value", Rename) + case "/metadata/annotations": + return RewriteAnnotationsMap(rules, jsonPatch, "value", Rename) + case "/metadata/finalizers": + return RewriteFinalizers(rules, jsonPatch, "value", Rename) + case "/metadata/ownerReferences": + return RewriteOwnerReferences(rules, jsonPatch, "value", Rename) + case "/metadata": + return TransformObject(jsonPatch, "value", func(metadataObj []byte) ([]byte, error) { + return RewriteMetadata(rules, metadataObj, Rename) + }) + } + + encLabel, found := strings.CutPrefix(path, "/metadata/labels/") + if found { + label := decodeJSONPatchPath(encLabel) + rwrLabel := rules.LabelsRewriter().Rewrite(label, Rename) + if label != rwrLabel { + return sjson.SetBytes(jsonPatch, "path", "/metadata/labels/"+encodeJSONPatchPath(rwrLabel)) + } + } + + encAnno, found := strings.CutPrefix(path, "/metadata/annotations/") + if found { + anno := decodeJSONPatchPath(encAnno) + rwrAnno := rules.AnnotationsRewriter().Rewrite(anno, Rename) + if anno != rwrAnno { + return sjson.SetBytes(jsonPatch, "path", "/metadata/annotations/"+encodeJSONPatchPath(rwrAnno)) + } + } + + encFin, found := strings.CutPrefix(path, "/metadata/finalizers/") + if found { + fin := decodeJSONPatchPath(encFin) + rwrFin := rules.FinalizersRewriter().Rewrite(fin, Rename) + if fin != rwrFin { + return sjson.SetBytes(jsonPatch, "path", "/metadata/finalizers/"+encodeJSONPatchPath(rwrFin)) + } + } + + return jsonPatch, nil + }) +} + +func RewriteLabelsMap(rules *RewriteRules, obj []byte, path string, action Action) ([]byte, error) { + return RewriteMapStringString(obj, path, func(k, v string) (string, string) { + return rules.LabelsRewriter().RewriteNameValue(k, v, action) + }) +} + +func RewriteAnnotationsMap(rules *RewriteRules, obj []byte, path string, action Action) ([]byte, error) { + return RewriteMapStringString(obj, path, func(k, v string) (string, string) { + return rules.AnnotationsRewriter().RewriteNameValue(k, v, action) + }) +} + +func RewriteFinalizers(rules *RewriteRules, obj []byte, path string, action Action) ([]byte, error) { + return TransformArrayOfStrings(obj, path, func(finalizer string) string { + return rules.FinalizersRewriter().Rewrite(finalizer, action) + }) +} + +const ( + tildeChar = "~" + tildePlaceholder = "~0" + slashChar = "/" + slashPlaceholder = "~1" +) + +// decodeJSONPatchPath restores ~ and / from ~0 and ~1. +// See https://jsonpatch.com/#json-pointer +func decodeJSONPatchPath(path string) string { + // Restore / first to prevent tilde doubling. + res := strings.Replace(path, slashPlaceholder, slashChar, -1) + return strings.Replace(res, tildePlaceholder, tildeChar, -1) +} + +// encodeJSONPatchPath replaces ~ and / to ~0 and ~1. +// See https://jsonpatch.com/#json-pointer +func encodeJSONPatchPath(path string) string { + // Replace ~ first to prevent tilde doubling. + res := strings.Replace(path, tildeChar, tildePlaceholder, -1) + return strings.Replace(res, slashChar, slashPlaceholder, -1) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/path.go b/images/kube-api-rewriter/pkg/rewriter/path.go new file mode 100644 index 0000000..712d208 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/path.go @@ -0,0 +1,191 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +// RewritePath return rewritten TargetPath along with original group and resource type. +// TODO: this rewriter is not conform to S in SOLID. Should split to ParseAPIEndpoint and RewriteAPIEndpoint. +//func (rw *RuleBasedRewriter) RewritePath(urlPath string) (*TargetRequest, error) { +// // Is it a webhook? +// if webhookRule, ok := rw.Rules.Webhooks[urlPath]; ok { +// return &TargetRequest{ +// Webhook: &webhookRule, +// }, nil +// } +// +// // Is it an API request? +// if strings.HasPrefix(urlPath, "/apis/") || urlPath == "/apis" { +// // TODO refactor RewriteAPIPath to produce a TargetPath, not an array in PathItems. +// cleanedPath := strings.Trim(urlPath, "/") +// pathItems := strings.Split(cleanedPath, "/") +// +// // First, try to rewrite CRD request. +// res := RewriteCRDPath(pathItems, rw.Rules) +// if res != nil { +// return res, nil +// } +// // Next, rewrite usual request. +// res, err := RewriteAPIsPath(pathItems, rw.Rules) +// if err != nil { +// return nil, err +// } +// if res == nil { +// // e.g. no rewrite rule find. +// return nil, nil +// } +// if len(res.PathItems) > 0 { +// res.TargetPath = "/" + path.Join(res.PathItems...) +// } +// return res, nil +// } +// +// if strings.HasPrefix(urlPath, "/api/") || urlPath == "/api" { +// return &TargetRequest{ +// IsCoreAPI: true, +// }, nil +// } +// +// return nil, nil +//} + +// Constants with indices of API endpoints portions. +// Request cluster scoped resource: +// - /apis/GROUP/VERSION/RESOURCETYPE/NAME/SUBRESOURCE +// | | | | +// APISIdx | | | +// GroupIDx | | +// VersionIDx ---+ | +// ClusterResourceIdx ---+ + +// +// Request namespaced resource: +// - /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME/SUBRESOURCE +// | | | +// NamespacesIdx --------+ | | +// NamespaceIdx --------------------+ | +// NamespacedResourceIdx----------------------+ +// +// Request CRD: +// - /apis/apiextensions.k8s.io/v1/customresourcedefinitions/RESOURCETYPE.GROUP +// | | | +// GroupIdx | | +// ClusterResourceIdx -------------+ | +// CRDNameIdx -----------------------------------------------+ + +//const ( +// APISIdx = 0 +// GroupIdx = 1 +// VersionIdx = 2 +// NamespacesIdx = 3 +// NamespaceIdx = 4 +// ClusterResourceIdx = 3 +// NamespacedResourceIdx = 5 +//) + +// RewriteAPIsPath rewrites GROUP and RESOURCETYPE in these API calls: +// - /apis/GROUP +// - /apis/GROUP/VERSION +// - /apis/GROUP/VERSION/RESOURCETYPE +// - /apis/GROUP/VERSION/RESOURCETYPE/NAME +// - /apis/GROUP/VERSION/RESOURCETYPE/NAME/SUBRESOURCE +// +// - /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE +// - /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME +// - /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME/SUBRESOURCE +//func RewriteAPIsPath(pathItems []string, rules *RewriteRules) (*TargetRequest, error) { +// if len(pathItems) == 0 { +// return nil, nil +// } +// +// res := &TargetRequest{ +// PathItems: make([]string, 0, len(pathItems)), +// } +// +// if len(pathItems) == 1 { +// if pathItems[APISIdx] == "apis" { +// // Do not rewrite URL, but rewrite response later. +// res.PathItems = append(res.PathItems, pathItems[APISIdx]) +// return res, nil +// } +// // The single path item should be "apis". +// return nil, nil +// } +// +// res.PathItems = append(res.PathItems, pathItems[APISIdx]) +// +// // Check if the GROUP portion match Rules. +// apiGroupName := "" +// apiGroupMatch := false +// group := pathItems[GroupIdx] +// for groupName, apiGroupRule := range rules.Rules { +// if apiGroupRule.GroupRule.Group == group { +// res.OrigGroup = group +// res.PathItems = append(res.PathItems, rules.RenamedGroup) +// apiGroupName = groupName +// apiGroupMatch = true +// break +// } +// } +// +// if !apiGroupMatch { +// return nil, nil +// } +// // Stop if GROUP is the last item in path. +// if len(pathItems) <= GroupIdx+1 { +// return res, nil +// } +// +// // Add VERSION portion. +// res.PathItems = append(res.PathItems, pathItems[VersionIdx]) +// // Stop if VERSION is the last item in path. +// if len(pathItems) <= VersionIdx+1 { +// return res, nil +// } +// +// // Check is namespaced resource is requested. +// resourceTypeIdx := ClusterResourceIdx +// if pathItems[NamespacesIdx] == "namespaces" { +// res.PathItems = append(res.PathItems, pathItems[NamespacesIdx]) +// res.PathItems = append(res.PathItems, pathItems[NamespaceIdx]) +// resourceTypeIdx = NamespacedResourceIdx +// } +// +// // Check if the RESOURCETYPE portion match Rules. +// resourceType := pathItems[resourceTypeIdx] +// resourceTypeMatched := true +// for _, rule := range rules.Rules[apiGroupName].ResourceRules { +// if rule.Plural == resourceType { +// res.OrigResourceType = resourceType +// res.PathItems = append(res.PathItems, rules.RenameResource(rule.Plural)) +// resourceTypeMatched = true +// break +// } +// } +// if !resourceTypeMatched { +// return nil, nil +// } +// // Return if RESOURCETYPE is the last item in path. +// if len(pathItems) == resourceTypeIdx+1 { +// return res, nil +// } +// +// // Copy remaining items: NAME and SUBRESOURCE. +// for i := resourceTypeIdx + 1; i < len(pathItems); i++ { +// res.PathItems = append(res.PathItems, pathItems[i]) +// } +// +// return res, nil +//} diff --git a/images/kube-api-rewriter/pkg/rewriter/policy.go b/images/kube-api-rewriter/pkg/rewriter/policy.go new file mode 100644 index 0000000..60e301f --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/policy.go @@ -0,0 +1,28 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +const ( + PodDisruptionBudgetKind = "PodDisruptionBudget" + PodDisruptionBudgetListKind = "PodDisruptionBudgetList" +) + +func RewritePDBOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + return RewriteResourceOrList(obj, PodDisruptionBudgetListKind, func(singleObj []byte) ([]byte, error) { + return RewriteLabelsMap(rules, singleObj, "spec.selector.matchLabels", action) + }) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/prefixed_name_rewriter.go b/images/kube-api-rewriter/pkg/rewriter/prefixed_name_rewriter.go new file mode 100644 index 0000000..26246ef --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/prefixed_name_rewriter.go @@ -0,0 +1,288 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import "strings" + +const PreservedPrefix = "preserved-original-" + +type PrefixedNameRewriter struct { + namesRenameIdx map[string]string + namesRestoreIdx map[string]string + prefixRenameIdx map[string]string + prefixRestoreIdx map[string]string +} + +func NewPrefixedNameRewriter(replaceRules MetadataReplace) *PrefixedNameRewriter { + return &PrefixedNameRewriter{ + namesRenameIdx: indexRules(replaceRules.Names), + namesRestoreIdx: indexRulesReverse(replaceRules.Names), + prefixRenameIdx: indexRules(replaceRules.Prefixes), + prefixRestoreIdx: indexRulesReverse(replaceRules.Prefixes), + } +} + +func (p *PrefixedNameRewriter) Rewrite(name string, action Action) string { + switch action { + case Rename: + name, _ = p.rename(name, "") + case Restore: + name, _ = p.restore(name, "") + } + return name +} + +func (p *PrefixedNameRewriter) RewriteNameValue(name, value string, action Action) (string, string) { + switch action { + case Rename: + return p.rename(name, value) + case Restore: + return p.restore(name, value) + } + return name, value +} + +func (p *PrefixedNameRewriter) RewriteNameValues(name string, values []string, action Action) (string, []string) { + if len(values) == 0 { + return p.Rewrite(name, action), values + } + switch action { + case Rename: + return p.rewriteNameValues(name, values, p.rename) + case Restore: + return p.rewriteNameValues(name, values, p.restore) + } + return name, values +} + +func (p *PrefixedNameRewriter) RewriteSlice(names []string, action Action) []string { + switch action { + case Rename: + return p.rewriteSlice(names, p.rename) + case Restore: + return p.rewriteSlice(names, p.restore) + } + return names +} + +func (p *PrefixedNameRewriter) RewriteMap(names map[string]string, action Action) map[string]string { + switch action { + case Rename: + return p.rewriteMap(names, p.rename) + case Restore: + return p.rewriteMap(names, p.restore) + } + return names +} + +func (p *PrefixedNameRewriter) Rename(name, value string) (string, string) { + return p.rename(name, value) +} + +func (p *PrefixedNameRewriter) Restore(name, value string) (string, string) { + return p.restore(name, value) +} + +func (p *PrefixedNameRewriter) RenameSlice(names []string) []string { + return p.rewriteSlice(names, p.rename) +} + +func (p *PrefixedNameRewriter) RestoreSlice(names []string) []string { + return p.rewriteSlice(names, p.restore) +} + +func (p *PrefixedNameRewriter) RenameMap(names map[string]string) map[string]string { + return p.rewriteMap(names, p.rename) +} + +func (p *PrefixedNameRewriter) RestoreMap(names map[string]string) map[string]string { + return p.rewriteMap(names, p.restore) +} + +// rewriteNameValues rewrite name and values, e.g. for matchExpressions. +// Method uses all rules to detect a new name, first matching rule is applied. +// Values may be rewritten partially depending on specified name-value rules. +func (p *PrefixedNameRewriter) rewriteNameValues(name string, values []string, fn func(string, string) (string, string)) (string, []string) { + rwrName := name + rwrValues := make([]string, 0, len(values)) + + for _, value := range values { + n, v := fn(name, value) + // Set new name only for the first matching rule. + if n != name && rwrName == name { + rwrName = n + } + rwrValues = append(rwrValues, v) + } + + return rwrName, rwrValues +} + +func (p *PrefixedNameRewriter) rewriteMap(names map[string]string, fn func(string, string) (string, string)) map[string]string { + if names == nil { + return nil + } + result := make(map[string]string) + for name, value := range names { + rwrName, rwrValue := fn(name, value) + result[rwrName] = rwrValue + } + return result +} + +// rewriteSlice do not rewrite values, only names. +func (p *PrefixedNameRewriter) rewriteSlice(names []string, fn func(string, string) (string, string)) []string { + if names == nil { + return nil + } + result := make([]string, 0, len(names)) + for _, name := range names { + rwrName, _ := fn(name, "") + result = append(result, rwrName) + } + return result +} + +// rename rewrites original names and values. If label was preserved, rewrite it to original state. +func (p *PrefixedNameRewriter) rename(name, value string) (string, string) { + if p.isPreserved(name) { + return p.restorePreservedName(name), value + } + + // First try to find name and value. + if value != "" { + idxKey := joinKV(name, value) + if renamedIdxValue, ok := p.namesRenameIdx[idxKey]; ok { + return splitKV(renamedIdxValue) + } + } + // No exact rule for name and value, try to find exact name match. + if renamed, ok := p.namesRenameIdx[name]; ok { + return renamed, value + } + // No exact name, find prefix. + prefix, remainder, found := strings.Cut(name, "/") + if !found { + return name, value + } + if renamedPrefix, ok := p.prefixRenameIdx[prefix]; ok { + return renamedPrefix + "/" + remainder, value + } + return name, value +} + +// restore rewrites renamed names and values to their original state. +// If name is already original, preserve it with prefix, to make it unknown for client but keep in place for UPDATE/PATCH operations. +func (p *PrefixedNameRewriter) restore(name, value string) (string, string) { + if p.isOriginal(name, value) { + return p.preserveName(name), value + } + + // First try to find name and value. + if value != "" { + idxKey := joinKV(name, value) + if restoredIdxValue, ok := p.namesRestoreIdx[idxKey]; ok { + return splitKV(restoredIdxValue) + } + } + // No exact rule for name and value, try to find exact name match. + if restored, ok := p.namesRestoreIdx[name]; ok { + return restored, value + } + // No exact name, find prefix. + prefix, remainder, found := strings.Cut(name, "/") + if !found { + return name, value + } + if restoredPrefix, ok := p.prefixRestoreIdx[prefix]; ok { + return restoredPrefix + "/" + remainder, value + } + return name, value +} + +// isOriginal returns true if label should be renamed. +func (p *PrefixedNameRewriter) isOriginal(name, value string) bool { + if value != "" { + // Label is "original" if there is rule for renaming name and value. + idxKey := joinKV(name, value) + if _, ok := p.namesRenameIdx[idxKey]; ok { + return true + } + } + + // Try to find rule for exact name match. + if _, ok := p.namesRenameIdx[name]; ok { + return true + } + // No exact name, find rule for prefix. + prefix, _, found := strings.Cut(name, "/") + if !found { + // Label is only a name, but no rule for name found, so it is not "original". + return false + } + if _, ok := p.prefixRenameIdx[prefix]; ok { + return true + } + return false +} + +func (p *PrefixedNameRewriter) isPreserved(name string) bool { + return strings.HasPrefix(name, PreservedPrefix) +} + +func (p *PrefixedNameRewriter) preserveName(name string) string { + return PreservedPrefix + name +} + +func (p *PrefixedNameRewriter) restorePreservedName(name string) string { + return strings.TrimPrefix(name, PreservedPrefix) +} + +func indexRules(rules []MetadataReplaceRule) map[string]string { + idx := make(map[string]string, len(rules)) + for _, rule := range rules { + if rule.OriginalValue != "" && rule.RenamedValue != "" { + idxKey := joinKV(rule.Original, rule.OriginalValue) + idx[idxKey] = rule.Renamed + "=" + rule.RenamedValue + continue + } + idx[rule.Original] = rule.Renamed + } + return idx +} + +func indexRulesReverse(rules []MetadataReplaceRule) map[string]string { + idx := make(map[string]string, len(rules)) + for _, rule := range rules { + if rule.OriginalValue != "" && rule.RenamedValue != "" { + idxKey := joinKV(rule.Renamed, rule.RenamedValue) + idx[idxKey] = rule.Original + "=" + rule.OriginalValue + continue + } + idx[rule.Renamed] = rule.Original + } + return idx +} + +func joinKV(name, value string) string { + return name + "=" + value +} + +func splitKV(idxValue string) (name, value string) { + name, value, _ = strings.Cut(idxValue, "=") + return +} diff --git a/images/kube-api-rewriter/pkg/rewriter/rbac.go b/images/kube-api-rewriter/pkg/rewriter/rbac.go new file mode 100644 index 0000000..004d166 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/rbac.go @@ -0,0 +1,159 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +const ( + ClusterRoleKind = "ClusterRole" + ClusterRoleListKind = "ClusterRoleList" + RoleKind = "Role" + RoleListKind = "RoleList" + RoleBindingKind = "RoleBinding" + RoleBindingListKind = "RoleBindingList" + ControllerRevisionKind = "ControllerRevision" + ControllerRevisionListKind = "ControllerRevisionList" + ClusterRoleBindingKind = "ClusterRoleBinding" + ClusterRoleBindingListKind = "ClusterRoleBindingList" + APIServiceKind = "APIService" + APIServiceListKind = "APIServiceList" +) + +func RewriteClusterRoleOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + if action == Rename { + return RewriteResourceOrList(obj, ClusterRoleListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "rules", func(item []byte) ([]byte, error) { + return RenameResourceRule(rules, item) + }) + }) + } + return RewriteResourceOrList(obj, ClusterRoleListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "rules", func(item []byte) ([]byte, error) { + return RestoreResourceRule(rules, item) + }) + }) +} + +func RewriteRoleOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + if action == Rename { + return RewriteResourceOrList(obj, RoleListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "rules", func(item []byte) ([]byte, error) { + return RenameResourceRule(rules, item) + }) + }) + } + return RewriteResourceOrList(obj, RoleListKind, func(singleObj []byte) ([]byte, error) { + return RewriteArray(singleObj, "rules", func(item []byte) ([]byte, error) { + return RestoreResourceRule(rules, item) + }) + }) +} + +// RenameResourceRule renames apiGroups and resources in a single rule. +// Rule examples: +// - apiGroups: +// - original.group.io +// resources: +// - '*' +// verbs: +// - '*' +// - apiGroups: +// - original.group.io +// resources: +// - someresources +// - someresources/finalizers +// - someresources/status +// - someresources/scale +// verbs: +// - watch +// - list +// - create +func RenameResourceRule(rules *RewriteRules, obj []byte) ([]byte, error) { + var err error + + renameResources := false + obj, err = TransformArrayOfStrings(obj, "apiGroups", func(apiGroup string) string { + if rules.HasGroup(apiGroup) { + renameResources = true + return rules.RenameApiVersion(apiGroup) + } + if apiGroup == "*" { + renameResources = true + } + return apiGroup + }) + if err != nil { + return nil, err + } + + // Do not rename resources for unknown group. + if !renameResources { + return obj, nil + } + + return TransformArrayOfStrings(obj, "resources", func(resourceType string) string { + if resourceType == "*" || resourceType == "" { + return resourceType + } + + // Rename if there is rule for resourceType. + _, resRule := rules.GroupResourceRules(resourceType) + if resRule != nil { + return rules.RenameResource(resourceType) + } + return resourceType + }) +} + +// RestoreResourceRule restores apiGroups and resources in a single rule. +func RestoreResourceRule(rules *RewriteRules, obj []byte) ([]byte, error) { + var err error + + restoreResources := false + obj, err = TransformArrayOfStrings(obj, "apiGroups", func(apiGroup string) string { + if rules.IsRenamedGroup(apiGroup) { + restoreResources = true + return rules.RestoreApiVersion(apiGroup) + } + if apiGroup == "*" { + restoreResources = true + } + return apiGroup + }) + if err != nil { + return nil, err + } + + // Do not rename resources for unknown group. + if !restoreResources { + return obj, nil + } + + return TransformArrayOfStrings(obj, "resources", func(resourceType string) string { + if resourceType == "*" || resourceType == "" { + return resourceType + } + // Get rules for resource by restored resourceType. + originalResourceType := rules.RestoreResource(resourceType) + _, resRule := rules.GroupResourceRules(originalResourceType) + if resRule != nil { + // NOTE: subresource not trimmed. + return originalResourceType + } + + // No rules for resourceType, return as-is + return resourceType + }) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/rbac_test.go b/images/kube-api-rewriter/pkg/rewriter/rbac_test.go new file mode 100644 index 0000000..9075b32 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/rbac_test.go @@ -0,0 +1,184 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenameRoleRule(t *testing.T) { + + tests := []struct { + name string + rule string + expect string + }{ + { + "group and resources", + `{"apiGroups":["original.group.io"], +"resources": ["someresources","someresources/finalizers","someresources/status"], +"verbs": ["watch", "list", "create"] +}`, + `{"apiGroups":["prefixed.resources.group.io"], +"resources": ["prefixedsomeresources","prefixedsomeresources/finalizers","prefixedsomeresources/status"], +"verbs": ["watch", "list", "create"] +}`, + }, + { + "only resources", + `{"apiGroups":["*"], +"resources": ["someresources","someresources/finalizers","someresources/status"], +"verbs": ["watch", "list", "create"] +}`, + `{"apiGroups":["*"], +"resources": ["prefixedsomeresources","prefixedsomeresources/finalizers","prefixedsomeresources/status"], +"verbs": ["watch", "list", "create"] +}`, + }, + { + "only group", + `{"apiGroups":["original.group.io"], +"resources": ["*"], +"verbs": ["watch", "list", "create"] +}`, + `{"apiGroups":["prefixed.resources.group.io"], +"resources": ["*"], +"verbs": ["watch", "list", "create"] +}`, + }, + { + "several groups", + `{"apiGroups":["original.group.io","other.group.io"], +"resources": ["*"], +"verbs": ["watch", "list", "create"] +}`, + `{"apiGroups":["prefixed.resources.group.io","other.prefixed.resources.group.io"], +"resources": ["*"], +"verbs": ["watch", "list", "create"] +}`, + }, + { + "allow all", + `{"apiGroups":["*"], "resources":["*"], "verbs":["*"]}`, + `{"apiGroups":["*"], "resources":["*"], "verbs":["*"]}`, + }, + { + "unknown group", + `{"apiGroups":["unknown.group.io"], "resources":["someresources"], "verbs":["*"]}`, + `{"apiGroups":["unknown.group.io"], "resources":["someresources"], "verbs":["*"]}`, + }, + { + "core resource", + `{"apiGroups":[""], "resources":["pods"], "verbs":["create"]}`, + `{"apiGroups":[""], "resources":["pods"], "verbs":["create"]}`, + }, + } + + rwr := createTestRewriter() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resBytes, err := RenameResourceRule(rwr.Rules, []byte(tt.rule)) + require.NoError(t, err, "should rename rule") + + actual := string(resBytes) + require.Equal(t, tt.expect, actual) + }) + } +} + +func TestRestoreRoleRule(t *testing.T) { + tests := []struct { + name string + rule string + expect string + }{ + { + "group and resources", + `{"apiGroups":["prefixed.resources.group.io"], +"resources": ["prefixedsomeresources","prefixedsomeresources/finalizers","prefixedsomeresources/status"], +"verbs": ["watch", "list", "create"] +}`, + `{"apiGroups":["original.group.io"], +"resources": ["someresources","someresources/finalizers","someresources/status"], +"verbs": ["watch", "list", "create"] +}`, + }, + { + "only resources", + `{"apiGroups":["*"], +"resources": ["prefixedsomeresources","prefixedsomeresources/finalizers","prefixedsomeresources/status"], +"verbs": ["watch", "list", "create"] +}`, + `{"apiGroups":["*"], +"resources": ["someresources","someresources/finalizers","someresources/status"], +"verbs": ["watch", "list", "create"] +}`, + }, + { + "only group", + `{"apiGroups":["prefixed.resources.group.io"], + "resources": ["*"], + "verbs": ["watch", "list", "create"] + }`, + `{"apiGroups":["original.group.io"], + "resources": ["*"], + "verbs": ["watch", "list", "create"] + }`, + }, + { + "several groups", + `{"apiGroups":["prefixed.resources.group.io","other.prefixed.resources.group.io"], + "resources": ["*"], + "verbs": ["watch", "list", "create"] + }`, + `{"apiGroups":["original.group.io","other.group.io"], + "resources": ["*"], + "verbs": ["watch", "list", "create"] + }`, + }, + { + "allow all", + `{"apiGroups":["*"], "resources":["*"], "verbs":["*"]}`, + `{"apiGroups":["*"], "resources":["*"], "verbs":["*"]}`, + }, + { + "unknown group", + `{"apiGroups":["unknown.group.io"], "resources":["someresources"], "verbs":["*"]}`, + `{"apiGroups":["unknown.group.io"], "resources":["someresources"], "verbs":["*"]}`, + }, + { + "core resource", + `{"apiGroups":[""], "resources":["pods","configmaps"], "verbs":["create"]}`, + `{"apiGroups":[""], "resources":["pods","configmaps"], "verbs":["create"]}`, + }, + } + + rwr := createTestRewriter() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resBytes, err := RestoreResourceRule(rwr.Rules, []byte(tt.rule)) + require.NoError(t, err, "should rename rule") + + actual := string(resBytes) + require.Equal(t, tt.expect, actual) + }) + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/resource.go b/images/kube-api-rewriter/pkg/rewriter/resource.go new file mode 100644 index 0000000..50693bb --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/resource.go @@ -0,0 +1,165 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func RewriteCustomResourceOrList(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + kind := gjson.GetBytes(obj, "kind").String() + if action == Restore { + kind = rules.RestoreKind(kind) + } + origGroupName, origResName, isList := rules.ResourceByKind(kind) + if origGroupName == "" && origResName == "" { + // Return as-is if kind is not in rules. + return obj, nil + } + if isList { + if action == Restore { + return RestoreResourcesList(rules, obj) + } + + return RenameResourcesList(rules, obj) + } + + // Responses of GET, LIST, DELETE requests. + // AdmissionReview requests from API Server. + if action == Restore { + return RestoreResource(rules, obj) + } + // CREATE, UPDATE, PATCH requests. + // TODO need to implement for + return RenameResource(rules, obj) +} + +func RenameResourcesList(rules *RewriteRules, obj []byte) ([]byte, error) { + obj, err := RenameAPIVersionAndKind(rules, obj) + if err != nil { + return nil, err + } + + // Rewrite apiVersion and kind in each item. + return RewriteArray(obj, "items", func(singleResource []byte) ([]byte, error) { + return RenameResource(rules, singleResource) + }) +} + +func RestoreResourcesList(rules *RewriteRules, obj []byte) ([]byte, error) { + obj, err := RestoreAPIVersionAndKind(rules, obj) + if err != nil { + return nil, err + } + + // Restore apiVersion and kind in each item. + return RewriteArray(obj, "items", func(singleResource []byte) ([]byte, error) { + return RestoreResource(rules, singleResource) + }) +} + +func RenameResource(rules *RewriteRules, obj []byte) ([]byte, error) { + obj, err := RenameAPIVersionAndKind(rules, obj) + if err != nil { + return nil, err + } + + // Rewrite apiVersion in each managedFields. + return RenameManagedFields(rules, obj) +} + +func RestoreResource(rules *RewriteRules, obj []byte) ([]byte, error) { + obj, err := RestoreAPIVersionAndKind(rules, obj) + if err != nil { + return nil, err + } + + // Rewrite apiVersion in each managedFields. + return RestoreManagedFields(rules, obj) +} + +func RenameAPIVersionAndKind(rules *RewriteRules, obj []byte) ([]byte, error) { + apiVersion := gjson.GetBytes(obj, "apiVersion").String() + obj, err := sjson.SetBytes(obj, "apiVersion", rules.RenameApiVersion(apiVersion)) + if err != nil { + return nil, err + } + + kind := gjson.GetBytes(obj, "kind").String() + return sjson.SetBytes(obj, "kind", rules.RenameKind(kind)) +} + +func RestoreAPIVersionAndKind(rules *RewriteRules, obj []byte) ([]byte, error) { + apiVersion := gjson.GetBytes(obj, "apiVersion").String() + apiVersion = rules.RestoreApiVersion(apiVersion) + obj, err := sjson.SetBytes(obj, "apiVersion", apiVersion) + if err != nil { + return nil, err + } + + kind := gjson.GetBytes(obj, "kind").String() + return sjson.SetBytes(obj, "kind", rules.RestoreKind(kind)) +} + +func RewriteOwnerReferences(rules *RewriteRules, obj []byte, path string, action Action) ([]byte, error) { + return RewriteArray(obj, path, func(ownerRefObj []byte) ([]byte, error) { + return RewriteAPIVersionAndKind(rules, ownerRefObj, action) + }) +} + +// RestoreManagedFields restores apiVersion in managedFields items. +// +// Example response from the server: +// +// "metadata": { +// "managedFields":[ +// { "apiVersion":"renamed.resource.group.io/v1", "fieldsType":"FieldsV1", "fieldsV1":{ ... }}, "manager": "Go-http-client", ...}, +// { "apiVersion":"renamed.resource.group.io/v1", "fieldsType":"FieldsV1", "fieldsV1":{ ... }}, "manager": "kubectl-edit", ...} +// ], +func RestoreManagedFields(rules *RewriteRules, obj []byte) ([]byte, error) { + return RewriteArray(obj, "metadata.managedFields", func(managedField []byte) ([]byte, error) { + return TransformString(managedField, "apiVersion", func(apiVersion string) string { + return rules.RestoreApiVersion(apiVersion) + }) + }) +} + +// RenameManagedFields renames apiVersion in managedFields items. +// +// Example request from the client: +// +// "metadata": { +// "managedFields":[ +// { "apiVersion":"original.group.io/v1", "fieldsType":"FieldsV1", "fieldsV1":{ ... }}, "manager": "Go-http-client", ...}, +// { "apiVersion":"original.group.io/v1", "fieldsType":"FieldsV1", "fieldsV1":{ ... }}, "manager": "kubectl-edit", ...} +// ], +func RenameManagedFields(rules *RewriteRules, obj []byte) ([]byte, error) { + return RewriteArray(obj, "metadata.managedFields", func(managedField []byte) ([]byte, error) { + return TransformString(managedField, "apiVersion", func(apiVersion string) string { + return rules.RenameApiVersion(apiVersion) + }) + }) +} + +func RenameResourcePatch(rules *RewriteRules, patch []byte) ([]byte, error) { + patch, err := RewritePatchSourceRefs(rules, patch) + if err != nil { + return nil, err + } + return RenameMetadataPatch(rules, patch) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/resource_test.go b/images/kube-api-rewriter/pkg/rewriter/resource_test.go new file mode 100644 index 0000000..696717f --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/resource_test.go @@ -0,0 +1,383 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bufio" + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestRewriteMetadata(t *testing.T) { + tests := []struct { + name string + obj client.Object + newObj client.Object + action Action + expectLabels map[string]string + expectAnnotations map[string]string + }{ + { + "rename labels on Pod", + &corev1.Pod{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pod", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "bar", + Labels: map[string]string{ + "labelgroup.io": "labelvalue", + "component.labelgroup.io/labelkey": "labelvalue", + }, + Annotations: map[string]string{ + "annogroup.io": "annovalue", + }, + }, + }, + &corev1.Pod{}, + Rename, + map[string]string{ + "replacedlabelgroup.io": "labelvalue", + "component.replacedlabelgroup.io/labelkey": "labelvalue", + }, + map[string]string{ + "replacedanno.io": "annovalue", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NotNil(t, tt.obj, "should not be nil") + + rwr := createTestRewriter() + bytes, err := json.Marshal(tt.obj) + require.NoError(t, err, "should marshal object %q %s/%s", tt.obj.GetObjectKind().GroupVersionKind().Kind, tt.obj.GetName(), tt.obj.GetNamespace()) + + rwBytes, err := TransformObject(bytes, "metadata", func(metadataObj []byte) ([]byte, error) { + return RewriteMetadata(rwr.Rules, metadataObj, tt.action) + }) + require.NoError(t, err, "should rewrite object") + + err = json.Unmarshal(rwBytes, &tt.newObj) + + require.NoError(t, err, "should unmarshal object") + + require.Equal(t, tt.expectLabels, tt.newObj.GetLabels(), "expect rewrite labels '%v' to be '%s', got '%s'", tt.obj.GetLabels(), tt.expectLabels, tt.newObj.GetLabels()) + require.Equal(t, tt.expectAnnotations, tt.newObj.GetAnnotations(), "expect rewrite annotations '%v' to be '%s', got '%s'", tt.obj.GetAnnotations(), tt.expectAnnotations, tt.newObj.GetAnnotations()) + }) + } +} + +func TestRestoreKnownCustomResourceList(t *testing.T) { + listKnownCR := `GET /apis/original.group.io/v1/someresources HTTP/1.1 +Host: 127.0.0.1 + +` + responseBody := `{ +"kind":"PrefixedSomeResourceList", +"apiVersion":"prefixed.resources.group.io/v1", +"metadata":{"resourceVersion":"412742959"}, +"items":[ + { + "metadata": { + "name": "resource-name", + "namespace": "ns-name", + "labels": { + "component.replacedlabelgroup.io/labelName": "labelValue" + }, + "annotations":{ + "replacedanno.io": "annoValue" + }, + "ownerReferences": [ + { + "apiVersion": "prefixed.resources.group.io/v1", + "kind": "PrefixedSomeResource", + "name": "owner-name", + "uid": "30b43f23-0c36-442f-897f-fececdf54620", + "controller": true, + "blockOwnerDeletion": true + }, + { + "apiVersion": "other.product.group.io/v1alpha1", + "kind": "SomeResource", + "name": "another-owner-name", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "data": {"somekey":"somevalue"} + } +]}` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(listKnownCR))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriter() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + require.Equal(t, "original.group.io", targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(responseBody), Restore) + if err != nil { + t.Fatalf("should restore RevisionControllerList without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should restore RevisionControllerList: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`kind`, "SomeResourceList"}, + {`items.0.metadata.labels.component\.replacedlabelgroup\.io/labelName`, ""}, + {`items.0.metadata.labels.component\.labelgroup\.io/labelName`, "labelValue"}, + {`items.0.metadata.annotations.replacedanno\.io`, ""}, + {`items.0.metadata.annotations.annogroup\.io`, "annoValue"}, + {`items.0.metadata.ownerReferences.0.apiVersion`, "original.group.io/v1"}, + {`items.0.metadata.ownerReferences.0.kind`, "SomeResource"}, + // "other.progduct.group.io" is not known for rules, this ownerRef should not be rewritten. + {`items.0.metadata.ownerReferences.1.apiVersion`, "other.product.group.io/v1alpha1"}, + {`items.0.metadata.ownerReferences.1.kind`, "SomeResource"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Log(string(resultBytes)) + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } +} + +// TODO this rewrite will be enabled later. Uncomment TestRestoreUnknownCustomResourceListWithKnownKind after enabling. +func TestNoRewriteForUnknownCustomResourceListWithKnownKind(t *testing.T) { + // Request list of resources with known kind but with unknown apiGroup. + // Check that RestoreResourceList will not rewrite apiVersion. + listUnknownCR := `GET /apis/other.product.group.io/v1alpha1/someresources HTTP/1.1 +Host: 127.0.0.1 + +` + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(listUnknownCR))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriter() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + + require.False(t, targetReq.ShouldRewriteRequest(), "should not rewrite request") + require.False(t, targetReq.ShouldRewriteResponse(), "should not rewrite response") +} + +// TODO Uncomment after enabling rewrite detection by apiVersion/kind for all resources. +/* +func TestRestoreUnknownCustomResourceListWithKnownKind(t *testing.T) { + // Request list of resources with known kind but with unknown apiGroup. + // Check that RestoreResourceList will not rewrite apiVersion. + listUnknownCR := `GET /apis/other.product.group.io/v1alpha1/someresources HTTP/1.1 +Host: 127.0.0.1 + +` + responseBody := `{ +"kind":"SomeResourceList", +"apiVersion":"other.product.group.io/v1alpha1", +"metadata":{"resourceVersion":"412742959"}, +"items":[ + { + "metadata": { + "name": "resource-name", + "namespace": "ns-name", + "labels": { + "component.replacedlabelgroup.io/labelName": "labelValue" + }, + "annotations":{ + "replacedanno.io": "annoValue" + }, + "ownerReferences": [ + { + "apiVersion": "prefixed.resources.group.io/v1", + "kind": "PrefixedSomeResource", + "name": "owner-name", + "uid": "30b43f23-0c36-442f-897f-fececdf54620", + "controller": true, + "blockOwnerDeletion": true + }, + { + "apiVersion": "other.product.group.io/v1alpha1", + "kind": "SomeResource", + "name": "another-owner-name", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "data": {"somekey":"somevalue"} + } +]}` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(listUnknownCR))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriter() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + + require.False(t, targetReq.ShouldRewriteRequest(), "should not rewrite request") + require.False(t, targetReq.ShouldRewriteResponse(), "should not rewrite response") + + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + require.Equal(t, "original.group.io", targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(responseBody), Restore) + if err != nil { + t.Fatalf("should restore RevisionControllerList without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should restore RevisionControllerList: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`kind`, "SomeResourceList"}, + {`apiVersion`, "other.product.group.io/v1alpha1"}, + {`items.0.metadata.labels.component\.replacedlabelgroup\.io/labelName`, ""}, + {`items.0.metadata.labels.component\.labelgroup\.io/labelName`, "labelValue"}, + {`items.0.metadata.annotations.replacedanno\.io`, ""}, + {`items.0.metadata.annotations.annogroup\.io`, "annoValue"}, + {`items.0.metadata.ownerReferences.0.apiVersion`, "original.group.io/v1"}, + {`items.0.metadata.ownerReferences.0.kind`, "SomeResource"}, + // "other.progduct.group.io" is not known for rules, this ownerRef should not be rewritten. + {`items.0.metadata.ownerReferences.1.apiVersion`, "other.product.group.io/v1alpha1"}, + {`items.0.metadata.ownerReferences.1.kind`, "SomeResource"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Log(string(resultBytes)) + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } +} +*/ + +func TestRenameKnownCustomResource(t *testing.T) { + postControllerRevision := `POST /apis/original.group.io/v1/someresources/namespaces/ns-name/resource-name HTTP/1.1 +Host: 127.0.0.1 + +` + requestBody := `{ +"kind":"SomeResource", +"apiVersion":"original.group.io/v1", +"metadata": { + "name": "resource-name", + "namespace": "ns-name", + "labels": { + "component.labelgroup.io/labelName": "labelValue" + }, + "annotations":{ + "annogroup.io": "annoValue" + }, + "ownerReferences": [ + { + "apiVersion": "original.group.io/v1", + "kind": "SomeResource", + "name": "owner-name", + "uid": "30b43f23-0c36-442f-897f-fececdf54620", + "controller": true, + "blockOwnerDeletion": true + }, + { + "apiVersion": "other.product.group.io/v1alpha1", + "kind": "SomeResource", + "name": "another-owner-name", + "controller": true, + "blockOwnerDeletion": true + } + ] +}, +"data": {"somekey":"somevalue"} +}` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(postControllerRevision + requestBody))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriter() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(requestBody), Rename) + if err != nil { + t.Fatalf("should rename SomeResource without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should rename SomeResource: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`kind`, "PrefixedSomeResource"}, + {`metadata.labels.component\.replacedlabelgroup\.io/labelName`, "labelValue"}, + {`metadata.labels.component\.labelgroup\.io/labelName`, ""}, + {`metadata.annotations.replacedanno\.io`, "annoValue"}, + {`metadata.annotations.annogroup\.io`, ""}, + {`metadata.ownerReferences.0.apiVersion`, "prefixed.resources.group.io/v1"}, + {`metadata.ownerReferences.0.kind`, "PrefixedSomeResource"}, + // "other.progduct.group.io" is not known for rules, this ownerRef should not be rewritten. + {`metadata.ownerReferences.1.apiVersion`, "other.product.group.io/v1alpha1"}, + {`metadata.ownerReferences.1.kind`, "SomeResource"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Log(string(resultBytes)) + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/rule_rewriter.go b/images/kube-api-rewriter/pkg/rewriter/rule_rewriter.go new file mode 100644 index 0000000..e9bd4be --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/rule_rewriter.go @@ -0,0 +1,431 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "net/url" + "regexp" + "strings" + + "github.com/tidwall/gjson" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type RuleBasedRewriter struct { + Rules *RewriteRules +} + +type Action string + +const ( + // Restore is an action to restore resources to original. + Restore Action = "restore" + // Rename is an action to rename original resources. + Rename Action = "rename" +) + +// RewriteAPIEndpoint renames group and resource in /apis/* endpoints. +// It assumes that ep contains original group and resourceType. +// Restoring of path is not implemented. +func (rw *RuleBasedRewriter) RewriteAPIEndpoint(ep *APIEndpoint) *APIEndpoint { + var rwrEndpoint *APIEndpoint + + switch { + case ep.IsRoot || ep.IsCore || ep.IsUnknown: + // Leave paths /, /api, /api/*, and unknown paths as is. + case ep.IsCRD: + // Rename CRD name resourcetype.group for resources with rules. + rwrEndpoint = rw.rewriteCRDEndpoint(ep.Clone()) + default: + // Rewrite group and resourceType parts for resources with rules. + rwrEndpoint = rw.rewriteCRApiEndpoint(ep.Clone()) + } + + rewritten := rwrEndpoint != nil + + if rwrEndpoint == nil { + rwrEndpoint = ep.Clone() + } + + // Rewrite key and values if query has labelSelector. + if strings.Contains(ep.RawQuery, "labelSelector") { + newRawQuery := rw.rewriteLabelSelector(rwrEndpoint.RawQuery) + if newRawQuery != rwrEndpoint.RawQuery { + rewritten = true + rwrEndpoint.RawQuery = newRawQuery + } + } + + if rewritten { + return rwrEndpoint + } + + return nil +} + +func (rw *RuleBasedRewriter) rewriteCRDEndpoint(ep *APIEndpoint) *APIEndpoint { + // Rewrite fieldSelector if CRD list is requested. + if ep.CRDGroup == "" && ep.CRDResourceType == "" { + if strings.Contains(ep.RawQuery, "metadata.name") { + // Rewrite name in field selector if any. + newQuery := rw.rewriteFieldSelector(ep.RawQuery) + if newQuery != "" { + res := ep.Clone() + res.RawQuery = newQuery + return res + } + } + return nil + } + + // Check if resource has rules + _, resourceRule := rw.Rules.ResourceRules(ep.CRDGroup, ep.CRDResourceType) + if resourceRule == nil { + // No rewrite for CRD without rules. + return nil + } + // Rewrite group and resourceType in CRD name. + res := ep.Clone() + res.CRDGroup = rw.Rules.RenameApiVersion(ep.CRDGroup) + res.CRDResourceType = rw.Rules.RenameResource(res.CRDResourceType) + res.Name = res.CRDResourceType + "." + res.CRDGroup + return res +} + +func (rw *RuleBasedRewriter) rewriteCRApiEndpoint(ep *APIEndpoint) *APIEndpoint { + // Early return if request has no group, e.g. discovery. + if ep.Group == "" { + return nil + } + + // Rename group and resource for CR requests. + // Check if group has rules. Return early if not. + groupRule := rw.Rules.GroupRule(ep.Group) + if groupRule == nil { + // No group and resourceType rewrite for group without rules. + return nil + } + newGroup := rw.Rules.RenameApiVersion(ep.Group) + + // Shortcut: return clone if only group is requested. + newResource := "" + if ep.ResourceType != "" { + _, resRule := rw.Rules.ResourceRules(ep.Group, ep.ResourceType) + if resRule == nil { + // No group and resourceType rewrite for resourceType without rules. + return nil + } + newResource = rw.Rules.RenameResource(ep.ResourceType) + } + + // Return rewritten endpoint if group or resource are changed. + if newGroup != "" || newResource != "" { + res := ep.Clone() + if newGroup != "" { + res.Group = newGroup + } + if newResource != "" { + res.ResourceType = newResource + } + + return res + } + + return nil +} + +var metadataNameRe = regexp.MustCompile(`metadata.name\%3D([a-z0-9-]+)((\.[a-z0-9-]+)*)`) + +// rewriteFieldSelector rewrites value for metadata.name in fieldSelector of CRDs listing. +// Example request: +// https://APISERVER/apis/apiextensions.k8s.io/v1/customresourcedefinitions?fieldSelector=metadata.name%3Dresources.original.group.io&... +func (rw *RuleBasedRewriter) rewriteFieldSelector(rawQuery string) string { + matches := metadataNameRe.FindStringSubmatch(rawQuery) + if matches == nil { + return "" + } + + resourceType := matches[1] + group := matches[2] + group = strings.TrimPrefix(group, ".") + + _, resRule := rw.Rules.ResourceRules(group, resourceType) + if resRule == nil { + return "" + } + + group = rw.Rules.RenameApiVersion(group) + resourceType = rw.Rules.RenameResource(resourceType) + + newSelector := `metadata.name%3D` + resourceType + "." + group + + return metadataNameRe.ReplaceAllString(rawQuery, newSelector) +} + +// rewriteLabelSelector rewrites labels in labelSelector +// Example request: +// https:///apis/apps/v1/namespaces//deployments?labelSelector=app%3Dsomething +func (rw *RuleBasedRewriter) rewriteLabelSelector(rawQuery string) string { + q, err := url.ParseQuery(rawQuery) + if err != nil { + return rawQuery + } + lsq := q.Get("labelSelector") + if lsq == "" { + return rawQuery + } + + labelSelector, err := metav1.ParseToLabelSelector(lsq) + if err != nil { + // The labelSelector is not well-formed. We pass it through, so + // API Server will return an error. + return rawQuery + } + + // Return early if labelSelector is empty, e.g. ?labelSelector=&limit=500 + if labelSelector == nil { + return rawQuery + } + + rwrMatchLabels := rw.Rules.LabelsRewriter().RenameMap(labelSelector.MatchLabels) + + rwrMatchExpressions := make([]metav1.LabelSelectorRequirement, 0) + for _, expr := range labelSelector.MatchExpressions { + rwrExpr := expr + rwrExpr.Key, rwrExpr.Values = rw.Rules.LabelsRewriter().RewriteNameValues(rwrExpr.Key, rwrExpr.Values, Rename) + rwrMatchExpressions = append(rwrMatchExpressions, rwrExpr) + } + + rwrLabelSelector := &metav1.LabelSelector{ + MatchLabels: rwrMatchLabels, + MatchExpressions: rwrMatchExpressions, + } + + res, err := metav1.LabelSelectorAsSelector(rwrLabelSelector) + if err != nil { + return rawQuery + } + + q.Set("labelSelector", res.String()) + return q.Encode() +} + +// RewriteJSONPayload does rewrite based on kind. +// TODO(future refactor): Remove targetReq in all callers. +func (rw *RuleBasedRewriter) RewriteJSONPayload(_ *TargetRequest, obj []byte, action Action) ([]byte, error) { + // Detect Kind + kind := gjson.GetBytes(obj, "kind").String() + + var rwrBytes []byte + var err error + + obj, err = rw.FilterExcludes(obj, action) + if err != nil { + return obj, err + } + + switch kind { + case "APIGroupList": + rwrBytes, err = RewriteAPIGroupList(rw.Rules, obj) + + case "APIGroup": + rwrBytes, err = RewriteAPIGroup(rw.Rules, obj) + + case "APIResourceList": + rwrBytes, err = RewriteAPIResourceList(rw.Rules, obj) + + case "APIGroupDiscoveryList": + rwrBytes, err = RewriteAPIGroupDiscoveryList(rw.Rules, obj) + + case "AdmissionReview": + rwrBytes, err = RewriteAdmissionReview(rw.Rules, obj) + + case CRDKind, CRDListKind: + rwrBytes, err = RewriteCRDOrList(rw.Rules, obj, action) + + case MutatingWebhookConfigurationKind, + MutatingWebhookConfigurationListKind: + rwrBytes, err = RewriteMutatingOrList(rw.Rules, obj, action) + + case ValidatingWebhookConfigurationKind, + ValidatingWebhookConfigurationListKind: + rwrBytes, err = RewriteValidatingOrList(rw.Rules, obj, action) + + case EventKind, EventListKind: + rwrBytes, err = RewriteEventOrList(rw.Rules, obj, action) + + case ClusterRoleKind, ClusterRoleListKind: + rwrBytes, err = RewriteClusterRoleOrList(rw.Rules, obj, action) + + case RoleKind, RoleListKind: + rwrBytes, err = RewriteRoleOrList(rw.Rules, obj, action) + case DeploymentKind, DeploymentListKind: + rwrBytes, err = RewriteDeploymentOrList(rw.Rules, obj, action) + case StatefulSetKind, StatefulSetListKind: + rwrBytes, err = RewriteStatefulSetOrList(rw.Rules, obj, action) + case DaemonSetKind, DaemonSetListKind: + rwrBytes, err = RewriteDaemonSetOrList(rw.Rules, obj, action) + case PodKind, PodListKind: + rwrBytes, err = RewritePodOrList(rw.Rules, obj, action) + case PodDisruptionBudgetKind, PodDisruptionBudgetListKind: + rwrBytes, err = RewritePDBOrList(rw.Rules, obj, action) + case JobKind, JobListKind: + rwrBytes, err = RewriteJobOrList(rw.Rules, obj, action) + case ServiceKind, ServiceListKind: + rwrBytes, err = RewriteServiceOrList(rw.Rules, obj, action) + case PersistentVolumeClaimKind, PersistentVolumeClaimListKind: + rwrBytes, err = RewritePVCOrList(rw.Rules, obj, action) + + case ServiceMonitorKind, ServiceMonitorListKind: + rwrBytes, err = RewriteServiceMonitorOrList(rw.Rules, obj, action) + + case ValidatingAdmissionPolicyBindingKind, ValidatingAdmissionPolicyBindingListKind: + rwrBytes, err = RewriteValidatingAdmissionPolicyBindingOrList(rw.Rules, obj, action) + case ValidatingAdmissionPolicyKind, ValidatingAdmissionPolicyListKind: + rwrBytes, err = RewriteValidatingAdmissionPolicyOrList(rw.Rules, obj, action) + default: + // TODO Add rw.Rules.IsKnownKind() to rewrite only known kinds. + rwrBytes, err = RewriteCustomResourceOrList(rw.Rules, obj, action) + } + // Return obj bytes as-is in case of the error. + if err != nil { + return obj, err + } + + // Always rewrite metadata: labels, annotations, finalizers, ownerReferences. + // Also rewrite spec-level kind references (e.g. spec.sourceRef.kind in HelmChart). + // TODO: add rewriter for managedFields. + return RewriteResourceOrList2(rwrBytes, func(singleObj []byte) ([]byte, error) { + singleObj, err = RewriteSpecKindRefs(rw.Rules, singleObj, action) + if err != nil { + return nil, err + } + return TransformObject(singleObj, "metadata", func(metadataObj []byte) ([]byte, error) { + return RewriteMetadata(rw.Rules, metadataObj, action) + }) + }) +} + +// RestoreBookmark restores apiVersion and kind in an object in WatchEvent with type BOOKMARK. Bookmark is not a full object, so RewriteJSONPayload may add unexpected fields. +// Bookmark example: {"kind":"ConfigMap","apiVersion":"v1","metadata":{"resourceVersion":"438083871","creationTimestamp":null}} +func (rw *RuleBasedRewriter) RestoreBookmark(targetReq *TargetRequest, obj []byte) ([]byte, error) { + return RestoreAPIVersionAndKind(rw.Rules, obj) +} + +// RewritePatch rewrites patches for some known objects. +// Only rename action is required for patches. +func (rw *RuleBasedRewriter) RewritePatch(targetReq *TargetRequest, patchBytes []byte) ([]byte, error) { + _, resRule := rw.Rules.ResourceRules(targetReq.OrigGroup(), targetReq.OrigResourceType()) + if resRule != nil { + if targetReq.IsCRD() { + return RenameCRDPatch(rw.Rules, resRule, patchBytes) + } + return RenameResourcePatch(rw.Rules, patchBytes) + } + + switch targetReq.OrigResourceType() { + case "services": + return RenameServicePatch(rw.Rules, patchBytes) + case "deployments", + "daemonsets", + "statefulsets": + return RenameSpecTemplatePatch(rw.Rules, patchBytes) + case "validatingwebhookconfigurations", + "mutatingwebhookconfigurations": + return RenameWebhookConfigurationPatch(rw.Rules, patchBytes) + } + + return RenameMetadataPatch(rw.Rules, patchBytes) +} + +// FilterExcludes removes excluded resources from the list or return SkipItem if resource itself is excluded. +func (rw *RuleBasedRewriter) FilterExcludes(obj []byte, action Action) ([]byte, error) { + if action != Restore { + return obj, nil + } + + kind := gjson.GetBytes(obj, "kind").String() + if !isExcludableKind(kind) { + return obj, nil + } + + if rw.Rules.ShouldExclude(obj, kind) { + return obj, SkipItem + } + + // Also check each item if obj is List + if !strings.HasSuffix(kind, "List") { + return obj, nil + } + + singleKind := strings.TrimSuffix(kind, "List") + obj, err := RewriteResourceOrList2(obj, func(singleObj []byte) ([]byte, error) { + if rw.Rules.ShouldExclude(singleObj, singleKind) { + return nil, SkipItem + } + return nil, nil + }) + if err != nil { + return obj, err + } + return obj, nil +} + +func shouldRewriteOwnerReferences(resourceType string) bool { + switch resourceType { + case CRDKind, CRDListKind, + RoleKind, RoleListKind, + RoleBindingKind, RoleBindingListKind, + PodDisruptionBudgetKind, PodDisruptionBudgetListKind, + ControllerRevisionKind, ControllerRevisionListKind, + ClusterRoleKind, ClusterRoleListKind, + ClusterRoleBindingKind, ClusterRoleBindingListKind, + APIServiceKind, APIServiceListKind, + DeploymentKind, DeploymentListKind, + DaemonSetKind, DaemonSetListKind, + StatefulSetKind, StatefulSetListKind, + PodKind, PodListKind, + JobKind, JobListKind, + ValidatingWebhookConfigurationKind, + ValidatingWebhookConfigurationListKind, + MutatingWebhookConfigurationKind, + MutatingWebhookConfigurationListKind, + ServiceKind, ServiceListKind, + PersistentVolumeClaimKind, PersistentVolumeClaimListKind, + PrometheusRuleKind, PrometheusRuleListKind, + ServiceMonitorKind, ServiceMonitorListKind: + return true + } + + return false +} + +// isExcludeKind returns true if kind may be excluded from rewriting. +// Discovery kinds and AdmissionReview have special schemas, it is sane to +// exclude resources in particular rewriters. +func isExcludableKind(kind string) bool { + switch kind { + case "APIGroupList", + "APIGroup", + "APIResourceList", + "APIGroupDiscoveryList", + "AdmissionReview": + return false + } + + return true +} diff --git a/images/kube-api-rewriter/pkg/rewriter/rule_rewriter_test.go b/images/kube-api-rewriter/pkg/rewriter/rule_rewriter_test.go new file mode 100644 index 0000000..bb6502a --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/rule_rewriter_test.go @@ -0,0 +1,418 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "bufio" + "bytes" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func createTestRewriter() *RuleBasedRewriter { + apiGroupRules := map[string]APIGroupRule{ + "original.group.io": { + GroupRule: GroupRule{ + Group: "original.group.io", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Renamed: "prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "someresources": { + Kind: "SomeResource", + ListKind: "SomeResourceList", + Plural: "someresources", + Singular: "someresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + Categories: []string{"all"}, + ShortNames: []string{"sr", "srs"}, + }, + "anotherresources": { + Kind: "AnotherResource", + ListKind: "AnotherResourceList", + Plural: "anotherresources", + Singular: "anotherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"ar"}, + }, + }, + }, + "other.group.io": { + GroupRule: GroupRule{ + Group: "other.group.io", + Versions: []string{"v2alpha3"}, + PreferredVersion: "v2alpha3", + Renamed: "other.prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "otherresources": { + Kind: "OtherResource", + ListKind: "OtherResourceList", + Plural: "otherresources", + Singular: "otherresource", + Versions: []string{"v1", "v1alpha1"}, + PreferredVersion: "v1", + ShortNames: []string{"or"}, + }, + }, + }, + } + + webhookRules := map[string]WebhookRule{ + "/validate-prefixed-resources-group-io-v1-prefixedsomeresource": { + Path: "/validate-original-group-io-v1-someresource", + Group: "original.group.io", + Resource: "someresources", + }, + } + + rules := &RewriteRules{ + KindPrefix: "Prefixed", + ResourceTypePrefix: "prefixed", + ShortNamePrefix: "p", + Categories: []string{"prefixed"}, + Rules: apiGroupRules, + Webhooks: webhookRules, + Labels: MetadataReplace{ + Prefixes: []MetadataReplaceRule{ + {Original: "labelgroup.io", Renamed: "replacedlabelgroup.io"}, + {Original: "component.labelgroup.io", Renamed: "component.replacedlabelgroup.io"}, + }, + Names: []MetadataReplaceRule{ + {Original: "labelgroup.io", Renamed: "replacedlabelgroup.io"}, + { + Original: "labelgroup.io", OriginalValue: "labelValueToRename", + Renamed: "replacedlabelgroup.io", RenamedValue: "renamedLabelValue", + }, + }, + }, + Annotations: MetadataReplace{ + Names: []MetadataReplaceRule{ + {Original: "annogroup.io", Renamed: "replacedanno.io"}, + }, + }, + } + rules.Init() + return &RuleBasedRewriter{ + Rules: rules, + } +} + +func TestRewriteAPIEndpoint(t *testing.T) { + tests := []struct { + name string + path string + expectPath string + expectQuery string + }{ + { + "rewritable group", + "/apis/original.group.io", + "/apis/prefixed.resources.group.io", + "", + }, + { + "rewritable group and version", + "/apis/original.group.io/v1", + "/apis/prefixed.resources.group.io/v1", + "", + }, + { + "rewritable resource list", + "/apis/original.group.io/v1/someresources", + "/apis/prefixed.resources.group.io/v1/prefixedsomeresources", + "", + }, + { + "rewritable resource by name", + "/apis/original.group.io/v1/someresources/srname", + "/apis/prefixed.resources.group.io/v1/prefixedsomeresources/srname", + "", + }, + { + "rewritable resource status", + "/apis/original.group.io/v1/someresources/srname/status", + "/apis/prefixed.resources.group.io/v1/prefixedsomeresources/srname/status", + "", + }, + { + "rewritable CRD", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/someresources.original.group.io", + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/prefixedsomeresources.prefixed.resources.group.io", + "", + }, + { + "labelSelector one label name", + "/api/v1/namespaces/nsname/pods?labelSelector=labelgroup.io&limit=0", + "/api/v1/namespaces/nsname/pods", + "labelSelector=replacedlabelgroup.io&limit=0", + }, + { + "labelSelector one prefixed label", + "/api/v1/pods?labelSelector=labelgroup.io%2Fsome-attr&limit=500", + "/api/v1/pods", + "labelSelector=replacedlabelgroup.io%2Fsome-attr&limit=500", + }, + { + "labelSelector label name and value", + "/api/v1/namespaces/d8-virtualization/pods?labelSelector=labelgroup.io%3Dlabelvalue&limit=500", + "/api/v1/namespaces/d8-virtualization/pods", + "labelSelector=replacedlabelgroup.io%3Dlabelvalue&limit=500", + }, + { + "labelSelector prefixed label and value", + "/api/v1/namespaces/d8-virtualization/pods?labelSelector=component.labelgroup.io%2Fsome-attr%3Dlabelvalue&limit=500", + "/api/v1/namespaces/d8-virtualization/pods", + "labelSelector=component.replacedlabelgroup.io%2Fsome-attr%3Dlabelvalue&limit=500", + }, + { + "labelSelector label name not in values", + "/api/v1/namespaces/d8-virtualization/pods?labelSelector=labelgroup.io+notin+%28value-one%2Cvalue-two%29&limit=500", + "/api/v1/namespaces/d8-virtualization/pods", + "labelSelector=replacedlabelgroup.io+notin+%28value-one%2Cvalue-two%29&limit=500", + }, + { + "labelSelector label name for deployments", + "/apis/apps/v1/deployments?labelSelector=labelgroup.io+notin+%28value-one%2ClabelValue%29&limit=500", + "/apis/apps/v1/deployments", + "labelSelector=replacedlabelgroup.io+notin+%28labelValue%2Cvalue-one%29&limit=500", + }, + { + "labelSelector label name and renamed value", + "/api/v1/namespaces/d8-virtualization/pods?labelSelector=labelgroup.io%3DlabelValueToRename&limit=500", + "/api/v1/namespaces/d8-virtualization/pods", + "labelSelector=replacedlabelgroup.io%3DrenamedLabelValue&limit=500", + }, + { + "labelSelector label name and renamed values", + "/api/v1/namespaces/d8-virtualization/pods?labelSelector=labelgroup.io+notin+%28value-one%2ClabelValueToRename%29&limit=500", + "/api/v1/namespaces/d8-virtualization/pods", + "labelSelector=replacedlabelgroup.io+notin+%28renamedLabelValue%2Cvalue-one%29&limit=500", + }, + { + "labelSelector label name and renamed values for deployments", + "/apis/apps/v1/deployments?labelSelector=labelgroup.io+notin+%28value-one%2ClabelValueToRename%29&limit=500", + "/apis/apps/v1/deployments", + "labelSelector=replacedlabelgroup.io+notin+%28renamedLabelValue%2Cvalue-one%29&limit=500", + }, + { + "labelSelector label name and renamed values for validating admission policy binding", + "/apis/admissionregistration.k8s.io/v1/validatingadmissionpolicybindings?labelSelector=labelgroup.io+notin+%28value-one%2ClabelValueToRename%29&limit=500", + "/apis/admissionregistration.k8s.io/v1/validatingadmissionpolicybindings", + "labelSelector=replacedlabelgroup.io+notin+%28renamedLabelValue%2Cvalue-one%29&limit=500", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.path) + require.NoError(t, err, "should parse path '%s'", tt.path) + + ep := ParseAPIEndpoint(u) + rwr := createTestRewriter() + + newEp := rwr.RewriteAPIEndpoint(ep) + + if tt.expectPath == "" { + require.Nil(t, newEp, "should not rewrite path '%s', got %+v", tt.path, newEp) + } + require.NotNil(t, newEp, "should rewrite path '%s', got nil endpoint. Original ep: %#v", tt.path, ep) + + require.Equal(t, tt.expectPath, newEp.Path(), "expect rewrite for path '%s' to be '%s', got '%s', newEp: %#v", tt.path, tt.expectPath, newEp.Path(), newEp) + require.Equal(t, tt.expectQuery, newEp.RawQuery, "expect rewrite query for path %q to be '%s', got '%s', newEp: %#v", tt.path, tt.expectQuery, newEp.RawQuery, newEp) + }) + } + +} + +func TestRestoreControllerRevisionList(t *testing.T) { + getControllerRevisions := `GET /apis/apps/v1/controllerrevisions HTTP/1.1 +Host: 127.0.0.1 + +` + responseBody := `{ +"kind":"ControllerRevisionList", +"apiVersion":"apps/v1", +"metadata":{"resourceVersion":"412742959"}, +"items":[ + { + "metadata": { + "name": "resource-name", + "namespace": "ns-name", + "labels": { + "component.replacedlabelgroup.io/labelName": "labelValue" + }, + "annotations":{ + "replacedanno.io": "annoValue" + }, + "ownerReferences": [ + { + "apiVersion": "prefixed.resources.group.io/v1", + "kind": "PrefixedSomeResource", + "name": "owner-name", + "uid": "30b43f23-0c36-442f-897f-fececdf54620", + "controller": true, + "blockOwnerDeletion": true + }, + { + "apiVersion": "other.product.group.io/v1alpha1", + "kind": "SomeResource", + "name": "another-owner-name", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "data": {"somekey":"somevalue"} + } +]}` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(getControllerRevisions))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriter() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + // require.Equal(t, origGroup, targetReq.OrigGroup(), "should set proper orig group") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(responseBody), Restore) + if err != nil { + t.Fatalf("should restore RevisionControllerList without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should restore RevisionControllerList: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`kind`, "ControllerRevisionList"}, + {`items.0.metadata.labels.component\.replacedlabelgroup\.io/labelName`, ""}, + {`items.0.metadata.labels.component\.labelgroup\.io/labelName`, "labelValue"}, + {`items.0.metadata.annotations.replacedanno\.io`, ""}, + {`items.0.metadata.annotations.annogroup\.io`, "annoValue"}, + {`items.0.metadata.ownerReferences.0.apiVersion`, "original.group.io/v1"}, + {`items.0.metadata.ownerReferences.0.kind`, "SomeResource"}, + // "other.progduct.group.io" is not known for rules, this ownerRef should not be rewritten. + {`items.0.metadata.ownerReferences.1.apiVersion`, "other.product.group.io/v1alpha1"}, + {`items.0.metadata.ownerReferences.1.kind`, "SomeResource"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Log(string(resultBytes)) + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } +} + +func TestRenameControllerRevision(t *testing.T) { + postControllerRevision := `POST /apis/apps/v1/controllerrevisions/namespaces/ns/ctrl-rev-name HTTP/1.1 +Host: 127.0.0.1 + +` + requestBody := `{ +"kind":"ControllerRevision", +"apiVersion":"apps/v1", +"metadata": { + "name": "resource-name", + "namespace": "ns-name", + "labels": { + "component.labelgroup.io/labelName": "labelValue" + }, + "annotations":{ + "annogroup.io": "annoValue" + }, + "ownerReferences": [ + { + "apiVersion": "original.group.io/v1", + "kind": "SomeResource", + "name": "owner-name", + "uid": "30b43f23-0c36-442f-897f-fececdf54620", + "controller": true, + "blockOwnerDeletion": true + }, + { + "apiVersion": "other.product.group.io/v1alpha1", + "kind": "SomeResource", + "name": "another-owner-name", + "controller": true, + "blockOwnerDeletion": true + } + ] +}, +"data": {"somekey":"somevalue"} +}` + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(postControllerRevision + requestBody))) + require.NoError(t, err, "should parse hardcoded http request") + require.NotNil(t, req.URL, "should parse url in hardcoded http request") + + rwr := createTestRewriter() + targetReq := NewTargetRequest(rwr, req) + require.NotNil(t, targetReq, "should get TargetRequest") + require.True(t, targetReq.ShouldRewriteRequest(), "should rewrite request") + require.True(t, targetReq.ShouldRewriteResponse(), "should rewrite response") + + resultBytes, err := rwr.RewriteJSONPayload(targetReq, []byte(requestBody), Rename) + if err != nil { + t.Fatalf("should rename RevisionController without error: %v", err) + } + if resultBytes == nil { + t.Fatalf("should rename RevisionController: %v", err) + } + + tests := []struct { + path string + expected string + }{ + {`kind`, "ControllerRevision"}, + {`metadata.labels.component\.replacedlabelgroup\.io/labelName`, "labelValue"}, + {`metadata.labels.component\.labelgroup\.io/labelName`, ""}, + {`metadata.annotations.replacedanno\.io`, "annoValue"}, + {`metadata.annotations.annogroup\.io`, ""}, + {`metadata.ownerReferences.0.apiVersion`, "prefixed.resources.group.io/v1"}, + {`metadata.ownerReferences.0.kind`, "PrefixedSomeResource"}, + // "other.progduct.group.io" is not known for rules, this ownerRef should not be rewritten. + {`metadata.ownerReferences.1.apiVersion`, "other.product.group.io/v1alpha1"}, + {`metadata.ownerReferences.1.kind`, "SomeResource"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := gjson.GetBytes(resultBytes, tt.path).String() + if actual != tt.expected { + t.Log(string(resultBytes)) + t.Fatalf("%s value should be %s, got %s", tt.path, tt.expected, actual) + } + }) + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/rules.go b/images/kube-api-rewriter/pkg/rewriter/rules.go new file mode 100644 index 0000000..f03265b --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/rules.go @@ -0,0 +1,438 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "strings" + + "github.com/tidwall/gjson" + + "github.com/deckhouse/kube-api-rewriter/pkg/rewriter/indexer" +) + +type RewriteRules struct { + KindPrefix string `json:"kindPrefix"` + ResourceTypePrefix string `json:"resourceTypePrefix"` + ShortNamePrefix string `json:"shortNamePrefix"` + Categories []string `json:"categories"` + Rules map[string]APIGroupRule `json:"rules"` + Webhooks map[string]WebhookRule `json:"webhooks"` + Labels MetadataReplace `json:"labels"` + Annotations MetadataReplace `json:"annotations"` + Finalizers MetadataReplace `json:"finalizers"` + Excludes []ExcludeRule `json:"excludes"` + + // KindRefPaths maps original Kind names to spec-level JSON paths that + // contain kind references (e.g. sourceRef). This drives data-driven + // rewriting of cross-resource kind fields instead of hardcoding them. + KindRefPaths map[string][]string `json:"kindRefPaths"` + + // TODO move these indexed rewriters into the RuleBasedRewriter. + labelsRewriter *PrefixedNameRewriter + annotationsRewriter *PrefixedNameRewriter + finalizersRewriter *PrefixedNameRewriter + + apiGroupsIndex *indexer.MapIndexer +} + +// Init should be called before using rules in the RuleBasedRewriter. +func (rr *RewriteRules) Init() { + rr.labelsRewriter = NewPrefixedNameRewriter(rr.Labels) + rr.annotationsRewriter = NewPrefixedNameRewriter(rr.Annotations) + rr.finalizersRewriter = NewPrefixedNameRewriter(rr.Finalizers) + + // Add all original Kinds and KindList as implicit excludes. + originalKinds := make([]string, 0) + for _, apiGroupRule := range rr.Rules { + for _, resourceRule := range apiGroupRule.ResourceRules { + originalKinds = append(originalKinds, resourceRule.Kind, resourceRule.ListKind) + } + } + if len(originalKinds) > 0 { + rr.Excludes = append(rr.Excludes, ExcludeRule{Kinds: originalKinds}) + } + + // Index apiGroups originals and their renames. + rr.apiGroupsIndex = indexer.NewMapIndexer() + for _, apiGroupRule := range rr.Rules { + rr.apiGroupsIndex.AddPair(apiGroupRule.GroupRule.Group, apiGroupRule.GroupRule.Renamed) + } +} + +type APIGroupRule struct { + GroupRule GroupRule `json:"groupRule"` + ResourceRules map[string]ResourceRule `json:"resourceRules"` +} + +type GroupRule struct { + Group string `json:"group"` + Versions []string `json:"versions"` + PreferredVersion string `json:"preferredVersion"` + Renamed string `json:"renamed"` +} + +type ResourceRule struct { + Kind string `json:"kind"` + ListKind string `json:"listKind"` + Plural string `json:"plural"` + Singular string `json:"singular"` + ShortNames []string `json:"shortNames"` + Categories []string `json:"categories"` + Versions []string `json:"versions"` + PreferredVersion string `json:"preferredVersion"` +} + +type WebhookRule struct { + Path string `json:"path"` + Group string `json:"group"` + Resource string `json:"resource"` +} + +type MetadataReplace struct { + Prefixes []MetadataReplaceRule + Names []MetadataReplaceRule +} + +type MetadataReplaceRule struct { + Original string `json:"original"` + Renamed string `json:"renamed"` + OriginalValue string `json:"originalValue"` + RenamedValue string `json:"renamedValue"` +} + +type ExcludeRule struct { + Kinds []string `json:"kinds"` + MatchNames []string `json:"matchNames"` + MatchLabels map[string]string `json:"matchLabels"` +} + +// GetAPIGroupList returns an array of groups in format applicable to use in APIGroupList: +// +// { +// name +// versions: [ { groupVersion, version } ... ] +// preferredVersion: { groupVersion, version } +// } +func (rr *RewriteRules) GetAPIGroupList() []interface{} { + groups := make([]interface{}, 0) + + for _, rGroup := range rr.Rules { + group := map[string]interface{}{ + "name": rGroup.GroupRule.Group, + "preferredVersion": map[string]interface{}{ + "groupVersion": rGroup.GroupRule.Group + "/" + rGroup.GroupRule.PreferredVersion, + "version": rGroup.GroupRule.PreferredVersion, + }, + } + versions := make([]interface{}, 0) + for _, ver := range rGroup.GroupRule.Versions { + versions = append(versions, map[string]interface{}{ + "groupVersion": rGroup.GroupRule.Group + "/" + ver, + "version": ver, + }) + } + group["versions"] = versions + groups = append(groups, group) + } + + return groups +} + +func (rr *RewriteRules) ResourceByKind(kind string) (string, string, bool) { + for groupName, group := range rr.Rules { + for resName, res := range group.ResourceRules { + if res.Kind == kind { + return groupName, resName, false + } + if res.ListKind == kind { + return groupName, resName, true + } + } + } + return "", "", false +} + +func (rr *RewriteRules) WebhookRule(path string) *WebhookRule { + if webhookRule, ok := rr.Webhooks[path]; ok { + return &webhookRule + } + return nil +} + +func (rr *RewriteRules) IsRenamedGroup(apiGroup string) bool { + // Trim version and delimeter. + apiGroup, _, _ = strings.Cut(apiGroup, "/") + return rr.apiGroupsIndex.IsRenamed(apiGroup) +} + +func (rr *RewriteRules) HasGroup(group string) bool { + // Trim version and delimeter. + group, _, _ = strings.Cut(group, "/") + _, ok := rr.Rules[group] + return ok +} + +func (rr *RewriteRules) GroupRule(group string) *GroupRule { + if groupRule, ok := rr.Rules[group]; ok { + return &groupRule.GroupRule + } + return nil +} + +// KindRules returns rule for group and resource by apiGroup and kind. +// apiGroup may be a group or a group with version. +func (rr *RewriteRules) KindRules(apiGroup, kind string) (*GroupRule, *ResourceRule) { + group, _, _ := strings.Cut(apiGroup, "/") + groupRule, ok := rr.Rules[group] + if !ok { + return nil, nil + } + + for _, resRule := range groupRule.ResourceRules { + if resRule.Kind == kind { + return &groupRule.GroupRule, &resRule + } + if resRule.ListKind == kind { + return &groupRule.GroupRule, &resRule + } + } + return nil, nil +} + +func (rr *RewriteRules) ResourceRules(apiGroup, resource string) (*GroupRule, *ResourceRule) { + group, _, _ := strings.Cut(apiGroup, "/") + groupRule, ok := rr.Rules[group] + if !ok { + return nil, nil + } + resource, _, _ = strings.Cut(resource, "/") + resourceRule, ok := rr.Rules[group].ResourceRules[resource] + if !ok { + return nil, nil + } + return &groupRule.GroupRule, &resourceRule +} + +func (rr *RewriteRules) GroupResourceRules(resourceType string) (*GroupRule, *ResourceRule) { + // Trim subresource and delimiter. + resourceType, _, _ = strings.Cut(resourceType, "/") + + for _, group := range rr.Rules { + for _, res := range group.ResourceRules { + if res.Plural == resourceType { + return &group.GroupRule, &res + } + } + } + return nil, nil +} + +func (rr *RewriteRules) GroupResourceRulesByKind(kind string) (*GroupRule, *ResourceRule) { + for _, group := range rr.Rules { + for _, res := range group.ResourceRules { + if res.Kind == kind { + return &group.GroupRule, &res + } + } + } + return nil, nil +} + +func (rr *RewriteRules) RenameResource(resource string) string { + return rr.ResourceTypePrefix + resource +} + +func (rr *RewriteRules) RenameKind(kind string) string { + return rr.KindPrefix + kind +} + +// RestoreResource restores renamed resource to its original state, keeping suffix. +// E.g. "prefixedsomeresources/scale" will be restored to "someresources/scale". +func (rr *RewriteRules) RestoreResource(resource string) string { + return strings.TrimPrefix(resource, rr.ResourceTypePrefix) +} + +func (rr *RewriteRules) RestoreKind(kind string) string { + return strings.TrimPrefix(kind, rr.KindPrefix) +} + +// RestoreApiVersion returns apiVersion with restored apiGroup part. +// It keeps with version suffix as-is if present. +func (rr *RewriteRules) RestoreApiVersion(apiVersion string) string { + apiGroup, version, found := strings.Cut(apiVersion, "/") + + // No version suffix find, consider apiVersion is only a group name. + if !found { + return rr.apiGroupsIndex.Restore(apiVersion) + } + + // Restore apiGroup part, keep version suffix. + return rr.apiGroupsIndex.Restore(apiGroup) + "/" + version +} + +// RenameApiVersion returns apiVersion with renamed apiGroup part. +// It keeps with version suffix as-is if present. +func (rr *RewriteRules) RenameApiVersion(apiVersion string) string { + apiGroup, version, found := strings.Cut(apiVersion, "/") + + // No version suffix find, consider apiVersion is only a group name. + if !found { + return rr.apiGroupsIndex.Rename(apiVersion) + } + + // Rename apiGroup part, keep version suffix. + return rr.apiGroupsIndex.Rename(apiGroup) + "/" + version +} + +func (rr *RewriteRules) RenameCategories(categories []string) []string { + if len(categories) == 0 { + return []string{} + } + return rr.Categories +} + +func (rr *RewriteRules) RestoreCategories(resourceRule *ResourceRule) []string { + if resourceRule == nil { + return []string{} + } + return resourceRule.Categories +} + +func (rr *RewriteRules) RenameShortName(shortName string) string { + return rr.ShortNamePrefix + shortName +} + +func (rr *RewriteRules) RestoreShortName(shortName string) string { + return strings.TrimPrefix(shortName, rr.ShortNamePrefix) +} + +func (rr *RewriteRules) RenameShortNames(shortNames []string) []string { + newNames := make([]string, 0, len(shortNames)) + for _, shortName := range shortNames { + newNames = append(newNames, rr.ShortNamePrefix+shortName) + } + return newNames +} + +func (rr *RewriteRules) RestoreShortNames(shortNames []string) []string { + newNames := make([]string, 0, len(shortNames)) + for _, shortName := range shortNames { + newNames = append(newNames, strings.TrimPrefix(shortName, rr.ShortNamePrefix)) + } + return newNames +} + +func (rr *RewriteRules) LabelsRewriter() *PrefixedNameRewriter { + return rr.labelsRewriter +} + +func (rr *RewriteRules) AnnotationsRewriter() *PrefixedNameRewriter { + return rr.annotationsRewriter +} + +func (rr *RewriteRules) FinalizersRewriter() *PrefixedNameRewriter { + return rr.finalizersRewriter +} + +// ShouldExclude returns true if object should be excluded from response back to the client. +// Set kind when obj has no kind, e.g. a list item. +func (rr *RewriteRules) ShouldExclude(obj []byte, kind string) bool { + for _, exclude := range rr.Excludes { + if exclude.Match(obj, kind) { + return true + } + } + return false +} + +// Match returns true if object matches all conditions in the exclude rule. +func (r ExcludeRule) Match(obj []byte, kind string) bool { + objKind := kind + if objKind == "" { + objKind = gjson.GetBytes(obj, "kind").String() + } + kindMatch := len(r.Kinds) == 0 + for _, kind := range r.Kinds { + if objKind == kind { + kindMatch = true + break + } + } + + objLabels := mapStringStringFromBytes(obj, "metadata.labels") + matchLabels := len(r.MatchLabels) == 0 || mapContainsMap(objLabels, r.MatchLabels) + + matchName := len(r.MatchNames) == 0 + objName := gjson.GetBytes(obj, "metadata.name").String() + for _, name := range r.MatchNames { + if objName == name { + matchName = true + break + } + } + + // Return true if every condition match. + return kindMatch && matchLabels && matchName +} + +func mapStringStringFromBytes(obj []byte, path string) map[string]string { + result := make(map[string]string) + for field, value := range gjson.GetBytes(obj, path).Map() { + result[field] = value.String() + } + return result +} + +func mapContainsMap(obj, match map[string]string) bool { + if len(match) == 0 { + return true + } + for k, v := range match { + if obj[k] != v { + return false + } + } + return true +} + +// KindRefPathsFor returns the spec-level JSON paths containing kind references +// for the given original Kind name. Returns nil if no paths are configured. +func (rr *RewriteRules) KindRefPathsFor(origKind string) []string { + if rr.KindRefPaths == nil { + return nil + } + return rr.KindRefPaths[origKind] +} + +// AllKindRefPaths returns a deduplicated union of all spec-level JSON paths +// across all kinds. Returns nil if no paths are configured. +func (rr *RewriteRules) AllKindRefPaths() []string { + if len(rr.KindRefPaths) == 0 { + return nil + } + seen := make(map[string]struct{}) + var result []string + for _, paths := range rr.KindRefPaths { + for _, p := range paths { + if _, ok := seen[p]; !ok { + seen[p] = struct{}{} + result = append(result, p) + } + } + } + return result +} diff --git a/images/kube-api-rewriter/pkg/rewriter/rules_test.go b/images/kube-api-rewriter/pkg/rewriter/rules_test.go new file mode 100644 index 0000000..4415960 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/rules_test.go @@ -0,0 +1,119 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func newTestExcludeRules() *RewriteRules { + rules := RewriteRules{ + Rules: map[string]APIGroupRule{ + "originalgroup.io": { + ResourceRules: map[string]ResourceRule{ + "someresources": { + Kind: "SomeResource", + ListKind: "SomeResourceList", + }, + }, + }, + "anothergroup.io": { + ResourceRules: map[string]ResourceRule{ + "anotheresources": { + Kind: "AnotherResource", + ListKind: "AnotherResourceList", + }, + }, + }, + }, + Excludes: []ExcludeRule{ + { + Kinds: []string{"RoleBinding"}, + MatchLabels: map[string]string{ + "labelName": "labelValue", + }, + }, + { + Kinds: []string{"Role"}, + MatchNames: []string{"role1", "role2"}, + }, + }, + } + rules.Init() + return &rules +} + +func TestExcludeRuleKindsOnly(t *testing.T) { + rules := newTestExcludeRules() + + tests := []struct { + name string + obj string + expectExcluded bool + }{ + { + "original kind SomeResource in excludes", + `{"kind":"SomeResource"}`, + true, + }, + { + "kind UnknownResource not in excludes", + `{"kind":"UnknownResource"}`, + false, + }, + { + "RoleBinding with label in excludes", + `{"kind":"RoleBinding","metadata":{"labels":{"labelName":"labelValue"}}}`, + true, + }, + { + "RoleBinding with label not in excludes", + `{"kind":"RoleBinding","metadata":{"labels":{"labelName":"nonExcludedValue"}}}`, + false, + }, + { + "Role with name in excludes", + `{"kind":"Role","metadata":{"name":"role1"}}`, + true, + }, + { + "Role with name not in excludes", + `{"kind":"Role","metadata":{"name":"role-not-excluded"}}`, + false, + }, + { + "RoleBinding with name as role in excludes", + `{"kind":"RoleBinding","metadata":{"name":"role1"}}`, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := rules.ShouldExclude([]byte(tt.obj), "") + + if tt.expectExcluded { + require.True(t, actual, "'%s' should be excluded. Not excluded obj: %s", tt.name, tt.obj) + } else { + require.False(t, actual, "'%s' should not be excluded. Excluded obj: %s", tt.name, tt.obj) + + } + }) + } +} diff --git a/images/kube-api-rewriter/pkg/rewriter/source_ref.go b/images/kube-api-rewriter/pkg/rewriter/source_ref.go new file mode 100644 index 0000000..54eb63b --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/source_ref.go @@ -0,0 +1,109 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// RewriteKindRef rewrites the "kind" field in an object that references another +// resource kind (e.g., spec.sourceRef in HelmChart). If "apiVersion" is also +// present, both fields are rewritten using RewriteAPIVersionAndKind. +func RewriteKindRef(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + kind := gjson.GetBytes(obj, "kind").String() + if kind == "" { + return obj, nil + } + + apiVersion := gjson.GetBytes(obj, "apiVersion").String() + if apiVersion != "" { + return RewriteAPIVersionAndKind(rules, obj, action) + } + + var rwrKind string + if action == Rename { + _, resRule := rules.GroupResourceRulesByKind(kind) + if resRule == nil { + return obj, nil + } + rwrKind = rules.RenameKind(kind) + } + if action == Restore { + restoredKind := rules.RestoreKind(kind) + _, resRule := rules.GroupResourceRulesByKind(restoredKind) + if resRule == nil { + return obj, nil + } + rwrKind = restoredKind + } + + if rwrKind == "" || rwrKind == kind { + return obj, nil + } + + return sjson.SetBytes(obj, "kind", rwrKind) +} + +// RewriteSpecKindRefs rewrites kind references in spec fields of known resources. +// It uses KindRefPaths from rules to determine which spec paths contain kind +// references for each resource kind. +func RewriteSpecKindRefs(rules *RewriteRules, obj []byte, action Action) ([]byte, error) { + kind := gjson.GetBytes(obj, "kind").String() + origKind := rules.RestoreKind(kind) + + paths := rules.KindRefPathsFor(origKind) + if len(paths) == 0 { + return obj, nil + } + + var err error + for _, path := range paths { + obj, err = TransformObject(obj, path, func(refObj []byte) ([]byte, error) { + return RewriteKindRef(rules, refObj, action) + }) + if err != nil { + return nil, err + } + } + return obj, nil +} + +// RewritePatchSourceRefs rewrites sourceRef kind references in merge patches. +// It tries all configured KindRefPaths since merge patches do not have a +// top-level kind field to determine the resource type. +func RewritePatchSourceRefs(rules *RewriteRules, patch []byte) ([]byte, error) { + if len(patch) == 0 || patch[0] != '{' { + return patch, nil + } + + paths := rules.AllKindRefPaths() + if len(paths) == 0 { + return patch, nil + } + + var err error + for _, path := range paths { + patch, err = TransformObject(patch, path, func(refObj []byte) ([]byte, error) { + return RewriteKindRef(rules, refObj, Rename) + }) + if err != nil { + return nil, err + } + } + return patch, nil +} diff --git a/images/kube-api-rewriter/pkg/rewriter/source_ref_test.go b/images/kube-api-rewriter/pkg/rewriter/source_ref_test.go new file mode 100644 index 0000000..3897067 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/source_ref_test.go @@ -0,0 +1,217 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// testRulesWithKindRefPaths builds rules with custom kind names to prove +// data-driven behavior. Uses "SomeResource" and "OtherResource" (NOT +// "HelmChart"/"HelmRelease") so the hardcoded switch will NOT match. +func testRulesWithKindRefPaths() *RewriteRules { + rules := &RewriteRules{ + KindPrefix: "Prefixed", + ResourceTypePrefix: "prefixed", + ShortNamePrefix: "p", + Rules: map[string]APIGroupRule{ + "original.group.io": { + GroupRule: GroupRule{ + Group: "original.group.io", + Versions: []string{"v1"}, + PreferredVersion: "v1", + Renamed: "prefixed.resources.group.io", + }, + ResourceRules: map[string]ResourceRule{ + "someresources": { + Kind: "SomeResource", + ListKind: "SomeResourceList", + Plural: "someresources", + Singular: "someresource", + Versions: []string{"v1"}, + PreferredVersion: "v1", + }, + "otherresources": { + Kind: "OtherResource", + ListKind: "OtherResourceList", + Plural: "otherresources", + Singular: "otherresource", + Versions: []string{"v1"}, + PreferredVersion: "v1", + }, + }, + }, + }, + KindRefPaths: map[string][]string{ + "SomeResource": {"spec.sourceRef"}, + "OtherResource": {"spec.chart.spec.sourceRef", "spec.chartRef"}, + }, + } + rules.Init() + return rules +} + +// TestRewriteSpecKindRefs_RestoreKnownKind tests that Restore rewrites a renamed +// kind back to its original in spec.sourceRef for SomeResource. +func TestRewriteSpecKindRefs_RestoreKnownKind(t *testing.T) { + rules := testRulesWithKindRefPaths() + + // SomeResource has been renamed to PrefixedSomeResource. Its sourceRef + // contains a renamed kind that should be restored. + obj := []byte(`{"kind":"PrefixedSomeResource","spec":{"sourceRef":{"kind":"PrefixedSomeResource"}}}`) + + result, err := RewriteSpecKindRefs(rules, obj, Restore) + require.NoError(t, err) + + got := gjson.GetBytes(result, "spec.sourceRef.kind").String() + require.Equal(t, "SomeResource", got, "sourceRef.kind should be restored to original") +} + +// TestRewriteSpecKindRefs_RenameKnownKind tests that Rename rewrites an original +// kind to the prefixed form in spec.sourceRef for SomeResource. +func TestRewriteSpecKindRefs_RenameKnownKind(t *testing.T) { + rules := testRulesWithKindRefPaths() + + // SomeResource (original kind) with sourceRef referencing another known kind. + obj := []byte(`{"kind":"SomeResource","spec":{"sourceRef":{"kind":"SomeResource"}}}`) + + result, err := RewriteSpecKindRefs(rules, obj, Rename) + require.NoError(t, err) + + got := gjson.GetBytes(result, "spec.sourceRef.kind").String() + require.Equal(t, "PrefixedSomeResource", got, "sourceRef.kind should be renamed with prefix") +} + +// TestRewriteSpecKindRefs_RestoreMultiplePaths tests that OtherResource with two +// paths (spec.chart.spec.sourceRef and spec.chartRef) both get rewritten. +func TestRewriteSpecKindRefs_RestoreMultiplePaths(t *testing.T) { + rules := testRulesWithKindRefPaths() + + obj := []byte(`{ + "kind":"PrefixedOtherResource", + "spec":{ + "chart":{"spec":{"sourceRef":{"kind":"PrefixedSomeResource"}}}, + "chartRef":{"kind":"PrefixedOtherResource"} + } + }`) + + result, err := RewriteSpecKindRefs(rules, obj, Restore) + require.NoError(t, err) + + sourceRefKind := gjson.GetBytes(result, "spec.chart.spec.sourceRef.kind").String() + require.Equal(t, "SomeResource", sourceRefKind, "chart.spec.sourceRef.kind should be restored") + + chartRefKind := gjson.GetBytes(result, "spec.chartRef.kind").String() + require.Equal(t, "OtherResource", chartRefKind, "chartRef.kind should be restored") +} + +// TestRewriteSpecKindRefs_UnknownKindPassThrough tests that a kind not in +// KindRefPaths (e.g. ConfigMap) is returned unchanged. +func TestRewriteSpecKindRefs_UnknownKindPassThrough(t *testing.T) { + rules := testRulesWithKindRefPaths() + + obj := []byte(`{"kind":"ConfigMap","spec":{"sourceRef":{"kind":"SomeResource"}}}`) + + result, err := RewriteSpecKindRefs(rules, obj, Restore) + require.NoError(t, err) + + // sourceRef should be untouched since ConfigMap is not in KindRefPaths. + got := gjson.GetBytes(result, "spec.sourceRef.kind").String() + require.Equal(t, "SomeResource", got, "unknown kind should pass through unchanged") +} + +// TestRewriteSpecKindRefs_NilKindRefPaths tests that nil KindRefPaths means +// all objects pass through unchanged. +func TestRewriteSpecKindRefs_NilKindRefPaths(t *testing.T) { + rules := testRulesWithKindRefPaths() + rules.KindRefPaths = nil + + obj := []byte(`{"kind":"PrefixedSomeResource","spec":{"sourceRef":{"kind":"PrefixedSomeResource"}}}`) + + result, err := RewriteSpecKindRefs(rules, obj, Restore) + require.NoError(t, err) + + // Should be unchanged since KindRefPaths is nil. + got := gjson.GetBytes(result, "spec.sourceRef.kind").String() + require.Equal(t, "PrefixedSomeResource", got, "nil KindRefPaths should pass through") +} + +// TestRewritePatchSourceRefs_RewritesAllPaths tests that patches rewrite kind +// references across all configured paths. +func TestRewritePatchSourceRefs_RewritesAllPaths(t *testing.T) { + rules := testRulesWithKindRefPaths() + + patch := []byte(`{ + "spec":{ + "sourceRef":{"kind":"SomeResource"}, + "chart":{"spec":{"sourceRef":{"kind":"OtherResource"}}}, + "chartRef":{"kind":"SomeResource"} + } + }`) + + result, err := RewritePatchSourceRefs(rules, patch) + require.NoError(t, err) + + sourceRefKind := gjson.GetBytes(result, "spec.sourceRef.kind").String() + require.Equal(t, "PrefixedSomeResource", sourceRefKind, "sourceRef.kind should be renamed") + + chartSourceRefKind := gjson.GetBytes(result, "spec.chart.spec.sourceRef.kind").String() + require.Equal(t, "PrefixedOtherResource", chartSourceRefKind, "chart.spec.sourceRef.kind should be renamed") + + chartRefKind := gjson.GetBytes(result, "spec.chartRef.kind").String() + require.Equal(t, "PrefixedSomeResource", chartRefKind, "chartRef.kind should be renamed") +} + +// TestRewritePatchSourceRefs_NilKindRefPaths tests that nil KindRefPaths means +// patches pass through unchanged. +func TestRewritePatchSourceRefs_NilKindRefPaths(t *testing.T) { + rules := testRulesWithKindRefPaths() + rules.KindRefPaths = nil + + patch := []byte(`{"spec":{"sourceRef":{"kind":"SomeResource"}}}`) + + result, err := RewritePatchSourceRefs(rules, patch) + require.NoError(t, err) + + got := gjson.GetBytes(result, "spec.sourceRef.kind").String() + require.Equal(t, "SomeResource", got, "nil KindRefPaths should pass through") +} + +// TestRewritePatchSourceRefs_EmptyPatch tests that empty input returns empty. +func TestRewritePatchSourceRefs_EmptyPatch(t *testing.T) { + rules := testRulesWithKindRefPaths() + + result, err := RewritePatchSourceRefs(rules, []byte{}) + require.NoError(t, err) + require.Empty(t, result) +} + +// TestRewritePatchSourceRefs_ArrayPatch tests that JSON array patches pass through. +func TestRewritePatchSourceRefs_ArrayPatch(t *testing.T) { + rules := testRulesWithKindRefPaths() + + patch := []byte(`[{"op":"replace","path":"/spec/sourceRef/kind","value":"SomeResource"}]`) + + result, err := RewritePatchSourceRefs(rules, patch) + require.NoError(t, err) + + // Array patches should pass through unchanged (they start with '[' not '{'). + require.Equal(t, string(patch), string(result)) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/target_request.go b/images/kube-api-rewriter/pkg/rewriter/target_request.go new file mode 100644 index 0000000..deb2d3a --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/target_request.go @@ -0,0 +1,306 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "fmt" + "net/http" +) + +type TargetRequest struct { + originEndpoint *APIEndpoint + targetEndpoint *APIEndpoint + + webhookRule *WebhookRule +} + +func NewTargetRequest(rwr *RuleBasedRewriter, req *http.Request) *TargetRequest { + if req == nil || req.URL == nil { + return nil + } + + // Is it a request to the webhook? + webhookRule := rwr.Rules.WebhookRule(req.URL.Path) + if webhookRule != nil { + return &TargetRequest{ + webhookRule: webhookRule, + } + } + + apiEndpoint := ParseAPIEndpoint(req.URL) + if apiEndpoint == nil { + return nil + } + + // rewrite path if needed + targetEndpoint := rwr.RewriteAPIEndpoint(apiEndpoint) + + return &TargetRequest{ + originEndpoint: apiEndpoint, + targetEndpoint: targetEndpoint, + } +} + +// Path return possibly rewritten path for target endpoint. +func (tr *TargetRequest) Path() string { + if tr.targetEndpoint != nil { + return tr.targetEndpoint.Path() + } + if tr.originEndpoint != nil { + return tr.originEndpoint.Path() + } + if tr.webhookRule != nil { + return tr.webhookRule.Path + } + + return "" +} + +func (tr *TargetRequest) IsCore() bool { + if tr.originEndpoint != nil { + return tr.originEndpoint.IsCore + } + return false +} + +func (tr *TargetRequest) IsCRD() bool { + if tr.originEndpoint != nil { + return tr.originEndpoint.IsCRD + } + return false +} + +func (tr *TargetRequest) IsWatch() bool { + if tr.originEndpoint != nil { + return tr.originEndpoint.IsWatch + } + return false +} + +func (tr *TargetRequest) IsWebhook() bool { + return tr.webhookRule != nil +} + +func (tr *TargetRequest) OrigGroup() string { + if tr.IsCRD() { + return tr.originEndpoint.CRDGroup + } + if tr.originEndpoint != nil { + return tr.originEndpoint.Group + } + if tr.webhookRule != nil { + return tr.webhookRule.Group + } + return "" +} + +func (tr *TargetRequest) OrigResourceType() string { + if tr.IsCRD() { + return tr.originEndpoint.CRDResourceType + } + if tr.originEndpoint != nil { + return tr.originEndpoint.ResourceType + } + if tr.webhookRule != nil { + return tr.webhookRule.Resource + } + return "" +} + +func (tr *TargetRequest) RawQuery() string { + if tr.targetEndpoint != nil { + return tr.targetEndpoint.RawQuery + } + if tr.originEndpoint != nil { + return tr.originEndpoint.RawQuery + } + return "" +} + +func (tr *TargetRequest) RequestURI() string { + path := tr.Path() + query := tr.RawQuery() + if query == "" { + return path + } + return fmt.Sprint(path, "?", query) +} + +// ShouldRewriteRequest returns true if incoming payload should +// be rewritten. +func (tr *TargetRequest) ShouldRewriteRequest() bool { + // Consider known webhook should be rewritten. Unknown paths will be passed as-is. + if tr.webhookRule != nil { + return true + } + + if tr.originEndpoint != nil { + if tr.originEndpoint.IsRoot || tr.originEndpoint.IsUnknown { + return false + } + + if tr.targetEndpoint == nil { + // Pass resources without rules as is, except some special types. + + // Rewrite request body when creating CRD. + if tr.originEndpoint.ResourceType == "customresourcedefinitions" && tr.originEndpoint.Name == "" { + return true + } + + return shouldRewriteResource(tr.originEndpoint.ResourceType) + } + } + + // Payload should be inspected to decide if rewrite is required. + return true +} + +// ShouldRewriteResponse return true if response rewrite is needed. +// Response may be passed as is if false. +func (tr *TargetRequest) ShouldRewriteResponse() bool { + // If there is webhook rule, response should be rewritten. + if tr.webhookRule != nil { + return true + } + + if tr.originEndpoint == nil { + return false + } + + if tr.originEndpoint.IsRoot || tr.originEndpoint.IsUnknown { + return false + } + + if tr.originEndpoint.IsCRD { + // Rewrite CRD List. + if tr.originEndpoint.Name == "" { + return true + } + // Rewrite CRD if group and resource was rewritten. + if tr.originEndpoint.Name != "" && tr.targetEndpoint != nil { + return true + } + return false + } + + // Rewrite if path was rewritten for known resource. + if tr.targetEndpoint != nil { + return true + } + + // Rewrite response from /apis discovery. + if tr.originEndpoint.Group == "" { + return true + } + + return shouldRewriteResource(tr.originEndpoint.ResourceType) +} + +func (tr *TargetRequest) ResourceForLog() string { + if tr.webhookRule != nil { + return tr.webhookRule.Resource + } + if tr.originEndpoint != nil { + ep := tr.originEndpoint + if ep.IsRoot { + return "ROOT" + } + if ep.IsUnknown { + return "UKNOWN" + } + if ep.IsCore { + // /api + if ep.Version == "" { + return "APIVersions/core" + } + // /api/v1 + if ep.ResourceType == "" { + return "APIResourceList/core" + } + // /api/v1/RESOURCE/NAME/SUBRESOURCE + // /api/v1/namespaces/NS/status + // /api/v1/namespaces/NS/RESOURCE/NAME/SUBRESOURCE + if ep.Subresource != "" { + return ep.ResourceType + "/" + ep.Subresource + } + // /api/v1/RESOURCETYPE + // /api/v1/RESOURCETYPE/NAME + // /api/v1/namespaces + // /api/v1/namespaces/NAMESPACE + // /api/v1/namespaces/NAMESPACE/RESOURCETYPE + // /api/v1/namespaces/NAMESPACE/RESOURCETYPE/NAME + return ep.ResourceType + } + // /apis + if ep.Group == "" { + return "APIGroupList" + } + // /apis/GROUP + if ep.Version == "" { + return "APIGroup/" + ep.Group + } + // /apis/GROUP/VERSION + if ep.ResourceType == "" { + return "APIResourceList/" + ep.Group + } + // /apis/GROUP/VERSION/RESOURCETYPE/NAME/SUBRESOURCE + // /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME/SUBRESOURCE + if ep.Subresource != "" { + return ep.ResourceType + "/" + ep.Subresource + } + // /apis/GROUP/VERSION/RESOURCETYPE + // /apis/GROUP/VERSION/RESOURCETYPE/NAME + // /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE + // /apis/GROUP/VERSION/namespaces/NAMESPACE/RESOURCETYPE/NAME + return ep.ResourceType + } + + return "UNKNOWN" +} + +func shouldRewriteResource(resourceType string) bool { + switch resourceType { + case "nodes", + "pods", + "configmaps", + "secrets", + "services", + "serviceaccounts", + "mutatingwebhookconfigurations", + "validatingwebhookconfigurations", + "clusterroles", + "roles", + "rolebindings", + "clusterrolebindings", + "deployments", + "statefulsets", + "daemonsets", + "jobs", + "persistentvolumeclaims", + "prometheusrules", + "servicemonitors", + "poddisruptionbudgets", + "controllerrevisions", + "apiservices", + "validatingadmissionpolicybindings", + "validatingadmissionpolicies", + "events": + return true + } + + return false +} diff --git a/images/kube-api-rewriter/pkg/rewriter/transformers.go b/images/kube-api-rewriter/pkg/rewriter/transformers.go new file mode 100644 index 0000000..ef68ec8 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/transformers.go @@ -0,0 +1,111 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter + +import ( + "encoding/json" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// TransformString transforms string value addressed by path. +func TransformString(obj []byte, path string, transformFn func(field string) string) ([]byte, error) { + pathStr := gjson.GetBytes(obj, path) + if !pathStr.Exists() { + return obj, nil + } + rwrString := transformFn(pathStr.String()) + return sjson.SetBytes(obj, path, rwrString) +} + +// TransformObject transforms object value addressed by path. +func TransformObject(obj []byte, path string, transformFn func(item []byte) ([]byte, error)) ([]byte, error) { + pathObj := gjson.GetBytes(obj, path) + if !pathObj.IsObject() { + return obj, nil + } + rwrObj, err := transformFn([]byte(pathObj.Raw)) + if err != nil { + return nil, err + } + return sjson.SetRawBytes(obj, path, rwrObj) +} + +// TransformArrayOfStrings transforms array value addressed by path. +func TransformArrayOfStrings(obj []byte, arrayPath string, transformFn func(item string) string) ([]byte, error) { + // Transform each item in list. Put back original items if transformFn returns nil bytes. + items := gjson.GetBytes(obj, arrayPath).Array() + if len(items) == 0 { + return obj, nil + } + rwrItems := make([]string, len(items)) + for i, item := range items { + rwrItems[i] = transformFn(item.String()) + } + + return sjson.SetBytes(obj, arrayPath, rwrItems) +} + +// TransformPatch treats obj as a JSON patch or Merge patch and calls +// a corresponding transformFn. +func TransformPatch( + obj []byte, + transformMerge func(mergePatch []byte) ([]byte, error), + transformJSON func(jsonPatch []byte) ([]byte, error)) ([]byte, error) { + if len(obj) == 0 { + return obj, nil + } + // Merge patch for Kubernetes resource is always starts with the curly bracket. + if string(obj[0]) == "{" && transformMerge != nil { + return transformMerge(obj) + } + + // JSON patch should start with the square bracket. + if string(obj[0]) == "[" && transformJSON != nil { + return RewriteArray(obj, Root, transformJSON) + } + + // Return patch as-is in other cases. + return obj, nil +} + +// Helpers for traversing JSON objects with support for root path. +// gjson supports @this, but sjson don't, so unique alias is used. + +const Root = "@ROOT" + +func GetBytes(obj []byte, path string) gjson.Result { + if path == Root { + return gjson.ParseBytes(obj) + } + return gjson.GetBytes(obj, path) +} + +func SetBytes(obj []byte, path string, value interface{}) ([]byte, error) { + if path == Root { + return json.Marshal(value) + } + return sjson.SetBytes(obj, path, value) +} + +func SetRawBytes(obj []byte, path string, value []byte) ([]byte, error) { + if path == Root { + return value, nil + } + return sjson.SetRawBytes(obj, path, value) +} diff --git a/images/kube-api-rewriter/pkg/rewriter/webhook.go b/images/kube-api-rewriter/pkg/rewriter/webhook.go new file mode 100644 index 0000000..dfa3c62 --- /dev/null +++ b/images/kube-api-rewriter/pkg/rewriter/webhook.go @@ -0,0 +1,17 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rewriter diff --git a/images/kube-api-rewriter/pkg/server/http_server.go b/images/kube-api-rewriter/pkg/server/http_server.go new file mode 100644 index 0000000..b309716 --- /dev/null +++ b/images/kube-api-rewriter/pkg/server/http_server.go @@ -0,0 +1,158 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package server + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + log "log/slog" + "net" + "net/http" + "sync" + + logutil "github.com/deckhouse/kube-api-rewriter/pkg/log" + "github.com/deckhouse/kube-api-rewriter/pkg/tls/certmanager" +) + +// HTTPServer starts HTTP server with root handler using listen address. +// Implements Runnable interface to be able to stop server. +type HTTPServer struct { + InstanceDesc string + ListenAddr string + RootHandler http.Handler + CertManager certmanager.CertificateManager + Err error + + initLock sync.Mutex + stopped bool + + listener net.Listener + instance *http.Server +} + +// init checks if listen is possible and creates new HTTP server instance. +// initLock is used to avoid data races with the Stop method. +func (s *HTTPServer) init() bool { + s.initLock.Lock() + defer s.initLock.Unlock() + if s.stopped { + // Stop was called earlier. + return false + } + + l, err := net.Listen("tcp", s.ListenAddr) + if err != nil { + s.Err = err + log.Error(fmt.Sprintf("%s: listen on %s err: %s", s.InstanceDesc, s.ListenAddr, err)) + return false + } + s.listener = l + log.Info(fmt.Sprintf("%s: listen for incoming requests on %s", s.InstanceDesc, s.ListenAddr)) + + mux := http.NewServeMux() + mux.Handle("/", s.RootHandler) + + s.instance = &http.Server{ + Handler: mux, + } + return true +} + +func (s *HTTPServer) Start() { + if !s.init() { + return + } + + // Start serving HTTP requests, block until server instance stops or returns an error. + var err error + if s.CertManager != nil { + go s.CertManager.Start() + s.setupTLS() + err = s.instance.ServeTLS(s.listener, "", "") + } else { + err = s.instance.Serve(s.listener) + } + // Ignore closed error: it's a consequence of stop. + if err != nil { + switch { + case errors.Is(err, http.ErrServerClosed): + case errors.Is(err, net.ErrClosed): + default: + s.Err = err + } + } + return +} + +func (s *HTTPServer) setupTLS() { + s.instance.TLSConfig = &tls.Config{ + GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert := s.CertManager.Current() + if cert == nil { + return nil, errors.New("no server certificate, server is not yet ready to receive traffic") + } + return cert, nil + }, + } +} + +// Stop shutdowns HTTP server instance and close a done channel. +// Stop and init may be run in parallel, so initLock is used to wait until +// variables are initialized. +func (s *HTTPServer) Stop() { + s.initLock.Lock() + defer s.initLock.Unlock() + + if s.stopped { + return + } + s.stopped = true + + if s.CertManager != nil { + s.CertManager.Stop() + } + // Shutdown instance if it was initialized. + if s.instance != nil { + log.Info(fmt.Sprintf("%s: stop", s.InstanceDesc)) + err := s.instance.Shutdown(context.Background()) + // Ignore ErrClosed. + if err != nil { + switch { + case errors.Is(err, http.ErrServerClosed): + case errors.Is(err, net.ErrClosed): + case s.Err != nil: + // log error to not reset runtime error. + log.Error(fmt.Sprintf("%s: stop instance", s.InstanceDesc), logutil.SlogErr(err)) + default: + s.Err = err + } + } + } +} + +// ConstructListenAddr return ip:port with defaults. +func ConstructListenAddr(addr, port, defaultAddr, defaultPort string) string { + if addr == "" { + addr = defaultAddr + } + if port == "" { + port = defaultPort + } + return addr + ":" + port +} diff --git a/images/kube-api-rewriter/pkg/server/runnable_group.go b/images/kube-api-rewriter/pkg/server/runnable_group.go new file mode 100644 index 0000000..952c5b7 --- /dev/null +++ b/images/kube-api-rewriter/pkg/server/runnable_group.go @@ -0,0 +1,90 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package server + +import ( + "sync" +) + +type Runnable interface { + Start() + Stop() +} + +// RunnableGroup is a group of Runnables that should run until one of them stops. +type RunnableGroup struct { + runnables []Runnable +} + +func NewRunnableGroup() *RunnableGroup { + return &RunnableGroup{ + runnables: make([]Runnable, 0), + } +} + +// Add register Runnable in a group. +// Note: not designed for parallel registering. +func (rg *RunnableGroup) Add(r Runnable) { + rg.runnables = append(rg.runnables, r) +} + +// Start starts all Runnables and stops all of them when at least one Runnable stops. +func (rg *RunnableGroup) Start() { + // Start all runnables. + oneStoppedCh := rg.startAll() + + // Block until one runnable is stopped. + <-oneStoppedCh + + // Wait until all Runnables stop. + rg.stopAll() +} + +// startAll calls Start for each Runnable in separate go routines. +// It waits until all go routines starts. +// It returns a channel, so caller can receive event when one of the Runnables stops. +func (rg *RunnableGroup) startAll() chan struct{} { + oneStopped := make(chan struct{}) + var closeOnce sync.Once + + for i := range rg.runnables { + r := rg.runnables[i] + go func() { + r.Start() + closeOnce.Do(func() { + close(oneStopped) + }) + }() + } + + return oneStopped +} + +// stopAll calls Stop for each Runnable in a separate go routine. +// It waits until all go routines starts. +func (rg *RunnableGroup) stopAll() { + var wg sync.WaitGroup + wg.Add(len(rg.runnables)) + for i := range rg.runnables { + r := rg.runnables[i] + go func() { + r.Stop() + wg.Done() + }() + } + wg.Wait() +} diff --git a/images/kube-api-rewriter/pkg/target/kubernetes.go b/images/kube-api-rewriter/pkg/target/kubernetes.go new file mode 100644 index 0000000..75416d2 --- /dev/null +++ b/images/kube-api-rewriter/pkg/target/kubernetes.go @@ -0,0 +1,55 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package target + +import ( + "fmt" + "net/http" + "net/url" + + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +type Kubernetes struct { + Config *rest.Config + Client *http.Client + APIServerURL *url.URL +} + +func NewKubernetesTarget() (*Kubernetes, error) { + var err error + k := &Kubernetes{} + + k.Config, err = config.GetConfig() + if err != nil { + return nil, fmt.Errorf("load Kubernetes client config: %w", err) + } + + // Configure HTTP client to Kubernetes API server. + k.Client, err = rest.HTTPClientFor(k.Config) + if err != nil { + return nil, fmt.Errorf("setup Kubernetes API http client: %w", err) + } + + k.APIServerURL, err = url.Parse(k.Config.Host) + if err != nil { + return nil, fmt.Errorf("parse API server URL: %w", err) + } + + return k, nil +} diff --git a/images/kube-api-rewriter/pkg/target/webhook.go b/images/kube-api-rewriter/pkg/target/webhook.go new file mode 100644 index 0000000..7c60e6f --- /dev/null +++ b/images/kube-api-rewriter/pkg/target/webhook.go @@ -0,0 +1,106 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package target + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "os" + "time" + + "github.com/deckhouse/kube-api-rewriter/pkg/tls/certmanager" + "github.com/deckhouse/kube-api-rewriter/pkg/tls/certmanager/filesystem" +) + +type Webhook struct { + Client *http.Client + URL *url.URL + CertManager certmanager.CertificateManager +} + +const ( + WebhookAddressVar = "WEBHOOK_ADDRESS" + WebhookServerNameVar = "WEBHOOK_SERVER_NAME" + WebhookCertFileVar = "WEBHOOK_CERT_FILE" + WebhookKeyFileVar = "WEBHOOK_KEY_FILE" +) + +var ( + defaultWebhookTimeout = 30 * time.Second + defaultWebhookAddress = "https://127.0.0.1:9443" +) + +func NewWebhookTarget() (*Webhook, error) { + var err error + webhook := &Webhook{} + + // Target address and serverName. + address := os.Getenv(WebhookAddressVar) + if address == "" { + address = defaultWebhookAddress + } + + serverName := os.Getenv(WebhookServerNameVar) + if serverName == "" { + serverName = address + } + + webhook.URL, err = url.Parse(address) + if err != nil { + return nil, err + } + + // Certificate settings. + certFile := os.Getenv(WebhookCertFileVar) + keyFile := os.Getenv(WebhookKeyFileVar) + if certFile == "" && keyFile != "" { + return nil, fmt.Errorf("should specify cert file in %s if %s is not empty", WebhookCertFileVar, WebhookKeyFileVar) + } + if certFile != "" && keyFile == "" { + return nil, fmt.Errorf("should specify key file in %s if %s is not empty", WebhookKeyFileVar, WebhookCertFileVar) + } + if certFile != "" && keyFile != "" { + webhook.CertManager = filesystem.NewFileCertificateManager(certFile, keyFile) + } + + // Construct TLS client without validation to connect to the local webhook server. + dialer := &net.Dialer{ + Timeout: defaultWebhookTimeout, + } + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + ServerName: serverName, + }, + DisableKeepAlives: true, + IdleConnTimeout: 5 * time.Minute, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + DialContext: dialer.DialContext, + } + + webhook.Client = &http.Client{ + Transport: tr, + Timeout: defaultWebhookTimeout, + } + + return webhook, nil +} diff --git a/images/kube-api-rewriter/pkg/tls/certmanager/certmanager.go b/images/kube-api-rewriter/pkg/tls/certmanager/certmanager.go new file mode 100644 index 0000000..e10a8c4 --- /dev/null +++ b/images/kube-api-rewriter/pkg/tls/certmanager/certmanager.go @@ -0,0 +1,27 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package certmanager + +import ( + "crypto/tls" +) + +type CertificateManager interface { + Start() + Stop() + Current() *tls.Certificate +} diff --git a/images/kube-api-rewriter/pkg/tls/certmanager/filesystem/file-cert-manager.go b/images/kube-api-rewriter/pkg/tls/certmanager/filesystem/file-cert-manager.go new file mode 100644 index 0000000..1f6d7fc --- /dev/null +++ b/images/kube-api-rewriter/pkg/tls/certmanager/filesystem/file-cert-manager.go @@ -0,0 +1,170 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package filesystem + +import ( + "crypto/tls" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + + logutil "github.com/deckhouse/kube-api-rewriter/pkg/log" + "github.com/deckhouse/kube-api-rewriter/pkg/tls/util" +) + +type FileCertificateManager struct { + stopCh chan struct{} + certAccessLock sync.Mutex + cert *tls.Certificate + certBytesPath string + keyBytesPath string + errorRetryInterval time.Duration +} + +func NewFileCertificateManager(certBytesPath, keyBytesPath string) *FileCertificateManager { + return &FileCertificateManager{ + certBytesPath: certBytesPath, + keyBytesPath: keyBytesPath, + stopCh: make(chan struct{}), + errorRetryInterval: 1 * time.Minute, + } +} + +func (f *FileCertificateManager) Start() { + objectUpdated := make(chan struct{}, 1) + watcher, err := fsnotify.NewWatcher() + if err != nil { + slog.Error("failed to create an inotify watcher", logutil.SlogErr(err)) + } + defer watcher.Close() + + certDir := filepath.Dir(f.certBytesPath) + err = watcher.Add(certDir) + if err != nil { + slog.Error(fmt.Sprintf("failed to establish a watch on %s", f.certBytesPath), logutil.SlogErr(err)) + } + keyDir := filepath.Dir(f.keyBytesPath) + if keyDir != certDir { + err = watcher.Add(keyDir) + if err != nil { + slog.Error(fmt.Sprintf("failed to establish a watch on %s", f.keyBytesPath), logutil.SlogErr(err)) + } + } + + go func() { + for { + select { + case _, ok := <-watcher.Events: + if !ok { + return + } + select { + case objectUpdated <- struct{}{}: + default: + slog.Debug("Dropping redundant wakeup for cert reload") + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + slog.Error(fmt.Sprintf("An error occurred when watching certificates files %s and %s", f.certBytesPath, f.keyBytesPath), logutil.SlogErr(err)) + } + } + }() + + // ensure we load the certificates on startup + objectUpdated <- struct{}{} + +sync: + for { + select { + case <-objectUpdated: + if err := f.rotateCerts(); err != nil { + go func() { + time.Sleep(f.errorRetryInterval) + select { + case objectUpdated <- struct{}{}: + default: + slog.Debug("Dropping redundant wakeup for cert reload") + } + }() + } + case <-f.stopCh: + break sync + } + } +} + +func (f *FileCertificateManager) Stop() { + f.certAccessLock.Lock() + defer f.certAccessLock.Unlock() + select { + case <-f.stopCh: + default: + close(f.stopCh) + } +} + +func (f *FileCertificateManager) rotateCerts() error { + crt, err := f.loadCertificates() + if err != nil { + return fmt.Errorf("failed to load the certificate %s and %s: %w", f.certBytesPath, f.keyBytesPath, err) + } + + f.certAccessLock.Lock() + defer f.certAccessLock.Unlock() + // update after the callback, to ensure that the reconfiguration succeeded + f.cert = crt + slog.Info(fmt.Sprintf("certificate with common name '%s' retrieved.", crt.Leaf.Subject.CommonName)) + return nil +} + +func (f *FileCertificateManager) loadCertificates() (serverCrt *tls.Certificate, err error) { + // #nosec No risk for path injection. Used for specific cert file for key rotation + certBytes, err := os.ReadFile(f.certBytesPath) + if err != nil { + return nil, err + } + // #nosec No risk for path injection. Used for specific cert file for key rotation + keyBytes, err := os.ReadFile(f.keyBytesPath) + if err != nil { + return nil, err + } + + crt, err := tls.X509KeyPair(certBytes, keyBytes) + if err != nil { + return nil, fmt.Errorf("failed to load certificate: %w\n", err) + } + + leaf, err := util.ParseCertsPEM(certBytes) + if err != nil { + return nil, fmt.Errorf("failed to load leaf certificate: %w\n", err) + } + crt.Leaf = leaf[0] + return &crt, nil +} + +func (f *FileCertificateManager) Current() *tls.Certificate { + f.certAccessLock.Lock() + defer f.certAccessLock.Unlock() + return f.cert +} diff --git a/images/kube-api-rewriter/pkg/tls/util/util.go b/images/kube-api-rewriter/pkg/tls/util/util.go new file mode 100644 index 0000000..7871dba --- /dev/null +++ b/images/kube-api-rewriter/pkg/tls/util/util.go @@ -0,0 +1,52 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "crypto/x509" + "encoding/pem" + "errors" +) + +const CertificateBlockType string = "CERTIFICATE" + +func ParseCertsPEM(pemCerts []byte) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + for len(pemCerts) > 0 { + var block *pem.Block + block, pemCerts = pem.Decode(pemCerts) + if block == nil { + break + } + // Only use PEM "CERTIFICATE" blocks without extra headers + if block.Type != CertificateBlockType || len(block.Headers) != 0 { + continue + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return certs, err + } + + certs = append(certs, cert) + } + + if len(certs) == 0 { + return nil, errors.New("data does not contain any valid RSA or ECDSA certificates") + } + return certs, nil +} diff --git a/images/kube-api-rewriter/werf.inc.yaml b/images/kube-api-rewriter/werf.inc.yaml new file mode 100644 index 0000000..3a02526 --- /dev/null +++ b/images/kube-api-rewriter/werf.inc.yaml @@ -0,0 +1,62 @@ +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact +final: false +fromImage: builder/src +git: + - add: {{ .ModuleDir }}/images/{{ .ImageName }} + to: /src/kube-api-rewriter + stageDependencies: + install: + - go.mod + - go.sum + - "**/*.go" +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }}-builder +final: false +fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-bookworm-1.25" "builder/golang-alt-svace-1.25" }} +import: + - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact + add: /src + to: /src + before: install +secrets: +- id: GOPROXY + value: {{ .GOPROXY }} +mount: + - fromPath: ~/go-pkg-cache + to: /go/pkg +shell: + install: + - export GOPROXY=$(cat /run/secrets/GOPROXY) + - cd /src/kube-api-rewriter + - go mod download + setup: + - cd /src/kube-api-rewriter + - export GOOS=linux + - export CGO_ENABLED=0 + - export GOARCH=amd64 + - | + {{- $_ := set $ "ProjectName" (list $.ImageName "kube-api-rewriter" | join "/") }} + {{- include "image-build.build" (set $ "BuildCommand" `go build -v -a -o kube-api-rewriter ./cmd/kube-api-rewriter`) | nindent 6 }} +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }} +fromImage: builder/scratch +git: + {{- include "image mount points" . }} +import: + - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-builder + add: /src/kube-api-rewriter/kube-api-rewriter + to: /app/kube-api-rewriter + after: install + # Make containerd compatible directories structure. + - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-builder + add: /var + to: /var + includePaths: + - run + after: install +imageSpec: + config: + user: "64535:64535" + workingDir: "/app" + entrypoint: ["/app/kube-api-rewriter"] diff --git a/images/nelm-source-controller/werf.inc.yaml b/images/nelm-source-controller/werf.inc.yaml new file mode 100644 index 0000000..dcec854 --- /dev/null +++ b/images/nelm-source-controller/werf.inc.yaml @@ -0,0 +1,3 @@ +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }} +from: registry.werf.io/nelm/source-controller:v0.1.4 diff --git a/images/operator-helm-artifact/.gitignore b/images/operator-helm-artifact/.gitignore new file mode 100644 index 0000000..9f0f3a1 --- /dev/null +++ b/images/operator-helm-artifact/.gitignore @@ -0,0 +1,30 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/* +Dockerfile.cross + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +# Kubernetes Generated files - skip generated files, except for vendored files +!vendor/**/zz_generated.* + +# editor and IDE paraphernalia +.idea +.vscode +*.swp +*.swo +*~ + +# Kubeconfig might contain secrets +*.kubeconfig diff --git a/images/operator-helm-artifact/cmd/operator-helm-controller/main.go b/images/operator-helm-artifact/cmd/operator-helm-controller/main.go new file mode 100644 index 0000000..9c5e1f5 --- /dev/null +++ b/images/operator-helm-artifact/cmd/operator-helm-controller/main.go @@ -0,0 +1,116 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "flag" + "os" + + "github.com/deckhouse/operator-helm/pkg/controller/helmclusteraddonchart" + helmv2 "github.com/werf/3p-helm-controller/api/v2" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/pkg/controller/helmclusteraddon" + "github.com/deckhouse/operator-helm/pkg/controller/helmclusteraddonrepository" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" +) + +var scheme = runtime.NewScheme() + +func init() { + _ = clientgoscheme.AddToScheme(scheme) + _ = helmv1alpha1.AddToScheme(scheme) + _ = sourcev1.AddToScheme(scheme) + _ = helmv2.AddToScheme(scheme) +} + +func main() { + var ( + metricsAddr string + healthProbeAddr string + enableLeaderElection bool + ) + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metrics endpoint binds to.") + flag.StringVar(&healthProbeAddr, "health-probe-bind-address", ":9440", "The address the health probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + + // TODO: replace zap by deckhouse logger + + opts := zap.Options{Development: false} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + logger := ctrl.Log.WithName("setup") + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + }, + HealthProbeBindAddress: healthProbeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "operator-helm-controller.helm.deckhouse.io", + }) + if err != nil { + logger.Error(err, "unable to create manager") + os.Exit(1) + } + + if err := helmclusteraddonrepository.SetupWithManager(mgr); err != nil { + logger.Error(err, "unable to setup HelmClusterAddonRepository controller") + os.Exit(1) + } + + if err := helmclusteraddon.SetupWithManager(mgr); err != nil { + logger.Error(err, "unable to setup HelmClusterAddon controller") + os.Exit(1) + } + + if err = (&helmv1alpha1.HelmClusterAddon{}).SetupWebhookWithManager(mgr); err != nil { + logger.Error(err, "unable to create webhook", "webhook", "HelmClusterAddon") + os.Exit(1) + } + + if err := helmclusteraddonchart.SetupWithManager(mgr); err != nil { + logger.Error(err, "unable to setup HelmClusterAddonChart controller") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up ready check") + os.Exit(1) + } + + logger.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + logger.Error(err, "manager exited with error") + os.Exit(1) + } +} diff --git a/images/operator-helm-artifact/go.mod b/images/operator-helm-artifact/go.mod new file mode 100644 index 0000000..0eb2c1b --- /dev/null +++ b/images/operator-helm-artifact/go.mod @@ -0,0 +1,83 @@ +module github.com/deckhouse/operator-helm + +go 1.25.0 + +replace github.com/deckhouse/operator-helm/api => ../../api + +require ( + github.com/deckhouse/operator-helm/api v0.0.0-00010101000000-000000000000 + github.com/opencontainers/go-digest v1.0.0 + github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 + github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1 + github.com/werf/3p-helm-controller/api v0.1.4 + github.com/werf/nelm-source-controller/api v0.1.4 + go.yaml.in/yaml/v3 v3.0.4 + helm.sh/helm/v3 v3.19.2 + k8s.io/api v0.35.1 + k8s.io/apimachinery v0.35.1 + k8s.io/client-go v0.35.1 + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + sigs.k8s.io/controller-runtime v0.23.1 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cyphar/filepath-securejoin v0.6.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/werf/3p-fluxcd-pkg/apis/acl v0.9.0-nelm.1 // indirect + github.com/werf/3p-fluxcd-pkg/apis/kustomize v1.14.0-nelm.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.12.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.35.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/images/operator-helm-artifact/go.sum b/images/operator-helm-artifact/go.sum new file mode 100644 index 0000000..e94cb26 --- /dev/null +++ b/images/operator-helm-artifact/go.sum @@ -0,0 +1,189 @@ +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= +github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/werf/3p-fluxcd-pkg/apis/acl v0.9.0-nelm.1 h1:b1P4avYWjjWuzPSOv6QZtk1ffl/iBfWBGK4qNAxaA94= +github.com/werf/3p-fluxcd-pkg/apis/acl v0.9.0-nelm.1/go.mod h1:00dBUg4SN+4Xu4LWrbQm5LdmRKVP9Fjbvb+rvqjHrVI= +github.com/werf/3p-fluxcd-pkg/apis/kustomize v1.14.0-nelm.1 h1:edZ5ugpeUvmjG+g9laet8qTBqDdQPl18aNr6k0xqdYY= +github.com/werf/3p-fluxcd-pkg/apis/kustomize v1.14.0-nelm.1/go.mod h1:dAboSMVeohict/XrpXrqyZodq+8Qp6dwafzkBzoCHcU= +github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 h1:rYX8cMeryBHH7sNPVSQm1IAVES08TiWvADaZsDj98Wk= +github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1/go.mod h1:14co1+Ub5rW0Bp3Qo4IzCHwEcaw06StyMu7Rv5pMVCY= +github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1 h1:ua0xt66rxKptzbG1zxy3u96qfV8XsFT9Jd2PU8L6mc8= +github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1/go.mod h1:fodaCyMGXGxYYSIdWvokrjki8e+DAhgu6BtzHbH2VJ8= +github.com/werf/3p-helm-controller/api v0.1.4 h1:s7g9UQOrDMUzVE+JtWOP2xApnPOKYlNe1tXkkWCisAw= +github.com/werf/3p-helm-controller/api v0.1.4/go.mod h1:tiPvDerlc5SwKIDmXB8L3kIMJHse+wigueoEGQq+588= +github.com/werf/nelm-source-controller/api v0.1.4 h1:/k3RT+hHdwKHntoebdcjhO+zboJIlljHJZlbcumoY08= +github.com/werf/nelm-source-controller/api v0.1.4/go.mod h1:++j7xw4YVDE8gR9x1HWhIagpo68jE1oEd4+6tMAgXgs= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +helm.sh/helm/v3 v3.19.2 h1:psQjaM8aIWrSVEly6PgYtLu/y6MRSmok4ERiGhZmtUY= +helm.sh/helm/v3 v3.19.2/go.mod h1:gX10tB5ErM+8fr7bglUUS/UfTOO8UUTYWIBH1IYNnpE= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddon/constants.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddon/constants.go new file mode 100644 index 0000000..bb00b7e --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddon/constants.go @@ -0,0 +1,69 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddon + +import "time" + +const ( + // ControllerName is the name of this controller, used for leader election and logging. + ControllerName = "helmclusteraddon-controller" + + // TargetNamespace is the namespace where internal customer resources are created. + TargetNamespace = "d8-operator-helm" + + // FinalizerName is the finalizer added to HelmClusterRepository to ensure cleanup. + FinalizerName = "helm.deckhouse.io/cleanup" + + ConditionTypeReady = "Ready" + ConditionTypeManaged = "Managed" + ConditionTypeInstalled = "Installed" + ConditionTypeUpdateInstalled = "UpdateInstalled" + ConditionTypeConfigurationApplied = "ConfigurationApplied" + ConditionTypePartiallyDegraded = "PartiallyDegraded" + + ReasonInitializing = "Initializing" + ReasonUnmanagedModeActivated = "UnmanagedModeActivated" + ReasonManagedModeActivated = "ManagedModeActivated" + ReasonUpdateSucceeded = "UpdateSucceeded" + ReasonInstallSucceeded = "InstallSucceeded" + ReasonInstallationInProgress = "InstallationInProgress" + ReasonUpdateInProgress = "UpdateInProgress" + ReasonInstallFailed = "InstallFailed" + ReasonUpdateFailed = "UpdateFailed" + + // ReasonProcessing indicates that facade resource is processing. + ReasonProcessing = "Processing" + + // ReasonReconcileFailed indicates a terminal error occurred during the reconcile pipeline. + ReasonReconcileFailed = "ReconcileFailed" + + // LabelManagedBy marks resources as managed by this controller. + LabelManagedBy = "helm.deckhouse.io/managed-by" + + // LabelManagedByValue is the value for the managed-by label. + LabelManagedByValue = "operator-helm" + + // LabelSourceName stores the name of the source facade resource. + LabelSourceName = "helm.deckhouse.io/cluster-addon" + + // InternalHelmReleaseDeployed indicates that specific release fon internal chart release history was deployed + InternalHelmReleaseDeployed = "deployed" + + // ReconcileRetryInterval is the default requeue interval when waiting for non-terminal + // states such as HelmRelease reaching a final condition. + ReconcileRetryInterval = 5 * time.Second +) diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddon/controller.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddon/controller.go new file mode 100644 index 0000000..53946c4 --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddon/controller.go @@ -0,0 +1,49 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddon + +import ( + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/pkg/utils" + helmv2 "github.com/werf/3p-helm-controller/api/v2" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +func SetupWithManager(mgr ctrl.Manager) error { + r := &Reconciler{ + Client: mgr.GetClient(), + } + + return ctrl.NewControllerManagedBy(mgr). + Named(ControllerName). + For(&helmv1alpha1.HelmClusterAddon{}). + Watches( + &sourcev1.HelmChart{}, + handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv2.HelmRelease{}, + handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Complete(r) +} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddon/reconciler.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddon/reconciler.go new file mode 100644 index 0000000..2a40a7c --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddon/reconciler.go @@ -0,0 +1,726 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddon + +import ( + "context" + "errors" + "fmt" + + "github.com/opencontainers/go-digest" + "github.com/werf/3p-fluxcd-pkg/chartutil" + helmv2 "github.com/werf/3p-helm-controller/api/v2" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + helmchartutil "helm.sh/helm/v3/pkg/chartutil" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/pkg/controller/helmclusteraddonchart" + "github.com/deckhouse/operator-helm/pkg/utils" +) + +type Reconciler struct { + Client client.Client +} + +type ReconcileContext struct { + addon *helmv1alpha1.HelmClusterAddon + addonBase *helmv1alpha1.HelmClusterAddon + addonChart *helmv1alpha1.HelmClusterAddonChart + addonRepository *helmv1alpha1.HelmClusterAddonRepository + internalHelmRelease *helmv2.HelmRelease + internalHelmChart *sourcev1.HelmChart + maintenanceModeEnabled bool + err []error +} + +func (r *ReconcileContext) AddonDeepCopy() *helmv1alpha1.HelmClusterAddon { + if r.addonBase == nil { + r.addonBase = r.addon.DeepCopy() + } + + return r.addonBase +} + +func (r *ReconcileContext) GetRepositoryType() (utils.InternalRepositoryType, error) { + return utils.GetRepositoryType(r.addonRepository.Spec.URL) +} + +type pipelineStep struct { + Name string + RunIf func(rctx *ReconcileContext) bool + Action func(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) + StopOnFailure bool + SkipOnError bool +} + +type pipelineStepResult struct { + Requeue bool +} + +func (s *pipelineStepResult) Reconcile() reconcile.Result { + if s.Requeue { + return reconcile.Result{RequeueAfter: ReconcileRetryInterval} + } + + return reconcile.Result{} +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := log.FromContext(ctx).WithValues("helmclusteraddon", req.Name) + + rctx := &ReconcileContext{addon: &helmv1alpha1.HelmClusterAddon{}} + + if err := r.Client.Get(ctx, req.NamespacedName, rctx.addon); err != nil { + if apierrors.IsNotFound(err) { + logger.Info("HelmClusterAddon not found, skipping") + + return reconcile.Result{}, nil + } + + return reconcile.Result{}, fmt.Errorf("getting HelmClusterAddon: %w", err) + } + + rctx.AddonDeepCopy() + + pipeline := []pipelineStep{ + { + Name: "Ensure that conditions initialized", + RunIf: func(rctx *ReconcileContext) bool { + return apimeta.FindStatusCondition(rctx.addon.Status.Conditions, ConditionTypeReady) == nil + }, + Action: r.initializeConditions, + StopOnFailure: true, + }, + { + Name: "Delete resources if deletion timestamp present", + RunIf: func(rctx *ReconcileContext) bool { + return !rctx.addon.DeletionTimestamp.IsZero() + }, + Action: r.reconcileDelete, + StopOnFailure: true, + }, + { + Name: "Add finalizer if it is absent", + RunIf: func(rctx *ReconcileContext) bool { + return !controllerutil.ContainsFinalizer(rctx.addon, FinalizerName) + }, + Action: r.addFinalizer, + StopOnFailure: true, + }, + { + Name: "Stop if maintenance mode is enabled", + RunIf: func(rctx *ReconcileContext) bool { return true }, + Action: r.checkIfMaintenanceModeEnabled, + StopOnFailure: true, + }, + { + Name: "Set status to Processing if needed", + RunIf: func(rctx *ReconcileContext) bool { return !rctx.maintenanceModeEnabled }, + Action: r.setStatusToProcessing, + StopOnFailure: true, + }, + { + Name: "Get HelmClusterAddonRepository", + RunIf: func(rctx *ReconcileContext) bool { return !rctx.maintenanceModeEnabled }, + Action: r.getHelmClusterAddonRepository, + StopOnFailure: true, + }, + { + Name: "Reconcile InternalNelmOperatorHelmChart", + RunIf: func(rctx *ReconcileContext) bool { return !rctx.maintenanceModeEnabled }, + Action: r.reconcileInternalHelmChart, + SkipOnError: true, + }, + { + Name: "Reconcile InternalNelmOperatorHelmRelease", + RunIf: func(rctx *ReconcileContext) bool { + return !rctx.maintenanceModeEnabled && rctx.internalHelmChart != nil && rctx.internalHelmChart.Status.Artifact != nil + }, + Action: r.reconcileInternalHelmRelease, + SkipOnError: true, + }, + { + Name: "Update HelmClusterAddon status", + RunIf: func(rctx *ReconcileContext) bool { return true }, + Action: r.updateStatus, + }, + } + + for _, step := range pipeline { + if step.RunIf(rctx) { + logger.Info("Running step", "step", step.Name) + + if len(rctx.err) > 0 && step.SkipOnError { + logger.Info("Step skipped due to error on the previous step", "step", step.Name) + + continue + } + + if result, err := step.Action(ctx, rctx); err != nil { + if step.StopOnFailure { + return reconcile.Result{}, err + } + + rctx.err = append(rctx.err, err) + } else if result.Requeue { + return result.Reconcile(), nil + } + + logger.Info("Step completed", "step", step.Name) + + continue + } + + logger.Info("Skipping optional step", "step", step.Name) + } + + return reconcile.Result{}, nil +} + +func (r *Reconciler) initializeConditions(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { + + conditionTypes := []string{ + ConditionTypeReady, + ConditionTypeManaged, + } + + for _, t := range conditionTypes { + if apimeta.FindStatusCondition(rctx.addon.Status.Conditions, t) == nil { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: t, + Status: metav1.ConditionUnknown, + Reason: ReasonInitializing, + }) + } + } + + if err := r.Client.Status().Update(ctx, rctx.addon); err != nil { + return pipelineStepResult{}, fmt.Errorf("failed to update helmclusteraddon status conditions: %w", err) + } + + return pipelineStepResult{Requeue: true}, nil +} + +func (r *Reconciler) reconcileDelete(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { + logger := log.FromContext(ctx).WithValues("helmclusteraddon", rctx.addon.Name) + + if !controllerutil.ContainsFinalizer(rctx.addon, FinalizerName) { + return pipelineStepResult{}, nil + } + + if err := r.ensureResourceDeleted(ctx, utils.GetInternalHelmReleaseName(rctx.addon.Name), TargetNamespace, &helmv2.HelmRelease{}); err != nil { + return pipelineStepResult{}, fmt.Errorf("deleting internal helm release: %w", err) + } + + if err := r.ensureResourceDeleted(ctx, utils.GetInternalHelmChartName(rctx.addon.Name), TargetNamespace, &sourcev1.HelmChart{}); err != nil { + return pipelineStepResult{}, fmt.Errorf("deleting internal helm chart: %w", err) + } + + controllerutil.RemoveFinalizer(rctx.addon, FinalizerName) + + if err := r.Client.Update(ctx, rctx.addon); err != nil { + return pipelineStepResult{}, fmt.Errorf("removing finalizer: %w", err) + } + + logger.Info("Cleanup complete") + + return pipelineStepResult{}, nil +} + +func (r *Reconciler) addFinalizer(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { + controllerutil.AddFinalizer(rctx.addon, FinalizerName) + + if err := r.Client.Update(ctx, rctx.addon); err != nil { + return pipelineStepResult{}, fmt.Errorf("failed to add finalizer to helm cluster addon: %w", err) + } + + return pipelineStepResult{Requeue: true}, nil +} + +func (r *Reconciler) checkIfMaintenanceModeEnabled(_ context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { + managedCond := apimeta.FindStatusCondition(rctx.addon.Status.Conditions, ConditionTypeManaged) + + if managedCond == nil { + return pipelineStepResult{}, fmt.Errorf("managed condition is not initialized") + } else if managedCond.Status == metav1.ConditionFalse && rctx.addon.Spec.Maintenance == string(helmv1alpha1.NoResourceReconciliation) { + rctx.maintenanceModeEnabled = true + } + + return pipelineStepResult{}, nil +} + +func (r *Reconciler) setStatusToProcessing(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { + readyCond := apimeta.FindStatusCondition(rctx.addon.Status.Conditions, ConditionTypeReady) + + if rctx.addon.Generation == rctx.addon.Status.ObservedGeneration || + (readyCond != nil && readyCond.Status == metav1.ConditionFalse && readyCond.Reason == ReasonProcessing) { + return pipelineStepResult{}, nil + } + + chartChanged := isChartSpecChanged(rctx.addon) + + valuesChanged := false + specRaw := "" + if rctx.addon.Spec.Values != nil { + specRaw = string(rctx.addon.Spec.Values.Raw) + } + lastRaw := "" + if rctx.addon.Status.LastAppliedValues != nil { + lastRaw = string(rctx.addon.Status.LastAppliedValues.Raw) + } + if specRaw != lastRaw { + valuesChanged = true + } + + if rctx.addon.Status.LastAppliedChart == nil { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeInstalled, + Status: metav1.ConditionFalse, + ObservedGeneration: rctx.addon.Generation, + Reason: ReasonInstallationInProgress, + Message: "", + }) + } else { + if chartChanged { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeUpdateInstalled, + Status: metav1.ConditionFalse, + ObservedGeneration: rctx.addon.Generation, + Reason: ReasonUpdateInProgress, + Message: "", + }) + } + if valuesChanged { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeConfigurationApplied, + Status: metav1.ConditionFalse, + ObservedGeneration: rctx.addon.Generation, + Reason: ReasonUpdateInProgress, + Message: "", + }) + } + + // Neither chart nor values changed (e.g. annotation bump) — treat as values-only change. + if !chartChanged && !valuesChanged { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeConfigurationApplied, + Status: metav1.ConditionFalse, + ObservedGeneration: rctx.addon.Generation, + Reason: ReasonUpdateInProgress, + Message: "", + }) + } + } + + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeReady, + Status: metav1.ConditionFalse, + ObservedGeneration: rctx.addon.Generation, + Reason: ReasonProcessing, + Message: "", + }) + + if err := r.Client.Status().Patch(ctx, rctx.addon, client.MergeFrom(rctx.AddonDeepCopy())); err != nil { + return pipelineStepResult{}, fmt.Errorf("updating helm cluster addon status: %w", err) + } + + return pipelineStepResult{Requeue: true}, nil +} + +func (r *Reconciler) getHelmClusterAddonRepository(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { + rctx.addonRepository = &helmv1alpha1.HelmClusterAddonRepository{} + + if err := r.Client.Get(ctx, types.NamespacedName{Name: rctx.addon.Spec.Chart.HelmClusterAddonRepository}, rctx.addonRepository); err != nil { + if apierrors.IsNotFound(err) { + return pipelineStepResult{}, fmt.Errorf("helm cluster addon repository not found: %w", err) + } + + return pipelineStepResult{}, fmt.Errorf("getting helm cluster addon repository: %w", err) + } + + return pipelineStepResult{}, nil +} + +func (r *Reconciler) reconcileInternalHelmChart(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { + logger := log.FromContext(ctx) + + repoType, err := rctx.GetRepositoryType() + if err != nil { + return pipelineStepResult{}, fmt.Errorf("getting repository type: %w", err) + } + + rctx.internalHelmChart = &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetInternalHelmChartName(rctx.addon.Name), + Namespace: TargetNamespace, + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, r.Client, rctx.internalHelmChart, func() error { + if rctx.internalHelmChart.Labels == nil { + rctx.internalHelmChart.Labels = map[string]string{} + } + + rctx.internalHelmChart.Labels[LabelManagedBy] = LabelManagedByValue + rctx.internalHelmChart.Labels[LabelSourceName] = rctx.addon.Name + rctx.internalHelmChart.Labels[helmclusteraddonchart.LabelSourceName] = utils.GetHelmClusterAddonChartName( + rctx.addon.Spec.Chart.HelmClusterAddonRepository, rctx.addon.Spec.Chart.HelmClusterAddonChartName) + + rctx.internalHelmChart.Spec.Chart = rctx.addon.Spec.Chart.HelmClusterAddonChartName + rctx.internalHelmChart.Spec.Version = rctx.addon.Spec.Chart.Version + + switch repoType { + case utils.InternalHelmRepository: + rctx.internalHelmChart.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ + Kind: sourcev1.HelmRepositoryKind, + Name: rctx.addon.Spec.Chart.HelmClusterAddonRepository, + } + case utils.InternalOCIRepository: + rctx.internalHelmChart.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ + Kind: sourcev1.OCIRepositoryKind, + Name: rctx.addon.Spec.Chart.HelmClusterAddonRepository, + } + default: + return fmt.Errorf("invalid repository type: %s", repoType) + } + + return nil + }) + if err != nil { + return pipelineStepResult{}, fmt.Errorf("cannot create or update internal nelm operator helm chart: %w", err) + } + + if rctx.addon.Spec.Maintenance == string(helmv1alpha1.NoResourceReconciliation) { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeManaged, + Status: metav1.ConditionFalse, + Reason: ReasonUnmanagedModeActivated, + Message: "", + }) + } else { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeManaged, + Status: metav1.ConditionTrue, + Reason: ReasonManagedModeActivated, + Message: "", + }) + } + + if op != controllerutil.OperationResultNone { + logger.Info("Successfully reconciled internal nelm operator helm chart", "operation", op, "repository", rctx.addon.Spec.Chart.HelmClusterAddonRepository, "chart", rctx.addon.Spec.Chart.HelmClusterAddonChartName) + } + + return pipelineStepResult{}, nil +} + +func (r *Reconciler) reconcileInternalHelmRelease(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { + logger := log.FromContext(ctx) + + rctx.internalHelmRelease = &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetInternalHelmReleaseName(rctx.addon.Name), + Namespace: TargetNamespace, + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, r.Client, rctx.internalHelmRelease, func() error { + if rctx.internalHelmRelease.Labels == nil { + rctx.internalHelmRelease.Labels = map[string]string{} + } + + rctx.internalHelmRelease.Labels[LabelManagedBy] = LabelManagedByValue + rctx.internalHelmRelease.Labels[LabelSourceName] = rctx.addon.Name + + rctx.internalHelmRelease.Spec.ReleaseName = rctx.addon.Name + rctx.internalHelmRelease.Spec.TargetNamespace = rctx.addon.Spec.Namespace + rctx.internalHelmRelease.Spec.Values = rctx.addon.Spec.Values + + rctx.internalHelmRelease.Spec.Suspend = false + + if rctx.addon.Spec.Maintenance == string(helmv1alpha1.NoResourceReconciliation) { + rctx.internalHelmRelease.Spec.Suspend = true + } + + rctx.internalHelmRelease.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ + Kind: sourcev1.HelmChartKind, + Name: utils.GetInternalHelmChartName(rctx.addon.Name), + Namespace: TargetNamespace, + } + + return nil + }) + if err != nil { + return pipelineStepResult{}, fmt.Errorf("reconcile internal nelm operator helm release: %w", err) + } + + if op != controllerutil.OperationResultNone { + logger.Info("Successfully reconciled internal nelm operator helm release", "operation", op, "chart", rctx.addon.Spec.Chart.HelmClusterAddonChartName) + } + + return pipelineStepResult{}, nil +} + +func (r *Reconciler) updateStatus(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { + logger := log.FromContext(ctx) + + if rctx.maintenanceModeEnabled { + return pipelineStepResult{}, nil + } + + addonReadyCond := apimeta.FindStatusCondition(rctx.addon.Status.Conditions, ConditionTypeReady) + if addonReadyCond == nil { + return pipelineStepResult{Requeue: true}, nil + } + + joinedErr := errors.Join(rctx.err...) + + if joinedErr != nil { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: ReasonReconcileFailed, + Message: joinedErr.Error(), + }) + rctx.addon.Status.ObservedGeneration = rctx.addon.Generation + + if err := r.Client.Status().Patch(ctx, rctx.addon, client.MergeFrom(rctx.AddonDeepCopy())); err != nil { + return pipelineStepResult{}, fmt.Errorf("updating HelmClusterAddon status on error: %w", err) + } + + return pipelineStepResult{}, nil + } + + if rctx.internalHelmRelease == nil { + return pipelineStepResult{Requeue: true}, nil + } + + if rctx.internalHelmRelease.Status.ObservedGeneration != rctx.internalHelmRelease.Generation { + return pipelineStepResult{Requeue: true}, nil + } + + helmReleaseReadyCond := apimeta.FindStatusCondition(rctx.internalHelmRelease.Status.Conditions, ConditionTypeReady) + if helmReleaseReadyCond == nil { + return pipelineStepResult{Requeue: true}, nil + } + + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeReady, + Status: helmReleaseReadyCond.Status, + Reason: helmReleaseReadyCond.Reason, + Message: helmReleaseReadyCond.Message, + }) + + terminal := false + + helmChartReadyCond := apimeta.FindStatusCondition(rctx.internalHelmChart.Status.Conditions, ConditionTypeReady) + if helmChartReadyCond != nil { + if helmChartReadyCond.Status == metav1.ConditionTrue { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypePartiallyDegraded, + Status: metav1.ConditionFalse, + Reason: helmReleaseReadyCond.Reason, + Message: "", + }) + } else { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypePartiallyDegraded, + Status: metav1.ConditionTrue, + Reason: helmChartReadyCond.Reason, + Message: "", + }) + } + } + + switch helmReleaseReadyCond.Reason { + case helmv2.InstallSucceededReason: + terminal = true + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeInstalled, + Status: metav1.ConditionTrue, + Reason: ReasonInstallSucceeded, + Message: "", + }) + + // Remove UpdateInstalled if present from a prior chart-change cycle. + apimeta.RemoveStatusCondition(&rctx.addon.Status.Conditions, ConditionTypeUpdateInstalled) + + if apimeta.IsStatusConditionPresentAndEqual(rctx.internalHelmChart.Status.Conditions, ConditionTypeReady, metav1.ConditionTrue) && + rctx.internalHelmChart.Generation == rctx.internalHelmChart.Generation { + rctx.addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonChartName: rctx.addon.Spec.Chart.HelmClusterAddonChartName, + HelmClusterAddonRepository: rctx.addon.Spec.Chart.HelmClusterAddonRepository, + Version: rctx.addon.Spec.Chart.Version, + } + } + + rctx.addon.Status.LastAppliedValues = rctx.addon.Spec.Values + case helmv2.UpgradeSucceededReason: + terminal = true + lastAppliedChartUpdateRequired := isLastAppliedChartUpdateRequired(rctx.addon, rctx.internalHelmChart, rctx.internalHelmRelease) + + if lastAppliedChartUpdateRequired { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeUpdateInstalled, + Status: metav1.ConditionTrue, + Reason: ReasonUpdateSucceeded, + Message: "", + }) + rctx.addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonChartName: rctx.addon.Spec.Chart.HelmClusterAddonChartName, + HelmClusterAddonRepository: rctx.addon.Spec.Chart.HelmClusterAddonRepository, + Version: rctx.addon.Spec.Chart.Version, + } + } + + if rctx.addon.Spec.Values != nil { + if addonValues, err := helmchartutil.ReadValues(rctx.addon.Spec.Values.Raw); err != nil { + logger.Error(err, "failed to decode helm cluster addon values; marking ConfigurationApplied without digest verification") + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeConfigurationApplied, + Status: metav1.ConditionTrue, + Reason: ReasonUpdateSucceeded, + Message: "", + }) + rctx.addon.Status.LastAppliedValues = rctx.addon.Spec.Values + } else { + latestRelease := rctx.internalHelmRelease.Status.History.Latest() + if latestRelease != nil && latestRelease.Status == InternalHelmReleaseDeployed && + latestRelease.ConfigDigest == chartutil.DigestValues(digest.Canonical, addonValues).String() { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeConfigurationApplied, + Status: metav1.ConditionTrue, + Reason: ReasonUpdateSucceeded, + Message: "Applied configuration with values digest " + latestRelease.ConfigDigest, + }) + rctx.addon.Status.LastAppliedValues = rctx.addon.Spec.Values + } + } + } else { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeConfigurationApplied, + Status: metav1.ConditionTrue, + Reason: ReasonUpdateSucceeded, + Message: "", + }) + rctx.addon.Status.LastAppliedValues = nil + } + + case helmv2.InstallFailedReason: + terminal = true + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeInstalled, + Status: metav1.ConditionFalse, + Reason: ReasonInstallFailed, + Message: helmReleaseReadyCond.Message, + }) + + case helmv2.UpgradeFailedReason: + terminal = true + if isChartSpecChanged(rctx.addon) { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeUpdateInstalled, + Status: metav1.ConditionFalse, + Reason: ReasonUpdateFailed, + Message: helmReleaseReadyCond.Message, + }) + } else { + apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ + Type: ConditionTypeConfigurationApplied, + Status: metav1.ConditionFalse, + Reason: ReasonUpdateFailed, + Message: helmReleaseReadyCond.Message, + }) + } + + case helmv2.ArtifactFailedReason, helmv2.RollbackFailedReason, helmv2.UninstallFailedReason: + terminal = true + } + + if terminal { + rctx.addon.Status.ObservedGeneration = rctx.addon.Generation + } + + if err := r.Client.Status().Patch(ctx, rctx.addon, client.MergeFrom(rctx.AddonDeepCopy())); err != nil { + return pipelineStepResult{}, fmt.Errorf("updating helm cluster addon status: %w", err) + } + + if !terminal { + return pipelineStepResult{Requeue: true}, nil + } + + return pipelineStepResult{}, nil +} + +func (r *Reconciler) ensureResourceDeleted(ctx context.Context, name, namespace string, obj client.Object) error { + if err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, obj); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + + return fmt.Errorf("checking existence of obsolete resource: %w", err) + } + + if err := r.Client.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("deleting obsolete resource: %w", err) + } + + return nil +} + +func isLastAppliedChartUpdateRequired(addon *helmv1alpha1.HelmClusterAddon, internalHelmChart *sourcev1.HelmChart, internalHelmRelease *helmv2.HelmRelease) bool { + if internalHelmChart.Status.ObservedGeneration != internalHelmChart.Generation { + return false + } + + if internalHelmRelease.Status.ObservedGeneration != internalHelmRelease.Generation { + return false + } + + if apimeta.IsStatusConditionPresentAndEqual(internalHelmChart.Status.Conditions, ConditionTypeReady, metav1.ConditionTrue) && + apimeta.IsStatusConditionPresentAndEqual(internalHelmRelease.Status.Conditions, ConditionTypeReady, metav1.ConditionTrue) && + internalHelmRelease.Status.History.Len() > 1 { + latest := internalHelmRelease.Status.History.Latest() + previous := internalHelmRelease.Status.History.Previous(true) + + if previous != nil && previous.Status == "superseded" && + latest != nil && + (latest.VersionedChartName() != previous.VersionedChartName() || + addon.Spec.Chart.HelmClusterAddonRepository != addon.Status.LastAppliedChart.HelmClusterAddonRepository) { + return true + } + } + + return false +} + +func isChartSpecChanged(addon *helmv1alpha1.HelmClusterAddon) bool { + if addon.Status.LastAppliedChart == nil { + return true + } + + return addon.Spec.Chart.HelmClusterAddonChartName != addon.Status.LastAppliedChart.HelmClusterAddonChartName || + addon.Spec.Chart.HelmClusterAddonRepository != addon.Status.LastAppliedChart.HelmClusterAddonRepository || + addon.Spec.Chart.Version != addon.Status.LastAppliedChart.Version +} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/constants.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/constants.go new file mode 100644 index 0000000..7e8edd2 --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/constants.go @@ -0,0 +1,34 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonchart + +const ( + // ControllerName is the name of this controller, used for leader election and logging. + ControllerName = "helmclusteraddonchart-controller" + + // TargetNamespace is the namespace where internal customer resources are created. + TargetNamespace = "d8-operator-helm" + + // LabelManagedBy marks resources as managed by this controller. + LabelManagedBy = "helm.deckhouse.io/managed-by" + + // LabelManagedByValue is the value for the managed-by label. + LabelManagedByValue = "operator-helm" + + // LabelSourceName stores the name of the source facade resource. + LabelSourceName = "helm.deckhouse.io/cluster-addon-chart" +) diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/controller.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/controller.go new file mode 100644 index 0000000..db5d0d3 --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/controller.go @@ -0,0 +1,43 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonchart + +import ( + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/pkg/utils" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +func SetupWithManager(mgr ctrl.Manager) error { + r := &Reconciler{ + Client: mgr.GetClient(), + } + + return ctrl.NewControllerManagedBy(mgr). + Named(ControllerName). + For(&helmv1alpha1.HelmClusterAddonChart{}). + Watches( + &sourcev1.HelmChart{}, + handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Complete(r) +} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/reconciler.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/reconciler.go new file mode 100644 index 0000000..1202ab1 --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/reconciler.go @@ -0,0 +1,80 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonchart + +import ( + "context" + "fmt" + + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +type Reconciler struct { + Client client.Client +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := log.FromContext(ctx).WithValues("helmclusteraddonchart", req.Name) + + chart := &helmv1alpha1.HelmClusterAddonChart{} + if err := r.Client.Get(ctx, req.NamespacedName, chart); client.IgnoreNotFound(err) != nil { + return ctrl.Result{}, fmt.Errorf("failed to get HelmClusterAddonChart: %w", err) + } + + if !chart.DeletionTimestamp.IsZero() { + return reconcile.Result{}, nil + } + + base := chart.DeepCopy() + + internalCharts := &sourcev1.HelmChartList{} + if err := r.Client.List(ctx, internalCharts, client.InNamespace(TargetNamespace)); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to list internal helm chart list: %w", err) + } + + needsUpdate := false + for i, v := range chart.Status.Versions { + found := false + for _, child := range internalCharts.Items { + if child.Spec.Version == v.Version && child.Status.Artifact != nil { + found = true + break + } + } + + if chart.Status.Versions[i].Pulled != found { + chart.Status.Versions[i].Pulled = found + needsUpdate = true + } + } + + if needsUpdate { + if err := r.Client.Status().Patch(ctx, chart, client.MergeFrom(base)); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to update HelmClusterAddonChart status: %w", err) + } + + logger.Info("HelmClusterAddonChart successfully reconciled") + } + + return reconcile.Result{}, nil +} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/client.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/client.go new file mode 100644 index 0000000..37b1651 --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/client.go @@ -0,0 +1,111 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonrepository + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "go.yaml.in/yaml/v3" + "k8s.io/apimachinery/pkg/util/wait" +) + +var HelmRepositoryDefaultClient HelmRepositoryClient + +type HelmRepositoryClient struct{} + +func (c *HelmRepositoryClient) FetchCharts(ctx context.Context, url string) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) { + if !strings.HasSuffix(url, "/index.yaml") { + url += "/index.yaml" + } + + var indexFile HelmRepositoryIndex + + backoff := wait.Backoff{ + Duration: 1 * time.Second, // Initial delay + Factor: 2.0, // Double the delay each time + Jitter: 0.1, // Add 10% randomness to prevent the thundering herd problem + Steps: 3, // Maximum number of retries (1s, 2s, 4s, 8s, 16s) + } + + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + err := wait.ExponentialBackoffWithContext(ctx, backoff, func(ctx context.Context) (done bool, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return true, fmt.Errorf("creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, nil + } + defer resp.Body.Close() + + if resp.StatusCode >= 500 { + return false, nil + } + + if resp.StatusCode >= 400 { + return true, fmt.Errorf("fatal client error: received status %d", resp.StatusCode) + } + + if err := yaml.NewDecoder(resp.Body).Decode(&indexFile); err != nil { + return true, fmt.Errorf("cannot decode response: %w", err) + } + + return true, nil + }) + if err != nil { + return nil, fmt.Errorf("helm repository index.yaml request failed: %w", err) + } + + charts := make(map[string][]helmv1alpha1.HelmClusterAddonChartVersion) + + for chartName, chartInfo := range indexFile.Entries { + charts[chartName] = make([]helmv1alpha1.HelmClusterAddonChartVersion, 0) + + for _, chartVersion := range chartInfo { + if chartVersion.Removed { + continue + } + + charts[chartName] = append(charts[chartName], helmv1alpha1.HelmClusterAddonChartVersion{ + Version: chartVersion.Version, + Digest: chartVersion.Digest, + }) + } + } + + return charts, nil +} + +type HelmRepositoryIndex struct { + APIVersion string `json:"apiVersion"` + Entries map[string][]HelmRepositoryChartVersion `json:"entries"` +} + +type HelmRepositoryChartVersion struct { + Version string `json:"version"` + Digest string `json:"digest"` + Removed bool `json:"removed,omitempty"` +} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/constants.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/constants.go new file mode 100644 index 0000000..7459ebe --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/constants.go @@ -0,0 +1,66 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonrepository + +import "time" + +const ( + // ControllerName is the name of this controller, used for leader election and logging. + ControllerName = "helmclusteraddonrepository-controller" + + // TargetNamespace is the namespace where internal customer resources are created. + TargetNamespace = "d8-operator-helm" + + // FinalizerName is the finalizer added to HelmClusterRepository to ensure cleanup. + FinalizerName = "helm.deckhouse.io/cleanup" + + // ConditionTypeReady is the condition type for readiness. + ConditionTypeReady = "Ready" + + // ConditionTypeSynced is the condition type to track chart sync status + ConditionTypeSynced = "Synced" + + // ReasonMirrorFailed indicates the internal HelmRepository create/update failed. + ReasonMirrorFailed = "MirrorFailed" + + // ReasonSyncSucceeded indicates that chart sync was successfully completed. + ReasonSyncSucceeded = "SyncSucceeded" + + // ReasonSyncInProgress indicates that chart sync is in progress. + ReasonSyncInProgress = "ReasonSyncInProgress" + + // ReasonSyncFailed indicates that charts sync was failed. + ReasonSyncFailed = "SyncFailed" + + // ReasonCleanupFailed indicates deletion of the internal HelmRepository failed. + ReasonCleanupFailed = "CleanupFailed" + + // LabelManagedBy marks resources as managed by this controller. + LabelManagedBy = "helm.deckhouse.io/managed-by" + + // LabelManagedByValue is the value for the managed-by label. + LabelManagedByValue = "operator-helm" + + // LabelSourceName stores the name of the source facade resource. + LabelSourceName = "helm.deckhouse.io/cluster-addon-repository" + + // DefaultInterval is the default reconciliation interval for the internal repository. + DefaultInterval = 5 * time.Minute + + // DefaultSyncInterval is the default repository charts sync interval. + DefaultSyncInterval = 5 * time.Minute +) diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/controller.go new file mode 100644 index 0000000..6509b09 --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/controller.go @@ -0,0 +1,54 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonrepository + +import ( + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/pkg/utils" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +func SetupWithManager(mgr ctrl.Manager) error { + r := &Reconciler{ + Client: mgr.GetClient(), + } + + return ctrl.NewControllerManagedBy(mgr). + Named(ControllerName). + For(&helmv1alpha1.HelmClusterAddonRepository{}). + Watches( + &sourcev1.HelmRepository{}, + handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &sourcev1.OCIRepository{}, + handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Complete(r) +} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/reconciler.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/reconciler.go new file mode 100644 index 0000000..9f39862 --- /dev/null +++ b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/reconciler.go @@ -0,0 +1,519 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonrepository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/deckhouse/operator-helm/pkg/utils" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/werf/3p-fluxcd-pkg/apis/meta" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" +) + +// Reconciler reconciles HelmClusterRepository objects by mirroring them +// to namespaced HelmRepository resources in the target namespace. +type Reconciler struct { + Client client.Client +} + +// Reconcile implements reconcile.Reconciler. +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := log.FromContext(ctx).WithValues("helmclusterrepository", req.Name) + + var repo helmv1alpha1.HelmClusterAddonRepository + + if err := r.Client.Get(ctx, req.NamespacedName, &repo); err != nil { + if apierrors.IsNotFound(err) { + logger.Info("HelmClusterAddonRepository not found, skipping") + + return reconcile.Result{}, nil + } + + return reconcile.Result{}, fmt.Errorf("getting HelmClusterAddonRepository: %w", err) + } + + repoType, err := utils.GetRepositoryType(repo.Spec.URL) + if err != nil { + return reconcile.Result{}, err + } + + if !repo.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, &repo, repoType) + } + + if !controllerutil.ContainsFinalizer(&repo, FinalizerName) { + controllerutil.AddFinalizer(&repo, FinalizerName) + + if err := r.Client.Update(ctx, &repo); err != nil { + return reconcile.Result{}, fmt.Errorf("adding finalizer: %w", err) + } + + return r.requeueAtSyncInterval(&repo) + } + + switch repoType { + case utils.InternalHelmRepository: + return r.reconcileInternalHelmRepository(ctx, &repo) + case utils.InternalOCIRepository: + return r.reconcileInternalOCIRepository(ctx, &repo) + default: + return r.requeueAtSyncInterval(&repo) + } +} + +func (r *Reconciler) reconcileInternalHelmRepository(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { + logger := log.FromContext(ctx) + + if err := r.reconcileInternalRepositoryAuthSecret(ctx, repo, utils.InternalHelmRepository); err != nil { + return reconcile.Result{}, err + } + + if err := r.reconcileInternalRepositoryTLSSecret(ctx, repo, utils.InternalHelmRepository); err != nil { + return reconcile.Result{}, err + } + + existing := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: repo.Name, + Namespace: TargetNamespace, + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, r.Client, existing, func() error { + existing.Spec.URL = repo.Spec.URL + existing.Spec.Interval = metav1.Duration{Duration: DefaultInterval} + existing.Spec.Insecure = !repo.Spec.TLSVerify + existing.Spec.CertSecretRef = nil + existing.Spec.SecretRef = nil + + if repo.Spec.Auth != nil { + existing.Spec.SecretRef = &meta.LocalObjectReference{ + Name: utils.GetInternalRepositoryAuthSecretName(utils.InternalHelmRepository, repo.Name), + } + existing.Spec.PassCredentials = true + } + + if repo.Spec.CACertificate != "" { + existing.Spec.CertSecretRef = &meta.LocalObjectReference{ + Name: utils.GetInternalRepositoryTLSSecretName(utils.InternalHelmRepository, repo.Name), + } + } + + existing.Labels = map[string]string{ + LabelManagedBy: LabelManagedByValue, + LabelSourceName: repo.Name, + } + + return nil + }) + if err != nil { + return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("reconciling helm repository: %w", err), ReasonMirrorFailed) + } + + if op != controllerutil.OperationResultNone { + logger.Info("Successfully reconciled helm repository", "operation", op) + } + + if changed, err := r.updateSuccessStatus(ctx, repo, existing.Status.Conditions); err != nil { + return reconcile.Result{}, fmt.Errorf("updating status after repository reconcile: %w", err) + } else if changed { + return r.requeueAtSyncInterval(repo) + } + + if apimeta.IsStatusConditionPresentAndEqual(repo.Status.Conditions, ConditionTypeReady, metav1.ConditionTrue) { + return r.reconcileHelmRepositoryCharts(ctx, repo) + } + + return r.requeueAtSyncInterval(repo) +} + +func (r *Reconciler) reconcileHelmRepositoryCharts(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { + logger := log.FromContext(ctx) + + syncCond := apimeta.FindStatusCondition(repo.Status.Conditions, ConditionTypeSynced) + if syncCond != nil && syncCond.Status == metav1.ConditionTrue && syncCond.LastTransitionTime.UTC().Add(DefaultSyncInterval).After(time.Now().UTC()) { + return r.requeueAtSyncInterval(repo) + } else if syncCond == nil || syncCond.Reason != ReasonSyncInProgress { + if err := r.updateSyncCondition(ctx, repo, metav1.ConditionFalse, ReasonSyncInProgress, ""); err != nil { + return reconcile.Result{}, fmt.Errorf("updating sync condition: %w", err) + } + + return r.requeueAtSyncInterval(repo) + } + + charts, err := HelmRepositoryDefaultClient.FetchCharts(ctx, repo.Spec.URL) + if err != nil { + return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeSynced, fmt.Errorf("cannot fetch chart info from repository: %w", err), ReasonSyncFailed) + } + + for chart, versions := range charts { + existing := &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetHelmClusterAddonChartName(repo.Name, chart), + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, r.Client, existing, func() error { + existing.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: repo.APIVersion, + Kind: repo.Kind, + Name: repo.Name, + UID: repo.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }, + } + + existing.Labels = map[string]string{ + LabelManagedBy: LabelManagedByValue, + LabelSourceName: repo.Name, + } + + return nil + }) + if err != nil { + if statusUpdateErr := r.updateSyncCondition(ctx, repo, metav1.ConditionFalse, ReasonSyncFailed, ""); statusUpdateErr != nil { + return reconcile.Result{}, fmt.Errorf("failed to update sync condition: %w", err) + } + + return reconcile.Result{}, fmt.Errorf("cannot create or update HelmClusterAddonChart: %w", err) + } + + existingVersionsMap := make(map[string]helmv1alpha1.HelmClusterAddonChartVersion) + for _, version := range existing.Status.Versions { + existingVersionsMap[version.Version] = version + } + + for i, version := range versions { + if existingVersion, found := existingVersionsMap[version.Version]; found && version.Digest == existingVersion.Digest { + versions[i].Pulled = existingVersion.Pulled + } + } + + if op != controllerutil.OperationResultNone { + logger.Info("Successfully reconciled HelmClusterAddonChart", "operation", op, "repository", repo.Name, "chart", chart) + } + + base := existing.DeepCopy() + existing.Status.Versions = versions + + if err := r.Client.Status().Patch(ctx, existing, client.MergeFrom(base)); err != nil { + if statusUpdateErr := r.updateSyncCondition(ctx, repo, metav1.ConditionFalse, ReasonSyncFailed, ""); statusUpdateErr != nil { + return reconcile.Result{}, fmt.Errorf("failed to update sync condition: %w", err) + } + + return reconcile.Result{}, fmt.Errorf("failed to update chart status: %w", err) + } + + logger.Info("Successfully sync HelmClusterAddonChart versions", "operation", op, "repository", repo.Name, "chart", chart) + } + + logger.Info(fmt.Sprintf("Scheduling next helm charts sync in %s", DefaultSyncInterval)) + + if err := r.updateSyncCondition(ctx, repo, metav1.ConditionTrue, ReasonSyncSucceeded, ""); err != nil { + return reconcile.Result{}, fmt.Errorf("updating sync condition: %w", err) + } + + return reconcile.Result{RequeueAfter: DefaultSyncInterval}, nil +} + +func (r *Reconciler) updateSyncCondition(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, status metav1.ConditionStatus, reason, message string) error { + base := repo.DeepCopy() + + apimeta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{ + Type: ConditionTypeSynced, + Status: status, + ObservedGeneration: repo.Generation, + Reason: reason, + Message: message, + }) + + if err := r.Client.Status().Patch(ctx, repo, client.MergeFrom(base)); err != nil { + return err + } + + return nil +} + +func (r *Reconciler) reconcileInternalOCIRepository(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { + logger := log.FromContext(ctx) + + if err := r.reconcileInternalRepositoryAuthSecret(ctx, repo, utils.InternalOCIRepository); err != nil { + return reconcile.Result{}, err + } + + if err := r.reconcileInternalRepositoryTLSSecret(ctx, repo, utils.InternalOCIRepository); err != nil { + return reconcile.Result{}, err + } + + existing := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: repo.Name, + Namespace: TargetNamespace, + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, r.Client, existing, func() error { + existing.Spec.URL = repo.Spec.URL + existing.Spec.Interval = metav1.Duration{Duration: DefaultInterval} + existing.Spec.Insecure = !repo.Spec.TLSVerify + existing.Spec.CertSecretRef = nil + existing.Spec.SecretRef = nil + + if repo.Spec.Auth != nil { + existing.Spec.SecretRef = &meta.LocalObjectReference{ + Name: utils.GetInternalRepositoryAuthSecretName(utils.InternalOCIRepository, repo.Name), + } + } + + if repo.Spec.CACertificate != "" { + existing.Spec.CertSecretRef = &meta.LocalObjectReference{ + Name: utils.GetInternalRepositoryTLSSecretName(utils.InternalOCIRepository, repo.Name), + } + } + + existing.Labels = map[string]string{ + LabelManagedBy: LabelManagedByValue, + LabelSourceName: repo.Name, + } + + return nil + }) + if err != nil { + return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("reconciling oci repository: %w", err), ReasonMirrorFailed) + } + + if op != controllerutil.OperationResultNone { + logger.Info("Successfully reconciled oci repository", "operation", op) + } else { + // TODO: implement chats sync for OCI repository + } + + if _, err := r.updateSuccessStatus(ctx, repo, existing.Status.Conditions); err != nil { + return reconcile.Result{}, err + } + + return r.requeueAtSyncInterval(repo) +} + +func (r *Reconciler) reconcileInternalRepositoryAuthSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) error { + secretName := utils.GetInternalRepositoryAuthSecretName(repoType, repo.Name) + + if repo.Spec.Auth == nil { + if err := r.ensureResourceDeleted(ctx, secretName, TargetNamespace, &corev1.Secret{}); err != nil { + return fmt.Errorf("cannot delete obsolete auth secret: %w", err) + } + + return nil + } + + authSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: TargetNamespace, + }, + } + + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, authSecret, func() error { + authSecret.Labels = map[string]string{ + LabelManagedBy: LabelManagedByValue, + LabelSourceName: repo.Name, + } + + authSecret.StringData = map[string]string{ + "username": repo.Spec.Auth.Username, + "password": repo.Spec.Auth.Password, + } + + return nil + }); err != nil { + return fmt.Errorf("cannot reconcile auth secret: %w", err) + } + + return nil +} + +func (r *Reconciler) reconcileInternalRepositoryTLSSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) error { + secretName := utils.GetInternalRepositoryTLSSecretName(repoType, repo.Name) + + if repo.Spec.CACertificate == "" { + if err := r.ensureResourceDeleted(ctx, secretName, TargetNamespace, &corev1.Secret{}); err != nil { + return fmt.Errorf("cannot delete obsolete tls secret: %w", err) + } + + return nil + } + + // TODO: consider adding CA certificate format validation + + tlsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: TargetNamespace, + }, + } + + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, tlsSecret, func() error { + tlsSecret.Labels = map[string]string{ + LabelManagedBy: LabelManagedByValue, + LabelSourceName: repo.Name, + } + + tlsSecret.StringData = map[string]string{ + "ca.crt": repo.Spec.CACertificate, + } + + return nil + }); err != nil { + return fmt.Errorf("cannot reconcile tls secret: %w", err) + } + + return nil +} + +func (r *Reconciler) ensureResourceDeleted(ctx context.Context, name, namespace string, obj client.Object) error { + if err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, obj); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + + return fmt.Errorf("checking existence of obsolete resource: %w", err) + } + + if err := r.Client.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("deleting obsolete resource: %w", err) + } + + return nil +} + +func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) (reconcile.Result, error) { + logger := log.FromContext(ctx).WithValues("helmclusteraddonrepository", repo.Name) + + if !controllerutil.ContainsFinalizer(repo, FinalizerName) { + return reconcile.Result{}, nil + } + + if err := r.ensureResourceDeleted( + ctx, + utils.GetInternalRepositoryAuthSecretName(repoType, repo.Name), + TargetNamespace, + &corev1.Secret{}, + ); err != nil { + return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("deleting internal auth secret: %w", err), ReasonCleanupFailed) + } + + if err := r.ensureResourceDeleted( + ctx, + utils.GetInternalRepositoryTLSSecretName(repoType, repo.Name), + TargetNamespace, + &corev1.Secret{}, + ); err != nil { + return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("deleting internal tls secret: %w", err), ReasonCleanupFailed) + } + + var internalRepository client.Object + + switch repoType { + case utils.InternalHelmRepository: + internalRepository = &sourcev1.HelmRepository{} + case utils.InternalOCIRepository: + internalRepository = &sourcev1.OCIRepository{} + default: + return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("cannot remove unsupported repisotory type: %s", repoType), ReasonCleanupFailed) + } + + if err := r.ensureResourceDeleted(ctx, repo.Name, TargetNamespace, internalRepository); err != nil { + return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("deleting internal repository: %w", err), ReasonCleanupFailed) + } + + controllerutil.RemoveFinalizer(repo, FinalizerName) + + if err := r.Client.Update(ctx, repo); err != nil { + return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + } + + logger.Info("Cleanup complete") + + return reconcile.Result{}, nil +} + +func (r *Reconciler) patchStatusError(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, conditionType string, reconcileErr error, reason string) error { + base := repo.DeepCopy() + + apimeta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionFalse, + Reason: reason, + Message: reconcileErr.Error(), + }) + + if patchErr := r.Client.Status().Patch(ctx, repo, client.MergeFrom(base)); patchErr != nil { + return errors.Join(reconcileErr, fmt.Errorf("failed to patch status: %w", patchErr)) + } + + return reconcileErr +} + +func (r *Reconciler) requeueAtSyncInterval(repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { + repoSyncCond := apimeta.FindStatusCondition(repo.Status.Conditions, ConditionTypeSynced) + if repoSyncCond != nil { + remaining := time.Until(repoSyncCond.LastTransitionTime.Add(DefaultSyncInterval)) + if remaining > 0 { + return reconcile.Result{RequeueAfter: remaining}, nil + } + } + + return reconcile.Result{RequeueAfter: DefaultSyncInterval}, nil +} + +func (r *Reconciler) updateSuccessStatus(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, internalConditions []metav1.Condition) (bool, error) { + var changed bool + + base := repo.DeepCopy() + + internalReadyCond := apimeta.FindStatusCondition(internalConditions, meta.ReadyCondition) + if internalReadyCond != nil { + changed = apimeta.SetStatusCondition(&repo.Status.Conditions, *internalReadyCond) + } + + if changed { + repo.Status.ObservedGeneration = repo.Generation + + if err := r.Client.Status().Patch(ctx, repo, client.MergeFrom(base)); err != nil { + return false, fmt.Errorf("patching status: %w", err) + } + } + + return changed, nil +} diff --git a/images/operator-helm-artifact/pkg/utils/mapper.go b/images/operator-helm-artifact/pkg/utils/mapper.go new file mode 100644 index 0000000..c3f390b --- /dev/null +++ b/images/operator-helm-artifact/pkg/utils/mapper.go @@ -0,0 +1,59 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func MapInternalToFacade(targetNamespace, labelManagedBy, labelManagedByValue, labelSourceName string) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + logger := log.FromContext(ctx) + + if obj.GetNamespace() != targetNamespace { + return nil + } + + labels := obj.GetLabels() + if labels[labelManagedBy] != labelManagedByValue { + return nil + } + + sourceName := labels[labelSourceName] + if sourceName == "" { + logger.Info("resource missing source label, skipping", + "name", obj.GetName(), "namespace", obj.GetNamespace()) + + return nil + } + + return []reconcile.Request{ + { + NamespacedName: types.NamespacedName{ + Name: sourceName, + Namespace: "", + }, + }, + } + } +} diff --git a/images/operator-helm-artifact/pkg/utils/name.go b/images/operator-helm-artifact/pkg/utils/name.go new file mode 100644 index 0000000..b9cd970 --- /dev/null +++ b/images/operator-helm-artifact/pkg/utils/name.go @@ -0,0 +1,112 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "fmt" + "hash/fnv" + "strings" +) + +func GetHash(s string) string { + h := fnv.New32a() + + _, _ = h.Write([]byte(s)) + + return fmt.Sprintf("%x", h.Sum32()) +} + +func GetInternalRepositoryAuthSecretName(repoType InternalRepositoryType, internalRepoName string) string { + prefix := "auth" + + hash := GetHash(fmt.Sprintf("%s-%s-%s", prefix, repoType, internalRepoName)) + + var result, postfix string + + result = prefix + "-" + string(repoType) + "-" + + if len(internalRepoName) > 35 { + result += internalRepoName[:35] + postfix = "-" + hash + } else { + result += internalRepoName + } + + return strings.TrimRight(result, "-") + postfix +} + +func GetInternalRepositoryTLSSecretName(repoType InternalRepositoryType, internalRepoName string) string { + prefix := "tls" + + hash := GetHash(fmt.Sprintf("%s-%s-%s", prefix, repoType, internalRepoName)) + + var result, postfix string + + result = prefix + "-" + string(repoType) + "-" + + if len(internalRepoName) > 35 { + result += internalRepoName[:35] + postfix = "-" + hash + } else { + result += internalRepoName + } + + return strings.TrimRight(result, "-") + postfix +} + +func GetHelmClusterAddonChartName(repoName, addonName string) string { + hash := GetHash(fmt.Sprintf("%s-%s", repoName, addonName)) + + var result, postfix string + + if len(repoName) > 20 { + result += repoName[:20] + postfix = "-" + hash + } else { + result += repoName + } + + if len(addonName) > 20 { + result += "-" + addonName[:20] + postfix = "-" + hash + } else { + result += "-" + addonName + } + + return strings.TrimRight(result, "-") + postfix +} + +func GetInternalHelmReleaseName(addonName string) string { + prefix := "addon" + hash := GetHash(fmt.Sprintf("%s-%s", prefix, addonName)) + + result := prefix + "-" + postfix := "" + + if len(addonName) > 40 { + result += addonName[:40] + postfix = "-" + hash + } else { + result += addonName + } + + return strings.TrimRight(result, "-") + postfix +} + +func GetInternalHelmChartName(addonName string) string { + return GetInternalHelmReleaseName(addonName) +} diff --git a/images/operator-helm-artifact/pkg/utils/repository.go b/images/operator-helm-artifact/pkg/utils/repository.go new file mode 100644 index 0000000..ad6da7c --- /dev/null +++ b/images/operator-helm-artifact/pkg/utils/repository.go @@ -0,0 +1,45 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "fmt" + "net/url" +) + +type InternalRepositoryType string + +const ( + InternalHelmRepository InternalRepositoryType = "helm" + InternalOCIRepository InternalRepositoryType = "oci" +) + +func GetRepositoryType(s string) (InternalRepositoryType, error) { + parsedURL, err := url.Parse(s) + if err != nil { + return "", fmt.Errorf("cannot parse url: %w", err) + } + + switch parsedURL.Scheme { + case "http", "https": + return InternalHelmRepository, nil + case "oci": + return InternalOCIRepository, nil + default: + return "", fmt.Errorf("unsupported repository schema in use: %s", parsedURL.Scheme) + } +} diff --git a/images/operator-helm-artifact/werf.inc.yaml b/images/operator-helm-artifact/werf.inc.yaml new file mode 100644 index 0000000..165af3d --- /dev/null +++ b/images/operator-helm-artifact/werf.inc.yaml @@ -0,0 +1,57 @@ +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact +final: false +fromImage: builder/src +git: +- add: {{ .ModuleDir }}/api + to: /src/api + stageDependencies: + install: + - go.mod + - go.sum + setup: + - "**/*.go" +- add: {{ .ModuleDir }}/images/{{ .ImageName }} + to: /src/images/operator-helm-artifact + stageDependencies: + install: + - go.mod + - go.sum + setup: + - "**/*.go" +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }} +final: false +fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-bookworm-1.25" "builder/golang-alt-svace-1.25" }} +import: +- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact + add: /src + to: /src + before: install +# TODO: uncomment as soon as CI will be ready +# secrets: +# - id: GOPROXY +# value: {{ .GOPROXY }} +# mount: +# - fromPath: ~/go-pkg-cache +# to: /go/pkg +shell: + install: + # TODO: uncomment as soon as CI will be ready + # - export GOPROXY=$(cat /run/secrets/GOPROXY) + - cd /src/images/operator-helm-artifact + - go mod download + setup: + - cd /src/images/operator-helm-artifact + - mkdir /out + - export GOOS=linux + - export GOARCH=amd64 + - export CGO_ENABLED=0 + + - | + echo "Build operator-helm-controller binary" + {{- $_ := set $ "ProjectName" (list $.ImageName "operator-helm-controller" | join "/") }} + + {{- $buildCommand := printf "go build -ldflags=\"-s -w\" -tags %s -v -a -o /out/operator-helm-controller ./cmd/operator-helm-controller" .MODULE_EDITION -}} + {{- include "image-build.build" (set $ "BuildCommand" $buildCommand) | nindent 4 }} + diff --git a/images/operator-helm-controller/mount-points.yaml b/images/operator-helm-controller/mount-points.yaml new file mode 100644 index 0000000..eefff43 --- /dev/null +++ b/images/operator-helm-controller/mount-points.yaml @@ -0,0 +1 @@ +dirs: [] diff --git a/images/operator-helm-controller/werf.inc.yaml b/images/operator-helm-controller/werf.inc.yaml new file mode 100644 index 0000000..6656877 --- /dev/null +++ b/images/operator-helm-controller/werf.inc.yaml @@ -0,0 +1,16 @@ +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }} +fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} +import: +- image: {{ .ModuleNamePrefix }}operator-helm-artifact + add: /out/operator-helm-controller + to: /app/operator-helm-controller + after: install +imageSpec: + config: + user: 64535 + workingDir: "/app" + entrypoint: ["/app/operator-helm-controller"] + diff --git a/module.yaml b/module.yaml new file mode 100644 index 0000000..214f98c --- /dev/null +++ b/module.yaml @@ -0,0 +1,23 @@ +name: operator-helm +stage: Experimental +requirements: + deckhouse: ">= 1.69" +subsystems: + # TODO: confirm with TL + - delivery +namespace: d8-operator-helm +descriptions: + en: An operator to deploy helm applications declaratively. + ru: Оператор для декларативного развертывания helm-приложений. +# TODO: confirm with TL +tags: ["delivery"] +disable: + confirmation: true + message: "Disabling of this module can cause disruptions in deployed applications operation." +accessibility: + editions: + # TODO: confirm with TL + _default: + available: true + enabledInBundles: + - Minimal diff --git a/openapi/config-values.yaml b/openapi/config-values.yaml new file mode 100644 index 0000000..7097068 --- /dev/null +++ b/openapi/config-values.yaml @@ -0,0 +1,26 @@ +type: object +properties: + highAvailability: + type: boolean + x-examples: [true, false] + description: | + Manually enable the high availability (HA) mode. + + By default, Deckhouse automatically decides whether to enable the HA mode. + To learn more about the HA mode, refer to [High reliability and availability](/products/kubernetes-platform/documentation/v1/admin/configuration/high-reliability-and-availability/enable.html#enabling-ha-mode-for-individual-components). + logLevel: + type: string + default: info + description: | + Sets a logging level. + + Working for this components: + - `helm-controller` + - `nelm-source-controller` + - `kube-api-rewriter` + - `deckhouse-helm-controller` + enum: + - "debug" + - "info" + - "warn" + - "error" diff --git a/openapi/doc-ru-config-values.yaml b/openapi/doc-ru-config-values.yaml new file mode 100644 index 0000000..f8c368c --- /dev/null +++ b/openapi/doc-ru-config-values.yaml @@ -0,0 +1,24 @@ +type: object +properties: + highAvailability: + description: | + Ручное управление режимом отказоустойчивости. + + По умолчанию режим отказоустойчивости определяется автоматически. + Подробнее про режим отказоустойчивости можно прочитать в разделе [Высокая надежность и доступность](/products/kubernetes-platform/documentation/v1/admin/configuration/high-reliability-and-availability/enable.html#включение-режима-ha-для-отдельных-компонентов). + logLevel: + type: string + default: info + description: | + Устанавливает уровень логирования. + + Работает для следующих компонентов: + - `helm-controller` + - `nelm-source-controller` + - `kube-api-rewriter` + - `deckhouse-helm-controller` + enum: + - "debug" + - "info" + - "warn" + - "error" diff --git a/openapi/values.yaml b/openapi/values.yaml new file mode 100644 index 0000000..47187f8 --- /dev/null +++ b/openapi/values.yaml @@ -0,0 +1,38 @@ +x-extend: + schema: config-values.yaml +type: object +properties: + internal: + type: object + default: {} + properties: + controller: + type: object + default: {} + properties: + cert: + type: object + default: {} + properties: + ca: + type: string + default: "" + crt: + type: string + default: "" + key: + type: string + default: "" + rootCA: + type: object + default: {} + properties: + ca: + type: string + default: "" + crt: + type: string + default: "" + key: + type: string + default: "" \ No newline at end of file diff --git a/oss.yaml b/oss.yaml new file mode 100644 index 0000000..3d56609 --- /dev/null +++ b/oss.yaml @@ -0,0 +1,12 @@ +- name: 3p-helm-controller + link: https://github.com/werf/3p-helm-controller + description: The helm-controller is a Kubernetes operator, allowing one to declaratively manage Helm chart releases. + license: Apache License 2.0 + version: v0.1.3 + id: 3p-helm-controller +- name: nelm-source-controller + link: https://github.com/werf/nelm-source-controller + description: The source-controller is a Kubernetes operator, specialised in artifacts acquisition from external sources such as Git, OCI, Helm repositories and S3-compatible buckets. + license: Apache License 2.0 + version: v0.1.4 + id: nelm-source-controller diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..8a9dc39 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,6 @@ +dependencies: +- name: deckhouse_lib_helm + repository: https://deckhouse.github.io/lib-helm + version: 1.55.1 +digest: sha256:5bdef3964d2672b8ff290f32e22569bc502e040e4e70274cab1762f27d9982e0 +generated: "2026-02-16T03:37:06.63855+03:00" diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl new file mode 100644 index 0000000..38cd5d2 --- /dev/null +++ b/templates/_helpers.tpl @@ -0,0 +1,19 @@ +{{- /* Return logLevel as a string. */}} +{{- define "moduleLogLevel" -}} +{{- dig "logLevel" "" .Values.operatorHelm -}} +{{- end }} + +{{- define "priorityClassName" -}} +system-cluster-critical +{{- end }} + +{{- define "vpa.policyUpdateMode" -}} +{{- $kubeVersion := .Values.global.discovery.kubernetesVersion -}} +{{- $updateMode := "" -}} +{{- if semverCompare ">=1.33.0" $kubeVersion -}} +{{- $updateMode = "InPlaceOrRecreate" -}} +{{- else -}} +{{- $updateMode = "Recreate" -}} +{{- end }} +{{- $updateMode }} +{{- end }} diff --git a/templates/admision-policy.yaml b/templates/admision-policy.yaml new file mode 100644 index 0000000..9339379 --- /dev/null +++ b/templates/admision-policy.yaml @@ -0,0 +1,62 @@ +{{- $kubeVersion := .Values.global.discovery.kubernetesVersion }} +{{- $apiVersion := "" }} +{{- if semverCompare ">=1.30.0" $kubeVersion }} +{{- $apiVersion = "admissionregistration.k8s.io/v1" }} +{{- else if semverCompare ">=1.28.0" $kubeVersion }} +{{- $apiVersion = "admissionregistration.k8s.io/v1beta1" }} +{{- else if semverCompare ">=1.26.0" $kubeVersion }} +{{- $apiVersion = "admissionregistration.k8s.io/v1alpha1" }} +{{- end }} + +{{- if $apiVersion }} +apiVersion: {{ $apiVersion }} +kind: ValidatingAdmissionPolicy +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: operator-helm-restricted-access-policy +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: + - "helm.internal.operator-helm.deckhouse.io" + - "source.internal.operator-helm.deckhouse.io" + apiVersions: ["*"] + operations: + - "CREATE" + - "UPDATE" + - "DELETE" + resources: ["*"] + - apiGroups: + - "helm.deckhouse.io" + apiVersions: ["*"] + operations: + - "CREATE" + - "UPDATE" + - "DELETE" + resources: + - "helmclusteraddoncharts" + validations: + - expression: | + request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || + request.userInfo.username.startsWith("system:serviceaccount:d8-system:") || + request.userInfo.username in [ + "system:serviceaccount:d8-operator-helm:operator-helm-controller", + "system:serviceaccount:d8-operator-helm:nelm-source-controller", + "system:serviceaccount:d8-operator-helm:helm-controller", + ] + message: "Operation forbidden for this user." +--- +apiVersion: {{ $apiVersion }} +kind: ValidatingAdmissionPolicyBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: operator-helm-restricted-access-policy-binding +spec: + policyName: operator-helm-restricted-access-policy + validationActions: + - "Deny" + matchResources: + namespaceSelector: {} + objectSelector: {} +{{- end }} \ No newline at end of file diff --git a/templates/helm-controller/_helpers.tpl b/templates/helm-controller/_helpers.tpl new file mode 100644 index 0000000..192ca22 --- /dev/null +++ b/templates/helm-controller/_helpers.tpl @@ -0,0 +1,6 @@ +{{- define "helm-controller.envs" -}} +- name: RUNTIME_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace +{{- end }} diff --git a/templates/helm-controller/deployment.yaml b/templates/helm-controller/deployment.yaml new file mode 100644 index 0000000..e333823 --- /dev/null +++ b/templates/helm-controller/deployment.yaml @@ -0,0 +1,137 @@ +{{- $priorityClassName := include "priorityClassName" . }} + +{{- define "helm_controller_resources" }} +cpu: 100m +memory: 64Mi +{{- end }} + +{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} +--- +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: helm-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "helm-controller" "workload-resource-policy.deckhouse.io" "master")) | nindent 2 }} +spec: + targetRef: + apiVersion: "apps/v1" + kind: Deployment + name: helm-controller + updatePolicy: + updateMode: {{ include "vpa.policyUpdateMode" . }} + resourcePolicy: + containerPolicies: + {{- include "kube_api_rewriter.vpa_container_policy" . | nindent 4 }} + {{- include "kube_rbac_proxy.vpa_container_policy" . | nindent 4 }} + - containerName: helm-controller + minAllowed: + {{- include "helm_controller_resources" . | nindent 8 }} + maxAllowed: + cpu: 1000m + memory: 1Gi +{{- end }} + +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: helm-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "helm-controller" )) | nindent 2 }} +spec: + minAvailable: {{ include "helm_lib_is_ha_to_value" (list . 1 0) }} + selector: + matchLabels: + app: helm-controller + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: helm-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "helm-controller")) | nindent 2 }} +spec: + replicas: {{ include "helm_lib_is_ha_to_value" (list . 3 1) }} + {{- if (include "helm_lib_ha_enabled" .) }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 0 + maxUnavailable: 1 + {{- end }} + revisionHistoryLimit: 2 + selector: + matchLabels: + app: helm-controller + template: + metadata: + labels: + app: helm-controller + annotations: + kubectl.kubernetes.io/default-container: helm-controller + spec: + containers: + {{- include "kube_api_rewriter.sidecar_container" . | nindent 8 }} + - name: helm-controller + {{- include "helm_lib_module_container_security_context_read_only_root_filesystem_capabilities_drop_all_pss_restricted" . | nindent 10 }} + image: {{ include "helm_lib_module_image" (list . "helmController") }} + imagePullPolicy: IfNotPresent + args: + - --watch-all-namespaces + - --log-level={{ include "moduleLogLevel" . }} + - --log-encoding=json + - --enable-leader-election + volumeMounts: + - mountPath: /tmp + name: temp + {{- include "kube_api_rewriter.kubeconfig_volume_mount" . | nindent 12 }} + ports: + - containerPort: 8080 + name: metrics + protocol: TCP + - containerPort: 9440 + name: healthz + resources: + requests: + {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 14 }} + {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- include "helm_controller_resources" . | nindent 14 }} + {{- end }} + env: + {{- include "kube_api_rewriter.kubeconfig_env" . | nindent 12 }} + {{- include "helm-controller.envs" . | nindent 12 }} + livenessProbe: + httpGet: + path: /healthz + port: healthz + scheme: HTTP + initialDelaySeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: healthz + scheme: HTTP + initialDelaySeconds: 10 + {{- $kubeRbacProxySettings := dict }} + {{- $_ := set $kubeRbacProxySettings "runAsUserNobody" false }} + {{- $_ := set $kubeRbacProxySettings "ignorePaths" "/proxy/healthz,/proxy/readyz" }} + {{- $_ := set $kubeRbacProxySettings "upstreams" (list + (dict "upstream" "http://127.0.0.1:8080/metrics" "path" "/metrics" "name" "helm-controller") + (dict "upstream" "http://127.0.0.1:9090/metrics" "path" "/proxy/metrics" "name" "kube-api-rewriter") + (dict "upstream" "http://127.0.0.1:9090/healthz" "path" "/proxy/healthz" "name" "kube-api-rewriter") + (dict "upstream" "http://127.0.0.1:9090/readyz" "path" "/proxy/readyz" "name" "kube-api-rewriter") + ) }} + {{- include "kube_rbac_proxy.sidecar_container" (tuple . $kubeRbacProxySettings) | nindent 8 }} + dnsPolicy: ClusterFirst + serviceAccountName: helm-controller + {{- include "helm_lib_module_pod_security_context_run_as_user_deckhouse" . | nindent 6 }} + {{- include "helm_lib_priority_class" (tuple . $priorityClassName) | nindent 6 }} + {{- include "helm_lib_node_selector" (tuple . "system") | nindent 6 }} + {{- include "helm_lib_tolerations" (tuple . "system") | nindent 6 }} + {{- include "helm_lib_pod_anti_affinity_for_ha" (list . (dict "app" "helm-controller")) | nindent 6 }} + volumes: + - emptyDir: {} + name: temp + {{- include "kube_api_rewriter.kubeconfig_volume" . | nindent 8 }} diff --git a/templates/helm-controller/rbac-for-us.yaml b/templates/helm-controller/rbac-for-us.yaml new file mode 100644 index 0000000..34432c1 --- /dev/null +++ b/templates/helm-controller/rbac-for-us.yaml @@ -0,0 +1,148 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: helm-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "helm-controller")) | nindent 2 }} +imagePullSecrets: +- name: operator-helm-module-registry +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: d8:{{ .Chart.Name }}:helm-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: d8:{{ .Chart.Name }}:helm-controller +subjects: +- kind: ServiceAccount + name: helm-controller + namespace: d8-{{ .Chart.Name }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: d8:{{ .Chart.Name }}:helm-controller +rules: +- apiGroups: ['*'] + resources: ['*'] + verbs: ['*'] +- nonResourceURLs: ['*'] + verbs: ['*'] +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - helm.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmreleases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - helm.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmreleases/finalizers + verbs: + - create + - delete + - get + - patch + - update +- apiGroups: + - helm.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmreleases/status + verbs: + - get + - patch + - update +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmcharts + - internalnelmoperatorocirepositories + verbs: + - get + - list + - watch +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmcharts/status + - internalnelmoperatorocirepositories/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: helm-controller + namespace: d8-{{ .Chart.Name }} +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - configmaps/status + verbs: + - get + - update + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - create +- apiGroups: + - "coordination.k8s.io" + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: helm-controller + namespace: d8-{{ .Chart.Name }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: helm-controller +subjects: +- kind: ServiceAccount + name: helm-controller diff --git a/templates/helm-controller/service-metrics.yaml b/templates/helm-controller/service-metrics.yaml new file mode 100644 index 0000000..1f2652c --- /dev/null +++ b/templates/helm-controller/service-metrics.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: helm-controller-metrics + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "helm-controller")) | nindent 2 }} +spec: + ports: + - name: metrics + port: 8080 + protocol: TCP + targetPort: https-metrics + selector: + app: helm-controller diff --git a/templates/helm-controller/service-monitor.yaml b/templates/helm-controller/service-monitor.yaml new file mode 100644 index 0000000..8161d87 --- /dev/null +++ b/templates/helm-controller/service-monitor.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: helm-controller + namespace: d8-monitoring + {{- include "helm_lib_module_labels" (list . (dict "app" "helm-controller" "prometheus" "main")) | nindent 2 }} +spec: + endpoints: + - bearerTokenSecret: + key: token + name: prometheus-token + path: /metrics + port: metrics + scheme: https + tlsConfig: + insecureSkipVerify: true + namespaceSelector: + matchNames: + - d8-{{ .Chart.Name }} + selector: + matchLabels: + app: "helm-controller" diff --git a/templates/kube-api-rewriter/_customize_patch_helpers.tpl b/templates/kube-api-rewriter/_customize_patch_helpers.tpl new file mode 100644 index 0000000..72b1d18 --- /dev/null +++ b/templates/kube-api-rewriter/_customize_patch_helpers.tpl @@ -0,0 +1,69 @@ +{{- /* Helpers to create patches for component customizer in Kubevirt and CDI configurations. + +- kube_api_rewriter.pod_spec_strategic_patch_json - creates a JSON patch for a pod spec to add kube-api-rewriter sidecar container. +- kube_api_rewriter.service_spec_port_patch_json - creates a JSON patch for a service spec to point it to the kube-api-rewriter webhook proxy. +- kube_api_rewriter.webhook_spec_port_patch_json - creates a JSON patch for a validating or mutating webhook spec to point it to the kube-api-rewriter webhook proxy. + +*/ -}} + +{{- define "kube_api_rewriter.pod_spec_strategic_patch_json" -}} + '{{ include "kube_api_rewriter.pod_spec_strategic_patch" . | fromYaml | toJson }}' +{{- end }} + +{{- define "kube_api_rewriter.pod_spec_strategic_patch" -}} + {{- $ctx := index . 0 -}} + {{- $mainContainerName := index . 1 -}} + {{- $settings := dict -}} + {{- if ge (len .) 3 -}} + {{- $settings = index . 2 -}} + {{- end -}} + {{- $isWebhook := hasKey $settings "WEBHOOK_ADDRESS" -}} +spec: + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: {{ $mainContainerName }} + spec: + volumes: + {{- include "kube_api_rewriter.kubeconfig_volume" . | nindent 6 }} + containers: + {{- include "kube_api_rewriter.sidecar_container" (tuple $ctx $settings) | nindent 6 }} + - name: {{ $mainContainerName }} + env: + {{- include "kube_api_rewriter.kubeconfig_env" . | nindent 8 }} + volumeMounts: + {{- include "kube_api_rewriter.kubeconfig_volume_mount" . | nindent 8 }} +{{- end -}} + + +{{- define "kube_api_rewriter.service_spec_port_patch_json" -}} + '{{ include "kube_api_rewriter.service_spec_port_patch" . | fromYaml | toJson }}' +{{- end }} + +{{- define "kube_api_rewriter.service_spec_port_patch" -}} +spec: + ports: + - name: {{ include "kube_api_rewriter.webhook_port_name" . }} + port: {{ include "kube_api_rewriter.webhook_port" . }} + protocol: TCP + targetPort: {{ include "kube_api_rewriter.webhook_port_name" . }} +{{- end }} + + +{{- define "kube_api_rewriter.webhook_spec_port_patch_json" -}} + '{{ include "kube_api_rewriter.webhook_spec_port_patch" . | fromYaml | toJson }}' +{{- end }} + +{{- define "kube_api_rewriter.webhook_spec_port_patch" -}} +{{- $webhookNames := list . -}} +{{- if (kindIs "slice" .) -}} +{{- $webhookNames = . -}} +{{- end -}} +webhooks: +{{- range $webhookNames }} +- name: {{ . }} + clientConfig: + service: + port: {{ include "kube_api_rewriter.webhook_port" . }} +{{- end -}} +{{- end -}} diff --git a/templates/kube-api-rewriter/_settings.tpl b/templates/kube-api-rewriter/_settings.tpl new file mode 100644 index 0000000..8f54135 --- /dev/null +++ b/templates/kube-api-rewriter/_settings.tpl @@ -0,0 +1,32 @@ +{{- define "kube_api_rewriter.sidecar_name" -}}proxy{{- end -}} + +{{- define "kube_api_rewriter.webhook_port" -}}24192{{- end -}} + +{{- /* Port name length must be no more than 15 characters. */ -}} +{{- define "kube_api_rewriter.webhook_port_name" -}}webhook-proxy{{- end -}} + +{{- define "kube_api_rewriter.pprof_port" -}}8129{{- end -}} + +{{- define "kube_api_rewriter.env" -}} +- name: LOG_LEVEL + value: {{ include "moduleLogLevel" . }} +{{- if eq (include "moduleLogLevel" .) "debug" }} +- name: PPROF_BIND_ADDRESS + value: ":{{ include "kube_api_rewriter.pprof_port" . }}" +{{- end }} +{{- end -}} + +{{- define "kube_api_rewriter.resources" -}} +cpu: 100m +memory: 30Mi +{{- end -}} + +{{- define "kube_api_rewriter.vpa_container_policy" -}} +- containerName: proxy + minAllowed: + cpu: 10m + memory: 30Mi + maxAllowed: + cpu: 20m + memory: 60Mi +{{- end -}} diff --git a/templates/kube-api-rewriter/_sidecar_helpers.tpl b/templates/kube-api-rewriter/_sidecar_helpers.tpl new file mode 100644 index 0000000..2ae379c --- /dev/null +++ b/templates/kube-api-rewriter/_sidecar_helpers.tpl @@ -0,0 +1,199 @@ +{{- /* Helpers to add kube-api-rewriter sidecar container to a pod. + +To connect to kube-api-rewriter main controller should has KUBECONFIG env, +volumeMount with kubeconfig, and Pod should has volume with kubeconfig ConfigMap. + +These settings are provided by helpers: + +- kube_api_rewriter.kubeconfig_env defines KUBECONFIG env with file from the + mounted ConfigMap. +- kube_api_rewriter.kubeconfig_volume_mount defines volumeMount for kubeconfig ConfigMap. +- kube_api_rewriter.kubeconfig_volume defines volume with kubeconfig ConfigMap. + +Kube-api-rewriter sidecar should be the first container in the Pod, to +main controller not fail on start. + +Kube-api-rewriter sidecar works in 2 modes: without webhook or with webhook rewriting. + +Sidecar without webhook is the simplest one: + +spec: + template: + spec: + containers: + {{ include "kube_api_rewriter.sidecar_container" . | nindent 8 }} + - name: main-controller + ... + env: + {{- include "kube_api_rewriter.kubeconfig_env" . | nindent 12 }} + ... + volumeMounts: + {{- include "kube_api_rewriter.kubeconfig_volume_mount" . | nindent 12 }} + ... + volumes: + {{- include "kube_api_rewriter.kubeconfig_volume" | nindent 8 }} + ... + + +Webhook mode requires additional settings: + +- WEBHOOK_ADDRESS - address of the webhook in the main controller +- WEBHOOK_CERT_FILE - path to the webhook certificate file. +- WEBHOOK_KEY_FILE - path to the webhook key file. +- webhookCertsVolumeName - name of the Pod volume with webhook certificates. +- webhookCertsMountPath - path to mount the webhook certificates. + +The assumption here is that main controller has a webhook server and +certificates are already mounted in the Pod, so kube-api-rewriter +can use certificates from that volume to impersonate the webhook server. + +Example of adding kube-api-rewriter to the Deployment: + +spec: + template: + spec: + containers: + {{- $rewriterSettings := dict }} + {{- $_ := set $rewriterSettings "WEBHOOK_ADDRESS" "https://127.0.0.1:6443" }} + {{- $_ := set $rewriterSettings "WEBHOOK_CERT_FILE" "/etc/webhook-certificates/tls.crt" }} + {{- $_ := set $rewriterSettings "WEBHOOK_KEY_FILE" "/etc/webhook-certificates/tls.key" }} + {{- $_ := set $rewriterSettings "webhookCertsVolumeName" "webhook-certs" }} + {{- $_ := set $rewriterSettings "webhookCertsMountPath" "/etc/webhook-certificates" }} + {{- include "kube_api_rewriter.sidecar_container" (tuple . $rewriterSettings) | nindent 6 }} + - name: main-controller + ... + env: + {{- include "kube_api_rewriter.kubeconfig_env" . | nindent 12 }} + ... + ports: + - containerPort: 6443 # Goes to the WEBHOOK_ADDRESS + name: webhooks + protocol: TCP + volumeMounts: + {{- include "kube_api_rewriter.kubeconfig_volume_mount" . | nindent 12 }} + - name: webhook-certs + mountPath: /etc/webhook-certificates # Goes to the webhookCertsMountPath + readOnly: true + ... + volumes: + {{- include "kube_api_rewriter.kubeconfig_volume" | nindent 8 }} + - name: webhook-certs # Name of the existing volume goes to the webhookCertsVolumeName. + secret: + optional: true + secretName: webhook-certs + ... + + */ -}} + +{{- define "kube_api_rewriter.image" -}} +{{- include "helm_lib_module_image" (list . "kubeApiRewriter") | toJson -}} +{{- end -}} + + +{{- define "kube_api_rewriter.kubeconfig_env" -}} +- name: KUBECONFIG + value: /kubeconfig.local/kube-api-rewriter.kubeconfig +{{- end }} + +{{- define "kube_api_rewriter.kubeconfig_volume" -}} +- name: kube-api-rewriter-kubeconfig + configMap: + defaultMode: 0644 + name: kube-api-rewriter-kubeconfig +{{- end }} + +{{- define "kube_api_rewriter.kubeconfig_volume_mount" -}} +- name: kube-api-rewriter-kubeconfig + mountPath: /kubeconfig.local +{{- end }} + + +{{- define "kube_api_rewriter.webhook_volume_mount" -}} +{{- $volumeName := index . 0 -}} +{{- $mountPath := index . 1 -}} +- mountPath: {{ $mountPath }} + name: {{ $volumeName }} + readOnly: true +{{- end }} + +{{- define "kube_api_rewriter.webhook_container_port" -}} +- containerPort: {{ include "kube_api_rewriter.webhook_port" . }} + name: {{ include "kube_api_rewriter.webhook_port_name" . }} + protocol: TCP +{{- end }} + +{{- /* Container port for the pprof server */ -}} +{{- define "kube_api_rewriter.pprof_container_port" -}} +- containerPort: {{ include "kube_api_rewriter.pprof_port" . }} + name: pprof + protocol: TCP +{{- end }} + +{{- /* Sidecar container spec with kube-api-rewriter */ -}} +{{- /* Usage without the webhook proxy: {{ include kube_api_rewriter.sidecar_container . }} */ -}} +{{- /* Usage with the webhook: {{ include kube_api_rewriter.sidecar_container (tuple . $webhookSettings) }} */ -}} +{{- define "kube_api_rewriter.sidecar_container" -}} + {{- $ctx := . -}} + {{- $settings := dict -}} + {{- if (kindIs "slice" .) -}} + {{- $ctx = index . 0 -}} + {{- if ge (len .) 2 -}} + {{- $settings = index . 1 -}} + {{- end -}} + {{- end -}} + {{- $isWebhook := hasKey $settings "WEBHOOK_ADDRESS" -}} +- name: {{ include "kube_api_rewriter.sidecar_name" $ctx }} + image: {{ include "kube_api_rewriter.image" $ctx }} + imagePullPolicy: IfNotPresent + env: + {{- if $isWebhook }} + - name: WEBHOOK_ADDRESS + value: "{{ $settings.WEBHOOK_ADDRESS }}" + - name: WEBHOOK_CERT_FILE + value: "{{ $settings.WEBHOOK_CERT_FILE }}" + - name: WEBHOOK_KEY_FILE + value: "{{ $settings.WEBHOOK_KEY_FILE }}" + {{- end }} + - name: MONITORING_BIND_ADDRESS + value: "127.0.0.1:9090" + {{- include "kube_api_rewriter.env" $ctx | nindent 4 }} + resources: + requests: + {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 6 }} + {{- if not ( $ctx.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- include "kube_api_rewriter.resources" . | nindent 6 }} + {{- end }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + livenessProbe: + httpGet: + path: /proxy/healthz + port: 8082 + scheme: HTTPS + initialDelaySeconds: 10 + readinessProbe: + httpGet: + path: /proxy/readyz + port: 8082 + scheme: HTTPS + initialDelaySeconds: 10 + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + {{- if $isWebhook }} + volumeMounts: + {{- include "kube_api_rewriter.webhook_volume_mount" (tuple $settings.webhookCertsVolumeName $settings.webhookCertsMountPath) | nindent 4 }} + {{- end }} + ports: + {{- if eq (include "moduleLogLevel" $ctx) "debug" }} + {{- include "kube_api_rewriter.pprof_container_port" . | nindent 4 }} + {{- end }} + {{- if $isWebhook -}} + {{- include "kube_api_rewriter.webhook_container_port" .| nindent 4 }} + {{- end -}} +{{- end -}} diff --git a/templates/kube-api-rewriter/cm-kubeconfig-local.yaml b/templates/kube-api-rewriter/cm-kubeconfig-local.yaml new file mode 100644 index 0000000..966a348 --- /dev/null +++ b/templates/kube-api-rewriter/cm-kubeconfig-local.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: kube-api-rewriter-kubeconfig + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +data: + kube-api-rewriter.kubeconfig: | + apiVersion: v1 + kind: Config + clusters: + - cluster: + server: http://127.0.0.1:23915 + name: kube-api-rewriter + contexts: + - context: + cluster: kube-api-rewriter + name: kube-api-rewriter + current-context: kube-api-rewriter diff --git a/templates/kube-rbac-proxy/_helpers.tpl b/templates/kube-rbac-proxy/_helpers.tpl new file mode 100644 index 0000000..ee21a1a --- /dev/null +++ b/templates/kube-rbac-proxy/_helpers.tpl @@ -0,0 +1,92 @@ +{{- define "kube_rbac_proxy.sidecar_container" -}} +{{- $ctx := index . 0 }} +{{- $settings := index . 1 }} +- name: {{ $settings.containerName | default "kube-rbac-proxy" }} + {{- include "helm_lib_module_container_security_context_read_only_root_filesystem_capabilities_drop_all_pss_restricted" $ctx | nindent 2 }} + {{- if eq $settings.runAsUserNobody true }} + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + {{- end }} + image: {{ include "helm_lib_module_common_image" (list $ctx "kubeRbacProxy") }} + imagePullPolicy: IfNotPresent + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + args: + - "--secure-listen-address=$(KUBE_RBAC_PROXY_LISTEN_ADDRESS):{{ $settings.listenPort | default "8082" }}" + - "--v={{ $settings.logLevel | default "2" }}" + - "--logtostderr=true" + - "--stale-cache-interval={{ $settings.staleCacheInterval | default "1h30m" }}" + {{- if hasKey $settings "ignorePaths" }} + - "--ignore-paths={{ $settings.ignorePaths }}" + {{- end }} + env: + - name: KUBE_RBAC_PROXY_LISTEN_ADDRESS + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.podIP + - name: KUBE_RBAC_PROXY_CONFIG + value: | + excludePaths: + - {{ $settings.excludePath | default "/config" }} + upstreams: + {{- range $settings.upstreams }} + - upstream: {{ .upstream }} + path: {{ .path }} + authorization: + resourceAttributes: + namespace: {{ .namespace | default "d8-operator-helm" }} + apiGroup: {{ .apiGroup | default "apps" }} + apiVersion: {{ .apiVersion | default "v1" }} + resource: {{ .resource | default "deployments" }} + subresource: {{ .subresource | default "prometheus-metrics" }} + name: {{ .name }} + {{- end }} + resources: + requests: + {{- include "helm_lib_module_ephemeral_storage_only_logs" $ctx | nindent 6 }} + {{- if not ( $ctx.Values.global.enabledModules | has "vertical-pod-autoscaler") }} + {{- include "helm_lib_container_kube_rbac_proxy_resources" $ctx | nindent 6 }} + {{- end }} + ports: + - containerPort: {{ $settings.listenPort | default "8082" }} + name: {{ $settings.portName | default "https-metrics" }} + protocol: TCP + livenessProbe: + tcpSocket: + port: {{ $settings.portName | default "https-metrics" }} + initialDelaySeconds: 10 + readinessProbe: + tcpSocket: + port: {{ $settings.portName | default "https-metrics" }} + initialDelaySeconds: 10 +{{- end -}} + +{{- define "kube_rbac_proxy.pod_spec_strategic_patch" -}} +{{- $ctx := index . 0 }} +{{- $settings := index . 1 }} +spec: + template: + spec: + containers: + {{- include "kube_rbac_proxy.sidecar_container" (tuple $ctx $settings) | nindent 6 }} +{{- end }} + +{{- define "kube_rbac_proxy.image" -}} +{{- include "helm_lib_module_common_image" (list . "kubeRbacProxy") -}} +{{- end -}} + +{{- define "kube_rbac_proxy.vpa_container_policy" -}} +- containerName: {{ $.containerName | default "kube-rbac-proxy" }} + minAllowed: + cpu: 10m + memory: 15Mi + maxAllowed: + cpu: 20m + memory: 30Mi +{{- end -}} + +{{- define "kube_rbac_proxy.pod_spec_strategic_patch_json" -}} + '{{ include "kube_rbac_proxy.pod_spec_strategic_patch" . | fromYaml | toJson }}' +{{- end }} diff --git a/templates/namespace.yaml b/templates/namespace.yaml new file mode 100644 index 0000000..c9603eb --- /dev/null +++ b/templates/namespace.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + {{- include "helm_lib_module_labels" (list . (dict "prometheus.deckhouse.io/rules-watcher-enabled" "true")) | nindent 2 }} + name: d8-{{ .Chart.Name }} +--- +{{- include "helm_lib_kube_rbac_proxy_ca_certificate" (list . (printf "d8-%s" .Chart.Name)) }} diff --git a/templates/nelm-source-controller/_helpers.tpl b/templates/nelm-source-controller/_helpers.tpl new file mode 100644 index 0000000..e0b5dc4 --- /dev/null +++ b/templates/nelm-source-controller/_helpers.tpl @@ -0,0 +1,8 @@ +{{- define "nelm-source-controller.envs" -}} +- name: RUNTIME_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace +- name: TUF_ROOT + value: /tmp/.sigstore +{{- end }} diff --git a/templates/nelm-source-controller/deployment.yaml b/templates/nelm-source-controller/deployment.yaml new file mode 100644 index 0000000..f8eef88 --- /dev/null +++ b/templates/nelm-source-controller/deployment.yaml @@ -0,0 +1,143 @@ +{{- $priorityClassName := include "priorityClassName" . }} + +{{- define "nelm_source_controller_resources" }} +cpu: 50m +memory: 64Mi +{{- end }} + +{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} +--- +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: nelm-source-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller" "workload-resource-policy.deckhouse.io" "master")) | nindent 2 }} +spec: + targetRef: + apiVersion: "apps/v1" + kind: Deployment + name: nelm-source-controller + updatePolicy: + updateMode: {{ include "vpa.policyUpdateMode" . }} + resourcePolicy: + containerPolicies: + {{- include "kube_api_rewriter.vpa_container_policy" . | nindent 4 }} + {{- include "kube_rbac_proxy.vpa_container_policy" . | nindent 4 }} + - containerName: nelm-source-controller + minAllowed: + {{- include "nelm_source_controller_resources" . | nindent 8 }} + maxAllowed: + cpu: 1000m + memory: 1Gi +{{- end }} + +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: nelm-source-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller" )) | nindent 2 }} +spec: + minAvailable: {{ include "helm_lib_is_ha_to_value" (list . 1 0) }} + selector: + matchLabels: + app: nelm-source-controller + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nelm-source-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller")) | nindent 2 }} +spec: + replicas: 1 + strategy: + type: Recreate + revisionHistoryLimit: 2 + selector: + matchLabels: + app: nelm-source-controller + template: + metadata: + labels: + app: nelm-source-controller + annotations: + kubectl.kubernetes.io/default-container: nelm-source-controller + spec: + containers: + {{- include "kube_api_rewriter.sidecar_container" . | nindent 8 }} + - name: nelm-source-controller + {{- include "helm_lib_module_container_security_context_read_only_root_filesystem_capabilities_drop_all_pss_restricted" . | nindent 10 }} + image: {{ include "helm_lib_module_image" (list . "nelmSourceController") }} + imagePullPolicy: IfNotPresent + args: + - --watch-all-namespaces + - --log-level={{ include "moduleLogLevel" . }} + - --log-encoding=json + - --enable-leader-election + - --storage-path=/data + - --storage-addr=:9091 + - --storage-adv-addr=nelm-source-controller.$(RUNTIME_NAMESPACE).svc.{{ .Values.global.discovery.clusterDomain }} + volumeMounts: + - mountPath: /data + name: data + - mountPath: /tmp + name: tmp + {{- include "kube_api_rewriter.kubeconfig_volume_mount" . | nindent 12 }} + ports: + - containerPort: 9091 + name: controller + protocol: TCP + - containerPort: 8080 + name: metrics + protocol: TCP + - containerPort: 9440 + name: healthz + protocol: TCP + resources: + requests: + {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 14 }} + {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- include "nelm_source_controller_resources" . | nindent 14 }} + {{- end }} + env: + {{- include "kube_api_rewriter.kubeconfig_env" . | nindent 12 }} + {{- include "nelm-source-controller.envs" . | nindent 12 }} + livenessProbe: + httpGet: + path: /healthz + port: healthz + scheme: HTTP + initialDelaySeconds: 10 + readinessProbe: + httpGet: + path: / + port: controller + scheme: HTTP + initialDelaySeconds: 10 + {{- $kubeRbacProxySettings := dict }} + {{- $_ := set $kubeRbacProxySettings "runAsUserNobody" false }} + {{- $_ := set $kubeRbacProxySettings "ignorePaths" "/proxy/healthz,/proxy/readyz" }} + {{- $_ := set $kubeRbacProxySettings "upstreams" (list + (dict "upstream" "http://127.0.0.1:8080/metrics" "path" "/metrics" "name" "nelm-source-controller") + (dict "upstream" "http://127.0.0.1:9090/metrics" "path" "/proxy/metrics" "name" "kube-api-rewriter") + (dict "upstream" "http://127.0.0.1:9090/healthz" "path" "/proxy/healthz" "name" "kube-api-rewriter") + (dict "upstream" "http://127.0.0.1:9090/readyz" "path" "/proxy/readyz" "name" "kube-api-rewriter") + ) }} + {{- include "kube_rbac_proxy.sidecar_container" (tuple . $kubeRbacProxySettings) | nindent 8 }} + dnsPolicy: ClusterFirst + serviceAccountName: nelm-source-controller + {{- include "helm_lib_module_pod_security_context_run_as_user_deckhouse" . | nindent 6 }} + {{- include "helm_lib_priority_class" (tuple . $priorityClassName) | nindent 6 }} + {{- include "helm_lib_node_selector" (tuple . "system") | nindent 6 }} + {{- include "helm_lib_tolerations" (tuple . "system") | nindent 6 }} + {{- include "helm_lib_pod_anti_affinity_for_ha" (list . (dict "app" "nelm-source-controller")) | nindent 6 }} + volumes: + - emptyDir: {} + name: data + - emptyDir: {} + name: tmp + {{- include "kube_api_rewriter.kubeconfig_volume" . | nindent 8 }} diff --git a/templates/nelm-source-controller/rbac-for-us.yaml b/templates/nelm-source-controller/rbac-for-us.yaml new file mode 100644 index 0000000..a05bf0e --- /dev/null +++ b/templates/nelm-source-controller/rbac-for-us.yaml @@ -0,0 +1,169 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nelm-source-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller")) | nindent 2 }} +imagePullSecrets: +- name: operator-helm-module-registry +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: d8:{{ .Chart.Name }}:nelm-source-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: d8:{{ .Chart.Name }}:nelm-source-controller +subjects: +- kind: ServiceAccount + name: nelm-source-controller + namespace: d8-{{ .Chart.Name }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: d8:{{ .Chart.Name }}:nelm-source-controller +rules: +- apiGroups: + - "" + resources: + - pods + - services + - secrets + - configmaps + verbs: + - get + - create + - update + - delete + - list + - watch + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - secrets + - serviceaccounts + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - create +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorbuckets + - internalnelmoperatorgitrepositories + - internalnelmoperatorhelmcharts + - internalnelmoperatorhelmrepositories + - internalnelmoperatorocirepositories + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorbuckets/finalizers + - internalnelmoperatorgitrepositories/finalizers + - internalnelmoperatorhelmcharts/finalizers + - internalnelmoperatorhelmrepositories/finalizers + - internalnelmoperatorocirepositories/finalizers + verbs: + - create + - delete + - get + - patch + - update +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorbuckets/status + - internalnelmoperatorgitrepositories/status + - internalnelmoperatorhelmcharts/status + - internalnelmoperatorhelmrepositories/status + - internalnelmoperatorocirepositories/status + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: nelm-source-controller + namespace: d8-{{ .Chart.Name }} +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - configmaps/status + verbs: + - get + - update + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - create +- apiGroups: + - "coordination.k8s.io" + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: nelm-source-controller + namespace: d8-{{ .Chart.Name }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: nelm-source-controller +subjects: +- kind: ServiceAccount + name: nelm-source-controller diff --git a/templates/nelm-source-controller/service-metrics.yaml b/templates/nelm-source-controller/service-metrics.yaml new file mode 100644 index 0000000..dff6d72 --- /dev/null +++ b/templates/nelm-source-controller/service-metrics.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: nelm-source-controller-metrics + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller")) | nindent 2 }} +spec: + ports: + - name: metrics + port: 8080 + protocol: TCP + targetPort: https-metrics + selector: + app: nelm-source-controller diff --git a/templates/nelm-source-controller/service-monitor.yaml b/templates/nelm-source-controller/service-monitor.yaml new file mode 100644 index 0000000..6538666 --- /dev/null +++ b/templates/nelm-source-controller/service-monitor.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: nelm-source-controller + namespace: d8-monitoring + {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller" "prometheus" "main")) | nindent 2 }} +spec: + endpoints: + - bearerTokenSecret: + key: token + name: prometheus-token + path: /metrics + port: metrics + scheme: https + tlsConfig: + insecureSkipVerify: true + namespaceSelector: + matchNames: + - d8-{{ .Chart.Name }} + selector: + matchLabels: + app: "nelm-source-controller" diff --git a/templates/nelm-source-controller/service.yaml b/templates/nelm-source-controller/service.yaml new file mode 100644 index 0000000..1d1bfa2 --- /dev/null +++ b/templates/nelm-source-controller/service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: nelm-source-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller")) | nindent 2 }} +spec: + ports: + - name: controller + port: 80 + targetPort: controller + protocol: TCP + selector: + app: nelm-source-controller diff --git a/templates/operator-helm-controller/deployment.yaml b/templates/operator-helm-controller/deployment.yaml new file mode 100644 index 0000000..7e01c31 --- /dev/null +++ b/templates/operator-helm-controller/deployment.yaml @@ -0,0 +1,141 @@ +{{- $priorityClassName := include "priorityClassName" . }} + +{{- define "operator_helm_controller_resources" }} +cpu: 50m +memory: 64Mi +{{- end }} + +{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} +--- +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: operator-helm-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "operator-helm-controller" "workload-resource-policy.deckhouse.io" "master")) | nindent 2 }} +spec: + targetRef: + apiVersion: "apps/v1" + kind: Deployment + name: operator-helm-controller + updatePolicy: + updateMode: {{ include "vpa.policyUpdateMode" . }} + resourcePolicy: + containerPolicies: + {{- include "kube_api_rewriter.vpa_container_policy" . | nindent 4 }} + {{- include "kube_rbac_proxy.vpa_container_policy" . | nindent 4 }} + - containerName: operator-helm-controller + minAllowed: + {{- include "operator_helm_controller_resources" . | nindent 8 }} + maxAllowed: + cpu: 1000m + memory: 1Gi +{{- end }} + +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: operator-helm-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "operator-helm-controller" )) | nindent 2 }} +spec: + minAvailable: {{ include "helm_lib_is_ha_to_value" (list . 1 0) }} + selector: + matchLabels: + app: operator-helm-controller + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: operator-helm-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "operator-helm-controller")) | nindent 2 }} +spec: + replicas: {{ include "helm_lib_is_ha_to_value" (list . 3 1) }} + {{- if (include "helm_lib_ha_enabled" .) }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 0 + maxUnavailable: 1 + {{- end }} + revisionHistoryLimit: 2 + selector: + matchLabels: + app: operator-helm-controller + template: + metadata: + labels: + app: operator-helm-controller + annotations: + kubectl.kubernetes.io/default-container: operator-helm-controller + spec: + containers: + {{- include "kube_api_rewriter.sidecar_container" . | nindent 8 }} + - name: operator-helm-controller + {{- include "helm_lib_module_container_security_context_read_only_root_filesystem_capabilities_drop_all_pss_restricted" . | nindent 10 }} + image: {{ include "helm_lib_module_image" (list . "operatorHelmController") }} + imagePullPolicy: IfNotPresent + args: + {{/* TODO: add log level option */}} + - --leader-elect + - --metrics-bind-address=:8080 + - --health-probe-bind-address=:9440 + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: admission-webhook-secret + {{- include "kube_api_rewriter.kubeconfig_volume_mount" . | nindent 12 }} + ports: + - containerPort: 9443 + name: controller + protocol: TCP + - containerPort: 8080 + name: metrics + protocol: TCP + - containerPort: 9440 + name: healthz + protocol: TCP + resources: + requests: + {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 14 }} + {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- include "operator_helm_controller_resources" . | nindent 14 }} + {{- end }} + env: + {{- include "kube_api_rewriter.kubeconfig_env" . | nindent 12 }} + livenessProbe: + httpGet: + path: /healthz + port: healthz + scheme: HTTP + initialDelaySeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: healthz + scheme: HTTP + initialDelaySeconds: 10 + {{- $kubeRbacProxySettings := dict }} + {{- $_ := set $kubeRbacProxySettings "runAsUserNobody" false }} + {{- $_ := set $kubeRbacProxySettings "ignorePaths" "/proxy/healthz,/proxy/readyz" }} + {{- $_ := set $kubeRbacProxySettings "upstreams" (list + (dict "upstream" "http://127.0.0.1:8080/metrics" "path" "/metrics" "name" "operator-helm-controller") + (dict "upstream" "http://127.0.0.1:9090/metrics" "path" "/proxy/metrics" "name" "kube-api-rewriter") + (dict "upstream" "http://127.0.0.1:9090/healthz" "path" "/proxy/healthz" "name" "kube-api-rewriter") + (dict "upstream" "http://127.0.0.1:9090/readyz" "path" "/proxy/readyz" "name" "kube-api-rewriter") + ) }} + {{- include "kube_rbac_proxy.sidecar_container" (tuple . $kubeRbacProxySettings) | nindent 8 }} + dnsPolicy: ClusterFirst + serviceAccountName: operator-helm-controller + {{- include "helm_lib_module_pod_security_context_run_as_user_deckhouse" . | nindent 6 }} + {{- include "helm_lib_priority_class" (tuple . $priorityClassName) | nindent 6 }} + {{- include "helm_lib_node_selector" (tuple . "system") | nindent 6 }} + {{- include "helm_lib_tolerations" (tuple . "system") | nindent 6 }} + {{- include "helm_lib_pod_anti_affinity_for_ha" (list . (dict "app" "operator-helm-controller")) | nindent 6 }} + volumes: + - name: admission-webhook-secret + secret: + secretName: operator-helm-controller-tls + {{- include "kube_api_rewriter.kubeconfig_volume" . | nindent 8 }} diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml new file mode 100644 index 0000000..7bc6f85 --- /dev/null +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -0,0 +1,216 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: operator-helm-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "operator-helm-controller")) | nindent 2 }} +imagePullSecrets: +- name: operator-helm-module-registry +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: d8:{{ .Chart.Name }}:operator-helm-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: d8:{{ .Chart.Name }}:operator-helm-controller +subjects: +- kind: ServiceAccount + name: operator-helm-controller + namespace: d8-{{ .Chart.Name }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: d8:{{ .Chart.Name }}:operator-helm-controller +rules: +- apiGroups: + - "" + resources: + - pods + - services + - secrets + - configmaps + verbs: + - get + - create + - update + - delete + - list + - watch + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - secrets + - serviceaccounts + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - create +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddons + - helmclusteraddons/status + - helmclusteraddoncharts + - helmclusteraddoncharts/status + - helmclusteraddonrepositories + - helmclusteraddonrepositories/status + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - helm.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmreleases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - helm.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmreleases/finalizers + verbs: + - create + - delete + - get + - patch + - update +- apiGroups: + - helm.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmreleases/status + verbs: + - get + - patch + - update +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorbuckets + - internalnelmoperatorgitrepositories + - internalnelmoperatorhelmcharts + - internalnelmoperatorhelmrepositories + - internalnelmoperatorocirepositories + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorbuckets/finalizers + - internalnelmoperatorgitrepositories/finalizers + - internalnelmoperatorhelmcharts/finalizers + - internalnelmoperatorhelmrepositories/finalizers + - internalnelmoperatorocirepositories/finalizers + verbs: + - create + - delete + - get + - patch + - update +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorbuckets/status + - internalnelmoperatorgitrepositories/status + - internalnelmoperatorhelmcharts/status + - internalnelmoperatorhelmrepositories/status + - internalnelmoperatorocirepositories/status + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: operator-helm-controller + namespace: d8-{{ .Chart.Name }} +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - configmaps/status + verbs: + - get + - update + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - create +- apiGroups: + - "coordination.k8s.io" + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: operator-helm-controller + namespace: d8-{{ .Chart.Name }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: operator-helm-controller +subjects: +- kind: ServiceAccount + name: operator-helm-controller diff --git a/templates/operator-helm-controller/secret-tls.yaml b/templates/operator-helm-controller/secret-tls.yaml new file mode 100644 index 0000000..b2f69a1 --- /dev/null +++ b/templates/operator-helm-controller/secret-tls.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: operator-helm-controller-tls + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "operator-helm-controller")) | nindent 2 }} +type: kubernetes.io/tls +data: + ca.crt: {{ .Values.operatorHelm.internal.controller.cert.ca | b64enc }} + tls.crt: {{ .Values.operatorHelm.internal.controller.cert.crt | b64enc }} + tls.key: {{ .Values.operatorHelm.internal.controller.cert.key | b64enc }} diff --git a/templates/operator-helm-controller/service-metrics.yaml b/templates/operator-helm-controller/service-metrics.yaml new file mode 100644 index 0000000..89b9345 --- /dev/null +++ b/templates/operator-helm-controller/service-metrics.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: operator-helm-controller-metrics + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "operator-helm-controller")) | nindent 2 }} +spec: + ports: + - name: metrics + port: 8080 + protocol: TCP + targetPort: https-metrics + selector: + app: operator-helm-controller + diff --git a/templates/operator-helm-controller/service-monitor.yaml b/templates/operator-helm-controller/service-monitor.yaml new file mode 100644 index 0000000..a17f1db --- /dev/null +++ b/templates/operator-helm-controller/service-monitor.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: operator-helm-controller + namespace: d8-monitoring + {{- include "helm_lib_module_labels" (list . (dict "app" "operator-helm-controller" "prometheus" "main")) | nindent 2 }} +spec: + endpoints: + - bearerTokenSecret: + key: token + name: prometheus-token + path: /metrics + port: metrics + scheme: https + tlsConfig: + insecureSkipVerify: true + namespaceSelector: + matchNames: + - d8-{{ .Chart.Name }} + selector: + matchLabels: + app: "operator-helm-controller" + diff --git a/templates/operator-helm-controller/service.yaml b/templates/operator-helm-controller/service.yaml new file mode 100644 index 0000000..b1356b6 --- /dev/null +++ b/templates/operator-helm-controller/service.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: operator-helm-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "operator-helm-controller")) | nindent 2 }} +spec: + ports: + - name: admission-webhook + port: 443 + targetPort: controller + protocol: TCP + - name: controller + port: 9443 + targetPort: controller + protocol: TCP + selector: + app: operator-helm-controller diff --git a/templates/operator-helm-controller/validation-webhook.yaml b/templates/operator-helm-controller/validation-webhook.yaml new file mode 100644 index 0000000..b1e3ed9 --- /dev/null +++ b/templates/operator-helm-controller/validation-webhook.yaml @@ -0,0 +1,23 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + {{- include "helm_lib_module_labels" (list . (dict "app" "operator-helm-controller")) | nindent 2 }} + name: "operator-helm-controller-admission-webhook" +webhooks: + - name: "helmclusteraddons.operator-helm-controller.validate.d8-operator-helm" + rules: + - apiGroups: ["helm.deckhouse.io"] + apiVersions: ["v1alpha1"] + operations: ["CREATE", "UPDATE"] + resources: ["helmclusteraddons"] + scope: "Cluster" + clientConfig: + service: + namespace: d8-{{ .Chart.Name }} + name: operator-helm-controller + path: /validate-helm-deckhouse-io-v1alpha1-helmclusteraddon + port: 443 + caBundle: | + {{ .Values.operatorHelm.internal.controller.cert.ca | b64enc }} + admissionReviewVersions: ["v1"] + sideEffects: None diff --git a/templates/rbac-to-us.yaml b/templates/rbac-to-us.yaml new file mode 100644 index 0000000..ed3697f --- /dev/null +++ b/templates/rbac-to-us.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: access-to-operator-helm + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: ["apps"] + resources: ["deployments/prometheus-metrics"] + resourceNames: ["operator-helm-controller", "helm-controller", "nelm-source-controller", "kube-api-rewriter"] + verbs: ["get"] + +{{- if (.Values.global.enabledModules | has "prometheus") }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: access-to-virtualization + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: access-to-operator-helm +subjects: +- kind: User + name: d8-monitoring:scraper +- kind: ServiceAccount + name: prometheus + namespace: d8-monitoring +{{- end }} diff --git a/templates/registry-secret.yaml b/templates/registry-secret.yaml new file mode 100644 index 0000000..2001911 --- /dev/null +++ b/templates/registry-secret.yaml @@ -0,0 +1,16 @@ +{{/* Use module specific dockercfg if set. Use global dockercfg if module included as embedded. */}} +{{- $dockercfg := dig "registry" "dockercfg" "" .Values.operatorHelm }} +{{- if eq $dockercfg "" }} +{{/* Workaround to exclude check https://github.com/deckhouse/dmt/pull/236 */}} +{{- $dockercfg = dig "modulesImages" "registry" "dockercfg" "" .Values.global }} +{{- end }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: operator-helm-module-registry + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +type: kubernetes.io/dockerconfigjson +data: + .dockerconfigjson: {{ $dockercfg | quote }} diff --git a/tmp/mc-operator-helm.yaml b/tmp/mc-operator-helm.yaml new file mode 100644 index 0000000..24275b2 --- /dev/null +++ b/tmp/mc-operator-helm.yaml @@ -0,0 +1,8 @@ +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: operator-helm +spec: + enabled: false + source: operator-helm + version: 1 diff --git a/tmp/modulepulloverride.yaml b/tmp/modulepulloverride.yaml new file mode 100644 index 0000000..9f2f086 --- /dev/null +++ b/tmp/modulepulloverride.yaml @@ -0,0 +1,8 @@ +apiVersion: deckhouse.io/v1alpha2 +kind: ModulePullOverride +metadata: + name: operator-helm +spec: + imageTag: mvp + rollback: true + scanInterval: 15s diff --git a/tmp/modulesource.yaml b/tmp/modulesource.yaml new file mode 100644 index 0000000..9bf6aa5 --- /dev/null +++ b/tmp/modulesource.yaml @@ -0,0 +1,8 @@ +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleSource +metadata: + name: operator-helm +spec: + registry: + repo: ghcr.io/deckhouse/operator-helm + scheme: HTTPS diff --git a/tools/validation/diff.go b/tools/validation/diff.go new file mode 100644 index 0000000..516388b --- /dev/null +++ b/tools/validation/diff.go @@ -0,0 +1,149 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strings" +) + +type DiffInfo struct { + Files []*DiffFileInfo +} + +func NewDiffInfo() *DiffInfo { + return &DiffInfo{ + Files: make([]*DiffFileInfo, 0), + } +} + +func (d *DiffInfo) Dump() string { + res := "" + for _, info := range d.Files { + res += fmt.Sprintf("%s -> %s, lines: %d\n", info.OldFileName, info.NewFileName, len(info.Lines)) + } + res += fmt.Sprintf("files: %d\n", len(d.Files)) + return res +} + +type DiffFileInfo struct { + NewFileName string + OldFileName string + Lines []string +} + +func (d *DiffFileInfo) IsAdded() bool { + return d.OldFileName == "/dev/null" +} + +func (d *DiffFileInfo) IsDeleted() bool { + return d.NewFileName == "/dev/null" +} + +func (d *DiffFileInfo) IsModified() bool { + return d.OldFileName != "/dev/null" && d.NewFileName != "/dev/null" && d.HasContent() +} + +func (d *DiffFileInfo) HasContent() bool { + return len(d.Lines) > 0 +} + +func (d *DiffFileInfo) NewLines() []string { + res := make([]string, 0) + for _, l := range d.Lines { + if strings.HasPrefix(l, "+") { + res = append(res, strings.TrimPrefix(l, "+")) + } + } + return res +} + +func NewDiffFileInfo() *DiffFileInfo { + return &DiffFileInfo{ + Lines: make([]string, 0), + } +} + +var diffStartRe = regexp.MustCompile(`^diff --git a/(.*) b/(.*)$`) +var oldFileNameRe = regexp.MustCompile(`^--- (/dev/null|a/(.*))$`) +var newFileNameRe = regexp.MustCompile(`^\+\+\+ (/dev/null|b/(.*))$`) +var endMetadataRe = regexp.MustCompile(`^@@[\-+ \d,]+@@(.*)$`) + +func ParseDiffOutput(r io.Reader) (*DiffInfo, error) { + res := NewDiffInfo() + tmp := NewDiffFileInfo() + firstLine := true + scanner := bufio.NewScanner(r) + metadataBlock := false + for scanner.Scan() { + text := scanner.Text() + + if diffStartRe.MatchString(text) { + if firstLine { + firstLine = false + } else { + // Append diffFileInfo when all lines are gathered and new diffFIleInfo is detected. + res.Files = append(res.Files, tmp) + tmp = NewDiffFileInfo() + } + metadataBlock = true + continue + } + + matches := newFileNameRe.FindStringSubmatch(text) + if len(matches) > 1 { + if matches[1] == "/dev/null" { + tmp.NewFileName = matches[1] + } else { + tmp.NewFileName = matches[2] + } + continue + } + + matches = oldFileNameRe.FindStringSubmatch(text) + if len(matches) > 1 { + if matches[1] == "/dev/null" { + tmp.OldFileName = matches[1] + } else { + tmp.OldFileName = matches[2] + } + continue + } + + if metadataBlock { + matches = endMetadataRe.FindStringSubmatch(text) + if len(matches) > 1 { + tmp.Lines = append(tmp.Lines, matches[1]) + metadataBlock = false + continue + } + } + + if !metadataBlock { + tmp.Lines = append(tmp.Lines, text) + } + } + // Push last diff info. + if tmp != nil { + res.Files = append(res.Files, tmp) + } + + return res, nil +} diff --git a/tools/validation/doc_changes.go b/tools/validation/doc_changes.go new file mode 100644 index 0000000..08c9c2c --- /dev/null +++ b/tools/validation/doc_changes.go @@ -0,0 +1,143 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "os" + "regexp" + "strings" +) + +var ( + resourceFileRe = regexp.MustCompile(`openapi/config-values.y[a]?ml$|crds/.+.y[a]?ml$`) + docFileRe = regexp.MustCompile(`\.md$`) + + excludeFileRe = regexp.MustCompile("crds/embedded/.+.y[a]?ml$") +) + +func RunDocChangesValidation(info *DiffInfo) (exitCode int) { + fmt.Printf("Run 'doc changes' validation ...\n") + + if len(info.Files) == 0 { + fmt.Printf("Nothing to validate, diff is empty\n") + return 0 + } + + exitCode = 0 + msgs := NewMessages() + for _, fileInfo := range info.Files { + if !fileInfo.HasContent() { + continue + } + + fileName := fileInfo.NewFileName + + if strings.Contains(fileName, "testdata") { + msgs.Add(NewSkip(fileName, "")) + continue + } + + if docFileRe.MatchString(fileName) { + msgs.Add(checkDocFile(fileName, info)) + continue + } + + if resourceFileRe.MatchString(fileName) && !excludeFileRe.MatchString(fileName) { + msgs.Add(checkResourceFile(fileName, info)) + continue + } + + msgs.Add(NewSkip(fileName, "")) + } + msgs.PrintReport() + + if msgs.CountErrors() > 0 { + exitCode = 1 + } + + return exitCode +} + +var possibleDocRootsRe = regexp.MustCompile(`docs/|docs/documentation`) +var docsDirAllowedFileRe = regexp.MustCompile(`docs/(CONFIGURATION|CR|FAQ|README|ADMIN_GUIDE|USER_GUIDE|CHARACTERISTICS_DESCRIPTION|INSTALL|RELEASE_NOTES)(\.ru)?.md`) +var docsDirFileRe = regexp.MustCompile(`docs/[^/]+.md`) + +func checkDocFile(fName string, diffInfo *DiffInfo) (msg Message) { + if !possibleDocRootsRe.MatchString(fName) { + return NewSkip(fName, "") + } + + if docsDirFileRe.MatchString(fName) && !docsDirAllowedFileRe.MatchString(fName) { + return NewError( + fName, + "name is not allowed", + `Rename this file or move it, for example, into 'internal' folder. +Only following file names are allowed in the module '/docs/' directory: + CLUSTER_CONFIGURATION.md + CONFIGURATION.md + CR.md + FAQ.md + README.md + RELEASE_NOTES.md + ADMIN_GUIDE.md + USER_GUIDE.md + CHARACTERISTICS_DESCRIPTION.md +(also their Russian versions ended with '.ru.md')`, + ) + } + + // Check if documentation for other language file is also modified. + var otherFileName = fName + if strings.HasSuffix(fName, `.ru.md`) { + otherFileName = strings.TrimSuffix(fName, ".ru.md") + ".md" + } else { + otherFileName = strings.TrimSuffix(fName, ".md") + ".ru.md" + } + return checkRelatedFileExists(fName, otherFileName, diffInfo) +} + +var docRuResourceRe = regexp.MustCompile(`doc-ru-.+.y[a]?ml$`) +var notDocRuResourceRe = regexp.MustCompile(`([^/]+\.y[a]?ml)$`) + +// Check if resource for other language is also modified. +func checkResourceFile(fName string, diffInfo *DiffInfo) (msg Message) { + otherFileName := fName + if docRuResourceRe.MatchString(fName) { + otherFileName = strings.Replace(fName, "doc-ru-", "", 1) + } else { + otherFileName = notDocRuResourceRe.ReplaceAllString(fName, `doc-ru-$1`) + } + return checkRelatedFileExists(fName, otherFileName, diffInfo) +} + +func checkRelatedFileExists(origName string, otherName string, diffInfo *DiffInfo) Message { + file, err := os.Open(otherName) + if err != nil { + return NewError(origName, "related is absent", fmt.Sprintf(`Documentation or resource file is changed +while related language file '%s' is absent.`, otherName)) + } + defer file.Close() + + for _, fileInfo := range diffInfo.Files { + if fileInfo.NewFileName == otherName { + return NewOK(origName) + } + } + return NewError(origName, "related not changed", fmt.Sprintf(`Documentation or resource file is changed +while related language file '%s' is not changed`, otherName)) +} diff --git a/tools/validation/go.mod b/tools/validation/go.mod new file mode 100644 index 0000000..3102e06 --- /dev/null +++ b/tools/validation/go.mod @@ -0,0 +1,3 @@ +module validation + +go 1.21.4 diff --git a/tools/validation/main.go b/tools/validation/main.go new file mode 100644 index 0000000..4829162 --- /dev/null +++ b/tools/validation/main.go @@ -0,0 +1,97 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "bytes" + "flag" + "fmt" + "os" + "os/exec" +) + +func main() { + var validationType string + flag.StringVar(&validationType, "type", "", "Validation type: cyrillic or doc-changes.") + var patchFile string + flag.StringVar(&patchFile, "file", "", "Patch file. git diff is executed if not passed.") + var title string + flag.StringVar(&title, "title", "", "Title string to check for cyrillic letters.") + var description string + flag.StringVar(&description, "description", "", "Description string to check for cyrillic letters.") + flag.Parse() + + var diffInfo *DiffInfo + var err error + if patchFile != "" { + // Parse file content. + diffInfo, err = readFile(patchFile) + if err != nil { + fmt.Printf("Read file '%s': %v", patchFile, err) + os.Exit(1) + } + } else { + // Parse 'git diff' output. + fmt.Printf("Run git diff ...\n") + diffInfo, err = executeGitDiff() + if err != nil { + fmt.Printf("Execute git diff: %v", err) + os.Exit(1) + } + } + + exitCode := 0 + switch validationType { + case "no-cyrillic": + exitCode = RunNoCyrillicValidation(diffInfo, title, description) + case "doc-changes": + exitCode = RunDocChangesValidation(diffInfo) + case "dump": + fmt.Printf("%s\n", diffInfo.Dump()) + default: + fmt.Printf("Unknown validation type '%s'\n", validationType) + os.Exit(2) + } + + if exitCode == 0 { + fmt.Printf("Validation successful.\n") + } else { + fmt.Printf("Validation failed.\n") + } + os.Exit(exitCode) +} + +func readFile(fName string) (*DiffInfo, error) { + content, err := os.ReadFile(fName) + if err != nil { + return nil, err + } + + br := bytes.NewReader(content) + return ParseDiffOutput(br) +} + +func executeGitDiff() (*DiffInfo, error) { + gitCmd := exec.Command("git", "diff", "origin/main...", "-w", "--ignore-blank-lines") + out, err := gitCmd.Output() + if err != nil { + return nil, err + } + + br := bytes.NewReader(out) + return ParseDiffOutput(br) +} diff --git a/tools/validation/messages.go b/tools/validation/messages.go new file mode 100644 index 0000000..6cd933a --- /dev/null +++ b/tools/validation/messages.go @@ -0,0 +1,176 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "strings" +) + +const OKType = "OK" +const SkipType = "Skip" +const ErrorType = "ERROR" + +type Message struct { + Type string + FileName string + Message string + Details string +} + +func NewOK(fileName string) Message { + return Message{ + Type: OKType, + FileName: fileName, + } +} + +func NewSkip(fileName string, msg string) Message { + return Message{ + Type: SkipType, + FileName: fileName, + Message: msg, + } +} + +func NewError(fileName string, msg string, details string) Message { + return Message{ + Type: ErrorType, + FileName: fileName, + Message: msg, + Details: details, + } +} + +func (msg Message) Format() string { + res := "" + if msg.Message == "" { + res += fmt.Sprintf(" * %s ... %s", msg.FileName, msg.Type) + } else { + res += fmt.Sprintf(" * %s ... %s: %s", msg.FileName, msg.Type, msg.Message) + } + if msg.Details != "" { + res += "\n" + indentTextBlock(msg.Details, 6) + } + return res +} + +func (msg Message) IsError() bool { + return msg.Type == ErrorType +} + +func (msg Message) IsSkip() bool { + return msg.Type == SkipType +} + +func (msg Message) IsOK() bool { + return msg.Type == OKType +} + +type Messages struct { + messages []Message +} + +func NewMessages() *Messages { + return &Messages{ + messages: make([]Message, 0), + } +} + +func (m *Messages) Add(msg Message) { + m.messages = append(m.messages, msg) +} + +func (m *Messages) Join(msgs *Messages) { + if msgs == nil { + return + } + for _, message := range msgs.messages { + m.Add(message) + } +} + +func (m *Messages) CountOK() int { + res := 0 + for _, msg := range m.messages { + if msg.IsOK() { + res++ + } + } + return res +} + +func (m *Messages) CountSkip() int { + res := 0 + for _, msg := range m.messages { + if msg.IsSkip() { + res++ + } + } + return res +} + +func (m *Messages) CountErrors() int { + res := 0 + for _, msg := range m.messages { + if msg.IsError() { + res++ + } + } + return res +} + +func (m *Messages) PrintReport() { + if m.CountSkip() > 0 { + fmt.Println("Skipped:") + for _, msg := range m.messages { + if msg.IsSkip() { + fmt.Println(msg.Format()) + } + } + } + if m.CountOK() > 0 { + fmt.Println("OK:") + for _, msg := range m.messages { + if msg.IsOK() { + fmt.Println(msg.Format()) + } + } + } + if m.CountErrors() > 0 { + fmt.Println("ERRORS:") + for _, msg := range m.messages { + if msg.IsError() { + fmt.Println(msg.Format()) + } + } + } +} + +func indentTextBlock(msg string, n int) string { + lines := strings.Split(msg, "\n") + var b strings.Builder + for i, line := range lines { + // leading newline and newlines between lines + if i > 0 { + b.WriteString("\n") + } + b.WriteString(strings.Repeat(" ", n)) + b.WriteString(line) + } + return b.String() +} diff --git a/tools/validation/no_cyrillic.go b/tools/validation/no_cyrillic.go new file mode 100644 index 0000000..d41c65a --- /dev/null +++ b/tools/validation/no_cyrillic.go @@ -0,0 +1,160 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "regexp" + "strings" +) + +var skipDocRe = regexp.MustCompile(`doc-ru-.+\.y[a]?ml$|\.ru\.md$`) +var skipI18NRe = regexp.MustCompile(`/i18n/`) +var skipSelfRe = regexp.MustCompile(`no_cyrillic(_test)?.go$`) + +func RunNoCyrillicValidation(info *DiffInfo, title string, description string) (exitCode int) { + fmt.Printf("Run 'no cyrillic' validation ...\n") + + exitCode = 0 + if title != "" { + fmt.Printf("Check title ... ") + msg, hasCyr := checkCyrillicLetters(title) + if hasCyr { + fmt.Printf("ERROR\n%s\n", msg) + exitCode = 1 + } else { + fmt.Printf("OK\n") + } + } + if description != "" { + // Here put cyrillic char -> C + fmt.Printf("Check description Сахар... ") + msg, hasCyr := checkCyrillicLetters(description) + if hasCyr { + fmt.Printf("ERROR\n%s\n", msg) + exitCode = 1 + } else { + fmt.Printf("OK\n") + } + } + // Some fishka + fmt.Printf("Check new and updated lines ... ") + if len(info.Files) == 0 { + fmt.Printf("OK, diff is empty\n") + } else { + fmt.Println("") + + msgs := NewMessages() + + //hasErrors := false + for _, fileInfo := range info.Files { + if !fileInfo.HasContent() { + continue + } + // Check only added or modified files + if !(fileInfo.IsAdded() || fileInfo.IsModified()) { + continue + } + + fileName := fileInfo.NewFileName + + if skipDocRe.MatchString(fileName) { + msgs.Add(NewSkip(fileName, "documentation")) + continue + } + + if skipI18NRe.MatchString(fileName) { + msgs.Add(NewSkip(fileName, "translation file")) + continue + } + + if skipSelfRe.MatchString(fileName) { + msgs.Add(NewSkip(fileName, "self")) + continue + } + + // Get added or modified lines + newLines := fileInfo.NewLines() + if len(newLines) == 0 { + msgs.Add(NewSkip(fileName, "no lines added")) + continue + } + + cyrMsg, hasCyr := checkCyrillicLettersInArray(newLines) + if hasCyr { + msgs.Add(NewError(fileName, "should not contain Cyrillic letters", cyrMsg)) + continue + } + + msgs.Add(NewOK(fileName)) + } + + msgs.PrintReport() + + if msgs.CountErrors() > 0 { + exitCode = 1 + } + } + + return exitCode +} + +var cyrRe = regexp.MustCompile(`[А-Яа-яЁё]+`) +var cyrPointerRe = regexp.MustCompile(`[А-Яа-яЁё]`) +var cyrFillerRe = regexp.MustCompile(`[^А-Яа-яЁё]`) + +func checkCyrillicLetters(in string) (string, bool) { + if strings.Contains(in, "\n") { + return checkCyrillicLettersInArray(strings.Split(in, "\n")) + } + return checkCyrillicLettersInString(in) +} + +// checkCyrillicLettersInString returns a fancy message if input string contains Cyrillic letters. +func checkCyrillicLettersInString(line string) (string, bool) { + if !cyrRe.MatchString(line) { + return "", false + } + + // Replace all tabs with spaces to prevent shifted cursor. + line = strings.Replace(line, "\t", " ", -1) + + // Make string with pointers to Cyrillic letters so user can detect hidden letters. + cursor := cyrFillerRe.ReplaceAllString(line, "-") + cursor = cyrPointerRe.ReplaceAllString(cursor, "^") + cursor = strings.TrimRight(cursor, "-") + + const formatPrefix = " " + + return formatPrefix + line + "\n" + formatPrefix + cursor, true +} + +// checkCyrillicLettersInArray returns a fancy message for each string in array that contains Cyrillic letters. +func checkCyrillicLettersInArray(lines []string) (string, bool) { + res := make([]string, 0) + + hasCyr := false + for _, line := range lines { + msg, has := checkCyrillicLettersInString(line) + if has { + hasCyr = true + res = append(res, msg) + } + } + + return strings.Join(res, "\n"), hasCyr +} diff --git a/tools/validation/no_cyrillic_test.go b/tools/validation/no_cyrillic_test.go new file mode 100644 index 0000000..f239014 --- /dev/null +++ b/tools/validation/no_cyrillic_test.go @@ -0,0 +1,96 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "strings" + "testing" +) + +func Test_found_msg(t *testing.T) { + // Simple check with one Cyrillic letter. + in := "fooБfoo" + expected := ` fooБfoo + ---^` + + actual, has := checkCyrillicLetters(in) + + if !has { + t.Errorf("Should detect cyrillic letters in string") + } + + if actual != expected { + t.Errorf("Expect '%s', got '%s'", expected, actual) + } + + // No Cyrillic letters. + in = "asdqwe 123456789 !@#$%^&*( ZXCVBNM" + expected = "" + actual, has = checkCyrillicLetters(in) + + if has { + t.Errorf("Should not detect cyrillic letters in string") + } + + if actual != expected { + t.Errorf("Expect '%s', got '%s'", expected, actual) + } + + // Multiple words with Cyrillic letters. + in = "asdqwe Там на qw q cheсk tеst qwd неведомых qqw" + expected = + " asdqwe Там на qw q cheсk tеst qwd неведомых qqw\n" + + " -------^^^-^^---------^---^-------^^^^^^^^^" + + actual, has = checkCyrillicLetters(in) + + if !has { + t.Errorf("Should detect cyrillic letters in string") + } + + if actual != expected { + fmt.Printf(" %s\n%s\n", + strings.Repeat("0123456789", len(actual)/2/10+1), + actual) + t.Errorf("Expect \n%s\n, got \n%s\n", expected, actual) + } + + // Multiple messages for string with '\n'. + in = "Lorem ipsum dolor sit amet,\n consectetur adipiscing elit,\n" + + "раскрою перед вами всю \nкартину и разъясню," + + "Ut enim ad minim veniam," + expected = + " раскрою перед вами всю \n" + + " ^^^^^^^-^^^^^-^^^^-^^^\n" + + " картину и разъясню,Ut enim ad minim veniam,\n" + + " ^^^^^^^-^-^^^^^^^^" + + actual, has = checkCyrillicLetters(in) + + if !has { + t.Errorf("Should detect cyrillic letters in string") + } + + if actual != expected { + fmt.Printf(" %s\n%s\n", + strings.Repeat("0123456789", len(actual)/2/10+1), + actual) + t.Errorf("Expect \n%s\n, got \n%s\n", expected, actual) + } + +} diff --git a/werf-giterminism.yaml b/werf-giterminism.yaml new file mode 100644 index 0000000..250f208 --- /dev/null +++ b/werf-giterminism.yaml @@ -0,0 +1,28 @@ +giterminismConfigVersion: 1 +config: + goTemplateRendering: # The rules for the Go-template functions + allowEnvVariables: + - /CI_.+/ + - GOPROXY + - MODULES_MODULE_TAG + - SOURCE_REPO + - SOURCE_REPO_GIT + - MODULE_EDITION + - DISTRO_PACKAGES_PROXY + - SVACE_ENABLED + - SVACE_ANALYZE_HOST + - SVACE_ANALYZE_SSH_USER + - DEBUG_COMPONENT + stapel: + mount: + allowBuildDir: true + allowFromPaths: + - ~/go-pkg-cache + secrets: + allowValueIds: + - SOURCE_REPO + - GOPROXY +helm: + allowUncommittedFiles: + - "Chart.lock" + - "charts/*.tgz" diff --git a/werf.yaml b/werf.yaml new file mode 100644 index 0000000..5b2d5a1 --- /dev/null +++ b/werf.yaml @@ -0,0 +1,114 @@ +project: operator-helm +configVersion: 1 +build: + imageSpec: + author: "Deckhouse Kubernetes Platform " + clearHistory: true + config: + keepEssentialWerfLabels: true + removeLabels: + - /.*/ +--- +# Base Images +{{- include "parse_base_images_map" . }} +--- +# Source repo settings +{{- $_ := set . "SOURCE_REPO" (env "SOURCE_REPO" "https://github.com") }} + +{{- $_ := set . "SOURCE_REPO_GIT" (env "SOURCE_REPO_GIT" "https://github.com") }} + +# Define packages proxy settings +{{- $_ := set . "DistroPackagesProxy" (env "DISTRO_PACKAGES_PROXY" "") }} + + +# svace analyze toggler +{{- $_ := set . "SVACE_ENABLED" (env "SVACE_ENABLED" "false") }} +{{- $_ := set . "SVACE_ANALYZE_HOST" (env "SVACE_ANALYZE_HOST" "example.host") }} +{{- $_ := set . "SVACE_ANALYZE_SSH_USER" (env "SVACE_ANALYZE_SSH_USER" "user") }} + +{{- $_ := set . "ImagesIDList" list }} + +{{- range $path, $content := .Files.Glob ".werf/*.yaml" }} + {{- tpl $content $ }} +{{- end }} +--- +image: images-digests +fromImage: builder/alpine +dependencies: + {{- range $ImageID := $.ImagesIDList }} + {{- $ImageNameCamel := $ImageID | splitList "/" | last | camelcase | untitle }} +- image: {{ $ImageID }} + before: setup + imports: + - type: ImageDigest + targetEnv: MODULE_IMAGE_DIGEST_{{ $ImageNameCamel }} + {{- end }} +shell: + beforeInstall: + - apk add --no-cache jq + setup: + - | + env | grep MODULE_IMAGE_DIGEST | jq -Rn ' + reduce inputs as $i ( + {}; + . * ( + $i | ltrimstr("MODULE_IMAGE_DIGEST_") | sub("=";"_") | + split("_") as [$imageName, $digest] | + {($imageName): $digest} + ) + ) + ' > /images_digests.json + cat images_digests.json +--- +image: bundle +fromImage: builder/scratch +import: +- image: prepare-bundle + add: /prep-bundle + to: / + after: setup +--- +image: prepare-bundle +fromImage: builder/alpine +import: +- image: images-digests + add: / + to: /prep-bundle + after: setup + includePaths: + - images_digests.json +- image: go-hooks-artifact + add: /go-hooks + to: /prep-bundle/hooks/go + after: setup +git: + - add: / + to: /prep-bundle + stageDependencies: + install: + - '**/*' + includePaths: + - charts + - crds + - build/components + - docs + - openapi + - monitoring + - templates + - Chart.yaml + - module.yaml + - .helmignore + excludePaths: + - build/components/README.md + - docs/images/*.drawio + - docs/images/*.sh + - docs/internal +shell: + install: + - ls -la /prep-bundle +--- +image: release-channel-version +fromImage: builder/scratch +shell: + install: + - echo '{"version":"{{ env "MODULES_MODULE_TAG" "dev" }}"}' > version.json diff --git a/werf_cleanup.yaml b/werf_cleanup.yaml new file mode 100644 index 0000000..9ca7769 --- /dev/null +++ b/werf_cleanup.yaml @@ -0,0 +1,18 @@ +configVersion: 1 +project: operator-helm +cleanup: + keepPolicies: + - references: + tag: /.*/ + limit: + in: 72h + - references: + branch: /.*/ + limit: + in: 168h # keep dev images build during last week which not main|pre-alpha + - references: + branch: /main|release-[0-9]+.*/ + limit: + last: 5 # keep 5 images for branches release-* and main + imagesPerReference: + last: 1 From 9a5b4ea471ff18d1f7cc11c064bc6844f73053de Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 11 Mar 2026 10:09:37 +0300 Subject: [PATCH 02/27] docs: update README.md Signed-off-by: Ilya Drey --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index 3a20abb..f997fab 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,3 @@ [Deckhouse Kubernetes Platform](https://deckhouse.io/) module to deploy helm applications declaratively. -## Description - - - -### Resource requirements: - - - -## What do I need to enable the module? - - From 23bac34f3d17e42224f2284d1be0567bb75a64fc Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 11 Mar 2026 10:10:07 +0300 Subject: [PATCH 03/27] chore: go.mod tidy Signed-off-by: Ilya Drey --- images/operator-helm-artifact/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/operator-helm-artifact/go.mod b/images/operator-helm-artifact/go.mod index 0eb2c1b..f642971 100644 --- a/images/operator-helm-artifact/go.mod +++ b/images/operator-helm-artifact/go.mod @@ -16,7 +16,6 @@ require ( k8s.io/api v0.35.1 k8s.io/apimachinery v0.35.1 k8s.io/client-go v0.35.1 - k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 sigs.k8s.io/controller-runtime v0.23.1 ) @@ -76,6 +75,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.35.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect From 724eb59aabee8133f917f8fffedc634af7b45710 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 11 Mar 2026 10:25:41 +0300 Subject: [PATCH 04/27] ci: update workflows Signed-off-by: Ilya Drey --- .github/workflows/build_dev.yaml | 20 +++++++------------- .github/workflows/deploy_dev.yaml | 8 ++------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build_dev.yaml b/.github/workflows/build_dev.yaml index 78e246c..24b1a3b 100644 --- a/.github/workflows/build_dev.yaml +++ b/.github/workflows/build_dev.yaml @@ -19,6 +19,8 @@ on: branches: - main - release-* + tags: + - 'v*' jobs: lint: @@ -37,7 +39,6 @@ jobs: name: Build and Push images outputs: MODULES_MODULE_TAG: ${{ steps.modules_module_tag.outputs.MODULES_MODULE_TAG }} - MODULES_MODULE_NAME: ${{ steps.modules_module_name.outputs.MODULES_MODULE_NAME }} steps: - name: Set vars id: modules_module_tag @@ -65,17 +66,10 @@ jobs: registry_login: ${{ vars.DEV_MODULES_REGISTRY_LOGIN }} registry_password: ${{ secrets.DEV_MODULES_REGISTRY_PASSWORD }} - - name: Get the repository name - id: modules_module_name - run: | - FULL_REPO="${{ github.repository }}" - REPO_NAME="${FULL_REPO#*/}" - echo "MODULES_MODULE_NAME=$REPO_NAME" >> "$GITHUB_OUTPUT" - - uses: deckhouse/modules-actions/build@main with: - module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules/${{ steps.modules_module_name.outputs.MODULES_MODULE_NAME }} - module_name: ${{ steps.modules_module_name.outputs.MODULES_MODULE_NAME }} + module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules/${{ vars.MODULES_MODULE_NAME }} + module_name: ${{ vars.MODULES_MODULE_NAME }} module_tag: ${{ steps.modules_module_tag.outputs.MODULES_MODULE_TAG }} svace_enabled: false @@ -94,7 +88,7 @@ jobs: apiVersion: deckhouse.io/v1alpha1 kind: ModulePullOverride metadata: - name: ${{ needs.build_dev.outputs.MODULES_MODULE_NAME }} + name: ${{ vars.MODULES_MODULE_NAME }} spec: imageTag: ${{ needs.build_dev.outputs.MODULES_MODULE_TAG }} source: deckhouse @@ -103,13 +97,13 @@ jobs: apiVersion: deckhouse.io/v1alpha1 kind: ModuleConfig metadata: - name: ${{ needs.build_dev.outputs.MODULES_MODULE_NAME }} + name: ${{ vars.MODULES_MODULE_NAME }} spec: enabled: true EOF Or patch an existing ModulePullOverride: - kubectl patch mpo ${{ needs.build_dev.outputs.MODULES_MODULE_NAME }} --type merge -p '{"spec":{"imageTag":"${{ needs.build_dev.outputs.MODULES_MODULE_TAG }}"}}' + kubectl patch mpo ${{ vars.MODULES_MODULE_NAME }} --type merge -p '{"spec":{"imageTag":"${{ needs.build_dev.outputs.MODULES_MODULE_TAG }}"}}' OUTER diff --git a/.github/workflows/deploy_dev.yaml b/.github/workflows/deploy_dev.yaml index 9570526..8f54e9c 100644 --- a/.github/workflows/deploy_dev.yaml +++ b/.github/workflows/deploy_dev.yaml @@ -31,13 +31,9 @@ jobs: registry_login: ${{ vars.DEV_MODULES_REGISTRY_LOGIN }} registry_password: ${{ secrets.DEV_MODULES_REGISTRY_PASSWORD }} - - name: Get the repository name - id: repo_name - run: echo "REPO_NAME=$(echo '${{ github.repository }}' | cut -d'/' -f2)" >> $GITHUB_OUTPUT - - uses: deckhouse/modules-actions/deploy@main with: - module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules/${{ steps.repo_name.outputs.REPO_NAME }} - module_name: ${{ steps.repo_name.outputs.REPO_NAME }} + module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules/${{ vars.MODULES_MODULE_NAME }} + module_name: ${{ vars.MODULES_MODULE_NAME }} module_tag: ${{ github.event.inputs.tag }} release_channel: ${{ github.event.inputs.release_channel }} From 2170ee9a8caf5a8ae6ed3a0912a0d39955a00517 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 11 Mar 2026 10:31:48 +0300 Subject: [PATCH 05/27] ci: update workflows Signed-off-by: Ilya Drey --- .github/workflows/build_dev.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_dev.yaml b/.github/workflows/build_dev.yaml index 24b1a3b..94edb9f 100644 --- a/.github/workflows/build_dev.yaml +++ b/.github/workflows/build_dev.yaml @@ -45,7 +45,7 @@ jobs: run: | if [[ "${{ github.ref_name }}" == 'main' ]]; then MODULES_MODULE_TAG="${{ github.ref_name }}" - elif [[ "${{ github.ref_name }}" =~ ^release-[0-9]+\.[0-9]+ ]]; then + elif [[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+ ]]; then MODULES_MODULE_TAG="${{ github.ref_name }}" elif [[ -n "${{ github.event.pull_request.number }}" ]]; then MODULES_MODULE_TAG="pr${{ github.event.pull_request.number }}" From 40d70c7a693b222b3efb7366d064e9dcc1df44b6 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 11 Mar 2026 11:51:33 +0300 Subject: [PATCH 06/27] ci: update workflows Signed-off-by: Ilya Drey --- .github/workflows/build_dev.yaml | 2 +- .github/workflows/deploy_dev.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_dev.yaml b/.github/workflows/build_dev.yaml index 94edb9f..faae533 100644 --- a/.github/workflows/build_dev.yaml +++ b/.github/workflows/build_dev.yaml @@ -68,7 +68,7 @@ jobs: - uses: deckhouse/modules-actions/build@main with: - module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules/${{ vars.MODULES_MODULE_NAME }} + module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules module_name: ${{ vars.MODULES_MODULE_NAME }} module_tag: ${{ steps.modules_module_tag.outputs.MODULES_MODULE_TAG }} svace_enabled: false diff --git a/.github/workflows/deploy_dev.yaml b/.github/workflows/deploy_dev.yaml index 8f54e9c..e60fbdd 100644 --- a/.github/workflows/deploy_dev.yaml +++ b/.github/workflows/deploy_dev.yaml @@ -33,7 +33,7 @@ jobs: - uses: deckhouse/modules-actions/deploy@main with: - module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules/${{ vars.MODULES_MODULE_NAME }} + module_source: dev-registry.deckhouse.io/sys/deckhouse-oss/modules module_name: ${{ vars.MODULES_MODULE_NAME }} module_tag: ${{ github.event.inputs.tag }} release_channel: ${{ github.event.inputs.release_channel }} From e74095d32366ba8964aed83a673c128b220ce61d Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 11 Mar 2026 12:43:45 +0300 Subject: [PATCH 07/27] docs: update operator-helm documentation Signed-off-by: Ilya Drey --- docs/CONFIGURATION.md | 4 ++-- docs/CONFIGURATION_RU.md | 4 ++-- docs/EXAMPLE.md | 3 ++- docs/EXAMPLE_RU.md | 3 ++- docs/USAGE.md | 1 + docs/USAGE_RU.md | 1 + 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6700ff7..c19b477 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,4 +1,4 @@ --- -title: "The operator-helm module: configuration" -weight: 30 +title: "Configuration" +weight: 20 --- diff --git a/docs/CONFIGURATION_RU.md b/docs/CONFIGURATION_RU.md index fe053c1..661c392 100644 --- a/docs/CONFIGURATION_RU.md +++ b/docs/CONFIGURATION_RU.md @@ -1,4 +1,4 @@ --- -title: "Модуль operator-helm: настройки" -weight: 30 +title: "Настройки" +weight: 20 --- diff --git a/docs/EXAMPLE.md b/docs/EXAMPLE.md index 3d6418a..9f47a7c 100644 --- a/docs/EXAMPLE.md +++ b/docs/EXAMPLE.md @@ -1,6 +1,7 @@ --- -title: "The operator-helm module: usage examples" +title: "Examples" description: "Deckhouse Kubernetes Platform — usage examples for the operator-helm module." +weight: 30 --- ## Adding a Helm Repository diff --git a/docs/EXAMPLE_RU.md b/docs/EXAMPLE_RU.md index ef6c9c4..0d212df 100644 --- a/docs/EXAMPLE_RU.md +++ b/docs/EXAMPLE_RU.md @@ -1,6 +1,7 @@ --- -title: "Модуль operator-helm: примеры использования" +title: "Примеры использования" description: "Deckhouse Kubernetes Platform — примеры использования модуля operator-helm." +weight: 30 --- ## Добавление Helm репозитория diff --git a/docs/USAGE.md b/docs/USAGE.md index 2851b6a..d02ac16 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1,6 +1,7 @@ --- title: "Usage" description: Usage of the operator-helm Deckhouse module. +weight: 15 --- ## Enabling the module diff --git a/docs/USAGE_RU.md b/docs/USAGE_RU.md index 7c70efa..a6c4670 100644 --- a/docs/USAGE_RU.md +++ b/docs/USAGE_RU.md @@ -1,6 +1,7 @@ --- title: "Использование" description: Использование модуля operator-helm. +weight: 15 --- ## Включение модуля From 042557a05cf690d6adfeadfffe4d2fb3c88ee890 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 11 Mar 2026 12:59:06 +0300 Subject: [PATCH 08/27] docs: update operator-helm documentation Signed-off-by: Ilya Drey --- docs/EXAMPLE_RU.md | 2 +- docs/README.md | 2 +- docs/README_RU.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/EXAMPLE_RU.md b/docs/EXAMPLE_RU.md index 0d212df..ea2911a 100644 --- a/docs/EXAMPLE_RU.md +++ b/docs/EXAMPLE_RU.md @@ -1,5 +1,5 @@ --- -title: "Примеры использования" +title: "Примеры" description: "Deckhouse Kubernetes Platform — примеры использования модуля operator-helm." weight: 30 --- diff --git a/docs/README.md b/docs/README.md index 5d43ec5..3c3ca2c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,4 +17,4 @@ Management of the module's resources is unified and accessible via: * Command Line Interface (CLI): using the `d8` or `kubectl` utility. * Web Interface: through the Deckhouse Kubernetes Platform graphical console. -See module usage examples in [Usage examples](examples.html) section. +See module usage examples in [Usage examples](example.html) section. diff --git a/docs/README_RU.md b/docs/README_RU.md index 157eb6d..70e9b20 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -17,4 +17,4 @@ weight: 10 * Интерфейс командной строки: с помощью утилиты `d8`, либо kubectl. * Web-интерфейс: через графическую консоль управления Deckhouse Kubernetes Platform. -Примеры использования модуля приведены в разделе [Примеры использования](examples.html). +Примеры использования модуля приведены в разделе [Примеры использования](example.html). From 6a2652513acb360fbd36651504b5e7bc15217cba Mon Sep 17 00:00:00 2001 From: Evgeniy Frolov Date: Thu, 12 Mar 2026 01:50:15 +0300 Subject: [PATCH 09/27] chore(module): import module.yaml (#3) Signed-off-by: Evgeniy Frolov --- werf.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/werf.yaml b/werf.yaml index 5b2d5a1..ff8d125 100644 --- a/werf.yaml +++ b/werf.yaml @@ -109,6 +109,13 @@ shell: --- image: release-channel-version fromImage: builder/scratch +import: +- image: prepare-bundle + add: /prep-bundle + to: / + after: install + includePaths: + - module.yaml shell: install: - echo '{"version":"{{ env "MODULES_MODULE_TAG" "dev" }}"}' > version.json From 7c1f84f9c389dd149cdde72943b3ac2ab5e8d888 Mon Sep 17 00:00:00 2001 From: Evgeniy Frolov Date: Thu, 12 Mar 2026 16:24:04 +0300 Subject: [PATCH 10/27] chore(ci): add release process (#5) --- .../{build_dev.yaml => build_dev.yml} | 38 +- .github/workflows/cve_scan_daily.yml | 56 ++ .../{deploy_dev.yaml => deploy_dev.yml} | 4 +- .../release_module_release-channels.yml | 500 ++++++++++++++++++ 4 files changed, 588 insertions(+), 10 deletions(-) rename .github/workflows/{build_dev.yaml => build_dev.yml} (74%) create mode 100644 .github/workflows/cve_scan_daily.yml rename .github/workflows/{deploy_dev.yaml => deploy_dev.yml} (95%) create mode 100644 .github/workflows/release_module_release-channels.yml diff --git a/.github/workflows/build_dev.yaml b/.github/workflows/build_dev.yml similarity index 74% rename from .github/workflows/build_dev.yaml rename to .github/workflows/build_dev.yml index faae533..2f8eb7e 100644 --- a/.github/workflows/build_dev.yaml +++ b/.github/workflows/build_dev.yml @@ -20,22 +20,22 @@ on: - main - release-* tags: - - 'v*' + - "v*" jobs: lint: - runs-on: ubuntu-latest + runs-on: [self-hosted, large] continue-on-error: true name: Lint steps: - uses: actions/checkout@v4 - uses: deckhouse/modules-actions/lint@main env: - DMT_METRICS_URL: ${{ secrets.DMT_METRICS_URL }} - DMT_METRICS_TOKEN: ${{ secrets.DMT_METRICS_TOKEN }} + DMT_METRICS_URL: ${{ secrets.DMT_METRICS_URL }} + DMT_METRICS_TOKEN: ${{ secrets.DMT_METRICS_TOKEN }} build_dev: - runs-on: ubuntu-latest + runs-on: [self-hosted, large] name: Build and Push images outputs: MODULES_MODULE_TAG: ${{ steps.modules_module_tag.outputs.MODULES_MODULE_TAG }} @@ -55,7 +55,7 @@ jobs: echo "::error title=Module image tag is required::Can't detect module tag from workflow context. Dev build uses branch name as tag for main and release branches, and PR number for builds from pull requests. Check workflow for correctness." exit 1 fi - + echo "MODULES_MODULE_TAG=$MODULES_MODULE_TAG" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v4 @@ -74,7 +74,7 @@ jobs: svace_enabled: false show_dev_manifest: - runs-on: ubuntu-latest + runs-on: [self-hosted, large] name: Show manifest needs: build_dev steps: @@ -82,7 +82,7 @@ jobs: run: | cat << OUTER Create ModuleConfig and ModulePullOverride resources to test this MR: - + cat < /dev/null; then + echo "$TAG is a valid release tag" + else + echo "Error: Invalid tag format. Use format vX.Y.Z" + exit 1 + fi + shell: bash + + job-CE: + name: Edition CE + runs-on: [self-hosted, large] + needs: print-vars + if: inputs.ce && !inputs.check_only + steps: + - name: Setup Docker config + run: | + echo "DOCKER_CONFIG=$(mktemp -d)" >> $GITHUB_ENV + + - run: echo "CE" + - name: SET VAR + id: set_vars + run: | + echo "MODULES_MODULE_SOURCE=$MODULES_REGISTRY/$MODULE_SOURCE_NAME/ce/modules" >> "$GITHUB_ENV" + echo "MODULE_EDITION=CE" >> "$GITHUB_ENV" + echo "MODULES_MODULE_SOURCE=$MODULES_REGISTRY/$MODULE_SOURCE_NAME/ce/modules" >> "$GITHUB_OUTPUT" + echo "MODULE_EDITION=CE" >> "$GITHUB_OUTPUT" + - name: ECHO VAR + run: | + echo $MODULES_MODULE_SOURCE + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.tag }} + + - name: Login to PROD_REGISTRY + uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.PROD_REGISTRY }} + registry_login: ${{ vars.PROD_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.PROD_MODULES_REGISTRY_PASSWORD }} + - name: Login to DEV_REGISTRY + uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.DEV_REGISTRY }} + registry_login: ${{ vars.DEV_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.DEV_MODULES_REGISTRY_PASSWORD }} + + - if: ${{ inputs.enableBuild }} + uses: deckhouse/modules-actions/build@v4 + with: + module_source: ${{ steps.set_vars.outputs.MODULES_MODULE_SOURCE }} + module_name: ${{ vars.MODULES_MODULE_NAME }} + module_tag: ${{ github.event.inputs.tag }} + + - uses: deckhouse/modules-actions/deploy@v2 + with: + module_source: ${{ steps.set_vars.outputs.MODULES_MODULE_SOURCE }} + module_name: ${{ vars.MODULES_MODULE_NAME }} + module_tag: ${{ github.event.inputs.tag }} + release_channel: ${{ github.event.inputs.channel }} + + - name: Cleanup Docker config + run: | + rm -rf $DOCKER_CONFIG + + job-EE: + name: Edition EE + needs: print-vars + runs-on: [self-hosted, large] + if: inputs.ee && !inputs.check_only + steps: + - name: Setup Docker config + run: | + echo "DOCKER_CONFIG=$(mktemp -d)" >> $GITHUB_ENV + + - run: echo "EE" + - name: SET VAR + id: set_vars + run: | + echo "MODULES_MODULE_SOURCE=$MODULES_REGISTRY/$MODULE_SOURCE_NAME/ee/modules" >> "$GITHUB_ENV" + echo "MODULE_EDITION=EE" >> "$GITHUB_ENV" + echo "MODULES_MODULE_SOURCE=$MODULES_REGISTRY/$MODULE_SOURCE_NAME/ee/modules" >> "$GITHUB_OUTPUT" + echo "MODULE_EDITION=EE" >> "$GITHUB_OUTPUT" + - name: ECHO VAR + run: | + echo $MODULES_MODULE_SOURCE + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.tag }} + + - uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.PROD_REGISTRY }} + registry_login: ${{ vars.PROD_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.PROD_MODULES_REGISTRY_PASSWORD }} + + - name: Login to PROD_REGISTRY + uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.PROD_REGISTRY }} + registry_login: ${{ vars.PROD_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.PROD_MODULES_REGISTRY_PASSWORD }} + - name: Login to DEV_REGISTRY + uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.DEV_REGISTRY }} + registry_login: ${{ vars.DEV_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.DEV_MODULES_REGISTRY_PASSWORD }} + + - if: ${{ inputs.enableBuild }} + uses: deckhouse/modules-actions/build@v4 + with: + module_source: ${{ steps.set_vars.outputs.MODULES_MODULE_SOURCE }} + module_name: ${{ vars.MODULES_MODULE_NAME }} + module_tag: ${{ github.event.inputs.tag }} + + - uses: deckhouse/modules-actions/deploy@v2 + with: + module_source: ${{ steps.set_vars.outputs.MODULES_MODULE_SOURCE }} + module_name: ${{ vars.MODULES_MODULE_NAME }} + module_tag: ${{ github.event.inputs.tag }} + release_channel: ${{ github.event.inputs.channel }} + + - name: Cleanup Docker config + run: | + rm -rf $DOCKER_CONFIG + + job-SE-Plus: + name: Edition SE Plus + needs: job-EE + runs-on: [self-hosted, large] + steps: + - name: Setup Docker config + run: | + echo "DOCKER_CONFIG=$(mktemp -d)" >> $GITHUB_ENV + + - run: echo "SE Plus" + - name: SET VAR + id: set_vars + run: | + echo "MODULES_MODULE_SOURCE=$MODULES_REGISTRY/$MODULE_SOURCE_NAME/se-plus/modules" >> "$GITHUB_ENV" + echo "MODULE_EDITION=EE" >> "$GITHUB_ENV" + echo "MODULES_MODULE_SOURCE=$MODULES_REGISTRY/$MODULE_SOURCE_NAME/se-plus/modules" >> "$GITHUB_OUTPUT" + echo "MODULE_EDITION=EE" >> "$GITHUB_OUTPUT" + - name: ECHO VAR + run: | + echo $MODULES_MODULE_SOURCE + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.tag }} + + - name: Login to PROD_REGISTRY + uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.PROD_REGISTRY }} + registry_login: ${{ vars.PROD_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.PROD_MODULES_REGISTRY_PASSWORD }} + - name: Login to DEV_REGISTRY + uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.DEV_REGISTRY }} + registry_login: ${{ vars.DEV_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.DEV_MODULES_REGISTRY_PASSWORD }} + + - if: ${{ inputs.enableBuild }} + uses: deckhouse/modules-actions/build@v4 + with: + module_source: ${{ steps.set_vars.outputs.MODULES_MODULE_SOURCE }} + module_name: ${{ vars.MODULES_MODULE_NAME }} + module_tag: ${{ github.event.inputs.tag }} + + - uses: deckhouse/modules-actions/deploy@v2 + with: + module_source: ${{ steps.set_vars.outputs.MODULES_MODULE_SOURCE }} + module_name: ${{ vars.MODULES_MODULE_NAME }} + module_tag: ${{ github.event.inputs.tag }} + release_channel: ${{ github.event.inputs.channel }} + + - name: Cleanup Docker config + run: | + rm -rf $DOCKER_CONFIG + + job-FE: + name: Edition FE + needs: job-EE + runs-on: [self-hosted, large] + steps: + - name: Setup Docker config + run: | + echo "DOCKER_CONFIG=$(mktemp -d)" >> $GITHUB_ENV + + - run: echo "FE" + - name: SET VAR + id: set_vars + run: | + echo "MODULES_MODULE_SOURCE=$MODULES_REGISTRY/$MODULE_SOURCE_NAME/fe/modules" >> "$GITHUB_ENV" + echo "MODULE_EDITION=EE" >> "$GITHUB_ENV" + echo "MODULES_MODULE_SOURCE=$MODULES_REGISTRY/$MODULE_SOURCE_NAME/fe/modules" >> "$GITHUB_OUTPUT" + echo "MODULE_EDITION=EE" >> "$GITHUB_OUTPUT" + - name: ECHO VAR + run: | + echo $MODULES_MODULE_SOURCE + - name: Validation for tag + run: | + echo ${{ github.event.inputs.tag }} | grep -P '^v\d+\.\d+\.\d+' + shell: bash + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.tag }} + + - name: Login to PROD_REGISTRY + uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.PROD_REGISTRY }} + registry_login: ${{ vars.PROD_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.PROD_MODULES_REGISTRY_PASSWORD }} + - name: Login to DEV_REGISTRY + uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.DEV_REGISTRY }} + registry_login: ${{ vars.DEV_MODULES_REGISTRY_LOGIN }} + registry_password: ${{ secrets.DEV_MODULES_REGISTRY_PASSWORD }} + + - if: ${{ inputs.enableBuild }} + uses: deckhouse/modules-actions/build@v4 + with: + module_source: ${{ steps.set_vars.outputs.MODULES_MODULE_SOURCE }} + module_name: ${{ vars.MODULES_MODULE_NAME }} + module_tag: ${{ github.event.inputs.tag }} + + - uses: deckhouse/modules-actions/deploy@v2 + with: + module_source: ${{ steps.set_vars.outputs.MODULES_MODULE_SOURCE }} + module_name: ${{ vars.MODULES_MODULE_NAME }} + module_tag: ${{ github.event.inputs.tag }} + release_channel: ${{ github.event.inputs.channel }} + + - name: Cleanup Docker config + run: | + rm -rf $DOCKER_CONFIG + + check-version-on-release-channel: + name: Check version on release channel + runs-on: ubuntu-latest + env: + GO_VERSION: "1.24.13" + input_channel: ${{ github.event.inputs.channel }} + input_version: ${{ github.event.inputs.tag }} + needs: + - job-EE + - job-SE-Plus + - job-FE + - job-CE + if: ${{ always() && ! contains(needs.*.result, 'failure') || inputs.check_only }} + strategy: + matrix: + check: ["registry", "releases", "documentation"] + steps: + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: "${{ env.GO_VERSION }}" + + - name: Login to PROD_REGISTRY + uses: deckhouse/modules-actions/setup@v2 + with: + registry: ${{ vars.PROD_READ_REGISTRY }} + registry_login: ${{ secrets.PROD_READ_REGISTRY_USER }} + registry_password: ${{ secrets.PROD_READ_REGISTRY_PASSWORD }} + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check version in registry + if: ${{ matrix.check == 'registry' && always() }} + run: | + CHANNEL=$input_channel \ + VERSION=$input_version \ + task -d tools/moduleversions check:registry + + - name: Check version on site ${{ matrix.check }} + if: ${{ matrix.check == 'releases' && always() }} + env: + input_check_only: ${{ inputs.check_only }} + run: | + # When check_only is true, we don't want to wait 120 seconds + # because we just want to check the version we're interested in on the website + if [ $input_check_only = false ]; then + echo "Waiting for site to update (versions are usually updated within 2 minutes)..." + sleep 120 + fi + + echo "Test that version deployed on site, retrying 5 times with delay of 60 seconds" + CHANNEL=$input_channel \ + VERSION=$input_version \ + COUNT=5 \ + task -d tools/moduleversions check:releases + - name: Check version on site ${{ matrix.check }} + if: ${{ matrix.check == 'documentation' && always() }} + env: + input_check_only: ${{ inputs.check_only }} + run: | + # When check_only is true, we don't want to wait 300 seconds + # because we just want to check the version we're interested in on the website + if [ $input_check_only = false ]; then + echo "Waiting for site to update (versions are usually updated within 5 minutes)..." + sleep 300 + fi + + echo "Test that version deployed on site, retrying 5 times with delay of 60 seconds" + CHANNEL=$input_channel \ + VERSION=$input_version \ + COUNT=5 \ + task -d tools/moduleversions check:docs + + send-release-results-to-loop: + name: Send release results to Loop + runs-on: ubuntu-latest + needs: + - job-CE + - job-EE + - job-SE-Plus + - job-FE + - check-version-on-release-channel + if: ${{ always() && inputs.send_results_to_loop }} + steps: + - name: Send results to Loop + env: + LOOP_WEBHOOK_URL: ${{ secrets.LOOP_WEBHOOK_URL }} + TAG: ${{ github.event.inputs.tag }} + CHANNEL: ${{ github.event.inputs.channel }} + CE_ENABLED: ${{ inputs.ce }} + EE_ENABLED: ${{ inputs.ee }} + CE_RESULT: ${{ needs.job-CE.result }} + EE_RESULT: ${{ needs.job-EE.result }} + SE_PLUS_RESULT: ${{ needs.job-SE-Plus.result }} + FE_RESULT: ${{ needs.job-FE.result }} + # will be `success` only if all jobs in the matrix have succeeded + CHECK_RESULT: ${{ needs.check-version-on-release-channel.result }} + run: | + export TZ=Europe/Moscow + DATE=$(date +"%Y-%m-%d %H:%M:%S UTC+03:00") + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + # Determine overall status + OVERALL_STATUS=":white_check_mark: **SUCCESS!**" + if [[ "$CE_RESULT" == "failure" ]] || [[ "$EE_RESULT" == "failure" ]] || \ + [[ "$SE_PLUS_RESULT" == "failure" ]] || [[ "$FE_RESULT" == "failure" ]] || \ + [[ "$CHECK_RESULT" == "failure" ]]; then + OVERALL_STATUS=":x: **FAILED!**" + elif [[ "$CE_RESULT" == "cancelled" ]] || [[ "$EE_RESULT" == "cancelled" ]] || \ + [[ "$SE_PLUS_RESULT" == "cancelled" ]] || [[ "$FE_RESULT" == "cancelled" ]] || \ + [[ "$CHECK_RESULT" == "cancelled" ]]; then + OVERALL_STATUS=":warning: **CANCELLED!**" + fi + + # Build editions status + get_status_emoji() { + case "$1" in + success) echo ":white_check_mark:" ;; + failure) echo ":x:" ;; + cancelled) echo ":warning:" ;; + skipped) echo ":fast_forward:" ;; + *) echo ":grey_question:" ;; + esac + } + + EDITIONS_STATUS="" + if [[ "$CE_ENABLED" == "true" ]]; then + EDITIONS_STATUS+="| CE | $(get_status_emoji $CE_RESULT) **${CE_RESULT^^}** |\n" + fi + if [[ "$EE_ENABLED" == "true" ]]; then + EDITIONS_STATUS+="| EE | $(get_status_emoji $EE_RESULT) **${EE_RESULT^^}** |\n" + EDITIONS_STATUS+="| SE Plus | $(get_status_emoji $SE_PLUS_RESULT) **${SE_PLUS_RESULT^^}** |\n" + EDITIONS_STATUS+="| FE | $(get_status_emoji $FE_RESULT) **${FE_RESULT^^}** |\n" + fi + + RELEASE_SUMMARY="## :dvp: **DVP | Release ${TAG} to ${CHANNEL}**\n\n" + RELEASE_SUMMARY+="**Status:** ${OVERALL_STATUS}\n" + RELEASE_SUMMARY+="**Date:** ${DATE}\n\n" + + if [[ -n "$EDITIONS_STATUS" ]]; then + RELEASE_SUMMARY+="| Edition | Status |\n" + RELEASE_SUMMARY+="|---|---|\n" + RELEASE_SUMMARY+="${EDITIONS_STATUS}" + RELEASE_SUMMARY+="\n" + fi + + RELEASE_SUMMARY+="**Version Check:** $(get_status_emoji $CHECK_RESULT) **${CHECK_RESULT^^}**\n" + RELEASE_SUMMARY+="[:link: GitHub Actions Output](${RUN_URL})\n\n" + + echo -e "$RELEASE_SUMMARY" + curl --request POST --header 'Content-Type: application/json' --data "{\"text\": \"${RELEASE_SUMMARY}\"}" $LOOP_WEBHOOK_URL From 2e23ccf99d99b95c8715c1b24cb9d0190db00368 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:26:12 +0300 Subject: [PATCH 11/27] refactor: operator-helm alpha release (#4) --- .github/workflows/build_dev.yml | 62 ++ Taskfile.yaml | 22 +- api/v1alpha1/conditions.go | 39 + .../v1alpha1}/constants.go | 12 +- api/v1alpha1/helm_cluster_addon.go | 110 +-- api/v1alpha1/helm_cluster_addon_chart.go | 24 + api/v1alpha1/helm_cluster_addon_repository.go | 23 + crds/helmclusteraddoncharts.yaml | 6 + ...ONFIGURATION_RU.md => CONFIGURATION.ru.md} | 0 docs/{CR_RU.md => CR.ru.md} | 0 docs/EXAMPLE.md | 16 +- docs/{EXAMPLE_RU.md => EXAMPLE.ru.md} | 16 +- docs/{README_RU.md => README.ru.md} | 0 docs/{USAGE_RU.md => USAGE.ru.md} | 2 +- images/distroless/werf.inc.yaml | 53 -- images/hooks/.golangci.yaml | 109 +++ images/hooks/go.mod | 2 +- images/hooks/go.sum | 51 +- .../hooks/tls-certificates-controller/hook.go | 3 +- images/hooks/werf.inc.yaml | 2 +- images/kube-api-rewriter/werf.inc.yaml | 2 +- images/operator-helm-artifact/.golangci.yaml | 109 +++ .../cmd/operator-helm-controller/main.go | 11 +- images/operator-helm-artifact/go.mod | 13 +- images/operator-helm-artifact/go.sum | 47 ++ .../internal/client/repository/client.go | 45 ++ .../client/repository/helm.go} | 15 +- .../internal/client/repository/oci.go | 109 +++ .../controller/helmclusteraddon/controller.go | 87 +++ .../controller/helmclusteraddon/reconciler.go | 264 +++++++ .../helmclusteraddonchart/controller.go | 19 +- .../helmclusteraddonchart/reconciler.go | 26 +- .../helmclusteraddonrepository/controller.go | 81 ++ .../helmclusteraddonrepository/reconciler.go | 167 ++++ .../internal/services/base_repo.go | 111 +++ .../internal/services/chart.go | 149 ++++ .../internal/services/helm_repo.go | 186 +++++ .../internal/services/maintenance.go | 132 ++++ .../internal/services/oci_repo.go | 209 +++++ .../internal/services/release.go | 153 ++++ .../internal/services/repo_sync.go | 249 ++++++ .../internal/services/status_manager.go | 200 +++++ .../internal/services/types.go | 99 +++ .../internal/utils/conditions.go | 31 + .../{pkg => internal}/utils/mapper.go | 0 .../{pkg => internal}/utils/name.go | 17 + .../{pkg => internal}/utils/repository.go | 0 .../webhook/helmclusteraddon/webhook.go | 71 ++ .../controller/helmclusteraddon/constants.go | 69 -- .../controller/helmclusteraddon/controller.go | 49 -- .../controller/helmclusteraddon/reconciler.go | 726 ------------------ .../helmclusteraddonrepository/constants.go | 66 -- .../helmclusteraddonrepository/controller.go | 54 -- .../helmclusteraddonrepository/reconciler.go | 519 ------------- images/operator-helm-artifact/werf.inc.yaml | 2 +- images/operator-helm-controller/werf.inc.yaml | 4 +- module.yaml | 3 - .../nelm-source-controller/rbac-for-us.yaml | 15 - .../operator-helm-controller/rbac-for-us.yaml | 16 +- tools/validation/doc_changes.go | 12 +- tools/validation/no_cyrillic.go | 160 ---- tools/validation/no_cyrillic_test.go | 96 --- 62 files changed, 2984 insertions(+), 1961 deletions(-) create mode 100644 api/v1alpha1/conditions.go rename {images/operator-helm-artifact/pkg/controller/helmclusteraddonchart => api/v1alpha1}/constants.go (75%) rename docs/{CONFIGURATION_RU.md => CONFIGURATION.ru.md} (100%) rename docs/{CR_RU.md => CR.ru.md} (100%) rename docs/{EXAMPLE_RU.md => EXAMPLE.ru.md} (87%) rename docs/{README_RU.md => README.ru.md} (100%) rename docs/{USAGE_RU.md => USAGE.ru.md} (99%) delete mode 100644 images/distroless/werf.inc.yaml create mode 100644 images/hooks/.golangci.yaml create mode 100644 images/operator-helm-artifact/.golangci.yaml create mode 100644 images/operator-helm-artifact/internal/client/repository/client.go rename images/operator-helm-artifact/{pkg/controller/helmclusteraddonrepository/client.go => internal/client/repository/helm.go} (88%) create mode 100644 images/operator-helm-artifact/internal/client/repository/oci.go create mode 100644 images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go create mode 100644 images/operator-helm-artifact/internal/controller/helmclusteraddon/reconciler.go rename images/operator-helm-artifact/{pkg => internal}/controller/helmclusteraddonchart/controller.go (72%) rename images/operator-helm-artifact/{pkg => internal}/controller/helmclusteraddonchart/reconciler.go (69%) create mode 100644 images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go create mode 100644 images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/reconciler.go create mode 100644 images/operator-helm-artifact/internal/services/base_repo.go create mode 100644 images/operator-helm-artifact/internal/services/chart.go create mode 100644 images/operator-helm-artifact/internal/services/helm_repo.go create mode 100644 images/operator-helm-artifact/internal/services/maintenance.go create mode 100644 images/operator-helm-artifact/internal/services/oci_repo.go create mode 100644 images/operator-helm-artifact/internal/services/release.go create mode 100644 images/operator-helm-artifact/internal/services/repo_sync.go create mode 100644 images/operator-helm-artifact/internal/services/status_manager.go create mode 100644 images/operator-helm-artifact/internal/services/types.go create mode 100644 images/operator-helm-artifact/internal/utils/conditions.go rename images/operator-helm-artifact/{pkg => internal}/utils/mapper.go (100%) rename images/operator-helm-artifact/{pkg => internal}/utils/name.go (88%) rename images/operator-helm-artifact/{pkg => internal}/utils/repository.go (100%) create mode 100644 images/operator-helm-artifact/internal/webhook/helmclusteraddon/webhook.go delete mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddon/constants.go delete mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddon/controller.go delete mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddon/reconciler.go delete mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/constants.go delete mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/controller.go delete mode 100644 images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/reconciler.go delete mode 100644 tools/validation/no_cyrillic.go delete mode 100644 tools/validation/no_cyrillic_test.go diff --git a/.github/workflows/build_dev.yml b/.github/workflows/build_dev.yml index 2f8eb7e..1bd9fc9 100644 --- a/.github/workflows/build_dev.yml +++ b/.github/workflows/build_dev.yml @@ -34,6 +34,68 @@ jobs: DMT_METRICS_URL: ${{ secrets.DMT_METRICS_URL }} DMT_METRICS_TOKEN: ${{ secrets.DMT_METRICS_TOKEN }} + lint_go: + runs-on: [self-hosted, large] + name: Run golangci-lint + steps: + - name: Set up Go ${{ vars.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: "${{ vars.GO_VERSION }}" + + - uses: actions/checkout@v4 + + - name: Install golangci-lint + run: | + echo "Installing golangci-lint..." + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v${{ vars.GOLANGCI_LINT_VERSION}} + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + echo "golangci-lint v${{ vars.GOLANGCI_LINT_VERSION}} installed successfully!" + + - name: Run golangci-lint in every directory with .golangci.yaml + shell: bash + run: | + # set -eo pipefail + set -e + + # Find directories containing .golangci.yaml + mapfile -t config_dirs < <( + find . \ + -type f -name '.golangci.yaml' -printf '%h\0' | \ + xargs -0 -n1 | sort -u + ) + count=${#config_dirs[@]} + echo "::notice title=Lint Setup::🔍 Found $count directories with linter configurations" + + report="" + error_count=0 + + for dir in "${config_dirs[@]}"; do + find_errors=0 + cd "$dir" || { echo "::error::Failed to access directory $dir"; continue; } + + if ! output=$(golangci-lint run); then + error_count=$(( error_count + 1 )) + echo "::group::📂 Linting directory ❌: $dir" + echo -e "❌ Errors:\n$output\n" + else + echo "::group::📂 Linting directory ✅: $dir" + echo -e "✅ All check passed\n" + fi + + cd - &>/dev/null + + echo "::endgroup::" + done + + has_errors=$( [[ "$error_count" -gt 0 ]] && echo true || echo false) + echo "has_errors=$has_errors" >> "$GITHUB_OUTPUT" + + if [ $error_count -gt 0 ]; then + echo "$error_count error more than 0, exit 1" + exit 1 + fi + build_dev: runs-on: [self-hosted, large] name: Build and Push images diff --git a/Taskfile.yaml b/Taskfile.yaml index e6649c9..a091976 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -5,7 +5,7 @@ silent: true vars: deckhouse_lib_helm_ver: 1.55.1 target: "" - VALIDATION_FILES: "tools/validation/{main,messages,diff,no_cyrillic,doc_changes}.go" + VALIDATION_FILES: "tools/validation/{main,messages,diff,doc_changes}.go" tasks: check-werf: @@ -58,23 +58,10 @@ tasks: -v ./:/tmp/operator-helm ghcr.io/deckhouse/virtualization/prettier:3.2.5 \ sh -c "cd /tmp/operator-helm ; prettier -w \"**/*.yaml\" \"**/*.yml\"" - dev:addlicense: - desc: |- - Add Flant CE license to files sh,go,py. Default directory is root of project, custom directory path can be passed like: "task dev:addlicense -- " - cmds: - - | - {{if .CLI_ARGS}} - go run tools/addlicense/{main,variables,msg,utils}.go -directory {{ .CLI_ARGS }} - {{else}} - go run tools/addlicense/{main,variables,msg,utils}.go -directory ./ - {{end}} - lint: cmds: - task: lint:doc-ru - task: lint:prettier:yaml - - task: virtualization-controller:dvcr:lint - - task: virtualization-controller:lint lint:doc-ru: desc: "Check the correspondence between description fields in the original crd and the Russian language version" @@ -88,17 +75,12 @@ tasks: lint:prettier:yaml: desc: "Check if yaml files are prettier-formatted." cmds: - # TODO: update image referecne + # TODO: update image reference - | docker run --rm \ -v ./:/tmp/operator-nelm ghcr.io/deckhouse/virtualization/prettier:3.2.5 \ sh -c "cd /tmp/operator-nelm ; prettier -c \"**/*.yaml\" \"**/*.yml\"" - validation:no-cyrillic: - desc: "No cyrillic" - cmds: - - go run {{ .VALIDATION_FILES }} --type no-cyrillic - validation:doc-changes: desc: "Doc-changes" cmds: diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/conditions.go new file mode 100644 index 0000000..cd6743d --- /dev/null +++ b/api/v1alpha1/conditions.go @@ -0,0 +1,39 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +const ( + ConditionTypeManaged = "Managed" + ConditionTypeInstalled = "Installed" + ConditionTypeUpdateInstalled = "UpdateInstalled" + ConditionTypeConfigurationApplied = "ConfigurationApplied" + ConditionTypePartiallyDegraded = "PartiallyDegraded" + ConditionTypeReady = "Ready" + ConditionTypeReleaseChart = "ConditionTypeReleaseChart" + ConditionTypeSynced = "Synced" + + ReasonHelmChartFailed = "HelmChartFailed" + ReasonHelmReleaseFailed = "HelmReleaseFailed" + ReasonMaintenanceModeActive = "MaintenanceModeActive" + ReasonMaintenanceModeInactive = "MaintenanceModeInactive" + ReasonSyncSucceeded = "SyncSucceeded" + ReasonSyncFailed = "SyncFailed" + ReasonRepositoryNotReady = "RepositoryNotReady" + ReasonReconciling = "Reconciling" + ReasonSuccess = "Success" + ReasonFailed = "Failed" +) diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/constants.go b/api/v1alpha1/constants.go similarity index 75% rename from images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/constants.go rename to api/v1alpha1/constants.go index 7e8edd2..9f46069 100644 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/constants.go +++ b/api/v1alpha1/constants.go @@ -14,21 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddonchart +package v1alpha1 const ( - // ControllerName is the name of this controller, used for leader election and logging. - ControllerName = "helmclusteraddonchart-controller" - // TargetNamespace is the namespace where internal customer resources are created. TargetNamespace = "d8-operator-helm" + // FinalizerName is the finalizer added to HelmClusterRepository to ensure cleanup. + FinalizerName = "helm.deckhouse.io/cleanup" + // LabelManagedBy marks resources as managed by this controller. LabelManagedBy = "helm.deckhouse.io/managed-by" // LabelManagedByValue is the value for the managed-by label. LabelManagedByValue = "operator-helm" - // LabelSourceName stores the name of the source facade resource. - LabelSourceName = "helm.deckhouse.io/cluster-addon-chart" + LabelDeckhouseHeritage = "heritage" + LabelDeckhouseHeritageValue = "deckhouse" ) diff --git a/api/v1alpha1/helm_cluster_addon.go b/api/v1alpha1/helm_cluster_addon.go index bf63e85..70b07ab 100644 --- a/api/v1alpha1/helm_cluster_addon.go +++ b/api/v1alpha1/helm_cluster_addon.go @@ -17,19 +17,19 @@ limitations under the License. package v1alpha1 import ( - "context" - "fmt" + "reflect" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) const ( HelmClusterAddonKind = "HelmClusterAddon" HelmClusterAddonResource = "helmclusteraddons" + + // LabelSourceName stores the name of the source facade resource. + HelmClusterAddonLabelSourceName = "helm.deckhouse.io/cluster-addon" ) // HelmClusterAddon represents a Helm addon that is installed across the whole cluster. @@ -52,10 +52,64 @@ type HelmClusterAddon struct { Status HelmClusterAddonStatus `json:"status,omitempty"` } -func (r *HelmClusterAddon) SetupWebhookWithManager(mgr ctrl.Manager) error { - return ctrl.NewWebhookManagedBy(mgr, r). - WithValidator(&HelmClusterAddonValidator{Client: mgr.GetClient()}). - Complete() +func (r *HelmClusterAddon) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *HelmClusterAddon) SetObservedGeneration(generation int64) { + r.Status.ObservedGeneration = generation +} + +func (r *HelmClusterAddon) GetObservedGeneration() int64 { + return r.Status.ObservedGeneration +} + +func (r *HelmClusterAddon) GetStatus() any { + return r.Status +} + +func (r *HelmClusterAddon) MaintenanceModeActivated() bool { + if r.Spec.Maintenance == string(NoResourceReconciliation) { + return true + } + + return false +} + +func (r *HelmClusterAddon) MaintenanceModeEnabled() bool { + if apimeta.IsStatusConditionPresentAndEqual(r.Status.Conditions, ConditionTypeManaged, metav1.ConditionFalse) { + return true + } + + return false +} + +func (r *HelmClusterAddon) GetConditionTypesForUpdate() []string { + conditionTypes := []string{"Ready"} + + if r.Status.LastAppliedChart == nil { + return append(conditionTypes, ConditionTypeInstalled) + } + + if r.IsChartStatusInfoOutdated() { + return append(conditionTypes, ConditionTypeUpdateInstalled) + } + + if !reflect.DeepEqual(r.Spec.Values, r.Status.LastAppliedValues) { + return append(conditionTypes, ConditionTypeConfigurationApplied) + } + + return conditionTypes +} + +func (r *HelmClusterAddon) IsChartStatusInfoOutdated() bool { + if r.Status.LastAppliedChart == nil { + return true + } + + return r.Spec.Chart.HelmClusterAddonChartName != r.Status.LastAppliedChart.HelmClusterAddonChartName || + r.Spec.Chart.HelmClusterAddonRepository != r.Status.LastAppliedChart.HelmClusterAddonRepository || + r.Spec.Chart.Version != r.Status.LastAppliedChart.Version } type HelmClusterAddonSpec struct { @@ -143,41 +197,3 @@ type HelmClusterAddonMaintenance string const ( NoResourceReconciliation HelmClusterAddonMaintenance = "NoResourceReconciliation" ) - -// +k8s:deepcopy-gen=false -type HelmClusterAddonValidator struct { - Client client.Client -} - -func (v *HelmClusterAddonValidator) ValidateCreate(ctx context.Context, addon *HelmClusterAddon) (admission.Warnings, error) { - return nil, v.checkUniqueness(ctx, addon) -} - -func (v *HelmClusterAddonValidator) ValidateUpdate(ctx context.Context, _, newObj *HelmClusterAddon) (admission.Warnings, error) { - return nil, v.checkUniqueness(ctx, newObj) -} - -func (v *HelmClusterAddonValidator) ValidateDelete(_ context.Context, _ *HelmClusterAddon) (admission.Warnings, error) { - return nil, nil -} - -func (v *HelmClusterAddonValidator) checkUniqueness(ctx context.Context, addon *HelmClusterAddon) error { - list := &HelmClusterAddonList{} - - if err := v.Client.List(ctx, list); err != nil { - return err - } - - for _, existing := range list.Items { - if existing.Name != addon.Name && - existing.Spec.Chart.HelmClusterAddonRepository == addon.Spec.Chart.HelmClusterAddonRepository && - existing.Spec.Chart.HelmClusterAddonChartName == addon.Spec.Chart.HelmClusterAddonChartName { - return fmt.Errorf( - "chart %s is already used by helmclusteraddon/%s", - addon.Spec.Chart.HelmClusterAddonChartName, existing.Name, - ) - } - } - - return nil -} diff --git a/api/v1alpha1/helm_cluster_addon_chart.go b/api/v1alpha1/helm_cluster_addon_chart.go index 5ddaab0..b1aa253 100644 --- a/api/v1alpha1/helm_cluster_addon_chart.go +++ b/api/v1alpha1/helm_cluster_addon_chart.go @@ -23,6 +23,8 @@ import ( const ( HelmClusterAddonChartKind = "HelmClusterAddonChart" HelmClusterAddonChartResource = "helmclusteraddoncharts" + + HelmClusterAddonChartLabelSourceName = "helm.deckhouse.io/cluster-addon-chart" ) // HelmClusterAddonChart represents a Helm chart and its versions from specific repository. @@ -41,6 +43,26 @@ type HelmClusterAddonChart struct { Status HelmClusterAddonChartStatus `json:"status,omitempty"` } +func (r *HelmClusterAddonChart) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *HelmClusterAddonChart) SetObservedGeneration(generation int64) { + r.Status.ObservedGeneration = generation +} + +func (r *HelmClusterAddonChart) GetObservedGeneration() int64 { + return r.Status.ObservedGeneration +} + +func (r *HelmClusterAddonChart) GetStatus() any { + return r.Status +} + +func (r *HelmClusterAddonChart) GetConditionTypesForUpdate() []string { + return []string{"Ready"} +} + type HelmClusterAddonChartSpec struct { // Helm chart name // +kubebuilder:validation:MinLength=1 @@ -55,6 +77,8 @@ type HelmClusterAddonChartStatus struct { // Conditions represent the latest available observations of the repository state. // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` + // Generating a resource that was last processed by the controller. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` // Available helm chart versions // +optional Versions []HelmClusterAddonChartVersion `json:"versions"` diff --git a/api/v1alpha1/helm_cluster_addon_repository.go b/api/v1alpha1/helm_cluster_addon_repository.go index 9e22938..52dd0c0 100644 --- a/api/v1alpha1/helm_cluster_addon_repository.go +++ b/api/v1alpha1/helm_cluster_addon_repository.go @@ -23,6 +23,9 @@ import ( const ( HelmClusterAddonRepositoryKind = "HelmClusterAddonRepository" HelmClusterAddonRepositoryResource = "helmclusteraddonrepositories" + + // HelmClusterAddonRepositoryLabelSourceName stores the name of the source facade resource. + HelmClusterAddonRepositoryLabelSourceName = "helm.deckhouse.io/cluster-addon-repository" ) // HelmClusterAddonRepository represents a Helm or an OCI compliant repository with Helm charts. @@ -43,6 +46,26 @@ type HelmClusterAddonRepository struct { Status HelmClusterAddonRepositoryStatus `json:"status,omitempty"` } +func (r *HelmClusterAddonRepository) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *HelmClusterAddonRepository) SetObservedGeneration(generation int64) { + r.Status.ObservedGeneration = generation +} + +func (r *HelmClusterAddonRepository) GetObservedGeneration() int64 { + return r.Status.ObservedGeneration +} + +func (r *HelmClusterAddonRepository) GetStatus() any { + return r.Status +} + +func (r *HelmClusterAddonRepository) GetConditionTypesForUpdate() []string { + return []string{"Ready"} +} + type HelmClusterAddonRepositorySpec struct { // URL of the Helm repository. Supports http(s):// and oci:// protocols. // +kubebuilder:validation:Required diff --git a/crds/helmclusteraddoncharts.yaml b/crds/helmclusteraddoncharts.yaml index 490de0a..6478674 100644 --- a/crds/helmclusteraddoncharts.yaml +++ b/crds/helmclusteraddoncharts.yaml @@ -106,6 +106,12 @@ spec: - type type: object type: array + observedGeneration: + description: + Generating a resource that was last processed by the + controller. + format: int64 + type: integer versions: description: Available helm chart versions items: diff --git a/docs/CONFIGURATION_RU.md b/docs/CONFIGURATION.ru.md similarity index 100% rename from docs/CONFIGURATION_RU.md rename to docs/CONFIGURATION.ru.md diff --git a/docs/CR_RU.md b/docs/CR.ru.md similarity index 100% rename from docs/CR_RU.md rename to docs/CR.ru.md diff --git a/docs/EXAMPLE.md b/docs/EXAMPLE.md index 9f47a7c..4bb8778 100644 --- a/docs/EXAMPLE.md +++ b/docs/EXAMPLE.md @@ -24,7 +24,7 @@ spec: After creating the repository, you can view the Helm charts available in it using the command below: ```shell -kubectl get helmclusteraddoncharts.helm.deckhouse.io -l helm.deckhouse.io/cluster-addon-repository=podinfo +kubectl get helmclusteraddoncharts.helm.deckhouse.io -l repository=podinfo NAME AGE podinfo-podinfo 56s ``` @@ -32,15 +32,15 @@ podinfo-podinfo 56s To view the list of versions available for a specific chart, run the following command: ```shell -kubectl get helmclusteraddoncharts.helm.deckhouse.io podinfo-podinfo -o yaml apiVersion: helm.deckhouse.io/v1alpha1 kind: HelmClusterAddonChart metadata: - creationTimestamp: "2026-03-11T05:31:14Z" + creationTimestamp: "2026-03-12T02:24:04Z" generation: 1 labels: - helm.deckhouse.io/cluster-addon-repository: podinfo - helm.deckhouse.io/managed-by: operator-helm + chart: podinfo + heritage: deckhouse + repository: podinfo name: podinfo-podinfo ownerReferences: - apiVersion: helm.deckhouse.io/v1alpha1 @@ -48,9 +48,9 @@ metadata: controller: true kind: HelmClusterAddonRepository name: podinfo - uid: 073d6efc-aa19-4ccd-9d8e-d3b1253f94cf - resourceVersion: "27054847" - uid: cef0e7aa-6d36-4ade-bc6d-9e66b853badf + uid: d5e026f9-6151-4f9f-a4bc-756d96b86e95 + resourceVersion: "28306911" + uid: 7f232359-5553-463e-beed-d6f175596b0b status: versions: - digest: a5c4b7381a0907128243354ab100d2eecc480d7dcac5014ff7272b0acef03780 diff --git a/docs/EXAMPLE_RU.md b/docs/EXAMPLE.ru.md similarity index 87% rename from docs/EXAMPLE_RU.md rename to docs/EXAMPLE.ru.md index ea2911a..223ef5f 100644 --- a/docs/EXAMPLE_RU.md +++ b/docs/EXAMPLE.ru.md @@ -24,22 +24,22 @@ spec: После создания репозитория, можно просмотреть доступные в нем Helm-чарты с помощью команды ниже: ```shell -kubectl get helmclusteraddoncharts.helm.deckhouse.io -l helm.deckhouse.io/cluster-addon-repository=podinfo +kubectl get helmclusteraddoncharts.helm.deckhouse.io -l repository=podinfo NAME AGE podinfo-podinfo 56s ``` Для просмотра списка версий, доступных для заданного чарта, необходимо выполнить команду ниже: ```shell -kubectl get helmclusteraddoncharts.helm.deckhouse.io podinfo-podinfo -o yaml apiVersion: helm.deckhouse.io/v1alpha1 kind: HelmClusterAddonChart metadata: - creationTimestamp: "2026-03-11T05:31:14Z" + creationTimestamp: "2026-03-12T02:24:04Z" generation: 1 labels: - helm.deckhouse.io/cluster-addon-repository: podinfo - helm.deckhouse.io/managed-by: operator-helm + chart: podinfo + heritage: deckhouse + repository: podinfo name: podinfo-podinfo ownerReferences: - apiVersion: helm.deckhouse.io/v1alpha1 @@ -47,9 +47,9 @@ metadata: controller: true kind: HelmClusterAddonRepository name: podinfo - uid: 073d6efc-aa19-4ccd-9d8e-d3b1253f94cf - resourceVersion: "27054847" - uid: cef0e7aa-6d36-4ade-bc6d-9e66b853badf + uid: d5e026f9-6151-4f9f-a4bc-756d96b86e95 + resourceVersion: "28306911" + uid: 7f232359-5553-463e-beed-d6f175596b0b status: versions: - digest: a5c4b7381a0907128243354ab100d2eecc480d7dcac5014ff7272b0acef03780 diff --git a/docs/README_RU.md b/docs/README.ru.md similarity index 100% rename from docs/README_RU.md rename to docs/README.ru.md diff --git a/docs/USAGE_RU.md b/docs/USAGE.ru.md similarity index 99% rename from docs/USAGE_RU.md rename to docs/USAGE.ru.md index a6c4670..87fa0b6 100644 --- a/docs/USAGE_RU.md +++ b/docs/USAGE.ru.md @@ -31,7 +31,7 @@ weight: 15 metadata: name: operator-helm spec: - enabled + enabled: true ``` ## Выключение модуля diff --git a/images/distroless/werf.inc.yaml b/images/distroless/werf.inc.yaml deleted file mode 100644 index 99f6e11..0000000 --- a/images/distroless/werf.inc.yaml +++ /dev/null @@ -1,53 +0,0 @@ ---- -image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact -fromImage: builder/alt -final: false -shell: - beforeInstall: - {{- include "alt packages proxy" . | nindent 2 }} - - | - apt-get install ca-certificates tzdata -y - {{- include "alt packages clean" . | nindent 2 }} - install: - - | - mkdir -p /relocate/etc/{pki,ssl} /relocate/usr/{bin,sbin,share,lib,lib64} - - cd /relocate - for dir in {bin,sbin,lib,lib64};do - ln -s usr/$dir $dir - done - # /var/run -> ../run symlink to prevent making /var/run a directory during the build. - # It is needed for better compatibility with containerd default top layer. - mkdir -p run - mkdir -p var - ln -s var/run ../run - cd / - - cp -pr /tmp /relocate - cp -pr /etc/passwd /etc/group /etc/hostname /etc/hosts /etc/shadow /etc/protocols /etc/services /etc/nsswitch.conf /relocate/etc - cp -pr /usr/share/ca-certificates /relocate/usr/share - cp -pr /usr/share/zoneinfo /relocate/usr/share - cp -pr /etc/pki/tls/cert.pem /relocate/etc/ssl - cp -pr /etc/pki/tls/certs /relocate/etc/ssl - cp -pr /etc/pki/ca-trust/ /relocate/etc/ - # Create 'deckhouse' user to run without root. - echo "deckhouse:x:64535:64535:deckhouse:/:/sbin/nologin" >> /relocate/etc/passwd - echo "deckhouse:x:64535:" >> /relocate/etc/group - echo "deckhouse:!::0:::::" >> /relocate/etc/shadow ---- -image: {{ .ModuleNamePrefix }}{{ .ImageName }} -final: false -fromImage: builder/scratch -import: - - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact - add: /relocate - to: / - before: setup -imageSpec: - config: - env: - PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - LANG: "" - LC_ALL: POSIX - user: 64535 - diff --git a/images/hooks/.golangci.yaml b/images/hooks/.golangci.yaml new file mode 100644 index 0000000..6806dc1 --- /dev/null +++ b/images/hooks/.golangci.yaml @@ -0,0 +1,109 @@ +# https://golangci-lint.run/usage/configuration/ +version: "2" + +run: + concurrency: 4 + timeout: 10m + +issues: + # Show all errors. + max-issues-per-linter: 0 + max-same-issues: 0 + exclude: + - "don't use an underscore in package name" + +output: + sort-results: true + +exclusions: + paths: + - "^zz_generated.*" + +formatters: + enable: + - gci + - gofmt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/deckhouse/) + - prefix(hooks) + no-inline-comments: true + custom-order: true + goimports: + local-prefixes: github.com/deckhouse/ + +linters: + default: none + enable: + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - contextcheck # [maybe too many false positives] checks the function whether use a non-inherited context + - dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - copyloopvar # detects places where loop variables are copied (Go 1.22+) + - gocritic # provides diagnostics that check for bugs, performance and style issues + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - ineffassign # detects when assignments to existing variables are not used + - misspell # finds commonly misspelled English words in comments + - nolintlint # reports ill-formed or insufficient nolint directives + - reassign # checks that package variables are not reassigned + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - testifylint # checks usage of github.com/stretchr/testify + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - unused # checks for unused constants, variables, functions and types + - usetesting # reports uses of functions with replacement inside the testing package + - testableexamples # checks if examples are testable (have an expected output) + - thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - whitespace # detects leading and trailing whitespace + - wastedassign # finds wasted assignment statements + - importas # checks import aliases against the configured convention + settings: + errcheck: + exclude-functions: + - "(*os.File).Close" + - "(*net.TCPConn).Close" + - "(io.ReadCloser).Close" + - "(net.Listener).Close" + - "(net.Conn).Close" + - "(net.Conn).Close" + - "(*golang.org/x/crypto/ssh.Session).Close" + - "(*github.com/fsnotify/fsnotify.Watcher).Close" + staticcheck: + dot-import-whitelist: + - github.com/onsi/ginkgo/v2 + - github.com/onsi/gomega + revive: + rules: + - name: dot-imports + disabled: true + - name: exported + disabled: true + - name: package-comments + disabled: true + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [funlen, gocognit, lll] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + importas: + # Do not allow unaliased imports of aliased packages. + # Default: false + no-unaliased: true + # Do not allow non-required aliases. + # Default: false + no-extra-aliases: false \ No newline at end of file diff --git a/images/hooks/go.mod b/images/hooks/go.mod index 6404416..95ec1c6 100644 --- a/images/hooks/go.mod +++ b/images/hooks/go.mod @@ -2,7 +2,7 @@ module hooks go 1.25.0 -require github.com/deckhouse/module-sdk v0.10.0 +require github.com/deckhouse/module-sdk v0.10.2 require ( github.com/DataDog/gostackparse v0.7.0 // indirect diff --git a/images/hooks/go.sum b/images/hooks/go.sum index dbc33f6..fcecf5b 100644 --- a/images/hooks/go.sum +++ b/images/hooks/go.sum @@ -1,3 +1,4 @@ +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= @@ -18,8 +19,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckhouse/deckhouse/pkg/log v0.2.0 h1:6tmZQLwNb1o/hP1gzJQBjcwfA/bubbgObovXzxq+Exo= github.com/deckhouse/deckhouse/pkg/log v0.2.0/go.mod h1:pbAxTSDcPmwyl3wwKDcEB3qdxHnRxqTV+J0K+sha8bw= -github.com/deckhouse/module-sdk v0.10.0 h1:VPhYvMVQ3pT32I2WL1ITtQyrYdpiUR0RocLw7S4TfNg= -github.com/deckhouse/module-sdk v0.10.0/go.mod h1:Z1jfmd0fICoYww0daMijWAU+OZTxeJUXfMciKKuYAYA= +github.com/deckhouse/module-sdk v0.10.2 h1:jYxFTgjdaZ9NKWKbFP95RvD55WJvhwjPAeSMFKhZb0o= +github.com/deckhouse/module-sdk v0.10.2/go.mod h1:Z1jfmd0fICoYww0daMijWAU+OZTxeJUXfMciKKuYAYA= github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -27,20 +28,28 @@ github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4Kfc github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gojuno/minimock/v3 v3.4.7 h1:vhE5zpniyPDRT0DXd5s3DbtZJVlcbmC5k80izYtj9lY= @@ -50,6 +59,7 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/certificate-transparency-go v1.1.7 h1:IASD+NtgSTJLPdzkthwvAG1ZVbF2WtFg4IvoA68XGSw= github.com/google/certificate-transparency-go v1.1.7/go.mod h1:FSSBo8fyMVgqptbfF6j5p/XNdgQftAhSmXcIxV9iphE= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= @@ -60,6 +70,7 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -69,7 +80,9 @@ github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -87,7 +100,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -95,12 +111,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -114,9 +132,13 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= @@ -129,7 +151,10 @@ github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -149,6 +174,7 @@ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/weppos/publicsuffix-go v0.12.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= @@ -177,7 +203,9 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -186,6 +214,7 @@ golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= @@ -200,12 +229,14 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -220,12 +251,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -233,7 +266,9 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -243,11 +278,13 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -258,16 +295,26 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/images/hooks/pkg/hooks/tls-certificates-controller/hook.go b/images/hooks/pkg/hooks/tls-certificates-controller/hook.go index 0265681..2eee888 100644 --- a/images/hooks/pkg/hooks/tls-certificates-controller/hook.go +++ b/images/hooks/pkg/hooks/tls-certificates-controller/hook.go @@ -18,9 +18,10 @@ package tls_certificates_controller import ( "fmt" - "hooks/pkg/settings" tlscertificate "github.com/deckhouse/module-sdk/common-hooks/tls-certificate" + + "hooks/pkg/settings" ) var _ = tlscertificate.RegisterInternalTLSHookEM(tlscertificate.GenSelfSignedTLSHookConf{ diff --git a/images/hooks/werf.inc.yaml b/images/hooks/werf.inc.yaml index ade8485..59f011f 100644 --- a/images/hooks/werf.inc.yaml +++ b/images/hooks/werf.inc.yaml @@ -24,7 +24,7 @@ shell: --- image: {{ .ModuleNamePrefix }}go-hooks-artifact final: false -fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-bookworm-1.25" "builder/golang-alt-svace-1.25" }} +fromImage: builder/golang-bookworm-1.25 import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact add: /src diff --git a/images/kube-api-rewriter/werf.inc.yaml b/images/kube-api-rewriter/werf.inc.yaml index 3a02526..812ff0e 100644 --- a/images/kube-api-rewriter/werf.inc.yaml +++ b/images/kube-api-rewriter/werf.inc.yaml @@ -13,7 +13,7 @@ git: --- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-builder final: false -fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-bookworm-1.25" "builder/golang-alt-svace-1.25" }} +fromImage: builder/golang-bookworm-1.25 import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact add: /src diff --git a/images/operator-helm-artifact/.golangci.yaml b/images/operator-helm-artifact/.golangci.yaml new file mode 100644 index 0000000..4260e0d --- /dev/null +++ b/images/operator-helm-artifact/.golangci.yaml @@ -0,0 +1,109 @@ +# https://golangci-lint.run/usage/configuration/ +version: "2" + +run: + concurrency: 4 + timeout: 10m + +issues: + # Show all errors. + max-issues-per-linter: 0 + max-same-issues: 0 + exclude: + - "don't use an underscore in package name" + +output: + sort-results: true + +exclusions: + paths: + - "^zz_generated.*" + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/deckhouse/) + gofumpt: + extra-rules: true + goimports: + local-prefixes: github.com/deckhouse/ + +linters: + default: none + enable: + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - contextcheck # [maybe too many false positives] checks the function whether use a non-inherited context + - dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - copyloopvar # detects places where loop variables are copied (Go 1.22+) + - gocritic # provides diagnostics that check for bugs, performance and style issues + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - ineffassign # detects when assignments to existing variables are not used + - misspell # finds commonly misspelled English words in comments + - nolintlint # reports ill-formed or insufficient nolint directives + - reassign # checks that package variables are not reassigned + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - testifylint # checks usage of github.com/stretchr/testify + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - unused # checks for unused constants, variables, functions and types + - usetesting # reports uses of functions with replacement inside the testing package + - testableexamples # checks if examples are testable (have an expected output) + - thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - whitespace # detects leading and trailing whitespace + - wastedassign # finds wasted assignment statements + - importas # checks import aliases against the configured convention + settings: + errcheck: + exclude-functions: + - "(*os.File).Close" + - "(*net.TCPConn).Close" + - "(io.ReadCloser).Close" + - "(net.Listener).Close" + - "(net.Conn).Close" + - "(net.Conn).Close" + - "(*golang.org/x/crypto/ssh.Session).Close" + - "(*github.com/fsnotify/fsnotify.Watcher).Close" + staticcheck: + dot-import-whitelist: + - github.com/onsi/ginkgo/v2 + - github.com/onsi/gomega + revive: + rules: + - name: dot-imports + disabled: true + - name: exported + disabled: true + - name: package-comments + disabled: true + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [funlen, gocognit, lll] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + importas: + # Do not allow unaliased imports of aliased packages. + # Default: false + no-unaliased: true + # Do not allow non-required aliases. + # Default: false + no-extra-aliases: false \ No newline at end of file diff --git a/images/operator-helm-artifact/cmd/operator-helm-controller/main.go b/images/operator-helm-artifact/cmd/operator-helm-controller/main.go index 9c5e1f5..7a6b257 100644 --- a/images/operator-helm-artifact/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-artifact/cmd/operator-helm-controller/main.go @@ -20,8 +20,8 @@ import ( "flag" "os" - "github.com/deckhouse/operator-helm/pkg/controller/helmclusteraddonchart" helmv2 "github.com/werf/3p-helm-controller/api/v2" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" _ "k8s.io/client-go/plugin/pkg/client/auth" @@ -31,9 +31,10 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/pkg/controller/helmclusteraddon" - "github.com/deckhouse/operator-helm/pkg/controller/helmclusteraddonrepository" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddon" + "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddonchart" + "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddonrepository" + helmclusteraddonwebhook "github.com/deckhouse/operator-helm/internal/webhook/helmclusteraddon" ) var scheme = runtime.NewScheme() @@ -89,7 +90,7 @@ func main() { os.Exit(1) } - if err = (&helmv1alpha1.HelmClusterAddon{}).SetupWebhookWithManager(mgr); err != nil { + if err = helmclusteraddonwebhook.SetupWebhookWithManager(mgr); err != nil { logger.Error(err, "unable to create webhook", "webhook", "HelmClusterAddon") os.Exit(1) } diff --git a/images/operator-helm-artifact/go.mod b/images/operator-helm-artifact/go.mod index f642971..175a19a 100644 --- a/images/operator-helm-artifact/go.mod +++ b/images/operator-helm-artifact/go.mod @@ -5,7 +5,9 @@ go 1.25.0 replace github.com/deckhouse/operator-helm/api => ../../api require ( + github.com/Masterminds/semver/v3 v3.4.0 github.com/deckhouse/operator-helm/api v0.0.0-00010101000000-000000000000 + github.com/google/go-containerregistry v0.20.6 github.com/opencontainers/go-digest v1.0.0 github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1 @@ -21,11 +23,14 @@ require ( ) require ( - github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/cyphar/filepath-securejoin v0.6.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/docker/cli v28.2.2+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -41,12 +46,15 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -54,7 +62,9 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect github.com/werf/3p-fluxcd-pkg/apis/acl v0.9.0-nelm.1 // indirect github.com/werf/3p-fluxcd-pkg/apis/kustomize v1.14.0-nelm.1 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -73,6 +83,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.4.0 // indirect k8s.io/apiextensions-apiserver v0.35.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect diff --git a/images/operator-helm-artifact/go.sum b/images/operator-helm-artifact/go.sum index e94cb26..64b26c1 100644 --- a/images/operator-helm-artifact/go.sum +++ b/images/operator-helm-artifact/go.sum @@ -6,6 +6,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -14,6 +16,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= +github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= @@ -40,8 +48,11 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= +github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -65,6 +76,8 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -81,6 +94,8 @@ github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -98,14 +113,19 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/werf/3p-fluxcd-pkg/apis/acl v0.9.0-nelm.1 h1:b1P4avYWjjWuzPSOv6QZtk1ffl/iBfWBGK4qNAxaA94= github.com/werf/3p-fluxcd-pkg/apis/acl v0.9.0-nelm.1/go.mod h1:00dBUg4SN+4Xu4LWrbQm5LdmRKVP9Fjbvb+rvqjHrVI= github.com/werf/3p-fluxcd-pkg/apis/kustomize v1.14.0-nelm.1 h1:edZ5ugpeUvmjG+g9laet8qTBqDdQPl18aNr6k0xqdYY= @@ -120,6 +140,7 @@ github.com/werf/nelm-source-controller/api v0.1.4 h1:/k3RT+hHdwKHntoebdcjhO+zboJ github.com/werf/nelm-source-controller/api v0.1.4/go.mod h1:++j7xw4YVDE8gR9x1HWhIagpo68jE1oEd4+6tMAgXgs= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -130,24 +151,47 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= @@ -159,8 +203,11 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= +gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= helm.sh/helm/v3 v3.19.2 h1:psQjaM8aIWrSVEly6PgYtLu/y6MRSmok4ERiGhZmtUY= helm.sh/helm/v3 v3.19.2/go.mod h1:gX10tB5ErM+8fr7bglUUS/UfTOO8UUTYWIBH1IYNnpE= k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= diff --git a/images/operator-helm-artifact/internal/client/repository/client.go b/images/operator-helm-artifact/internal/client/repository/client.go new file mode 100644 index 0000000..5a056ff --- /dev/null +++ b/images/operator-helm-artifact/internal/client/repository/client.go @@ -0,0 +1,45 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package repository + +import ( + "context" + "fmt" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/utils" +) + +type ClientInterface interface { + FetchCharts(ctx context.Context, url string, auth *AuthConfig) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) +} + +func NewClient(repoType utils.InternalRepositoryType) (ClientInterface, error) { + switch repoType { + case utils.InternalHelmRepository: + return HelmRepositoryDefaultClient, nil + case utils.InternalOCIRepository: + return OCIRepositoryDefaultClient, nil + default: + return nil, fmt.Errorf("unknown repository type: %s", repoType) + } +} + +type AuthConfig struct { + Username string + Password string +} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/client.go b/images/operator-helm-artifact/internal/client/repository/helm.go similarity index 88% rename from images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/client.go rename to images/operator-helm-artifact/internal/client/repository/helm.go index 37b1651..deec2a7 100644 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/client.go +++ b/images/operator-helm-artifact/internal/client/repository/helm.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddonrepository +package repository import ( "context" @@ -23,16 +23,17 @@ import ( "strings" "time" - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "go.yaml.in/yaml/v3" "k8s.io/apimachinery/pkg/util/wait" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) -var HelmRepositoryDefaultClient HelmRepositoryClient +var HelmRepositoryDefaultClient ClientInterface = &helmRepositoryClient{} -type HelmRepositoryClient struct{} +type helmRepositoryClient struct{} -func (c *HelmRepositoryClient) FetchCharts(ctx context.Context, url string) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) { +func (c *helmRepositoryClient) FetchCharts(ctx context.Context, url string, auth *AuthConfig) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) { if !strings.HasSuffix(url, "/index.yaml") { url += "/index.yaml" } @@ -55,6 +56,10 @@ func (c *HelmRepositoryClient) FetchCharts(ctx context.Context, url string) (map return true, fmt.Errorf("creating request: %w", err) } + if auth != nil { + req.SetBasicAuth(auth.Username, auth.Password) + } + resp, err := http.DefaultClient.Do(req) if err != nil { return false, nil diff --git a/images/operator-helm-artifact/internal/client/repository/oci.go b/images/operator-helm-artifact/internal/client/repository/oci.go new file mode 100644 index 0000000..67594ea --- /dev/null +++ b/images/operator-helm-artifact/internal/client/repository/oci.go @@ -0,0 +1,109 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +var OCIRepositoryDefaultClient ClientInterface = &ociRepositoryClient{} + +type ociRepositoryClient struct{} + +func (c *ociRepositoryClient) FetchCharts(ctx context.Context, url string, auth *AuthConfig) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) { + url = trimSchemaPrefixes(url) + urlParts := strings.Split(url, "/") + chartName := urlParts[len(urlParts)-1] + + repo, err := name.NewRepository(url) + if err != nil { + return nil, fmt.Errorf("failed to parse repository url: %w", err) + } + + options := []remote.Option{ + remote.WithContext(ctx), + remote.WithUserAgent("operator-helm-controller"), + remote.WithRetryBackoff(remote.Backoff{ + Duration: 1.0 * time.Second, + Factor: 3.0, + Jitter: 0.1, + Steps: 3, + }), + } + + if auth != nil { + options = append(options, remote.WithAuth(authn.FromConfig(authn.AuthConfig{ + Username: auth.Username, + Password: auth.Password, + }))) + } + + tags, err := remote.List(repo, options...) + if err != nil { + return nil, fmt.Errorf("listing image tags: %w", err) + } + + var chartVersions []helmv1alpha1.HelmClusterAddonChartVersion + + for _, tag := range tags { + if isCosignTag(tag) || !isSemverCompliantTag(tag) { + continue + } + + // Do not obtain digests as they are currently not used and require a HEAD request per tag. + chartVersions = append(chartVersions, helmv1alpha1.HelmClusterAddonChartVersion{ + Version: tag, + }) + } + + return map[string][]helmv1alpha1.HelmClusterAddonChartVersion{ + chartName: chartVersions, + }, nil +} + +func trimSchemaPrefixes(url string) string { + for _, prefix := range []string{"oci://", "http://", "https://"} { + url = strings.TrimPrefix(url, prefix) + } + + return url +} + +func isSemverCompliantTag(tag string) bool { + _, err := semver.NewVersion(tag) + return err == nil +} + +func isCosignTag(tag string) bool { + for _, suffix := range []string{".att", ".sbom", ".sig"} { + if strings.HasSuffix(tag, suffix) { + return true + } + } + + return false +} diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go new file mode 100644 index 0000000..6b044f7 --- /dev/null +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go @@ -0,0 +1,87 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddon + +import ( + helmv2 "github.com/werf/3p-helm-controller/api/v2" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + ControllerName = "helmclusteraddon-controller" +) + +func SetupWithManager(mgr ctrl.Manager) error { + client := mgr.GetClient() + + r := &reconciler{ + Client: mgr.GetClient(), + releaseService: services.NewReleaseService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + ociRepositoryService: services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + chartService: services.NewChartService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + maintenanceService: services.NewMaintenanceService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + statusManager: services.NewStatusManager(client, ControllerName), + } + + return ctrl.NewControllerManagedBy(mgr). + Named(ControllerName). + For(&helmv1alpha1.HelmClusterAddon{}). + Watches( + &sourcev1.HelmChart{}, + handler.EnqueueRequestsFromMapFunc( + utils.MapInternalToFacade( + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonLabelSourceName, + ), + ), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv2.HelmRelease{}, + handler.EnqueueRequestsFromMapFunc( + utils.MapInternalToFacade( + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonLabelSourceName, + ), + ), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &sourcev1.OCIRepository{}, + handler.EnqueueRequestsFromMapFunc( + utils.MapInternalToFacade( + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonLabelSourceName, + ), + ), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Complete(r) +} diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddon/reconciler.go b/images/operator-helm-artifact/internal/controller/helmclusteraddon/reconciler.go new file mode 100644 index 0000000..32f6725 --- /dev/null +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddon/reconciler.go @@ -0,0 +1,264 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddon + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/utils" +) + +type reconciler struct { + client.Client + + chartService *services.ChartService + ociRepositoryService *services.OCIRepoService + releaseService *services.ReleaseService + maintenanceService *services.MaintenanceService + statusManager *services.StatusManager +} + +func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := log.FromContext(ctx) + ctx = log.IntoContext(ctx, logger) + + addon := &helmv1alpha1.HelmClusterAddon{} + if err := r.Get(ctx, req.NamespacedName, addon); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcile.Result{}, fmt.Errorf("getting helm cluster addon: %w", err) + } + + if !addon.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, addon) + } + + if !controllerutil.ContainsFinalizer(addon, helmv1alpha1.FinalizerName) { + controllerutil.AddFinalizer(addon, helmv1alpha1.FinalizerName) + if err := r.Update(ctx, addon); err != nil { + return reconcile.Result{}, fmt.Errorf("adding finalizer: %w", err) + } + return reconcile.Result{}, nil + } + + err := r.statusManager.InitializeConditions(ctx, addon, + helmv1alpha1.ConditionTypeReady, + helmv1alpha1.ConditionTypeManaged, + helmv1alpha1.ConditionTypeInstalled, + helmv1alpha1.ConditionTypeUpdateInstalled, + helmv1alpha1.ConditionTypeConfigurationApplied, + helmv1alpha1.ConditionTypePartiallyDegraded, + ) + if err != nil { + return reconcile.Result{}, err + } + + maintenanceRes := r.maintenanceService.EnsureMaintenanceMode(ctx, addon) + if maintenanceRes.StatusUpdateRequired { + return reconcile.Result{}, r.statusManager.Update(ctx, addon, services.NoopStatusMutator, services.NoopStatusMapper, maintenanceRes) + } + + repo := &helmv1alpha1.HelmClusterAddonRepository{} + if err := r.Get(ctx, types.NamespacedName{Name: addon.Spec.Chart.HelmClusterAddonRepository}, repo); err != nil { + return reconcile.Result{}, r.statusManager.Update(ctx, addon, services.NoopStatusMutator, services.NoopStatusMapper, services.ReleaseResult{Status: services.Failed( + addon, + helmv1alpha1.ReasonFailed, + "Failed to get internal repository", + fmt.Errorf("getting internal repository: %w", err), + )}) + } + + repoType, err := utils.GetRepositoryType(repo.Spec.URL) + if err != nil { + return reconcile.Result{}, r.statusManager.Update(ctx, addon, services.NoopStatusMutator, services.NoopStatusMapper, services.ReleaseResult{Status: services.Failed( + addon, + helmv1alpha1.ReasonFailed, + fmt.Sprintf("Failed to parse repository type: %s", err.Error()), + err, + )}) + } + + var chartRes services.ChartResult + var repoRes services.OCIRepoResult + var releaseRes services.ReleaseResult + + switch repoType { + case utils.InternalHelmRepository: + chartRes = r.chartService.EnsureHelmChart(ctx, addon) + if !chartRes.IsPartiallyDegraded() { + apimeta.SetStatusCondition(&addon.Status.Conditions, metav1.Condition{ + Type: helmv1alpha1.ConditionTypePartiallyDegraded, + Status: metav1.ConditionFalse, + ObservedGeneration: addon.Generation, + Reason: helmv1alpha1.ReasonSuccess, + }) + } + case utils.InternalOCIRepository: + repoRes = r.ociRepositoryService.EnsureInternalOCIRepository(ctx, addon, repo) + if !repoRes.IsPartiallyDegraded() { + apimeta.SetStatusCondition(&addon.Status.Conditions, metav1.Condition{ + Type: helmv1alpha1.ConditionTypePartiallyDegraded, + Status: metav1.ConditionFalse, + ObservedGeneration: addon.Generation, + Reason: helmv1alpha1.ReasonSuccess, + }) + } + default: + return reconcile.Result{}, r.statusManager.Update(ctx, addon, services.NoopStatusMutator, services.NoopStatusMapper, services.ReleaseResult{Status: services.Failed( + addon, + helmv1alpha1.ReasonFailed, + fmt.Sprintf("Unsupported repository type: %s", repoType), + err, + )}) + } + + if chartRes.HasArtifact() || repoRes.HasArtifact() { + releaseRes = r.releaseService.EnsureHelmRelease(ctx, addon, repoType) + } + + if !releaseRes.IsReady() { + releaseRes = services.ReleaseResult{Status: services.Failed( + addon, + releaseRes.Status.Reason, + releaseRes.Status.Message, + nil, + )} + } + + return reconcile.Result{}, r.statusManager.Update( + ctx, + addon, + setStatusAttrs(repoType, chartRes, repoRes, releaseRes), + mapResourceStatus(), + chartRes, + repoRes, + releaseRes, + ) +} + +func (r *reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (reconcile.Result, error) { + logger := log.FromContext(ctx) + + if !controllerutil.ContainsFinalizer(addon, helmv1alpha1.FinalizerName) { + return reconcile.Result{}, nil + } + + if err := r.ociRepositoryService.RemoveOCIRepository(ctx, addon); err != nil && !apierrors.IsNotFound(err) { + return reconcile.Result{}, err + } + + if err := r.chartService.CleanupHelmChart(ctx, addon); err != nil && !apierrors.IsNotFound(err) { + return reconcile.Result{}, err + } + + if err := r.releaseService.CleanupHelmRelease(ctx, addon); err != nil && !apierrors.IsNotFound(err) { + return reconcile.Result{}, err + } + + controllerutil.RemoveFinalizer(addon, helmv1alpha1.FinalizerName) + if err := r.Update(ctx, addon); err != nil && !apierrors.IsNotFound(err) { + return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + } + + logger.Info("Cleanup complete") + + return reconcile.Result{}, nil +} + +func setStatusAttrs(repoType utils.InternalRepositoryType, chartRes services.ChartResult, repoRes services.OCIRepoResult, releaseRes services.ReleaseResult) services.StatusMutatorFunc { + return func(obj services.ObjectWithConditions, results []services.StatusProvider) (services.ObjectWithConditions, []services.StatusProvider) { + results = services.ConsolidateConditions(obj, results...) + addon := obj.(*helmv1alpha1.HelmClusterAddon) + + var updateChart, updateValues bool + + switch repoType { + case utils.InternalHelmRepository: + if chartRes.HasArtifact() && releaseRes.IsReady() { + if addon.Status.LastAppliedChart == nil { + updateChart = true + updateValues = true + } else { + if addon.IsChartStatusInfoOutdated() && chartRes.IsReady() { + updateChart = true + updateValues = true + } else { + updateValues = true + } + } + } + case utils.InternalOCIRepository: + if repoRes.HasArtifact() && releaseRes.IsReady() { + if addon.Status.LastAppliedChart == nil { + updateChart = true + updateValues = true + } else { + if addon.IsChartStatusInfoOutdated() && repoRes.IsReady() { + updateChart = true + updateValues = true + } else { + updateValues = true + } + } + } + } + + if updateChart { + addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonChartName: addon.Spec.Chart.HelmClusterAddonChartName, + HelmClusterAddonRepository: addon.Spec.Chart.HelmClusterAddonRepository, + Version: addon.Spec.Chart.Version, + } + } + + if updateValues { + if addon.Spec.Values == nil { + addon.Status.LastAppliedValues = nil + } else { + addon.Status.LastAppliedValues = addon.Spec.Values.DeepCopy() + } + } + + return obj, results + } +} + +func mapResourceStatus() services.StatusMapperFunc { + return func(conditionType string, status services.ResourceStatus) services.ResourceStatus { + if conditionType == helmv1alpha1.ConditionTypePartiallyDegraded { + // ConditionTrue means that HelmChartSucceeded, resetting status would exclude it from result. + if status.Status == metav1.ConditionTrue { + status.Status = "" + } + } + + return status + } +} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/controller.go b/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/controller.go similarity index 72% rename from images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/controller.go rename to images/operator-helm-artifact/internal/controller/helmclusteraddonchart/controller.go index db5d0d3..8e7eca2 100644 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/controller.go +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/controller.go @@ -17,17 +17,23 @@ limitations under the License. package helmclusteraddonchart import ( - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/pkg/utils" sourcev1 "github.com/werf/nelm-source-controller/api/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + // ControllerName is the name of this controller, used for leader election and logging. + ControllerName = "helmclusteraddonchart-controller" ) func SetupWithManager(mgr ctrl.Manager) error { - r := &Reconciler{ + r := &reconciler{ Client: mgr.GetClient(), } @@ -36,7 +42,12 @@ func SetupWithManager(mgr ctrl.Manager) error { For(&helmv1alpha1.HelmClusterAddonChart{}). Watches( &sourcev1.HelmChart{}, - handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), + handler.EnqueueRequestsFromMapFunc( + utils.MapInternalToFacade( + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonChartLabelSourceName)), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). Complete(r) diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/reconciler.go b/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/reconciler.go similarity index 69% rename from images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/reconciler.go rename to images/operator-helm-artifact/internal/controller/helmclusteraddonchart/reconciler.go index 1202ab1..543c798 100644 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddonchart/reconciler.go +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/reconciler.go @@ -29,16 +29,16 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) -type Reconciler struct { - Client client.Client +type reconciler struct { + client.Client } -func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { - logger := log.FromContext(ctx).WithValues("helmclusteraddonchart", req.Name) +func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := log.FromContext(ctx) chart := &helmv1alpha1.HelmClusterAddonChart{} - if err := r.Client.Get(ctx, req.NamespacedName, chart); client.IgnoreNotFound(err) != nil { - return ctrl.Result{}, fmt.Errorf("failed to get HelmClusterAddonChart: %w", err) + if err := r.Get(ctx, req.NamespacedName, chart); client.IgnoreNotFound(err) != nil { + return ctrl.Result{}, fmt.Errorf("failed to get helm cluster addon chart: %w", err) } if !chart.DeletionTimestamp.IsZero() { @@ -48,11 +48,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco base := chart.DeepCopy() internalCharts := &sourcev1.HelmChartList{} - if err := r.Client.List(ctx, internalCharts, client.InNamespace(TargetNamespace)); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to list internal helm chart list: %w", err) + if err := r.List(ctx, internalCharts, client.InNamespace(helmv1alpha1.TargetNamespace)); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to list internal helm charts: %w", err) } - needsUpdate := false + updateRequired := false for i, v := range chart.Status.Versions { found := false for _, child := range internalCharts.Items { @@ -64,13 +64,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco if chart.Status.Versions[i].Pulled != found { chart.Status.Versions[i].Pulled = found - needsUpdate = true + updateRequired = true } } - if needsUpdate { - if err := r.Client.Status().Patch(ctx, chart, client.MergeFrom(base)); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to update HelmClusterAddonChart status: %w", err) + if updateRequired { + if err := r.Status().Patch(ctx, chart, client.MergeFrom(base)); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to update helm cluster addon chart status: %w", err) } logger.Info("HelmClusterAddonChart successfully reconciled") diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go new file mode 100644 index 0000000..4d765fc --- /dev/null +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go @@ -0,0 +1,81 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonrepository + +import ( + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + ControllerName = "helmclusteraddonrepository-controller" +) + +func SetupWithManager(mgr ctrl.Manager) error { + client := mgr.GetClient() + + r := &reconciler{ + Client: client, + helmRepositoryService: services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + ociRepositoryService: services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + chartSyncService: services.NewRepoSyncService(client, mgr.GetScheme()), + statusManager: services.NewStatusManager(client, helmv1alpha1.LabelManagedByValue), + } + + return ctrl.NewControllerManagedBy(mgr). + Named(ControllerName). + For(&helmv1alpha1.HelmClusterAddonRepository{}). + Watches( + &sourcev1.HelmRepository{}, + handler.EnqueueRequestsFromMapFunc( + utils.MapInternalToFacade( + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName), + ), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc( + utils.MapInternalToFacade( + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName), + ), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv1alpha1.HelmClusterAddonChart{}, + handler.EnqueueRequestForOwner( + mgr.GetScheme(), + mgr.GetRESTMapper(), + &helmv1alpha1.HelmClusterAddonRepository{}, + handler.OnlyControllerOwner(), + ), + ).Complete(r) +} diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/reconciler.go b/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/reconciler.go new file mode 100644 index 0000000..e15ea0c --- /dev/null +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/reconciler.go @@ -0,0 +1,167 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonrepository + +import ( + "context" + "fmt" + "time" + + "github.com/werf/3p-fluxcd-pkg/apis/meta" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/utils" +) + +type reconciler struct { + client.Client + + helmRepositoryService *services.HelmRepoService + ociRepositoryService *services.OCIRepoService + chartSyncService *services.RepoSyncService + statusManager *services.StatusManager +} + +func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := log.FromContext(ctx) + ctx = log.IntoContext(ctx, logger) + + var repo helmv1alpha1.HelmClusterAddonRepository + if err := r.Get(ctx, req.NamespacedName, &repo); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcile.Result{}, fmt.Errorf("getting helm cluster addon repository: %w", err) + } + + repoType, err := utils.GetRepositoryType(repo.Spec.URL) + if err != nil { + logger.Error(err, "failed to determine repository type") + return reconcile.Result{}, err + } + + if !repo.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, &repo, repoType) + } + + if !controllerutil.ContainsFinalizer(&repo, helmv1alpha1.FinalizerName) { + controllerutil.AddFinalizer(&repo, helmv1alpha1.FinalizerName) + + if err := r.Update(ctx, &repo); err != nil { + return reconcile.Result{}, fmt.Errorf("adding finalizer: %w", err) + } + return r.requeueAtSyncInterval(&repo) + } + + if err := r.statusManager.InitializeConditions(ctx, &repo, + helmv1alpha1.ConditionTypeReady, + helmv1alpha1.ConditionTypeSynced, + ); err != nil { + return reconcile.Result{}, err + } + + var helmRepoRes services.HelmRepoResult + var ociRepoRes services.OCIRepoResult + var chartSyncRes services.RepoSyncResult + + switch repoType { + case utils.InternalHelmRepository: + helmRepoRes = r.helmRepositoryService.EnsureInternalHelmRepository(ctx, &repo) + case utils.InternalOCIRepository: + // TODO: need to add extra check to ensure that URL provided by user is valid OCI url and credentials are correct. + // Otherwise permanent ready status is invalid. + ociRepoRes = services.OCIRepoResult{ + Artifact: &meta.Artifact{}, + Status: services.ResourceStatus{ + ConditionType: helmv1alpha1.ConditionTypeReady, + Observed: true, + Status: metav1.ConditionTrue, + ObservedGeneration: repo.Generation, + Reason: helmv1alpha1.ReasonSuccess, + }, + } + default: + err := fmt.Errorf("unsupported repository type: %q", repoType) + helmRepoRes = services.HelmRepoResult{Status: services.Failed(&repo, "UnsupportedRepositoryType", err.Error(), err)} + } + + if helmRepoRes.IsReady() || ociRepoRes.IsReady() { + chartSyncRes = r.chartSyncService.EnsureAddonCharts(ctx, &repo, repoType) + } else { + chartSyncRes = services.RepoSyncResult{Status: services.Failed(&repo, helmv1alpha1.ReasonRepositoryNotReady, helmRepoRes.Status.Message, err)} + } + + if err := r.statusManager.Update(ctx, &repo, services.NoopStatusMutator, services.NoopStatusMapper, helmRepoRes, ociRepoRes, chartSyncRes); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to update status: %w", err) + } + + return r.requeueAtSyncInterval(&repo) +} + +func (r *reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) (reconcile.Result, error) { + logger := log.FromContext(ctx) + + if !controllerutil.ContainsFinalizer(repo, helmv1alpha1.FinalizerName) { + return reconcile.Result{}, nil + } + + switch repoType { + case utils.InternalHelmRepository: + if err := r.helmRepositoryService.CleanupHelmRepository(ctx, repo.Name); err != nil && !apierrors.IsNotFound(err) { + _ = r.statusManager.Update(ctx, repo, services.NoopStatusMutator, services.NoopStatusMapper, services.HelmRepoResult{ + Status: services.Failed(repo, helmv1alpha1.ReasonFailed, "Failed to remove dependencies", err), + }) + return reconcile.Result{}, err + } + case utils.InternalOCIRepository: + if err := r.ociRepositoryService.CleanupOCIRepository(ctx, repo.Name); err != nil && !apierrors.IsNotFound(err) { + _ = r.statusManager.Update(ctx, repo, services.NoopStatusMutator, services.NoopStatusMapper, services.HelmRepoResult{ + Status: services.Failed(repo, helmv1alpha1.ReasonFailed, "Failed to remove dependencies", err), + }) + return reconcile.Result{}, err + } + } + + controllerutil.RemoveFinalizer(repo, helmv1alpha1.FinalizerName) + if err := r.Update(ctx, repo); err != nil && !apierrors.IsNotFound(err) { + return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + } + + logger.Info("Cleanup complete") + + return reconcile.Result{}, nil +} + +func (r *reconciler) requeueAtSyncInterval(repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { + repoSyncCond := apimeta.FindStatusCondition(repo.Status.Conditions, helmv1alpha1.ConditionTypeSynced) + if repoSyncCond != nil { + remaining := time.Until(repoSyncCond.LastTransitionTime.Add(services.ChartsSyncInterval)) + if remaining > 0 { + return reconcile.Result{RequeueAfter: remaining}, nil + } + } + + return reconcile.Result{RequeueAfter: services.ChartsSyncInterval}, nil +} diff --git a/images/operator-helm-artifact/internal/services/base_repo.go b/images/operator-helm-artifact/internal/services/base_repo.go new file mode 100644 index 0000000..4e24474 --- /dev/null +++ b/images/operator-helm-artifact/internal/services/base_repo.go @@ -0,0 +1,111 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/utils" +) + +type BaseRepoService struct { + BaseService + + TargetNamespace string +} + +func (s *BaseRepoService) reconcileAuthSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) error { + secretName := utils.GetInternalRepositoryAuthSecretName(repoType, repo.Name) + + if repo.Spec.Auth == nil { + nn := types.NamespacedName{Name: secretName, Namespace: s.TargetNamespace} + if err := s.ensureResourceDeleted(ctx, nn, &corev1.Secret{}); err != nil { + return fmt.Errorf("deleting obsolete auth secret: %w", err) + } + return nil + } + + authSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: s.TargetNamespace, + }, + } + + if _, err := controllerutil.CreateOrPatch(ctx, s.Client, authSecret, func() error { + authSecret.Labels = map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repo.Name, + } + + authSecret.StringData = map[string]string{ + "username": repo.Spec.Auth.Username, + "password": repo.Spec.Auth.Password, + } + + return nil + }); err != nil { + return fmt.Errorf("creating auth secret: %w", err) + } + + return nil +} + +func (s *BaseRepoService) reconcileTLSSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) error { + secretName := utils.GetInternalRepositoryTLSSecretName(repoType, repo.Name) + + if repo.Spec.CACertificate == "" { + nn := types.NamespacedName{Name: secretName, Namespace: s.TargetNamespace} + if err := s.ensureResourceDeleted(ctx, nn, &corev1.Secret{}); err != nil { + return fmt.Errorf("deleting obsolete tls secret: %w", err) + } + return nil + } + + // TODO: consider adding CA certificate format validation + + tlsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: s.TargetNamespace, + }, + } + + if _, err := controllerutil.CreateOrPatch(ctx, s.Client, tlsSecret, func() error { + tlsSecret.Labels = map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonLabelSourceName: repo.Name, + } + + tlsSecret.StringData = map[string]string{ + "ca.crt": repo.Spec.CACertificate, + } + + return nil + }); err != nil { + return fmt.Errorf("cannot reconcile tls secret: %w", err) + } + + return nil +} diff --git a/images/operator-helm-artifact/internal/services/chart.go b/images/operator-helm-artifact/internal/services/chart.go new file mode 100644 index 0000000..7e76d2b --- /dev/null +++ b/images/operator-helm-artifact/internal/services/chart.go @@ -0,0 +1,149 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "context" + "fmt" + + "github.com/werf/3p-fluxcd-pkg/apis/meta" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/utils" +) + +type ChartService struct { + BaseService + + TargetNamespace string +} + +func NewChartService(client client.Client, scheme *runtime.Scheme, targetNamespace string) *ChartService { + return &ChartService{ + BaseService: BaseService{ + Client: client, + Scheme: scheme, + }, + TargetNamespace: targetNamespace, + } +} + +type ChartResult struct { + Status ResourceStatus + Artifact *meta.Artifact +} + +func (r ChartResult) GetStatus() ResourceStatus { + return r.Status +} + +func (r ChartResult) IsReady() bool { + return r.Artifact != nil && r.Status.Observed && r.Status.Status == metav1.ConditionTrue +} + +func (r ChartResult) IsPartiallyDegraded() bool { + return r.Artifact != nil && r.Status.Status != metav1.ConditionTrue && r.Status.Observed +} + +func (r ChartResult) HasArtifact() bool { + return r.Artifact != nil && r.Status.Observed +} + +func (r ChartResult) GetConditionType() string { + return helmv1alpha1.ConditionTypePartiallyDegraded +} + +func (s *ChartService) EnsureHelmChart(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) ChartResult { + logger := log.FromContext(ctx) + + existing := &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetInternalHelmChartName(addon.Name), + Namespace: s.TargetNamespace, + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, s.Client, existing, func() error { + applyHelmChartSpec(addon, existing) + + return nil + }) + if err != nil { + return ChartResult{Status: Failed( + addon, + helmv1alpha1.ReasonHelmChartFailed, + "Failed to create helm chart", + fmt.Errorf("creating or updating helm chart: %w", err), + )} + } + + if op != controllerutil.OperationResultNone { + logger.Info("Reconciled helm chart", "operation", op) + } + + if cond, ok := utils.IsConditionObserved(existing.GetConditions(), helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + logger.Info("Successfully reconciled helm chart", "operation", op, "chart", addon.Spec.Chart.HelmClusterAddonChartName) + return ChartResult{ + Artifact: existing.Status.Artifact, + Status: ResourceStatus{ + Observed: ok, + Status: cond.Status, + ObservedGeneration: addon.Generation, + Reason: cond.Reason, + Message: cond.Message, + NotReflectable: existing.Status.Artifact != nil, + }, + } + } + + return ChartResult{Status: Unknown(addon, helmv1alpha1.ReasonReconciling)} +} + +func (s *ChartService) CleanupHelmChart(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { + nn := types.NamespacedName{Name: utils.GetInternalHelmChartName(addon.Name), Namespace: s.TargetNamespace} + if err := s.ensureResourceDeleted(ctx, nn, &sourcev1.HelmChart{}); err != nil { + return fmt.Errorf("failed to delete helm chart: %w", err) + } + + return nil +} + +func applyHelmChartSpec(addon *helmv1alpha1.HelmClusterAddon, existing *sourcev1.HelmChart) { + if existing.Labels == nil { + existing.Labels = map[string]string{} + } + + existing.Labels[helmv1alpha1.LabelManagedBy] = helmv1alpha1.LabelManagedByValue + existing.Labels[helmv1alpha1.HelmClusterAddonLabelSourceName] = addon.Name + existing.Labels[helmv1alpha1.HelmClusterAddonChartLabelSourceName] = utils.GetHelmClusterAddonChartName( + addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName) + + existing.Spec.Chart = addon.Spec.Chart.HelmClusterAddonChartName + existing.Spec.Version = addon.Spec.Chart.Version + + existing.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ + Kind: sourcev1.HelmRepositoryKind, + Name: addon.Spec.Chart.HelmClusterAddonRepository, + } +} diff --git a/images/operator-helm-artifact/internal/services/helm_repo.go b/images/operator-helm-artifact/internal/services/helm_repo.go new file mode 100644 index 0000000..ea2d003 --- /dev/null +++ b/images/operator-helm-artifact/internal/services/helm_repo.go @@ -0,0 +1,186 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "context" + "fmt" + "time" + + "github.com/werf/3p-fluxcd-pkg/apis/meta" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + InternalRepositoryInterval = 5 * time.Minute + ChartsSyncInterval = 5 * time.Minute +) + +type HelmRepoService struct { + BaseService + BaseRepoService + + TargetNamespace string +} + +func NewHelmRepoService(client client.Client, scheme *runtime.Scheme, namespace string) *HelmRepoService { + return &HelmRepoService{ + BaseService: BaseService{ + Client: client, + Scheme: scheme, + }, + BaseRepoService: BaseRepoService{ + BaseService: BaseService{ + Client: client, + Scheme: scheme, + }, + TargetNamespace: namespace, + }, + TargetNamespace: namespace, + } +} + +type HelmRepoResult struct { + Status ResourceStatus +} + +func (r HelmRepoResult) GetStatus() ResourceStatus { + return r.Status +} + +func (r HelmRepoResult) IsReady() bool { + return r.Status.IsReady() +} + +func (r HelmRepoResult) GetConditionType() string { + return helmv1alpha1.ConditionTypeReady +} + +func (s *HelmRepoService) EnsureInternalHelmRepository(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) HelmRepoResult { + logger := log.FromContext(ctx) + + if err := s.reconcileAuthSecret(ctx, repo, utils.InternalHelmRepository); err != nil { + return HelmRepoResult{Status: Failed(repo, helmv1alpha1.ReasonFailed, "Failed to reconcile auth secret", err)} + } + + if err := s.reconcileTLSSecret(ctx, repo, utils.InternalHelmRepository); err != nil { + return HelmRepoResult{Status: Failed(repo, helmv1alpha1.ReasonFailed, "Failed to reconcile tls secret", err)} + } + + existing := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: repo.Name, + Namespace: s.TargetNamespace, + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, s.Client, existing, func() error { + applyHelmRepositorySpec(repo, existing) + + return nil + }) + if err != nil { + return HelmRepoResult{ + Status: Failed( + repo, + helmv1alpha1.ReasonFailed, + "Failed to reconcile helm repository", + fmt.Errorf("creating helm repository: %w", err)), + } + } + + if op != controllerutil.OperationResultNone { + logger.Info("Reconciled helm repository", "operation", op) + } + + if cond, ok := utils.IsConditionObserved(existing.Status.Conditions, helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + return HelmRepoResult{Status: ResourceStatus{ + Observed: ok, + Status: cond.Status, + ObservedGeneration: repo.Generation, + Reason: cond.Reason, + Message: cond.Message, + }} + } + + return HelmRepoResult{Status: Unknown(repo, helmv1alpha1.ReasonReconciling)} +} + +func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, repoName string) error { + resources := []struct { + name string + obj client.Object + }{ + { + name: utils.GetInternalRepositoryAuthSecretName(utils.InternalHelmRepository, repoName), + obj: &corev1.Secret{}, + }, + { + name: utils.GetInternalRepositoryTLSSecretName(utils.InternalHelmRepository, repoName), + obj: &corev1.Secret{}, + }, + { + name: repoName, + obj: &sourcev1.HelmRepository{}, + }, + } + + for _, r := range resources { + nn := types.NamespacedName{Name: r.name, Namespace: s.TargetNamespace} + if err := s.ensureResourceDeleted(ctx, nn, r.obj); err != nil { + return fmt.Errorf("cleaning up %T %s: %w", r.obj, r.name, err) + } + } + + return nil +} + +func applyHelmRepositorySpec(repo *helmv1alpha1.HelmClusterAddonRepository, existing *sourcev1.HelmRepository) { + existing.Spec.URL = repo.Spec.URL + existing.Spec.Interval = metav1.Duration{Duration: InternalRepositoryInterval} + existing.Spec.Insecure = !repo.Spec.TLSVerify + existing.Spec.CertSecretRef = nil + existing.Spec.SecretRef = nil + + if repo.Spec.Auth != nil { + existing.Spec.SecretRef = &meta.LocalObjectReference{ + Name: utils.GetInternalRepositoryAuthSecretName(utils.InternalHelmRepository, repo.Name), + } + existing.Spec.PassCredentials = true + } + + if repo.Spec.CACertificate != "" { + existing.Spec.CertSecretRef = &meta.LocalObjectReference{ + Name: utils.GetInternalRepositoryTLSSecretName(utils.InternalHelmRepository, repo.Name), + } + } + + existing.Labels = map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repo.Name, + } +} diff --git a/images/operator-helm-artifact/internal/services/maintenance.go b/images/operator-helm-artifact/internal/services/maintenance.go new file mode 100644 index 0000000..091c952 --- /dev/null +++ b/images/operator-helm-artifact/internal/services/maintenance.go @@ -0,0 +1,132 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "context" + "fmt" + + helmv2 "github.com/werf/3p-helm-controller/api/v2" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/utils" +) + +type MaintenanceService struct { + BaseService + + TargetNamespace string +} + +func NewMaintenanceService(client client.Client, scheme *runtime.Scheme, targetNamespace string) *MaintenanceService { + return &MaintenanceService{ + BaseService: BaseService{ + Client: client, + Scheme: scheme, + }, + TargetNamespace: targetNamespace, + } +} + +type MaintenanceResult struct { + Status ResourceStatus + StatusUpdateRequired bool +} + +func (r MaintenanceResult) GetStatus() ResourceStatus { + return r.Status +} + +func (r MaintenanceResult) IsReady() bool { + return r.Status.IsReady() +} + +func (r MaintenanceResult) GetConditionType() string { + return helmv1alpha1.ConditionTypeManaged +} + +func (s *MaintenanceService) EnsureMaintenanceMode(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) MaintenanceResult { + logger := log.FromContext(ctx) + + if addon.MaintenanceModeActivated() && !addon.MaintenanceModeEnabled() { + logger.Info("Enabling maintenance mode") + err := s.updateHelmReleaseSuspendState(ctx, addon, true) + if err != nil { + return MaintenanceResult{StatusUpdateRequired: true, Status: Failed(addon, helmv1alpha1.ReasonFailed, "Failed to enable maintenance mode", err)} + } + return MaintenanceResult{ + StatusUpdateRequired: true, + Status: ResourceStatus{ + Status: metav1.ConditionFalse, + ObservedGeneration: addon.Generation, + Reason: helmv1alpha1.ReasonMaintenanceModeActive, + }, + } + } + + if !addon.MaintenanceModeActivated() && (addon.MaintenanceModeEnabled() || + apimeta.IsStatusConditionPresentAndEqual(addon.Status.Conditions, helmv1alpha1.ConditionTypeManaged, metav1.ConditionUnknown)) { + logger.Info("Disabling maintenance mode") + err := s.updateHelmReleaseSuspendState(ctx, addon, false) + if err != nil { + return MaintenanceResult{StatusUpdateRequired: true, Status: Failed(addon, helmv1alpha1.ReasonFailed, "Failed to disable maintenance mode", nil)} + } + return MaintenanceResult{ + StatusUpdateRequired: true, + Status: ResourceStatus{ + Status: metav1.ConditionTrue, + ObservedGeneration: addon.Generation, + Reason: helmv1alpha1.ReasonMaintenanceModeInactive, + }, + } + } + + return MaintenanceResult{Status: Success(addon)} +} + +func (s *MaintenanceService) updateHelmReleaseSuspendState(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon, suspend bool) error { + helmRelease := &helmv2.HelmRelease{} + if err := s.Client.Get(ctx, types.NamespacedName{ + Name: utils.GetInternalHelmReleaseName(addon.Name), + Namespace: s.TargetNamespace, + }, helmRelease); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("getting helm release: %w", err) + } + + if helmRelease.Spec.Suspend == suspend { + return nil + } + + base := helmRelease.DeepCopy() + helmRelease.Spec.Suspend = suspend + + if err := s.Client.Patch(ctx, helmRelease, client.MergeFrom(base)); err != nil { + return fmt.Errorf("setting helm release suspend state: %w", err) + } + + return nil +} diff --git a/images/operator-helm-artifact/internal/services/oci_repo.go b/images/operator-helm-artifact/internal/services/oci_repo.go new file mode 100644 index 0000000..f4709e7 --- /dev/null +++ b/images/operator-helm-artifact/internal/services/oci_repo.go @@ -0,0 +1,209 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "context" + "fmt" + + "github.com/werf/3p-fluxcd-pkg/apis/meta" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/utils" +) + +type OCIRepoService struct { + BaseService + BaseRepoService + + TargetNamespace string +} + +func NewOCIRepoService(client client.Client, scheme *runtime.Scheme, namespace string) *OCIRepoService { + return &OCIRepoService{ + BaseService: BaseService{ + Client: client, + Scheme: scheme, + }, + BaseRepoService: BaseRepoService{ + BaseService: BaseService{ + Client: client, + Scheme: scheme, + }, + TargetNamespace: namespace, + }, + TargetNamespace: namespace, + } +} + +type OCIRepoResult struct { + Status ResourceStatus + Artifact *meta.Artifact +} + +func (r OCIRepoResult) GetStatus() ResourceStatus { + return r.Status +} + +func (r OCIRepoResult) IsReady() bool { + return r.Artifact != nil && r.Status.Observed && r.Status.Status == metav1.ConditionTrue +} + +func (r OCIRepoResult) IsPartiallyDegraded() bool { + return r.Artifact != nil && r.Status.Status != metav1.ConditionTrue && r.Status.Observed +} + +func (r OCIRepoResult) HasArtifact() bool { + return r.Artifact != nil && r.Status.Observed +} + +func (r OCIRepoResult) GetConditionType() string { + if r.Status.ConditionType == "" { + return helmv1alpha1.ConditionTypePartiallyDegraded + } + return r.Status.ConditionType +} + +func (s *OCIRepoService) EnsureInternalOCIRepository(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon, repo *helmv1alpha1.HelmClusterAddonRepository) OCIRepoResult { + logger := log.FromContext(ctx) + + if err := s.reconcileAuthSecret(ctx, repo, utils.InternalHelmRepository); err != nil { + return OCIRepoResult{Status: Failed(addon, helmv1alpha1.ReasonFailed, "Failed to reconcile auth secret", err)} + } + + if err := s.reconcileTLSSecret(ctx, repo, utils.InternalHelmRepository); err != nil { + return OCIRepoResult{Status: Failed(addon, helmv1alpha1.ReasonFailed, "Failed to reconcile tls secret", err)} + } + + existing := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetInternalOCIRepositoryName(addon.Name), + Namespace: s.TargetNamespace, + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, s.Client, existing, func() error { + applyOCIRepositorySpec(addon, repo, existing) + + return nil + }) + if err != nil { + return OCIRepoResult{ + Status: Failed( + addon, + helmv1alpha1.ReasonFailed, + "Failed to reconcile oci repository", + fmt.Errorf("creating oci repository: %w", err)), + } + } + + if op != controllerutil.OperationResultNone { + logger.Info("Reconciled oci repository", "operation", op) + } + + if cond, ok := utils.IsConditionObserved(existing.Status.Conditions, helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + return OCIRepoResult{ + Artifact: existing.Status.Artifact, + Status: ResourceStatus{ + Observed: ok, + Status: cond.Status, + ObservedGeneration: addon.Generation, + Reason: cond.Reason, + Message: cond.Message, + NotReflectable: existing.Status.Artifact != nil, + }, + } + } + + return OCIRepoResult{Status: Unknown(addon, helmv1alpha1.ReasonReconciling)} +} + +func (s *OCIRepoService) CleanupOCIRepository(ctx context.Context, repoName string) error { + resources := []struct { + name string + obj client.Object + }{ + { + name: utils.GetInternalRepositoryAuthSecretName(utils.InternalHelmRepository, repoName), + obj: &corev1.Secret{}, + }, + { + name: utils.GetInternalRepositoryTLSSecretName(utils.InternalHelmRepository, repoName), + obj: &corev1.Secret{}, + }, + } + + for _, r := range resources { + nn := types.NamespacedName{Name: r.name, Namespace: s.TargetNamespace} + if err := s.ensureResourceDeleted(ctx, nn, r.obj); err != nil { + return fmt.Errorf("cleaning up %T %s: %w", r.obj, r.name, err) + } + } + + return nil +} + +func (s *OCIRepoService) RemoveOCIRepository(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { + name := utils.GetInternalOCIRepositoryName(addon.Name) + nn := types.NamespacedName{Name: name, Namespace: s.TargetNamespace} + if err := s.ensureResourceDeleted(ctx, nn, &sourcev1.OCIRepository{}); err != nil { + return fmt.Errorf("removing oci repository: %w", err) + } + + return nil +} + +func applyOCIRepositorySpec(addon *helmv1alpha1.HelmClusterAddon, repo *helmv1alpha1.HelmClusterAddonRepository, existing *sourcev1.OCIRepository) { + existing.Spec.URL = repo.Spec.URL + existing.Spec.Reference = &sourcev1.OCIRepositoryRef{ + Tag: addon.Spec.Chart.Version, + } + existing.Spec.Interval = metav1.Duration{Duration: InternalRepositoryInterval} + existing.Spec.Insecure = !repo.Spec.TLSVerify + existing.Spec.CertSecretRef = nil + existing.Spec.SecretRef = nil + + if repo.Spec.Auth != nil { + existing.Spec.SecretRef = &meta.LocalObjectReference{ + Name: utils.GetInternalRepositoryAuthSecretName(utils.InternalOCIRepository, repo.Name), + } + } + + if repo.Spec.CACertificate != "" { + existing.Spec.CertSecretRef = &meta.LocalObjectReference{ + Name: utils.GetInternalRepositoryTLSSecretName(utils.InternalOCIRepository, repo.Name), + } + } + + existing.Spec.LayerSelector = &sourcev1.OCILayerSelector{ + MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + Operation: "copy", + } + + existing.Labels = map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonLabelSourceName: addon.Name, + } +} diff --git a/images/operator-helm-artifact/internal/services/release.go b/images/operator-helm-artifact/internal/services/release.go new file mode 100644 index 0000000..a8ead2c --- /dev/null +++ b/images/operator-helm-artifact/internal/services/release.go @@ -0,0 +1,153 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "context" + "fmt" + + helmv2 "github.com/werf/3p-helm-controller/api/v2" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/utils" +) + +type ReleaseService struct { + BaseService + + TargetNamespace string +} + +func NewReleaseService(client client.Client, scheme *runtime.Scheme, targetNamespace string) *ReleaseService { + return &ReleaseService{ + BaseService: BaseService{ + Client: client, + Scheme: scheme, + }, + TargetNamespace: targetNamespace, + } +} + +type ReleaseResult struct { + Status ResourceStatus + History helmv2.Snapshots +} + +func (r ReleaseResult) GetStatus() ResourceStatus { + return r.Status +} + +func (r ReleaseResult) IsReady() bool { + return r.Status.IsReady() +} + +func (r ReleaseResult) GetConditionType() string { + return helmv1alpha1.ConditionTypeReady +} + +func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon, repoType utils.InternalRepositoryType) ReleaseResult { + logger := log.FromContext(ctx) + + existing := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetInternalHelmReleaseName(addon.Name), + Namespace: s.TargetNamespace, + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, s.Client, existing, func() error { + return applyHelmReleaseSpec(addon, existing, repoType, s.TargetNamespace) + }) + if err != nil { + return ReleaseResult{Status: Failed( + addon, + helmv1alpha1.ReasonHelmReleaseFailed, + "Failed to create helm release", + fmt.Errorf("reconciling helm release: %w", err), + )} + } + + if cond, ok := utils.IsConditionObserved(existing.GetConditions(), helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + logger.Info("Successfully reconciled helm release", "operation", op) + return ReleaseResult{ + History: existing.Status.History, + Status: ResourceStatus{ + Observed: ok, + Status: cond.Status, + ObservedGeneration: addon.Generation, + Reason: cond.Reason, + Message: cond.Message, + }, + } + } + + return ReleaseResult{Status: Unknown(addon, helmv1alpha1.ReasonReconciling)} +} + +func (s *ReleaseService) CleanupHelmRelease(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { + nn := types.NamespacedName{Name: utils.GetInternalHelmReleaseName(addon.Name), Namespace: s.TargetNamespace} + if err := s.ensureResourceDeleted(ctx, nn, &helmv2.HelmRelease{}); err != nil { + return fmt.Errorf("failed to delete helm release: %w", err) + } + + return nil +} + +func applyHelmReleaseSpec(addon *helmv1alpha1.HelmClusterAddon, existing *helmv2.HelmRelease, repoType utils.InternalRepositoryType, targetNamespace string) error { + if existing.Labels == nil { + existing.Labels = map[string]string{} + } + + existing.Labels[helmv1alpha1.LabelManagedBy] = helmv1alpha1.LabelManagedByValue + existing.Labels[helmv1alpha1.HelmClusterAddonLabelSourceName] = addon.Name + + existing.Spec.ReleaseName = addon.Name + existing.Spec.TargetNamespace = addon.Spec.Namespace + existing.Spec.Values = addon.Spec.Values + + existing.Spec.Suspend = false + + if addon.Spec.Maintenance == string(helmv1alpha1.NoResourceReconciliation) { + existing.Spec.Suspend = true + } + + switch repoType { + case utils.InternalHelmRepository: + existing.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ + Kind: sourcev1.HelmChartKind, + Name: utils.GetInternalHelmChartName(addon.Name), + Namespace: targetNamespace, + } + case utils.InternalOCIRepository: + existing.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ + Kind: sourcev1.OCIRepositoryKind, + Name: utils.GetInternalOCIRepositoryName(addon.Name), + Namespace: targetNamespace, + } + default: + return fmt.Errorf("invalid repository type: %s", repoType) + } + + return nil +} diff --git a/images/operator-helm-artifact/internal/services/repo_sync.go b/images/operator-helm-artifact/internal/services/repo_sync.go new file mode 100644 index 0000000..1edd1ba --- /dev/null +++ b/images/operator-helm-artifact/internal/services/repo_sync.go @@ -0,0 +1,249 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "context" + "fmt" + "time" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + + // LabelRepositoryName stores HelmClusterAddonRepository name. + LabelRepositoryName = "repository" + + // LabelChartName stores chart name. + LabelChartName = "chart" +) + +type RepoSyncService struct { + BaseService +} + +func NewRepoSyncService(client client.Client, scheme *runtime.Scheme) *RepoSyncService { + return &RepoSyncService{ + BaseService: BaseService{ + Client: client, + Scheme: scheme, + }, + } +} + +type RepoSyncResult struct { + Status ResourceStatus +} + +func (r RepoSyncResult) GetStatus() ResourceStatus { + return r.Status +} + +func (r RepoSyncResult) IsReady() bool { + return r.Status.IsReady() +} + +func (r RepoSyncResult) GetConditionType() string { + return helmv1alpha1.ConditionTypeSynced +} + +func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) RepoSyncResult { + logger := log.FromContext(ctx) + + if !isRepoSyncRequired(repo) { + return RepoSyncResult{Status: Success(repo)} + } else if !isRepoSyncInProgress(repo) { + return RepoSyncResult{Status: Unknown(repo, helmv1alpha1.ReasonReconciling)} + } + + repoClient, err := repoclient.NewClient(repoType) + if err != nil { + return RepoSyncResult{ + Status: Failed( + repo, + helmv1alpha1.ReasonSyncFailed, + "Failed to get repository client on chart sync", + fmt.Errorf("getting repository client: %w", err), + ), + } + } + + var authConfig *repoclient.AuthConfig + if repo.Spec.Auth != nil { + authConfig = &repoclient.AuthConfig{ + Username: repo.Spec.Auth.Username, + Password: repo.Spec.Auth.Password, + } + } + + charts, err := repoClient.FetchCharts(ctx, repo.Spec.URL, authConfig) + if err != nil { + return RepoSyncResult{ + Status: Failed( + repo, + helmv1alpha1.ReasonSyncFailed, + "Failed to fetch charts from repository", + fmt.Errorf("fetching charts: %w", err), + ), + } + } + + desiredCharts := make(map[string]struct{}, len(charts)) + + for chart, versions := range charts { + addonChartName := utils.GetHelmClusterAddonChartName(repo.Name, chart) + existing := &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: addonChartName, + }, + } + + desiredCharts[existing.Name] = struct{}{} + + op, err := controllerutil.CreateOrPatch(ctx, s.Client, existing, func() error { + existing.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: repo.APIVersion, + Kind: repo.Kind, + Name: repo.Name, + UID: repo.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }, + } + existing.Labels = map[string]string{ + helmv1alpha1.LabelDeckhouseHeritage: helmv1alpha1.LabelDeckhouseHeritageValue, + LabelRepositoryName: repo.Name, + LabelChartName: chart, + } + return nil + }) + if err != nil { + return RepoSyncResult{ + Status: Failed( + repo, + helmv1alpha1.ReasonSyncFailed, + fmt.Sprintf("Failed to create HelmClusterAddonChart %q", addonChartName), + fmt.Errorf("cannot create or update HelmClusterAddonChart: %w", err), + ), + } + } + + existingVersionsMap := make(map[string]helmv1alpha1.HelmClusterAddonChartVersion) + for _, version := range existing.Status.Versions { + existingVersionsMap[version.Version] = version + } + + for i, version := range versions { + if existingVersion, found := existingVersionsMap[version.Version]; found && version.Digest == existingVersion.Digest { + versions[i].Pulled = existingVersion.Pulled + } + } + + if op != controllerutil.OperationResultNone { + logger.Info("Reconciled HelmClusterAddonChart", "operation", op, "addonChartName", addonChartName) + } + + base := existing.DeepCopy() + existing.Status.Versions = versions + + if err := s.Client.Status().Patch(ctx, existing, client.MergeFrom(base)); err != nil { + return RepoSyncResult{ + Status: Failed( + repo, + helmv1alpha1.ReasonSyncFailed, + fmt.Sprintf("Failed to update HelmClusterAddonChart %q versions", addonChartName), + fmt.Errorf("updating chart versions: %w", err), + ), + } + } + + logger.Info("Successfully synced HelmClusterAddonChart versions", "operation", op, "addonChartName", addonChartName) + } + + var existingCharts helmv1alpha1.HelmClusterAddonChartList + if err := s.Client.List(ctx, &existingCharts, client.MatchingLabels{LabelRepositoryName: repo.Name}); err != nil { + return RepoSyncResult{ + Status: Failed( + repo, + helmv1alpha1.ReasonSyncFailed, + "Failed to list stale charts for pruning", + fmt.Errorf("listing existing charts for pruning: %w", err), + ), + } + } + + for i := range existingCharts.Items { + staleChart := &existingCharts.Items[i] + if _, wanted := desiredCharts[staleChart.Name]; wanted { + continue + } + + if err := s.ensureResourceDeleted(ctx, types.NamespacedName{Name: staleChart.Name}, staleChart); err != nil { + return RepoSyncResult{ + Status: Failed( + repo, + helmv1alpha1.ReasonSyncFailed, + "Failed to delete stale charts", + fmt.Errorf("deleting stale charts: %w", err), + ), + } + } + } + + logger.Info(fmt.Sprintf("Scheduling next repo sync in %s", ChartsSyncInterval)) + + return RepoSyncResult{ + Status: ResourceStatus{ + Observed: true, + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonSyncSucceeded, + ObservedGeneration: repo.Generation, + Message: "", + Err: nil, + }, + } +} + +func isRepoSyncRequired(repo *helmv1alpha1.HelmClusterAddonRepository) bool { + syncCond := apimeta.FindStatusCondition(repo.Status.Conditions, helmv1alpha1.ConditionTypeSynced) + if syncCond != nil && syncCond.Status == metav1.ConditionTrue && syncCond.LastTransitionTime.UTC().Add(ChartsSyncInterval).After(time.Now().UTC()) { + return false + } + return true +} + +func isRepoSyncInProgress(repo *helmv1alpha1.HelmClusterAddonRepository) bool { + syncCond := apimeta.FindStatusCondition(repo.Status.Conditions, helmv1alpha1.ConditionTypeSynced) + if syncCond != nil && syncCond.Status == metav1.ConditionUnknown && syncCond.Reason == helmv1alpha1.ReasonReconciling { + return true + } + + return false +} diff --git a/images/operator-helm-artifact/internal/services/status_manager.go b/images/operator-helm-artifact/internal/services/status_manager.go new file mode 100644 index 0000000..7ea82a4 --- /dev/null +++ b/images/operator-helm-artifact/internal/services/status_manager.go @@ -0,0 +1,200 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "context" + "fmt" + "reflect" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type ObjectWithConditions interface { + client.Object + GetConditions() *[]metav1.Condition + GetGeneration() int64 + GetObservedGeneration() int64 + SetObservedGeneration(int64) + GetConditionTypesForUpdate() []string + GetStatus() interface{} +} + +type StatusProvider interface { + GetStatus() ResourceStatus + GetConditionType() string +} + +type GenerationProvider interface { + GetObservedGeneration() int64 +} + +type StatusManager struct { + client.Client + + FieldOwner string +} + +func NewStatusManager(c client.Client, fieldOwner string) *StatusManager { + return &StatusManager{ + Client: c, + FieldOwner: fieldOwner, + } +} + +type StatusMutatorFunc func(ObjectWithConditions, []StatusProvider) (ObjectWithConditions, []StatusProvider) + +var NoopStatusMutator = StatusMutatorFunc(func(o ObjectWithConditions, s []StatusProvider) (ObjectWithConditions, []StatusProvider) { return o, s }) + +type StatusMapperFunc func(string, ResourceStatus) ResourceStatus + +var NoopStatusMapper = StatusMapperFunc(func(_ string, status ResourceStatus) ResourceStatus { + return status +}) + +func (s *StatusManager) Update(ctx context.Context, obj ObjectWithConditions, mutatorFunc StatusMutatorFunc, statusMapperFunc StatusMapperFunc, results ...StatusProvider) error { + logger := log.FromContext(ctx) + + oldObj := obj.DeepCopyObject().(ObjectWithConditions) + + if mutatorFunc != nil { + obj, results = mutatorFunc(obj, results) + } + + conditions := obj.GetConditions() + currentGen := obj.GetGeneration() + minObservedGen := currentGen + + for _, res := range results { + if res == nil { + continue + } + + status := res.GetStatus() + + status = statusMapperFunc(res.GetConditionType(), status) + + if status.Status == "" || status.Reason == "" { + continue + } + + if status.Err != nil { + logger.Error(status.Err, status.Message, + "condition", res.GetConditionType(), + "reason", status.Reason) + } + + meta.SetStatusCondition(conditions, metav1.Condition{ + Type: res.GetConditionType(), + Status: status.Status, + Reason: status.Reason, + Message: status.Message, + ObservedGeneration: status.ObservedGeneration, + }) + + if status.ObservedGeneration < minObservedGen { + minObservedGen = status.ObservedGeneration + } + } + + oldObservedGen := oldObj.GetObservedGeneration() + if minObservedGen > oldObservedGen { + obj.SetObservedGeneration(minObservedGen) + } else { + obj.SetObservedGeneration(oldObservedGen) + } + + if reflect.DeepEqual(obj.GetStatus(), oldObj.GetStatus()) { + return nil + } + + return s.Status().Patch(ctx, obj, client.MergeFrom(oldObj)) +} + +func (s *StatusManager) InitializeConditions(ctx context.Context, obj ObjectWithConditions, conditionTypes ...string) error { + oldObj := obj.DeepCopyObject().(ObjectWithConditions) + patchBase := client.MergeFrom(oldObj) + + conditions := obj.GetConditions() + changed := false + + for _, t := range conditionTypes { + if meta.FindStatusCondition(*conditions, t) == nil { + meta.SetStatusCondition(conditions, metav1.Condition{ + Type: t, + Status: metav1.ConditionUnknown, + Reason: "Initialized", + }) + changed = true + } + } + + if changed { + logger := log.FromContext(ctx) + logger.Info("Initializing conditions", "name", obj.GetName(), "types", conditionTypes) + + if err := s.Client.Status().Patch(ctx, obj, patchBase); err != nil { + return fmt.Errorf("initializing conditions: %w", err) + } + } + + return nil +} + +func ConsolidateConditions(obj ObjectWithConditions, results ...StatusProvider) []StatusProvider { + var result []StatusProvider + + conditionTypes := obj.GetConditionTypesForUpdate() + if len(results) == 0 { + return result + } + + var decisionRes StatusProvider + for _, res := range results { + if res == nil { + continue + } + + status := res.GetStatus() + if status.Status == "" || status.Reason == "" { + continue + } + + if status.NotReflectable { + result = append(result, res) + continue + } + + decisionRes = res + if !status.IsReady() { + break + } + } + + if decisionRes == nil { + return result + } + + for _, conditionType := range conditionTypes { + result = append(result, AsCondition(decisionRes, conditionType)) + } + + return result +} diff --git a/images/operator-helm-artifact/internal/services/types.go b/images/operator-helm-artifact/internal/services/types.go new file mode 100644 index 0000000..5830128 --- /dev/null +++ b/images/operator-helm-artifact/internal/services/types.go @@ -0,0 +1,99 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +type BaseService struct { + Client client.Client + Scheme *runtime.Scheme +} + +func (s *BaseService) ensureResourceDeleted(ctx context.Context, nn types.NamespacedName, obj client.Object) error { + err := s.Client.Get(ctx, nn, obj) + if err != nil { + return client.IgnoreNotFound(err) + } + + if err := s.Client.Delete(ctx, obj); err != nil { + return fmt.Errorf("failed to delete resource %s/%s: %w", nn.Namespace, nn.Name, err) + } + + return nil +} + +type ResourceStatus struct { + ConditionType string + Observed bool + Status metav1.ConditionStatus + ObservedGeneration int64 + Reason string + Message string + NotReflectable bool + Err error +} + +func (s ResourceStatus) IsReady() bool { + return s.Status == metav1.ConditionTrue && s.Observed +} + +type statusProxy struct { + StatusProvider + newType string +} + +func (p statusProxy) GetConditionType() string { return p.newType } + +func AsCondition(res StatusProvider, conditionType string) StatusProvider { + return statusProxy{StatusProvider: res, newType: conditionType} +} + +func Success(obj client.Object) ResourceStatus { + return ResourceStatus{ + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonSuccess, + ObservedGeneration: obj.GetGeneration(), + } +} + +func Failed(obj client.Object, reason, message string, err error) ResourceStatus { + return ResourceStatus{ + Status: metav1.ConditionFalse, + Reason: reason, + ObservedGeneration: obj.GetGeneration(), + Message: message, + Err: err, + } +} + +func Unknown(obj client.Object, reason string) ResourceStatus { + return ResourceStatus{ + Status: metav1.ConditionUnknown, + Reason: reason, + ObservedGeneration: obj.GetGeneration(), + } +} diff --git a/images/operator-helm-artifact/internal/utils/conditions.go b/images/operator-helm-artifact/internal/utils/conditions.go new file mode 100644 index 0000000..3d9df65 --- /dev/null +++ b/images/operator-helm-artifact/internal/utils/conditions.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func IsConditionObserved(conditions []metav1.Condition, conditionType string, generation int64) (*metav1.Condition, bool) { + cond := apimeta.FindStatusCondition(conditions, conditionType) + if cond == nil || cond.ObservedGeneration != generation { + return cond, false + } + + return cond, true +} diff --git a/images/operator-helm-artifact/pkg/utils/mapper.go b/images/operator-helm-artifact/internal/utils/mapper.go similarity index 100% rename from images/operator-helm-artifact/pkg/utils/mapper.go rename to images/operator-helm-artifact/internal/utils/mapper.go diff --git a/images/operator-helm-artifact/pkg/utils/name.go b/images/operator-helm-artifact/internal/utils/name.go similarity index 88% rename from images/operator-helm-artifact/pkg/utils/name.go rename to images/operator-helm-artifact/internal/utils/name.go index b9cd970..f88995c 100644 --- a/images/operator-helm-artifact/pkg/utils/name.go +++ b/images/operator-helm-artifact/internal/utils/name.go @@ -110,3 +110,20 @@ func GetInternalHelmReleaseName(addonName string) string { func GetInternalHelmChartName(addonName string) string { return GetInternalHelmReleaseName(addonName) } + +func GetInternalOCIRepositoryName(addonName string) string { + prefix := "addon" + hash := GetHash(fmt.Sprintf("%s-%s", prefix, addonName)) + + result := prefix + "-" + postfix := "" + + if len(addonName) > 40 { + result += addonName[:40] + postfix = "-" + hash + } else { + result += addonName + } + + return strings.TrimRight(result, "-") + postfix +} diff --git a/images/operator-helm-artifact/pkg/utils/repository.go b/images/operator-helm-artifact/internal/utils/repository.go similarity index 100% rename from images/operator-helm-artifact/pkg/utils/repository.go rename to images/operator-helm-artifact/internal/utils/repository.go diff --git a/images/operator-helm-artifact/internal/webhook/helmclusteraddon/webhook.go b/images/operator-helm-artifact/internal/webhook/helmclusteraddon/webhook.go new file mode 100644 index 0000000..4870245 --- /dev/null +++ b/images/operator-helm-artifact/internal/webhook/helmclusteraddon/webhook.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddon + +import ( + "context" + "fmt" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +func SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, &helmv1alpha1.HelmClusterAddon{}). + WithValidator(&UniqRepositoryAndChartNameWebhookValidator{Client: mgr.GetClient()}). + Complete() +} + +type UniqRepositoryAndChartNameWebhookValidator struct { + Client client.Client +} + +func (v *UniqRepositoryAndChartNameWebhookValidator) ValidateCreate(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (admission.Warnings, error) { + return nil, v.checkUniqueness(ctx, addon) +} + +func (v *UniqRepositoryAndChartNameWebhookValidator) ValidateUpdate(ctx context.Context, _, newObj *helmv1alpha1.HelmClusterAddon) (admission.Warnings, error) { + return nil, v.checkUniqueness(ctx, newObj) +} + +func (v *UniqRepositoryAndChartNameWebhookValidator) ValidateDelete(_ context.Context, _ *helmv1alpha1.HelmClusterAddon) (admission.Warnings, error) { + return nil, nil +} + +func (v *UniqRepositoryAndChartNameWebhookValidator) checkUniqueness(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { + list := &helmv1alpha1.HelmClusterAddonList{} + + if err := v.Client.List(ctx, list); err != nil { + return err + } + + for _, existing := range list.Items { + if existing.Name != addon.Name && + existing.Spec.Chart.HelmClusterAddonRepository == addon.Spec.Chart.HelmClusterAddonRepository && + existing.Spec.Chart.HelmClusterAddonChartName == addon.Spec.Chart.HelmClusterAddonChartName { + return fmt.Errorf( + "chart %s is already used by helmclusteraddon/%s", + addon.Spec.Chart.HelmClusterAddonChartName, existing.Name, + ) + } + } + + return nil +} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddon/constants.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddon/constants.go deleted file mode 100644 index bb00b7e..0000000 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddon/constants.go +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2026 Flant JSC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package helmclusteraddon - -import "time" - -const ( - // ControllerName is the name of this controller, used for leader election and logging. - ControllerName = "helmclusteraddon-controller" - - // TargetNamespace is the namespace where internal customer resources are created. - TargetNamespace = "d8-operator-helm" - - // FinalizerName is the finalizer added to HelmClusterRepository to ensure cleanup. - FinalizerName = "helm.deckhouse.io/cleanup" - - ConditionTypeReady = "Ready" - ConditionTypeManaged = "Managed" - ConditionTypeInstalled = "Installed" - ConditionTypeUpdateInstalled = "UpdateInstalled" - ConditionTypeConfigurationApplied = "ConfigurationApplied" - ConditionTypePartiallyDegraded = "PartiallyDegraded" - - ReasonInitializing = "Initializing" - ReasonUnmanagedModeActivated = "UnmanagedModeActivated" - ReasonManagedModeActivated = "ManagedModeActivated" - ReasonUpdateSucceeded = "UpdateSucceeded" - ReasonInstallSucceeded = "InstallSucceeded" - ReasonInstallationInProgress = "InstallationInProgress" - ReasonUpdateInProgress = "UpdateInProgress" - ReasonInstallFailed = "InstallFailed" - ReasonUpdateFailed = "UpdateFailed" - - // ReasonProcessing indicates that facade resource is processing. - ReasonProcessing = "Processing" - - // ReasonReconcileFailed indicates a terminal error occurred during the reconcile pipeline. - ReasonReconcileFailed = "ReconcileFailed" - - // LabelManagedBy marks resources as managed by this controller. - LabelManagedBy = "helm.deckhouse.io/managed-by" - - // LabelManagedByValue is the value for the managed-by label. - LabelManagedByValue = "operator-helm" - - // LabelSourceName stores the name of the source facade resource. - LabelSourceName = "helm.deckhouse.io/cluster-addon" - - // InternalHelmReleaseDeployed indicates that specific release fon internal chart release history was deployed - InternalHelmReleaseDeployed = "deployed" - - // ReconcileRetryInterval is the default requeue interval when waiting for non-terminal - // states such as HelmRelease reaching a final condition. - ReconcileRetryInterval = 5 * time.Second -) diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddon/controller.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddon/controller.go deleted file mode 100644 index 53946c4..0000000 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddon/controller.go +++ /dev/null @@ -1,49 +0,0 @@ -/* -Copyright 2026 Flant JSC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package helmclusteraddon - -import ( - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/pkg/utils" - helmv2 "github.com/werf/3p-helm-controller/api/v2" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/predicate" -) - -func SetupWithManager(mgr ctrl.Manager) error { - r := &Reconciler{ - Client: mgr.GetClient(), - } - - return ctrl.NewControllerManagedBy(mgr). - Named(ControllerName). - For(&helmv1alpha1.HelmClusterAddon{}). - Watches( - &sourcev1.HelmChart{}, - handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), - ). - Watches( - &helmv2.HelmRelease{}, - handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), - ). - Complete(r) -} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddon/reconciler.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddon/reconciler.go deleted file mode 100644 index 2a40a7c..0000000 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddon/reconciler.go +++ /dev/null @@ -1,726 +0,0 @@ -/* -Copyright 2026 Flant JSC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package helmclusteraddon - -import ( - "context" - "errors" - "fmt" - - "github.com/opencontainers/go-digest" - "github.com/werf/3p-fluxcd-pkg/chartutil" - helmv2 "github.com/werf/3p-helm-controller/api/v2" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" - helmchartutil "helm.sh/helm/v3/pkg/chartutil" - apierrors "k8s.io/apimachinery/pkg/api/errors" - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/pkg/controller/helmclusteraddonchart" - "github.com/deckhouse/operator-helm/pkg/utils" -) - -type Reconciler struct { - Client client.Client -} - -type ReconcileContext struct { - addon *helmv1alpha1.HelmClusterAddon - addonBase *helmv1alpha1.HelmClusterAddon - addonChart *helmv1alpha1.HelmClusterAddonChart - addonRepository *helmv1alpha1.HelmClusterAddonRepository - internalHelmRelease *helmv2.HelmRelease - internalHelmChart *sourcev1.HelmChart - maintenanceModeEnabled bool - err []error -} - -func (r *ReconcileContext) AddonDeepCopy() *helmv1alpha1.HelmClusterAddon { - if r.addonBase == nil { - r.addonBase = r.addon.DeepCopy() - } - - return r.addonBase -} - -func (r *ReconcileContext) GetRepositoryType() (utils.InternalRepositoryType, error) { - return utils.GetRepositoryType(r.addonRepository.Spec.URL) -} - -type pipelineStep struct { - Name string - RunIf func(rctx *ReconcileContext) bool - Action func(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) - StopOnFailure bool - SkipOnError bool -} - -type pipelineStepResult struct { - Requeue bool -} - -func (s *pipelineStepResult) Reconcile() reconcile.Result { - if s.Requeue { - return reconcile.Result{RequeueAfter: ReconcileRetryInterval} - } - - return reconcile.Result{} -} - -func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { - logger := log.FromContext(ctx).WithValues("helmclusteraddon", req.Name) - - rctx := &ReconcileContext{addon: &helmv1alpha1.HelmClusterAddon{}} - - if err := r.Client.Get(ctx, req.NamespacedName, rctx.addon); err != nil { - if apierrors.IsNotFound(err) { - logger.Info("HelmClusterAddon not found, skipping") - - return reconcile.Result{}, nil - } - - return reconcile.Result{}, fmt.Errorf("getting HelmClusterAddon: %w", err) - } - - rctx.AddonDeepCopy() - - pipeline := []pipelineStep{ - { - Name: "Ensure that conditions initialized", - RunIf: func(rctx *ReconcileContext) bool { - return apimeta.FindStatusCondition(rctx.addon.Status.Conditions, ConditionTypeReady) == nil - }, - Action: r.initializeConditions, - StopOnFailure: true, - }, - { - Name: "Delete resources if deletion timestamp present", - RunIf: func(rctx *ReconcileContext) bool { - return !rctx.addon.DeletionTimestamp.IsZero() - }, - Action: r.reconcileDelete, - StopOnFailure: true, - }, - { - Name: "Add finalizer if it is absent", - RunIf: func(rctx *ReconcileContext) bool { - return !controllerutil.ContainsFinalizer(rctx.addon, FinalizerName) - }, - Action: r.addFinalizer, - StopOnFailure: true, - }, - { - Name: "Stop if maintenance mode is enabled", - RunIf: func(rctx *ReconcileContext) bool { return true }, - Action: r.checkIfMaintenanceModeEnabled, - StopOnFailure: true, - }, - { - Name: "Set status to Processing if needed", - RunIf: func(rctx *ReconcileContext) bool { return !rctx.maintenanceModeEnabled }, - Action: r.setStatusToProcessing, - StopOnFailure: true, - }, - { - Name: "Get HelmClusterAddonRepository", - RunIf: func(rctx *ReconcileContext) bool { return !rctx.maintenanceModeEnabled }, - Action: r.getHelmClusterAddonRepository, - StopOnFailure: true, - }, - { - Name: "Reconcile InternalNelmOperatorHelmChart", - RunIf: func(rctx *ReconcileContext) bool { return !rctx.maintenanceModeEnabled }, - Action: r.reconcileInternalHelmChart, - SkipOnError: true, - }, - { - Name: "Reconcile InternalNelmOperatorHelmRelease", - RunIf: func(rctx *ReconcileContext) bool { - return !rctx.maintenanceModeEnabled && rctx.internalHelmChart != nil && rctx.internalHelmChart.Status.Artifact != nil - }, - Action: r.reconcileInternalHelmRelease, - SkipOnError: true, - }, - { - Name: "Update HelmClusterAddon status", - RunIf: func(rctx *ReconcileContext) bool { return true }, - Action: r.updateStatus, - }, - } - - for _, step := range pipeline { - if step.RunIf(rctx) { - logger.Info("Running step", "step", step.Name) - - if len(rctx.err) > 0 && step.SkipOnError { - logger.Info("Step skipped due to error on the previous step", "step", step.Name) - - continue - } - - if result, err := step.Action(ctx, rctx); err != nil { - if step.StopOnFailure { - return reconcile.Result{}, err - } - - rctx.err = append(rctx.err, err) - } else if result.Requeue { - return result.Reconcile(), nil - } - - logger.Info("Step completed", "step", step.Name) - - continue - } - - logger.Info("Skipping optional step", "step", step.Name) - } - - return reconcile.Result{}, nil -} - -func (r *Reconciler) initializeConditions(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { - - conditionTypes := []string{ - ConditionTypeReady, - ConditionTypeManaged, - } - - for _, t := range conditionTypes { - if apimeta.FindStatusCondition(rctx.addon.Status.Conditions, t) == nil { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: t, - Status: metav1.ConditionUnknown, - Reason: ReasonInitializing, - }) - } - } - - if err := r.Client.Status().Update(ctx, rctx.addon); err != nil { - return pipelineStepResult{}, fmt.Errorf("failed to update helmclusteraddon status conditions: %w", err) - } - - return pipelineStepResult{Requeue: true}, nil -} - -func (r *Reconciler) reconcileDelete(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { - logger := log.FromContext(ctx).WithValues("helmclusteraddon", rctx.addon.Name) - - if !controllerutil.ContainsFinalizer(rctx.addon, FinalizerName) { - return pipelineStepResult{}, nil - } - - if err := r.ensureResourceDeleted(ctx, utils.GetInternalHelmReleaseName(rctx.addon.Name), TargetNamespace, &helmv2.HelmRelease{}); err != nil { - return pipelineStepResult{}, fmt.Errorf("deleting internal helm release: %w", err) - } - - if err := r.ensureResourceDeleted(ctx, utils.GetInternalHelmChartName(rctx.addon.Name), TargetNamespace, &sourcev1.HelmChart{}); err != nil { - return pipelineStepResult{}, fmt.Errorf("deleting internal helm chart: %w", err) - } - - controllerutil.RemoveFinalizer(rctx.addon, FinalizerName) - - if err := r.Client.Update(ctx, rctx.addon); err != nil { - return pipelineStepResult{}, fmt.Errorf("removing finalizer: %w", err) - } - - logger.Info("Cleanup complete") - - return pipelineStepResult{}, nil -} - -func (r *Reconciler) addFinalizer(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { - controllerutil.AddFinalizer(rctx.addon, FinalizerName) - - if err := r.Client.Update(ctx, rctx.addon); err != nil { - return pipelineStepResult{}, fmt.Errorf("failed to add finalizer to helm cluster addon: %w", err) - } - - return pipelineStepResult{Requeue: true}, nil -} - -func (r *Reconciler) checkIfMaintenanceModeEnabled(_ context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { - managedCond := apimeta.FindStatusCondition(rctx.addon.Status.Conditions, ConditionTypeManaged) - - if managedCond == nil { - return pipelineStepResult{}, fmt.Errorf("managed condition is not initialized") - } else if managedCond.Status == metav1.ConditionFalse && rctx.addon.Spec.Maintenance == string(helmv1alpha1.NoResourceReconciliation) { - rctx.maintenanceModeEnabled = true - } - - return pipelineStepResult{}, nil -} - -func (r *Reconciler) setStatusToProcessing(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { - readyCond := apimeta.FindStatusCondition(rctx.addon.Status.Conditions, ConditionTypeReady) - - if rctx.addon.Generation == rctx.addon.Status.ObservedGeneration || - (readyCond != nil && readyCond.Status == metav1.ConditionFalse && readyCond.Reason == ReasonProcessing) { - return pipelineStepResult{}, nil - } - - chartChanged := isChartSpecChanged(rctx.addon) - - valuesChanged := false - specRaw := "" - if rctx.addon.Spec.Values != nil { - specRaw = string(rctx.addon.Spec.Values.Raw) - } - lastRaw := "" - if rctx.addon.Status.LastAppliedValues != nil { - lastRaw = string(rctx.addon.Status.LastAppliedValues.Raw) - } - if specRaw != lastRaw { - valuesChanged = true - } - - if rctx.addon.Status.LastAppliedChart == nil { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeInstalled, - Status: metav1.ConditionFalse, - ObservedGeneration: rctx.addon.Generation, - Reason: ReasonInstallationInProgress, - Message: "", - }) - } else { - if chartChanged { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeUpdateInstalled, - Status: metav1.ConditionFalse, - ObservedGeneration: rctx.addon.Generation, - Reason: ReasonUpdateInProgress, - Message: "", - }) - } - if valuesChanged { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeConfigurationApplied, - Status: metav1.ConditionFalse, - ObservedGeneration: rctx.addon.Generation, - Reason: ReasonUpdateInProgress, - Message: "", - }) - } - - // Neither chart nor values changed (e.g. annotation bump) — treat as values-only change. - if !chartChanged && !valuesChanged { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeConfigurationApplied, - Status: metav1.ConditionFalse, - ObservedGeneration: rctx.addon.Generation, - Reason: ReasonUpdateInProgress, - Message: "", - }) - } - } - - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeReady, - Status: metav1.ConditionFalse, - ObservedGeneration: rctx.addon.Generation, - Reason: ReasonProcessing, - Message: "", - }) - - if err := r.Client.Status().Patch(ctx, rctx.addon, client.MergeFrom(rctx.AddonDeepCopy())); err != nil { - return pipelineStepResult{}, fmt.Errorf("updating helm cluster addon status: %w", err) - } - - return pipelineStepResult{Requeue: true}, nil -} - -func (r *Reconciler) getHelmClusterAddonRepository(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { - rctx.addonRepository = &helmv1alpha1.HelmClusterAddonRepository{} - - if err := r.Client.Get(ctx, types.NamespacedName{Name: rctx.addon.Spec.Chart.HelmClusterAddonRepository}, rctx.addonRepository); err != nil { - if apierrors.IsNotFound(err) { - return pipelineStepResult{}, fmt.Errorf("helm cluster addon repository not found: %w", err) - } - - return pipelineStepResult{}, fmt.Errorf("getting helm cluster addon repository: %w", err) - } - - return pipelineStepResult{}, nil -} - -func (r *Reconciler) reconcileInternalHelmChart(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { - logger := log.FromContext(ctx) - - repoType, err := rctx.GetRepositoryType() - if err != nil { - return pipelineStepResult{}, fmt.Errorf("getting repository type: %w", err) - } - - rctx.internalHelmChart = &sourcev1.HelmChart{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetInternalHelmChartName(rctx.addon.Name), - Namespace: TargetNamespace, - }, - } - - op, err := controllerutil.CreateOrPatch(ctx, r.Client, rctx.internalHelmChart, func() error { - if rctx.internalHelmChart.Labels == nil { - rctx.internalHelmChart.Labels = map[string]string{} - } - - rctx.internalHelmChart.Labels[LabelManagedBy] = LabelManagedByValue - rctx.internalHelmChart.Labels[LabelSourceName] = rctx.addon.Name - rctx.internalHelmChart.Labels[helmclusteraddonchart.LabelSourceName] = utils.GetHelmClusterAddonChartName( - rctx.addon.Spec.Chart.HelmClusterAddonRepository, rctx.addon.Spec.Chart.HelmClusterAddonChartName) - - rctx.internalHelmChart.Spec.Chart = rctx.addon.Spec.Chart.HelmClusterAddonChartName - rctx.internalHelmChart.Spec.Version = rctx.addon.Spec.Chart.Version - - switch repoType { - case utils.InternalHelmRepository: - rctx.internalHelmChart.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ - Kind: sourcev1.HelmRepositoryKind, - Name: rctx.addon.Spec.Chart.HelmClusterAddonRepository, - } - case utils.InternalOCIRepository: - rctx.internalHelmChart.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ - Kind: sourcev1.OCIRepositoryKind, - Name: rctx.addon.Spec.Chart.HelmClusterAddonRepository, - } - default: - return fmt.Errorf("invalid repository type: %s", repoType) - } - - return nil - }) - if err != nil { - return pipelineStepResult{}, fmt.Errorf("cannot create or update internal nelm operator helm chart: %w", err) - } - - if rctx.addon.Spec.Maintenance == string(helmv1alpha1.NoResourceReconciliation) { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeManaged, - Status: metav1.ConditionFalse, - Reason: ReasonUnmanagedModeActivated, - Message: "", - }) - } else { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeManaged, - Status: metav1.ConditionTrue, - Reason: ReasonManagedModeActivated, - Message: "", - }) - } - - if op != controllerutil.OperationResultNone { - logger.Info("Successfully reconciled internal nelm operator helm chart", "operation", op, "repository", rctx.addon.Spec.Chart.HelmClusterAddonRepository, "chart", rctx.addon.Spec.Chart.HelmClusterAddonChartName) - } - - return pipelineStepResult{}, nil -} - -func (r *Reconciler) reconcileInternalHelmRelease(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { - logger := log.FromContext(ctx) - - rctx.internalHelmRelease = &helmv2.HelmRelease{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetInternalHelmReleaseName(rctx.addon.Name), - Namespace: TargetNamespace, - }, - } - - op, err := controllerutil.CreateOrPatch(ctx, r.Client, rctx.internalHelmRelease, func() error { - if rctx.internalHelmRelease.Labels == nil { - rctx.internalHelmRelease.Labels = map[string]string{} - } - - rctx.internalHelmRelease.Labels[LabelManagedBy] = LabelManagedByValue - rctx.internalHelmRelease.Labels[LabelSourceName] = rctx.addon.Name - - rctx.internalHelmRelease.Spec.ReleaseName = rctx.addon.Name - rctx.internalHelmRelease.Spec.TargetNamespace = rctx.addon.Spec.Namespace - rctx.internalHelmRelease.Spec.Values = rctx.addon.Spec.Values - - rctx.internalHelmRelease.Spec.Suspend = false - - if rctx.addon.Spec.Maintenance == string(helmv1alpha1.NoResourceReconciliation) { - rctx.internalHelmRelease.Spec.Suspend = true - } - - rctx.internalHelmRelease.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ - Kind: sourcev1.HelmChartKind, - Name: utils.GetInternalHelmChartName(rctx.addon.Name), - Namespace: TargetNamespace, - } - - return nil - }) - if err != nil { - return pipelineStepResult{}, fmt.Errorf("reconcile internal nelm operator helm release: %w", err) - } - - if op != controllerutil.OperationResultNone { - logger.Info("Successfully reconciled internal nelm operator helm release", "operation", op, "chart", rctx.addon.Spec.Chart.HelmClusterAddonChartName) - } - - return pipelineStepResult{}, nil -} - -func (r *Reconciler) updateStatus(ctx context.Context, rctx *ReconcileContext) (pipelineStepResult, error) { - logger := log.FromContext(ctx) - - if rctx.maintenanceModeEnabled { - return pipelineStepResult{}, nil - } - - addonReadyCond := apimeta.FindStatusCondition(rctx.addon.Status.Conditions, ConditionTypeReady) - if addonReadyCond == nil { - return pipelineStepResult{Requeue: true}, nil - } - - joinedErr := errors.Join(rctx.err...) - - if joinedErr != nil { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeReady, - Status: metav1.ConditionFalse, - Reason: ReasonReconcileFailed, - Message: joinedErr.Error(), - }) - rctx.addon.Status.ObservedGeneration = rctx.addon.Generation - - if err := r.Client.Status().Patch(ctx, rctx.addon, client.MergeFrom(rctx.AddonDeepCopy())); err != nil { - return pipelineStepResult{}, fmt.Errorf("updating HelmClusterAddon status on error: %w", err) - } - - return pipelineStepResult{}, nil - } - - if rctx.internalHelmRelease == nil { - return pipelineStepResult{Requeue: true}, nil - } - - if rctx.internalHelmRelease.Status.ObservedGeneration != rctx.internalHelmRelease.Generation { - return pipelineStepResult{Requeue: true}, nil - } - - helmReleaseReadyCond := apimeta.FindStatusCondition(rctx.internalHelmRelease.Status.Conditions, ConditionTypeReady) - if helmReleaseReadyCond == nil { - return pipelineStepResult{Requeue: true}, nil - } - - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeReady, - Status: helmReleaseReadyCond.Status, - Reason: helmReleaseReadyCond.Reason, - Message: helmReleaseReadyCond.Message, - }) - - terminal := false - - helmChartReadyCond := apimeta.FindStatusCondition(rctx.internalHelmChart.Status.Conditions, ConditionTypeReady) - if helmChartReadyCond != nil { - if helmChartReadyCond.Status == metav1.ConditionTrue { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypePartiallyDegraded, - Status: metav1.ConditionFalse, - Reason: helmReleaseReadyCond.Reason, - Message: "", - }) - } else { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypePartiallyDegraded, - Status: metav1.ConditionTrue, - Reason: helmChartReadyCond.Reason, - Message: "", - }) - } - } - - switch helmReleaseReadyCond.Reason { - case helmv2.InstallSucceededReason: - terminal = true - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeInstalled, - Status: metav1.ConditionTrue, - Reason: ReasonInstallSucceeded, - Message: "", - }) - - // Remove UpdateInstalled if present from a prior chart-change cycle. - apimeta.RemoveStatusCondition(&rctx.addon.Status.Conditions, ConditionTypeUpdateInstalled) - - if apimeta.IsStatusConditionPresentAndEqual(rctx.internalHelmChart.Status.Conditions, ConditionTypeReady, metav1.ConditionTrue) && - rctx.internalHelmChart.Generation == rctx.internalHelmChart.Generation { - rctx.addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ - HelmClusterAddonChartName: rctx.addon.Spec.Chart.HelmClusterAddonChartName, - HelmClusterAddonRepository: rctx.addon.Spec.Chart.HelmClusterAddonRepository, - Version: rctx.addon.Spec.Chart.Version, - } - } - - rctx.addon.Status.LastAppliedValues = rctx.addon.Spec.Values - case helmv2.UpgradeSucceededReason: - terminal = true - lastAppliedChartUpdateRequired := isLastAppliedChartUpdateRequired(rctx.addon, rctx.internalHelmChart, rctx.internalHelmRelease) - - if lastAppliedChartUpdateRequired { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeUpdateInstalled, - Status: metav1.ConditionTrue, - Reason: ReasonUpdateSucceeded, - Message: "", - }) - rctx.addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ - HelmClusterAddonChartName: rctx.addon.Spec.Chart.HelmClusterAddonChartName, - HelmClusterAddonRepository: rctx.addon.Spec.Chart.HelmClusterAddonRepository, - Version: rctx.addon.Spec.Chart.Version, - } - } - - if rctx.addon.Spec.Values != nil { - if addonValues, err := helmchartutil.ReadValues(rctx.addon.Spec.Values.Raw); err != nil { - logger.Error(err, "failed to decode helm cluster addon values; marking ConfigurationApplied without digest verification") - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeConfigurationApplied, - Status: metav1.ConditionTrue, - Reason: ReasonUpdateSucceeded, - Message: "", - }) - rctx.addon.Status.LastAppliedValues = rctx.addon.Spec.Values - } else { - latestRelease := rctx.internalHelmRelease.Status.History.Latest() - if latestRelease != nil && latestRelease.Status == InternalHelmReleaseDeployed && - latestRelease.ConfigDigest == chartutil.DigestValues(digest.Canonical, addonValues).String() { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeConfigurationApplied, - Status: metav1.ConditionTrue, - Reason: ReasonUpdateSucceeded, - Message: "Applied configuration with values digest " + latestRelease.ConfigDigest, - }) - rctx.addon.Status.LastAppliedValues = rctx.addon.Spec.Values - } - } - } else { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeConfigurationApplied, - Status: metav1.ConditionTrue, - Reason: ReasonUpdateSucceeded, - Message: "", - }) - rctx.addon.Status.LastAppliedValues = nil - } - - case helmv2.InstallFailedReason: - terminal = true - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeInstalled, - Status: metav1.ConditionFalse, - Reason: ReasonInstallFailed, - Message: helmReleaseReadyCond.Message, - }) - - case helmv2.UpgradeFailedReason: - terminal = true - if isChartSpecChanged(rctx.addon) { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeUpdateInstalled, - Status: metav1.ConditionFalse, - Reason: ReasonUpdateFailed, - Message: helmReleaseReadyCond.Message, - }) - } else { - apimeta.SetStatusCondition(&rctx.addon.Status.Conditions, metav1.Condition{ - Type: ConditionTypeConfigurationApplied, - Status: metav1.ConditionFalse, - Reason: ReasonUpdateFailed, - Message: helmReleaseReadyCond.Message, - }) - } - - case helmv2.ArtifactFailedReason, helmv2.RollbackFailedReason, helmv2.UninstallFailedReason: - terminal = true - } - - if terminal { - rctx.addon.Status.ObservedGeneration = rctx.addon.Generation - } - - if err := r.Client.Status().Patch(ctx, rctx.addon, client.MergeFrom(rctx.AddonDeepCopy())); err != nil { - return pipelineStepResult{}, fmt.Errorf("updating helm cluster addon status: %w", err) - } - - if !terminal { - return pipelineStepResult{Requeue: true}, nil - } - - return pipelineStepResult{}, nil -} - -func (r *Reconciler) ensureResourceDeleted(ctx context.Context, name, namespace string, obj client.Object) error { - if err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, obj); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - - return fmt.Errorf("checking existence of obsolete resource: %w", err) - } - - if err := r.Client.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) { - return fmt.Errorf("deleting obsolete resource: %w", err) - } - - return nil -} - -func isLastAppliedChartUpdateRequired(addon *helmv1alpha1.HelmClusterAddon, internalHelmChart *sourcev1.HelmChart, internalHelmRelease *helmv2.HelmRelease) bool { - if internalHelmChart.Status.ObservedGeneration != internalHelmChart.Generation { - return false - } - - if internalHelmRelease.Status.ObservedGeneration != internalHelmRelease.Generation { - return false - } - - if apimeta.IsStatusConditionPresentAndEqual(internalHelmChart.Status.Conditions, ConditionTypeReady, metav1.ConditionTrue) && - apimeta.IsStatusConditionPresentAndEqual(internalHelmRelease.Status.Conditions, ConditionTypeReady, metav1.ConditionTrue) && - internalHelmRelease.Status.History.Len() > 1 { - latest := internalHelmRelease.Status.History.Latest() - previous := internalHelmRelease.Status.History.Previous(true) - - if previous != nil && previous.Status == "superseded" && - latest != nil && - (latest.VersionedChartName() != previous.VersionedChartName() || - addon.Spec.Chart.HelmClusterAddonRepository != addon.Status.LastAppliedChart.HelmClusterAddonRepository) { - return true - } - } - - return false -} - -func isChartSpecChanged(addon *helmv1alpha1.HelmClusterAddon) bool { - if addon.Status.LastAppliedChart == nil { - return true - } - - return addon.Spec.Chart.HelmClusterAddonChartName != addon.Status.LastAppliedChart.HelmClusterAddonChartName || - addon.Spec.Chart.HelmClusterAddonRepository != addon.Status.LastAppliedChart.HelmClusterAddonRepository || - addon.Spec.Chart.Version != addon.Status.LastAppliedChart.Version -} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/constants.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/constants.go deleted file mode 100644 index 7459ebe..0000000 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/constants.go +++ /dev/null @@ -1,66 +0,0 @@ -/* -Copyright 2026 Flant JSC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package helmclusteraddonrepository - -import "time" - -const ( - // ControllerName is the name of this controller, used for leader election and logging. - ControllerName = "helmclusteraddonrepository-controller" - - // TargetNamespace is the namespace where internal customer resources are created. - TargetNamespace = "d8-operator-helm" - - // FinalizerName is the finalizer added to HelmClusterRepository to ensure cleanup. - FinalizerName = "helm.deckhouse.io/cleanup" - - // ConditionTypeReady is the condition type for readiness. - ConditionTypeReady = "Ready" - - // ConditionTypeSynced is the condition type to track chart sync status - ConditionTypeSynced = "Synced" - - // ReasonMirrorFailed indicates the internal HelmRepository create/update failed. - ReasonMirrorFailed = "MirrorFailed" - - // ReasonSyncSucceeded indicates that chart sync was successfully completed. - ReasonSyncSucceeded = "SyncSucceeded" - - // ReasonSyncInProgress indicates that chart sync is in progress. - ReasonSyncInProgress = "ReasonSyncInProgress" - - // ReasonSyncFailed indicates that charts sync was failed. - ReasonSyncFailed = "SyncFailed" - - // ReasonCleanupFailed indicates deletion of the internal HelmRepository failed. - ReasonCleanupFailed = "CleanupFailed" - - // LabelManagedBy marks resources as managed by this controller. - LabelManagedBy = "helm.deckhouse.io/managed-by" - - // LabelManagedByValue is the value for the managed-by label. - LabelManagedByValue = "operator-helm" - - // LabelSourceName stores the name of the source facade resource. - LabelSourceName = "helm.deckhouse.io/cluster-addon-repository" - - // DefaultInterval is the default reconciliation interval for the internal repository. - DefaultInterval = 5 * time.Minute - - // DefaultSyncInterval is the default repository charts sync interval. - DefaultSyncInterval = 5 * time.Minute -) diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/controller.go deleted file mode 100644 index 6509b09..0000000 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -/* -Copyright 2026 Flant JSC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package helmclusteraddonrepository - -import ( - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/pkg/utils" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" - corev1 "k8s.io/api/core/v1" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/predicate" -) - -func SetupWithManager(mgr ctrl.Manager) error { - r := &Reconciler{ - Client: mgr.GetClient(), - } - - return ctrl.NewControllerManagedBy(mgr). - Named(ControllerName). - For(&helmv1alpha1.HelmClusterAddonRepository{}). - Watches( - &sourcev1.HelmRepository{}, - handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), - ). - Watches( - &sourcev1.OCIRepository{}, - handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), - ). - Watches( - &corev1.Secret{}, - handler.EnqueueRequestsFromMapFunc(utils.MapInternalToFacade(TargetNamespace, LabelManagedBy, LabelManagedByValue, LabelSourceName)), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), - ). - Complete(r) -} diff --git a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/reconciler.go b/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/reconciler.go deleted file mode 100644 index 9f39862..0000000 --- a/images/operator-helm-artifact/pkg/controller/helmclusteraddonrepository/reconciler.go +++ /dev/null @@ -1,519 +0,0 @@ -/* -Copyright 2026 Flant JSC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package helmclusteraddonrepository - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/deckhouse/operator-helm/pkg/utils" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/werf/3p-fluxcd-pkg/apis/meta" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" -) - -// Reconciler reconciles HelmClusterRepository objects by mirroring them -// to namespaced HelmRepository resources in the target namespace. -type Reconciler struct { - Client client.Client -} - -// Reconcile implements reconcile.Reconciler. -func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { - logger := log.FromContext(ctx).WithValues("helmclusterrepository", req.Name) - - var repo helmv1alpha1.HelmClusterAddonRepository - - if err := r.Client.Get(ctx, req.NamespacedName, &repo); err != nil { - if apierrors.IsNotFound(err) { - logger.Info("HelmClusterAddonRepository not found, skipping") - - return reconcile.Result{}, nil - } - - return reconcile.Result{}, fmt.Errorf("getting HelmClusterAddonRepository: %w", err) - } - - repoType, err := utils.GetRepositoryType(repo.Spec.URL) - if err != nil { - return reconcile.Result{}, err - } - - if !repo.DeletionTimestamp.IsZero() { - return r.reconcileDelete(ctx, &repo, repoType) - } - - if !controllerutil.ContainsFinalizer(&repo, FinalizerName) { - controllerutil.AddFinalizer(&repo, FinalizerName) - - if err := r.Client.Update(ctx, &repo); err != nil { - return reconcile.Result{}, fmt.Errorf("adding finalizer: %w", err) - } - - return r.requeueAtSyncInterval(&repo) - } - - switch repoType { - case utils.InternalHelmRepository: - return r.reconcileInternalHelmRepository(ctx, &repo) - case utils.InternalOCIRepository: - return r.reconcileInternalOCIRepository(ctx, &repo) - default: - return r.requeueAtSyncInterval(&repo) - } -} - -func (r *Reconciler) reconcileInternalHelmRepository(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { - logger := log.FromContext(ctx) - - if err := r.reconcileInternalRepositoryAuthSecret(ctx, repo, utils.InternalHelmRepository); err != nil { - return reconcile.Result{}, err - } - - if err := r.reconcileInternalRepositoryTLSSecret(ctx, repo, utils.InternalHelmRepository); err != nil { - return reconcile.Result{}, err - } - - existing := &sourcev1.HelmRepository{ - ObjectMeta: metav1.ObjectMeta{ - Name: repo.Name, - Namespace: TargetNamespace, - }, - } - - op, err := controllerutil.CreateOrPatch(ctx, r.Client, existing, func() error { - existing.Spec.URL = repo.Spec.URL - existing.Spec.Interval = metav1.Duration{Duration: DefaultInterval} - existing.Spec.Insecure = !repo.Spec.TLSVerify - existing.Spec.CertSecretRef = nil - existing.Spec.SecretRef = nil - - if repo.Spec.Auth != nil { - existing.Spec.SecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryAuthSecretName(utils.InternalHelmRepository, repo.Name), - } - existing.Spec.PassCredentials = true - } - - if repo.Spec.CACertificate != "" { - existing.Spec.CertSecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryTLSSecretName(utils.InternalHelmRepository, repo.Name), - } - } - - existing.Labels = map[string]string{ - LabelManagedBy: LabelManagedByValue, - LabelSourceName: repo.Name, - } - - return nil - }) - if err != nil { - return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("reconciling helm repository: %w", err), ReasonMirrorFailed) - } - - if op != controllerutil.OperationResultNone { - logger.Info("Successfully reconciled helm repository", "operation", op) - } - - if changed, err := r.updateSuccessStatus(ctx, repo, existing.Status.Conditions); err != nil { - return reconcile.Result{}, fmt.Errorf("updating status after repository reconcile: %w", err) - } else if changed { - return r.requeueAtSyncInterval(repo) - } - - if apimeta.IsStatusConditionPresentAndEqual(repo.Status.Conditions, ConditionTypeReady, metav1.ConditionTrue) { - return r.reconcileHelmRepositoryCharts(ctx, repo) - } - - return r.requeueAtSyncInterval(repo) -} - -func (r *Reconciler) reconcileHelmRepositoryCharts(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { - logger := log.FromContext(ctx) - - syncCond := apimeta.FindStatusCondition(repo.Status.Conditions, ConditionTypeSynced) - if syncCond != nil && syncCond.Status == metav1.ConditionTrue && syncCond.LastTransitionTime.UTC().Add(DefaultSyncInterval).After(time.Now().UTC()) { - return r.requeueAtSyncInterval(repo) - } else if syncCond == nil || syncCond.Reason != ReasonSyncInProgress { - if err := r.updateSyncCondition(ctx, repo, metav1.ConditionFalse, ReasonSyncInProgress, ""); err != nil { - return reconcile.Result{}, fmt.Errorf("updating sync condition: %w", err) - } - - return r.requeueAtSyncInterval(repo) - } - - charts, err := HelmRepositoryDefaultClient.FetchCharts(ctx, repo.Spec.URL) - if err != nil { - return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeSynced, fmt.Errorf("cannot fetch chart info from repository: %w", err), ReasonSyncFailed) - } - - for chart, versions := range charts { - existing := &helmv1alpha1.HelmClusterAddonChart{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetHelmClusterAddonChartName(repo.Name, chart), - }, - } - - op, err := controllerutil.CreateOrPatch(ctx, r.Client, existing, func() error { - existing.OwnerReferences = []metav1.OwnerReference{ - { - APIVersion: repo.APIVersion, - Kind: repo.Kind, - Name: repo.Name, - UID: repo.UID, - Controller: ptr.To(true), - BlockOwnerDeletion: ptr.To(true), - }, - } - - existing.Labels = map[string]string{ - LabelManagedBy: LabelManagedByValue, - LabelSourceName: repo.Name, - } - - return nil - }) - if err != nil { - if statusUpdateErr := r.updateSyncCondition(ctx, repo, metav1.ConditionFalse, ReasonSyncFailed, ""); statusUpdateErr != nil { - return reconcile.Result{}, fmt.Errorf("failed to update sync condition: %w", err) - } - - return reconcile.Result{}, fmt.Errorf("cannot create or update HelmClusterAddonChart: %w", err) - } - - existingVersionsMap := make(map[string]helmv1alpha1.HelmClusterAddonChartVersion) - for _, version := range existing.Status.Versions { - existingVersionsMap[version.Version] = version - } - - for i, version := range versions { - if existingVersion, found := existingVersionsMap[version.Version]; found && version.Digest == existingVersion.Digest { - versions[i].Pulled = existingVersion.Pulled - } - } - - if op != controllerutil.OperationResultNone { - logger.Info("Successfully reconciled HelmClusterAddonChart", "operation", op, "repository", repo.Name, "chart", chart) - } - - base := existing.DeepCopy() - existing.Status.Versions = versions - - if err := r.Client.Status().Patch(ctx, existing, client.MergeFrom(base)); err != nil { - if statusUpdateErr := r.updateSyncCondition(ctx, repo, metav1.ConditionFalse, ReasonSyncFailed, ""); statusUpdateErr != nil { - return reconcile.Result{}, fmt.Errorf("failed to update sync condition: %w", err) - } - - return reconcile.Result{}, fmt.Errorf("failed to update chart status: %w", err) - } - - logger.Info("Successfully sync HelmClusterAddonChart versions", "operation", op, "repository", repo.Name, "chart", chart) - } - - logger.Info(fmt.Sprintf("Scheduling next helm charts sync in %s", DefaultSyncInterval)) - - if err := r.updateSyncCondition(ctx, repo, metav1.ConditionTrue, ReasonSyncSucceeded, ""); err != nil { - return reconcile.Result{}, fmt.Errorf("updating sync condition: %w", err) - } - - return reconcile.Result{RequeueAfter: DefaultSyncInterval}, nil -} - -func (r *Reconciler) updateSyncCondition(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, status metav1.ConditionStatus, reason, message string) error { - base := repo.DeepCopy() - - apimeta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{ - Type: ConditionTypeSynced, - Status: status, - ObservedGeneration: repo.Generation, - Reason: reason, - Message: message, - }) - - if err := r.Client.Status().Patch(ctx, repo, client.MergeFrom(base)); err != nil { - return err - } - - return nil -} - -func (r *Reconciler) reconcileInternalOCIRepository(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { - logger := log.FromContext(ctx) - - if err := r.reconcileInternalRepositoryAuthSecret(ctx, repo, utils.InternalOCIRepository); err != nil { - return reconcile.Result{}, err - } - - if err := r.reconcileInternalRepositoryTLSSecret(ctx, repo, utils.InternalOCIRepository); err != nil { - return reconcile.Result{}, err - } - - existing := &sourcev1.OCIRepository{ - ObjectMeta: metav1.ObjectMeta{ - Name: repo.Name, - Namespace: TargetNamespace, - }, - } - - op, err := controllerutil.CreateOrPatch(ctx, r.Client, existing, func() error { - existing.Spec.URL = repo.Spec.URL - existing.Spec.Interval = metav1.Duration{Duration: DefaultInterval} - existing.Spec.Insecure = !repo.Spec.TLSVerify - existing.Spec.CertSecretRef = nil - existing.Spec.SecretRef = nil - - if repo.Spec.Auth != nil { - existing.Spec.SecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryAuthSecretName(utils.InternalOCIRepository, repo.Name), - } - } - - if repo.Spec.CACertificate != "" { - existing.Spec.CertSecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryTLSSecretName(utils.InternalOCIRepository, repo.Name), - } - } - - existing.Labels = map[string]string{ - LabelManagedBy: LabelManagedByValue, - LabelSourceName: repo.Name, - } - - return nil - }) - if err != nil { - return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("reconciling oci repository: %w", err), ReasonMirrorFailed) - } - - if op != controllerutil.OperationResultNone { - logger.Info("Successfully reconciled oci repository", "operation", op) - } else { - // TODO: implement chats sync for OCI repository - } - - if _, err := r.updateSuccessStatus(ctx, repo, existing.Status.Conditions); err != nil { - return reconcile.Result{}, err - } - - return r.requeueAtSyncInterval(repo) -} - -func (r *Reconciler) reconcileInternalRepositoryAuthSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) error { - secretName := utils.GetInternalRepositoryAuthSecretName(repoType, repo.Name) - - if repo.Spec.Auth == nil { - if err := r.ensureResourceDeleted(ctx, secretName, TargetNamespace, &corev1.Secret{}); err != nil { - return fmt.Errorf("cannot delete obsolete auth secret: %w", err) - } - - return nil - } - - authSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: TargetNamespace, - }, - } - - if _, err := controllerutil.CreateOrPatch(ctx, r.Client, authSecret, func() error { - authSecret.Labels = map[string]string{ - LabelManagedBy: LabelManagedByValue, - LabelSourceName: repo.Name, - } - - authSecret.StringData = map[string]string{ - "username": repo.Spec.Auth.Username, - "password": repo.Spec.Auth.Password, - } - - return nil - }); err != nil { - return fmt.Errorf("cannot reconcile auth secret: %w", err) - } - - return nil -} - -func (r *Reconciler) reconcileInternalRepositoryTLSSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) error { - secretName := utils.GetInternalRepositoryTLSSecretName(repoType, repo.Name) - - if repo.Spec.CACertificate == "" { - if err := r.ensureResourceDeleted(ctx, secretName, TargetNamespace, &corev1.Secret{}); err != nil { - return fmt.Errorf("cannot delete obsolete tls secret: %w", err) - } - - return nil - } - - // TODO: consider adding CA certificate format validation - - tlsSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: TargetNamespace, - }, - } - - if _, err := controllerutil.CreateOrPatch(ctx, r.Client, tlsSecret, func() error { - tlsSecret.Labels = map[string]string{ - LabelManagedBy: LabelManagedByValue, - LabelSourceName: repo.Name, - } - - tlsSecret.StringData = map[string]string{ - "ca.crt": repo.Spec.CACertificate, - } - - return nil - }); err != nil { - return fmt.Errorf("cannot reconcile tls secret: %w", err) - } - - return nil -} - -func (r *Reconciler) ensureResourceDeleted(ctx context.Context, name, namespace string, obj client.Object) error { - if err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, obj); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - - return fmt.Errorf("checking existence of obsolete resource: %w", err) - } - - if err := r.Client.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) { - return fmt.Errorf("deleting obsolete resource: %w", err) - } - - return nil -} - -func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) (reconcile.Result, error) { - logger := log.FromContext(ctx).WithValues("helmclusteraddonrepository", repo.Name) - - if !controllerutil.ContainsFinalizer(repo, FinalizerName) { - return reconcile.Result{}, nil - } - - if err := r.ensureResourceDeleted( - ctx, - utils.GetInternalRepositoryAuthSecretName(repoType, repo.Name), - TargetNamespace, - &corev1.Secret{}, - ); err != nil { - return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("deleting internal auth secret: %w", err), ReasonCleanupFailed) - } - - if err := r.ensureResourceDeleted( - ctx, - utils.GetInternalRepositoryTLSSecretName(repoType, repo.Name), - TargetNamespace, - &corev1.Secret{}, - ); err != nil { - return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("deleting internal tls secret: %w", err), ReasonCleanupFailed) - } - - var internalRepository client.Object - - switch repoType { - case utils.InternalHelmRepository: - internalRepository = &sourcev1.HelmRepository{} - case utils.InternalOCIRepository: - internalRepository = &sourcev1.OCIRepository{} - default: - return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("cannot remove unsupported repisotory type: %s", repoType), ReasonCleanupFailed) - } - - if err := r.ensureResourceDeleted(ctx, repo.Name, TargetNamespace, internalRepository); err != nil { - return reconcile.Result{}, r.patchStatusError(ctx, repo, ConditionTypeReady, fmt.Errorf("deleting internal repository: %w", err), ReasonCleanupFailed) - } - - controllerutil.RemoveFinalizer(repo, FinalizerName) - - if err := r.Client.Update(ctx, repo); err != nil { - return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) - } - - logger.Info("Cleanup complete") - - return reconcile.Result{}, nil -} - -func (r *Reconciler) patchStatusError(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, conditionType string, reconcileErr error, reason string) error { - base := repo.DeepCopy() - - apimeta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{ - Type: conditionType, - Status: metav1.ConditionFalse, - Reason: reason, - Message: reconcileErr.Error(), - }) - - if patchErr := r.Client.Status().Patch(ctx, repo, client.MergeFrom(base)); patchErr != nil { - return errors.Join(reconcileErr, fmt.Errorf("failed to patch status: %w", patchErr)) - } - - return reconcileErr -} - -func (r *Reconciler) requeueAtSyncInterval(repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { - repoSyncCond := apimeta.FindStatusCondition(repo.Status.Conditions, ConditionTypeSynced) - if repoSyncCond != nil { - remaining := time.Until(repoSyncCond.LastTransitionTime.Add(DefaultSyncInterval)) - if remaining > 0 { - return reconcile.Result{RequeueAfter: remaining}, nil - } - } - - return reconcile.Result{RequeueAfter: DefaultSyncInterval}, nil -} - -func (r *Reconciler) updateSuccessStatus(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, internalConditions []metav1.Condition) (bool, error) { - var changed bool - - base := repo.DeepCopy() - - internalReadyCond := apimeta.FindStatusCondition(internalConditions, meta.ReadyCondition) - if internalReadyCond != nil { - changed = apimeta.SetStatusCondition(&repo.Status.Conditions, *internalReadyCond) - } - - if changed { - repo.Status.ObservedGeneration = repo.Generation - - if err := r.Client.Status().Patch(ctx, repo, client.MergeFrom(base)); err != nil { - return false, fmt.Errorf("patching status: %w", err) - } - } - - return changed, nil -} diff --git a/images/operator-helm-artifact/werf.inc.yaml b/images/operator-helm-artifact/werf.inc.yaml index 165af3d..850fa01 100644 --- a/images/operator-helm-artifact/werf.inc.yaml +++ b/images/operator-helm-artifact/werf.inc.yaml @@ -22,7 +22,7 @@ git: --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} final: false -fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-bookworm-1.25" "builder/golang-alt-svace-1.25" }} +fromImage: builder/golang-bookworm-1.25 import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact add: /src diff --git a/images/operator-helm-controller/werf.inc.yaml b/images/operator-helm-controller/werf.inc.yaml index 6656877..76d8d4f 100644 --- a/images/operator-helm-controller/werf.inc.yaml +++ b/images/operator-helm-controller/werf.inc.yaml @@ -1,6 +1,6 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} -fromImage: {{ .ModuleNamePrefix }}distroless +fromImage: base/distroless git: {{- include "image mount points" . }} import: @@ -10,7 +10,5 @@ import: after: install imageSpec: config: - user: 64535 workingDir: "/app" entrypoint: ["/app/operator-helm-controller"] - diff --git a/module.yaml b/module.yaml index 214f98c..aa9f45c 100644 --- a/module.yaml +++ b/module.yaml @@ -3,20 +3,17 @@ stage: Experimental requirements: deckhouse: ">= 1.69" subsystems: - # TODO: confirm with TL - delivery namespace: d8-operator-helm descriptions: en: An operator to deploy helm applications declaratively. ru: Оператор для декларативного развертывания helm-приложений. -# TODO: confirm with TL tags: ["delivery"] disable: confirmation: true message: "Disabling of this module can cause disruptions in deployed applications operation." accessibility: editions: - # TODO: confirm with TL _default: available: true enabledInBundles: diff --git a/templates/nelm-source-controller/rbac-for-us.yaml b/templates/nelm-source-controller/rbac-for-us.yaml index a05bf0e..c8e9cd9 100644 --- a/templates/nelm-source-controller/rbac-for-us.yaml +++ b/templates/nelm-source-controller/rbac-for-us.yaml @@ -28,21 +28,6 @@ metadata: {{- include "helm_lib_module_labels" (list .) | nindent 2 }} name: d8:{{ .Chart.Name }}:nelm-source-controller rules: -- apiGroups: - - "" - resources: - - pods - - services - - secrets - - configmaps - verbs: - - get - - create - - update - - delete - - list - - watch - - patch - apiGroups: - "" resources: diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index 7bc6f85..d693fad 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -28,21 +28,6 @@ metadata: {{- include "helm_lib_module_labels" (list .) | nindent 2 }} name: d8:{{ .Chart.Name }}:operator-helm-controller rules: -- apiGroups: - - "" - resources: - - pods - - services - - secrets - - configmaps - verbs: - - get - - create - - update - - delete - - list - - watch - - patch - apiGroups: - "" resources: @@ -166,6 +151,7 @@ rules: - "" resources: - configmaps + - secrets verbs: - get - list diff --git a/tools/validation/doc_changes.go b/tools/validation/doc_changes.go index 08c9c2c..97b9036 100644 --- a/tools/validation/doc_changes.go +++ b/tools/validation/doc_changes.go @@ -73,8 +73,8 @@ func RunDocChangesValidation(info *DiffInfo) (exitCode int) { return exitCode } -var possibleDocRootsRe = regexp.MustCompile(`docs/|docs/documentation`) -var docsDirAllowedFileRe = regexp.MustCompile(`docs/(CONFIGURATION|CR|FAQ|README|ADMIN_GUIDE|USER_GUIDE|CHARACTERISTICS_DESCRIPTION|INSTALL|RELEASE_NOTES)(\.ru)?.md`) +var possibleDocRootsRe = regexp.MustCompile(`docs/`) +var docsDirAllowedFileRe = regexp.MustCompile(`docs/(CONFIGURATION|CR|README|RELEASE_NOTES|USAGE|EXAMPLE)(\.ru)?.md`) var docsDirFileRe = regexp.MustCompile(`docs/[^/]+.md`) func checkDocFile(fName string, diffInfo *DiffInfo) (msg Message) { @@ -88,15 +88,11 @@ func checkDocFile(fName string, diffInfo *DiffInfo) (msg Message) { "name is not allowed", `Rename this file or move it, for example, into 'internal' folder. Only following file names are allowed in the module '/docs/' directory: - CLUSTER_CONFIGURATION.md CONFIGURATION.md CR.md - FAQ.md + EXAMPLE.md + USAGE.md README.md - RELEASE_NOTES.md - ADMIN_GUIDE.md - USER_GUIDE.md - CHARACTERISTICS_DESCRIPTION.md (also their Russian versions ended with '.ru.md')`, ) } diff --git a/tools/validation/no_cyrillic.go b/tools/validation/no_cyrillic.go deleted file mode 100644 index d41c65a..0000000 --- a/tools/validation/no_cyrillic.go +++ /dev/null @@ -1,160 +0,0 @@ -/* -Copyright 2024 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "fmt" - "regexp" - "strings" -) - -var skipDocRe = regexp.MustCompile(`doc-ru-.+\.y[a]?ml$|\.ru\.md$`) -var skipI18NRe = regexp.MustCompile(`/i18n/`) -var skipSelfRe = regexp.MustCompile(`no_cyrillic(_test)?.go$`) - -func RunNoCyrillicValidation(info *DiffInfo, title string, description string) (exitCode int) { - fmt.Printf("Run 'no cyrillic' validation ...\n") - - exitCode = 0 - if title != "" { - fmt.Printf("Check title ... ") - msg, hasCyr := checkCyrillicLetters(title) - if hasCyr { - fmt.Printf("ERROR\n%s\n", msg) - exitCode = 1 - } else { - fmt.Printf("OK\n") - } - } - if description != "" { - // Here put cyrillic char -> C - fmt.Printf("Check description Сахар... ") - msg, hasCyr := checkCyrillicLetters(description) - if hasCyr { - fmt.Printf("ERROR\n%s\n", msg) - exitCode = 1 - } else { - fmt.Printf("OK\n") - } - } - // Some fishka - fmt.Printf("Check new and updated lines ... ") - if len(info.Files) == 0 { - fmt.Printf("OK, diff is empty\n") - } else { - fmt.Println("") - - msgs := NewMessages() - - //hasErrors := false - for _, fileInfo := range info.Files { - if !fileInfo.HasContent() { - continue - } - // Check only added or modified files - if !(fileInfo.IsAdded() || fileInfo.IsModified()) { - continue - } - - fileName := fileInfo.NewFileName - - if skipDocRe.MatchString(fileName) { - msgs.Add(NewSkip(fileName, "documentation")) - continue - } - - if skipI18NRe.MatchString(fileName) { - msgs.Add(NewSkip(fileName, "translation file")) - continue - } - - if skipSelfRe.MatchString(fileName) { - msgs.Add(NewSkip(fileName, "self")) - continue - } - - // Get added or modified lines - newLines := fileInfo.NewLines() - if len(newLines) == 0 { - msgs.Add(NewSkip(fileName, "no lines added")) - continue - } - - cyrMsg, hasCyr := checkCyrillicLettersInArray(newLines) - if hasCyr { - msgs.Add(NewError(fileName, "should not contain Cyrillic letters", cyrMsg)) - continue - } - - msgs.Add(NewOK(fileName)) - } - - msgs.PrintReport() - - if msgs.CountErrors() > 0 { - exitCode = 1 - } - } - - return exitCode -} - -var cyrRe = regexp.MustCompile(`[А-Яа-яЁё]+`) -var cyrPointerRe = regexp.MustCompile(`[А-Яа-яЁё]`) -var cyrFillerRe = regexp.MustCompile(`[^А-Яа-яЁё]`) - -func checkCyrillicLetters(in string) (string, bool) { - if strings.Contains(in, "\n") { - return checkCyrillicLettersInArray(strings.Split(in, "\n")) - } - return checkCyrillicLettersInString(in) -} - -// checkCyrillicLettersInString returns a fancy message if input string contains Cyrillic letters. -func checkCyrillicLettersInString(line string) (string, bool) { - if !cyrRe.MatchString(line) { - return "", false - } - - // Replace all tabs with spaces to prevent shifted cursor. - line = strings.Replace(line, "\t", " ", -1) - - // Make string with pointers to Cyrillic letters so user can detect hidden letters. - cursor := cyrFillerRe.ReplaceAllString(line, "-") - cursor = cyrPointerRe.ReplaceAllString(cursor, "^") - cursor = strings.TrimRight(cursor, "-") - - const formatPrefix = " " - - return formatPrefix + line + "\n" + formatPrefix + cursor, true -} - -// checkCyrillicLettersInArray returns a fancy message for each string in array that contains Cyrillic letters. -func checkCyrillicLettersInArray(lines []string) (string, bool) { - res := make([]string, 0) - - hasCyr := false - for _, line := range lines { - msg, has := checkCyrillicLettersInString(line) - if has { - hasCyr = true - res = append(res, msg) - } - } - - return strings.Join(res, "\n"), hasCyr -} diff --git a/tools/validation/no_cyrillic_test.go b/tools/validation/no_cyrillic_test.go deleted file mode 100644 index f239014..0000000 --- a/tools/validation/no_cyrillic_test.go +++ /dev/null @@ -1,96 +0,0 @@ -/* -Copyright 2024 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "fmt" - "strings" - "testing" -) - -func Test_found_msg(t *testing.T) { - // Simple check with one Cyrillic letter. - in := "fooБfoo" - expected := ` fooБfoo - ---^` - - actual, has := checkCyrillicLetters(in) - - if !has { - t.Errorf("Should detect cyrillic letters in string") - } - - if actual != expected { - t.Errorf("Expect '%s', got '%s'", expected, actual) - } - - // No Cyrillic letters. - in = "asdqwe 123456789 !@#$%^&*( ZXCVBNM" - expected = "" - actual, has = checkCyrillicLetters(in) - - if has { - t.Errorf("Should not detect cyrillic letters in string") - } - - if actual != expected { - t.Errorf("Expect '%s', got '%s'", expected, actual) - } - - // Multiple words with Cyrillic letters. - in = "asdqwe Там на qw q cheсk tеst qwd неведомых qqw" - expected = - " asdqwe Там на qw q cheсk tеst qwd неведомых qqw\n" + - " -------^^^-^^---------^---^-------^^^^^^^^^" - - actual, has = checkCyrillicLetters(in) - - if !has { - t.Errorf("Should detect cyrillic letters in string") - } - - if actual != expected { - fmt.Printf(" %s\n%s\n", - strings.Repeat("0123456789", len(actual)/2/10+1), - actual) - t.Errorf("Expect \n%s\n, got \n%s\n", expected, actual) - } - - // Multiple messages for string with '\n'. - in = "Lorem ipsum dolor sit amet,\n consectetur adipiscing elit,\n" + - "раскрою перед вами всю \nкартину и разъясню," + - "Ut enim ad minim veniam," - expected = - " раскрою перед вами всю \n" + - " ^^^^^^^-^^^^^-^^^^-^^^\n" + - " картину и разъясню,Ut enim ad minim veniam,\n" + - " ^^^^^^^-^-^^^^^^^^" - - actual, has = checkCyrillicLetters(in) - - if !has { - t.Errorf("Should detect cyrillic letters in string") - } - - if actual != expected { - fmt.Printf(" %s\n%s\n", - strings.Repeat("0123456789", len(actual)/2/10+1), - actual) - t.Errorf("Expect \n%s\n, got \n%s\n", expected, actual) - } - -} From 1546d37fdef66ca39ae3f681e7d4a997d37fb831 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:23:18 +0300 Subject: [PATCH 12/27] fix: credential changes propagation for OCI repos (#6) Signed-off-by: Ilya Drey --- .../controller/helmclusteraddon/controller.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go index 6b044f7..4d963e5 100644 --- a/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go @@ -19,6 +19,7 @@ package helmclusteraddon import ( helmv2 "github.com/werf/3p-helm-controller/api/v2" sourcev1 "github.com/werf/nelm-source-controller/api/v1" + corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -83,5 +84,21 @@ func SetupWithManager(mgr ctrl.Manager) error { ), ), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc( + utils.MapInternalToFacade( + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonLabelSourceName), + ), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv1alpha1.HelmClusterAddonRepository{}, + &handler.EnqueueRequestForObject{}, + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). Complete(r) } From c0577aa78ebda97b8fef7b0497c2e3d53389bf11 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:17:10 +0300 Subject: [PATCH 13/27] doc: update module documentation by Cursor AI (#7) Signed-off-by: Ilya Drey --- crds/doc-ru-helmclusteraddoncharts.yaml | 28 +++++++++ crds/doc-ru-helmclusteraddonrepositories.yaml | 36 +++++++++++ crds/doc-ru-helmclusteraddons.yaml | 51 ++++++++++++++++ docs/CONFIGURATION.md | 1 + docs/CONFIGURATION.ru.md | 1 + docs/CR.md | 1 + docs/CR.ru.md | 3 +- docs/EXAMPLE.md | 58 +++++++++--------- docs/EXAMPLE.ru.md | 59 +++++++++---------- docs/README.md | 26 ++++---- docs/README.ru.md | 26 ++++---- module.yaml | 4 +- openapi/doc-ru-config-values.yaml | 7 --- 13 files changed, 203 insertions(+), 98 deletions(-) create mode 100644 crds/doc-ru-helmclusteraddoncharts.yaml create mode 100644 crds/doc-ru-helmclusteraddonrepositories.yaml create mode 100644 crds/doc-ru-helmclusteraddons.yaml diff --git a/crds/doc-ru-helmclusteraddoncharts.yaml b/crds/doc-ru-helmclusteraddoncharts.yaml new file mode 100644 index 0000000..563b055 --- /dev/null +++ b/crds/doc-ru-helmclusteraddoncharts.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: helmclusteraddoncharts.helm.deckhouse.io +spec: + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmClusterAddonChart описывает Helm-чарт и его версии из определённого репозитория. + properties: + status: + properties: + conditions: + description: Условия отражают последние наблюдения за состоянием репозитория. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. + versions: + description: Доступные версии Helm-чарта. + items: + properties: + digest: + description: Дайджест Helm-чарта. + pulled: + description: Признак загрузки чарта из репозитория. + version: + description: Версия Helm-чарта. diff --git a/crds/doc-ru-helmclusteraddonrepositories.yaml b/crds/doc-ru-helmclusteraddonrepositories.yaml new file mode 100644 index 0000000..958b336 --- /dev/null +++ b/crds/doc-ru-helmclusteraddonrepositories.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: helmclusteraddonrepositories.helm.deckhouse.io +spec: + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmClusterAddonRepository описывает Helm-репозиторий или OCI-репозиторий с Helm-чартами. + properties: + spec: + properties: + auth: + description: Учётные данные для аутентификации в репозитории. + properties: + password: + description: Пароль для аутентификации в репозитории. + username: + description: Имя пользователя для аутентификации в репозитории. + caCertificate: + description: CA-сертификат в формате PEM для проверки TLS. + tlsVerify: + description: Включает или выключает проверку TLS-сертификата. + url: + description: | + URL Helm-репозитория. + + Поддерживаются протоколы `http(s)://` и `oci://`. + status: + properties: + conditions: + description: Условия отражают последние наблюдения за состоянием репозитория. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. diff --git a/crds/doc-ru-helmclusteraddons.yaml b/crds/doc-ru-helmclusteraddons.yaml new file mode 100644 index 0000000..d5da75a --- /dev/null +++ b/crds/doc-ru-helmclusteraddons.yaml @@ -0,0 +1,51 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: helmclusteraddons.helm.deckhouse.io +spec: + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmClusterAddon описывает Helm-аддон, устанавливаемый на уровне кластера. + properties: + spec: + properties: + chart: + properties: + helmClusterAddonChart: + description: | + Имя Helm-чарта для установки из указанного репозитория (например, «ingress-nginx» или «redis»). + helmClusterAddonRepository: + description: | + Имя ресурса HelmClusterAddonRepository, содержащего параметры подключения и учётные данные для доступа к репозиторию, в котором расположен чарт. + version: + description: Версия Helm-чарта для HelmClusterAddon. + maintenance: + description: | + Стратегия согласования ресурса. + + При значении `NoResourceReconciliation` контроллер прекращает обновление управляемых ресурсов, что позволяет выполнять ручное вмешательство или обслуживание без перезаписи изменений оператором. + При пустом значении (`""`) используется стандартное согласование. + namespace: + description: Пространство имён для развёртывания релиза аддона. + values: + description: Пользовательские значения для релиза HelmClusterAddon. + status: + properties: + conditions: + description: Условия отражают последние наблюдения за состоянием ресурса. + lastAppliedChart: + description: Последний применённый чарт, инициировавший установку или обновление аддона. + properties: + helmClusterAddonChart: + description: Имя Helm-чарта. + helmClusterAddonRepository: + description: Имя ресурса HelmClusterAddonRepository. + version: + description: Версия Helm-чарта. + lastAppliedValues: + description: Последние применённые значения, инициировавшие установку или обновление аддона. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c19b477..80620c1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,4 +1,5 @@ --- title: "Configuration" +description: "Deckhouse Kubernetes Platform — configuration parameters of the operator-helm module." weight: 20 --- diff --git a/docs/CONFIGURATION.ru.md b/docs/CONFIGURATION.ru.md index 661c392..7ceffe5 100644 --- a/docs/CONFIGURATION.ru.md +++ b/docs/CONFIGURATION.ru.md @@ -1,4 +1,5 @@ --- title: "Настройки" +description: "Deckhouse Kubernetes Platform, параметры конфигурации модуля operator-helm." weight: 20 --- diff --git a/docs/CR.md b/docs/CR.md index 4f1f169..ea293af 100644 --- a/docs/CR.md +++ b/docs/CR.md @@ -1,4 +1,5 @@ --- title: "Custom Resources" +description: "Deckhouse Kubernetes Platform — Custom resources of the operator-helm module." weight: 60 --- diff --git a/docs/CR.ru.md b/docs/CR.ru.md index 4f1f169..e22d7ed 100644 --- a/docs/CR.ru.md +++ b/docs/CR.ru.md @@ -1,4 +1,5 @@ --- -title: "Custom Resources" +title: "Кастомные ресурсы" +description: "Deckhouse Kubernetes Platform, кастомные ресурсы (custom resources) модуля operator-helm." weight: 60 --- diff --git a/docs/EXAMPLE.md b/docs/EXAMPLE.md index 4bb8778..60973f7 100644 --- a/docs/EXAMPLE.md +++ b/docs/EXAMPLE.md @@ -4,13 +4,9 @@ description: "Deckhouse Kubernetes Platform — usage examples for the operator- weight: 30 --- -## Adding a Helm Repository +## Adding a Helm repository -{{< alert level="warning" >}} -In the MVP stage, only Helm repositories (using schema "http(s)://") are supported as chart sources. Support for OCI registries (using schema "oci://") will be added in the alpha version. -{{< /alert >}} - -To add a repository, you need to create a HelmClusterAddonRepository resource: +To add a repository, create a HelmClusterAddonRepository resource: ```yaml apiVersion: helm.deckhouse.io/v1alpha1 @@ -21,49 +17,49 @@ spec: url: https://stefanprodan.github.io/podinfo ``` -After creating the repository, you can view the Helm charts available in it using the command below: +After creating the repository, view the available Helm charts: ```shell -kubectl get helmclusteraddoncharts.helm.deckhouse.io -l repository=podinfo +d8 k get helmclusteraddoncharts.helm.deckhouse.io -l repository=podinfo +``` + +Example output: + +```text NAME AGE podinfo-podinfo 56s ``` -To view the list of versions available for a specific chart, run the following command: +To view the list of versions available for a specific chart: ```shell +d8 k get helmclusteraddonchart podinfo-podinfo -o yaml +``` + +Example output: + +```yaml apiVersion: helm.deckhouse.io/v1alpha1 kind: HelmClusterAddonChart metadata: - creationTimestamp: "2026-03-12T02:24:04Z" - generation: 1 labels: chart: podinfo heritage: deckhouse repository: podinfo name: podinfo-podinfo - ownerReferences: - - apiVersion: helm.deckhouse.io/v1alpha1 - blockOwnerDeletion: true - controller: true - kind: HelmClusterAddonRepository - name: podinfo - uid: d5e026f9-6151-4f9f-a4bc-756d96b86e95 - resourceVersion: "28306911" - uid: 7f232359-5553-463e-beed-d6f175596b0b status: versions: - - digest: a5c4b7381a0907128243354ab100d2eecc480d7dcac5014ff7272b0acef03780 - pulled: false - version: 6.11.0 - - digest: 9f1cdb52fc5a57848f377b146919f8eb2c4a2c0ab8815bd019ec41c1d1895c0c - pulled: false - version: 6.10.2 + - digest: a5c4b7381a0907128243354ab100d2eecc480d7dcac5014ff7272b0acef03780 + pulled: false + version: 6.11.0 + - digest: 9f1cdb52fc5a57848f377b146919f8eb2c4a2c0ab8815bd019ec41c1d1895c0c + pulled: false + version: 6.10.2 ``` -## Deploying an Application +## Deploying an application -To deploy an application, you must create a `HelmClusterAddon` resource, specifying the name of the previously created repository, the chart name and version, and the namespace where the application will be deployed. +To deploy an application, create a HelmClusterAddon resource specifying the repository name, chart name and version, and the target namespace: ```yaml apiVersion: helm.deckhouse.io/v1alpha1 @@ -79,9 +75,9 @@ spec: ``` {{< alert level="warning" >}} -Only one instance of `HelmClusterAddon` using a specific Helm chart from a specific repository can be deployed at a time. However, different Helm charts from the same repository can be deployed simultaneously. +Only one instance of HelmClusterAddon using a specific Helm chart from a specific repository can be deployed at a time. Different Helm charts from the same repository can be deployed simultaneously. {{< /alert >}} {{< alert level="info" >}} -It is permissible to omit a specific chart version in the .spec.chart.version parameter. In this case, the latest version of the application will be installed. -{{< /alert >}} \ No newline at end of file +The `.spec.chart.version` parameter is optional. If omitted, the latest available version of the chart will be installed. +{{< /alert >}} diff --git a/docs/EXAMPLE.ru.md b/docs/EXAMPLE.ru.md index 223ef5f..bfa9f62 100644 --- a/docs/EXAMPLE.ru.md +++ b/docs/EXAMPLE.ru.md @@ -4,13 +4,9 @@ description: "Deckhouse Kubernetes Platform — примеры использо weight: 30 --- -## Добавление Helm репозитория +## Добавление Helm-репозитория -{{< alert level="warning" >}} -На стадии MVP в качестве источников чартов поддерживаются только Helm репозитории (http(s)://). Поддержка OCI репозиториев (oci://) будет добавлена в alpha версии. -{{< /alert >}} - -Для добавления репозитория необходимо добавить ресурс `HelmClusterAddonRepository`: +Для добавления репозитория создайте ресурс HelmClusterAddonRepository: ```yaml apiVersion: helm.deckhouse.io/v1alpha1 @@ -21,48 +17,49 @@ spec: url: https://stefanprodan.github.io/podinfo ``` -После создания репозитория, можно просмотреть доступные в нем Helm-чарты с помощью команды ниже: +После создания репозитория можно просмотреть доступные в нём Helm-чарты: ```shell -kubectl get helmclusteraddoncharts.helm.deckhouse.io -l repository=podinfo +d8 k get helmclusteraddoncharts.helm.deckhouse.io -l repository=podinfo +``` + +Пример вывода: + +```text NAME AGE podinfo-podinfo 56s ``` -Для просмотра списка версий, доступных для заданного чарта, необходимо выполнить команду ниже: +Для просмотра списка версий, доступных для заданного чарта: + ```shell +d8 k get helmclusteraddonchart podinfo-podinfo -o yaml +``` + +Пример вывода: + +```yaml apiVersion: helm.deckhouse.io/v1alpha1 kind: HelmClusterAddonChart metadata: - creationTimestamp: "2026-03-12T02:24:04Z" - generation: 1 labels: chart: podinfo heritage: deckhouse repository: podinfo name: podinfo-podinfo - ownerReferences: - - apiVersion: helm.deckhouse.io/v1alpha1 - blockOwnerDeletion: true - controller: true - kind: HelmClusterAddonRepository - name: podinfo - uid: d5e026f9-6151-4f9f-a4bc-756d96b86e95 - resourceVersion: "28306911" - uid: 7f232359-5553-463e-beed-d6f175596b0b status: versions: - - digest: a5c4b7381a0907128243354ab100d2eecc480d7dcac5014ff7272b0acef03780 - pulled: false - version: 6.11.0 - - digest: 9f1cdb52fc5a57848f377b146919f8eb2c4a2c0ab8815bd019ec41c1d1895c0c - pulled: false - version: 6.10.2 + - digest: a5c4b7381a0907128243354ab100d2eecc480d7dcac5014ff7272b0acef03780 + pulled: false + version: 6.11.0 + - digest: 9f1cdb52fc5a57848f377b146919f8eb2c4a2c0ab8815bd019ec41c1d1895c0c + pulled: false + version: 6.10.2 ``` -## Деплой приложения +## Развёртывание приложения -Для деплоя приложения необходимо создать ресурс `HelmClusterAddon` указав имя ранее созданного репозитория, имя и версию чарта, и namespace в который будет развернуто приложение. +Для развёртывания приложения создайте ресурс HelmClusterAddon, указав имя репозитория, имя и версию чарта, а также целевое пространство имён: ```yaml apiVersion: helm.deckhouse.io/v1alpha1 @@ -78,9 +75,9 @@ spec: ``` {{< alert level="warning" >}} -Одновременного допускается развертывание только одного экземпляра `HelmClusterAddon` использующего заданный Helm чарт из заданного репозитория. При этом из одноного репозитория одновременно могут быть развернуты разные Helm чарты. +Одновременно допускается развёртывание только одного экземпляра HelmClusterAddon, использующего заданный Helm-чарт из заданного репозитория. При этом из одного репозитория одновременно могут быть развёрнуты разные Helm-чарты. {{< /alert >}} {{< alert level="info" >}} -Допустимо не указывать конкретную версию чарта в параметре `.Spec.chart.version`. В этом случае будет установлена последняя версия приложения. -{{< /alert >}} \ No newline at end of file +Параметр `.spec.chart.version` является необязательным. Если он не указан, будет установлена последняя доступная версия чарта. +{{< /alert >}} diff --git a/docs/README.md b/docs/README.md index 3c3ca2c..a73f907 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,20 +1,20 @@ --- -title: "operator-helm" -menuTitle: "operator-helm" -moduleStatus: Experimental +title: "Module operator-helm" +description: "Deckhouse Kubernetes Platform — the operator-helm module for declarative Helm chart management." weight: 10 --- -The operator-helm module is designed for declarative management of Helm charts. It enables application deployment via Custom Resources (CRs), minimizing the amount of required input data. +The `operator-helm` module provides declarative management of Helm chart deployments for cluster administrators and DevOps engineers. It deploys applications through custom resources, reducing the amount of manual configuration required. -## Supported Sources -The module provides flexibility in choosing application sources, supporting: -* Helm repositories (classic HTTP/HTTPS repositories); -* OCI registries that support Helm chart storage. +The module acts as a Kubernetes operator that reconciles the desired state described in HelmClusterAddon resources with the actual Helm releases in the cluster. -## Management Methods -Management of the module's resources is unified and accessible via: -* Command Line Interface (CLI): using the `d8` or `kubectl` utility. -* Web Interface: through the Deckhouse Kubernetes Platform graphical console. +## Main Features -See module usage examples in [Usage examples](example.html) section. +- Deploying Helm charts from classic HTTP/HTTPS repositories and OCI registries through a unified declarative API. +- Automatic chart version discovery and tracking via HelmClusterAddonChart resources. +- Configurable chart values through HelmClusterAddon resources. +- Maintenance mode to pause reconciliation on managed releases. +- TLS verification and authentication support for private Helm and OCI repositories. +- Management through CLI (`d8 k`) or the Deckhouse web interface. + +See [usage examples](example.html) for practical scenarios. diff --git a/docs/README.ru.md b/docs/README.ru.md index 70e9b20..55befb0 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -1,20 +1,20 @@ --- -title: "operator-helm" -menuTitle: "operator-helm" -moduleStatus: General Availability +title: "Модуль operator-helm" +description: "Deckhouse Kubernetes Platform — модуль operator-helm для декларативного управления Helm-чартами." weight: 10 --- -Модуль `operator-helm` предназначен для декларативного управления Helm-чартами. Он позволяет развертывать приложения через кастомные ресурсы (CR), сводя к минимуму объем необходимых входных данных. +Модуль `operator-helm` предназначен для декларативного управления развёртыванием Helm-чартов. Ориентирован на администраторов кластеров и DevOps-инженеров, которым необходимо автоматизировать установку приложений через кастомные ресурсы. -## Поддерживаемые источники -Модуль обеспечивает гибкость выбора источников приложений, работая с: -* Helm-репозиториями (классические HTTP/HTTPS репозитории); -* OCI-репозиториями, поддерживающими хранение Helm-чартов. +Модуль работает как оператор Kubernetes, приводя фактическое состояние Helm-релизов в соответствие с описанным в ресурсах HelmClusterAddon. -## Способы управления -Управление ресурсами модуля унифицировано и доступно через: -* Интерфейс командной строки: с помощью утилиты `d8`, либо kubectl. -* Web-интерфейс: через графическую консоль управления Deckhouse Kubernetes Platform. +## Основные возможности -Примеры использования модуля приведены в разделе [Примеры использования](example.html). +- Развёртывание Helm-чартов из классических HTTP/HTTPS-репозиториев и OCI-репозиториев через единый декларативный API. +- Автоматическое обнаружение и отслеживание версий чартов через ресурсы HelmClusterAddonChart. +- Настройка параметров чартов через ресурсы HelmClusterAddon. +- Режим обслуживания для приостановки согласования и ручного вмешательства в управляемые релизы. +- Поддержка проверки TLS-сертификатов и аутентификации для приватных OCI и Helm репозиториев. +- Управление через CLI (`d8 k`) или веб-интерфейс Deckhouse. + +Примеры использования приведены в разделе [примеры использования](example.html). diff --git a/module.yaml b/module.yaml index aa9f45c..82ba375 100644 --- a/module.yaml +++ b/module.yaml @@ -6,8 +6,8 @@ subsystems: - delivery namespace: d8-operator-helm descriptions: - en: An operator to deploy helm applications declaratively. - ru: Оператор для декларативного развертывания helm-приложений. + en: An operator to deploy Helm applications declaratively. + ru: Оператор для декларативного развертывания Helm-приложений. tags: ["delivery"] disable: confirmation: true diff --git a/openapi/doc-ru-config-values.yaml b/openapi/doc-ru-config-values.yaml index f8c368c..0bfe464 100644 --- a/openapi/doc-ru-config-values.yaml +++ b/openapi/doc-ru-config-values.yaml @@ -7,8 +7,6 @@ properties: По умолчанию режим отказоустойчивости определяется автоматически. Подробнее про режим отказоустойчивости можно прочитать в разделе [Высокая надежность и доступность](/products/kubernetes-platform/documentation/v1/admin/configuration/high-reliability-and-availability/enable.html#включение-режима-ha-для-отдельных-компонентов). logLevel: - type: string - default: info description: | Устанавливает уровень логирования. @@ -17,8 +15,3 @@ properties: - `nelm-source-controller` - `kube-api-rewriter` - `deckhouse-helm-controller` - enum: - - "debug" - - "info" - - "warn" - - "error" From cdd159128706671103126fc9cf695b24f7b16445 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:04:25 +0300 Subject: [PATCH 14/27] fix: minor imporvements (#8) --- api/v1alpha1/conditions.go | 2 - api/v1alpha1/helm_cluster_addon_chart.go | 4 - crds/doc-ru-helmclusteraddoncharts.yaml | 4 - crds/helmclusteraddoncharts.yaml | 7 -- .../cmd/operator-helm-controller/main.go | 9 -- .../internal/client/repository/helm.go | 1 - .../controller/helmclusteraddon/controller.go | 26 +++--- .../helmclusteraddonchart/controller.go | 54 ------------ .../helmclusteraddonchart/reconciler.go | 80 ----------------- .../helmclusteraddonrepository/controller.go | 20 +++-- .../types.go => manager/status/helpers.go} | 74 ++++++---------- .../status/manager.go} | 45 ++++++---- .../helmclusteraddon/reconciler.go | 86 +++++++++++++------ .../helmclusteraddonrepository/reconciler.go | 80 +++++++++++------ .../services/{base_repo.go => base.go} | 28 +++++- .../services/{chart.go => chart_service.go} | 17 ++-- .../{helm_repo.go => helm_repo_service.go} | 33 +++---- ...{maintenance.go => maintenance_service.go} | 65 +++++++------- .../{oci_repo.go => oci_repo_service.go} | 72 ++++++++++++---- .../{release.go => release_service.go} | 15 ++-- .../{repo_sync.go => repo_sync_service.go} | 50 ++++------- .../internal/utils/conditions.go | 31 ------- .../internal/utils/mapper.go | 2 +- .../internal/utils/name.go | 70 +++++++++------ images/operator-helm-artifact/werf.inc.yaml | 9 -- openapi/config-values.yaml | 16 ---- openapi/doc-ru-config-values.yaml | 9 -- templates/helm-controller/deployment.yaml | 1 - .../nelm-source-controller/deployment.yaml | 1 - .../operator-helm-controller/deployment.yaml | 1 - 30 files changed, 406 insertions(+), 506 deletions(-) delete mode 100644 images/operator-helm-artifact/internal/controller/helmclusteraddonchart/controller.go delete mode 100644 images/operator-helm-artifact/internal/controller/helmclusteraddonchart/reconciler.go rename images/operator-helm-artifact/internal/{services/types.go => manager/status/helpers.go} (52%) rename images/operator-helm-artifact/internal/{services/status_manager.go => manager/status/manager.go} (75%) rename images/operator-helm-artifact/internal/{controller => reconcile}/helmclusteraddon/reconciler.go (70%) rename images/operator-helm-artifact/internal/{controller => reconcile}/helmclusteraddonrepository/reconciler.go (63%) rename images/operator-helm-artifact/internal/services/{base_repo.go => base.go} (78%) rename images/operator-helm-artifact/internal/services/{chart.go => chart_service.go} (88%) rename images/operator-helm-artifact/internal/services/{helm_repo.go => helm_repo_service.go} (78%) rename images/operator-helm-artifact/internal/services/{maintenance.go => maintenance_service.go} (72%) rename images/operator-helm-artifact/internal/services/{oci_repo.go => oci_repo_service.go} (72%) rename images/operator-helm-artifact/internal/services/{release.go => release_service.go} (90%) rename images/operator-helm-artifact/internal/services/{repo_sync.go => repo_sync_service.go} (83%) delete mode 100644 images/operator-helm-artifact/internal/utils/conditions.go diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/conditions.go index cd6743d..ba12fc8 100644 --- a/api/v1alpha1/conditions.go +++ b/api/v1alpha1/conditions.go @@ -23,14 +23,12 @@ const ( ConditionTypeConfigurationApplied = "ConfigurationApplied" ConditionTypePartiallyDegraded = "PartiallyDegraded" ConditionTypeReady = "Ready" - ConditionTypeReleaseChart = "ConditionTypeReleaseChart" ConditionTypeSynced = "Synced" ReasonHelmChartFailed = "HelmChartFailed" ReasonHelmReleaseFailed = "HelmReleaseFailed" ReasonMaintenanceModeActive = "MaintenanceModeActive" ReasonMaintenanceModeInactive = "MaintenanceModeInactive" - ReasonSyncSucceeded = "SyncSucceeded" ReasonSyncFailed = "SyncFailed" ReasonRepositoryNotReady = "RepositoryNotReady" ReasonReconciling = "Reconciling" diff --git a/api/v1alpha1/helm_cluster_addon_chart.go b/api/v1alpha1/helm_cluster_addon_chart.go index b1aa253..916833c 100644 --- a/api/v1alpha1/helm_cluster_addon_chart.go +++ b/api/v1alpha1/helm_cluster_addon_chart.go @@ -88,10 +88,6 @@ type HelmClusterAddonChartVersion struct { // Helm chart version // +kubebuilder:validation:MinLength=1 Version string `json:"version"` - // Helm chart digest - Digest string `json:"digest,omitempty"` - // Chart pulled from repository - Pulled bool `json:"pulled"` } // HelmClusterAddonChartList contains a list of HelmClusterAddonCharts. diff --git a/crds/doc-ru-helmclusteraddoncharts.yaml b/crds/doc-ru-helmclusteraddoncharts.yaml index 563b055..2755585 100644 --- a/crds/doc-ru-helmclusteraddoncharts.yaml +++ b/crds/doc-ru-helmclusteraddoncharts.yaml @@ -20,9 +20,5 @@ spec: description: Доступные версии Helm-чарта. items: properties: - digest: - description: Дайджест Helm-чарта. - pulled: - description: Признак загрузки чарта из репозитория. version: description: Версия Helm-чарта. diff --git a/crds/helmclusteraddoncharts.yaml b/crds/helmclusteraddoncharts.yaml index 6478674..fe6ac61 100644 --- a/crds/helmclusteraddoncharts.yaml +++ b/crds/helmclusteraddoncharts.yaml @@ -116,18 +116,11 @@ spec: description: Available helm chart versions items: properties: - digest: - description: Helm chart digest - type: string - pulled: - description: Chart pulled from repository - type: boolean version: description: Helm chart version minLength: 1 type: string required: - - pulled - version type: object type: array diff --git a/images/operator-helm-artifact/cmd/operator-helm-controller/main.go b/images/operator-helm-artifact/cmd/operator-helm-controller/main.go index 7a6b257..e8a6c8f 100644 --- a/images/operator-helm-artifact/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-artifact/cmd/operator-helm-controller/main.go @@ -32,7 +32,6 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddon" - "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddonchart" "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddonrepository" helmclusteraddonwebhook "github.com/deckhouse/operator-helm/internal/webhook/helmclusteraddon" ) @@ -56,9 +55,6 @@ func main() { flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metrics endpoint binds to.") flag.StringVar(&healthProbeAddr, "health-probe-bind-address", ":9440", "The address the health probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") - - // TODO: replace zap by deckhouse logger - opts := zap.Options{Development: false} opts.BindFlags(flag.CommandLine) flag.Parse() @@ -95,11 +91,6 @@ func main() { os.Exit(1) } - if err := helmclusteraddonchart.SetupWithManager(mgr); err != nil { - logger.Error(err, "unable to setup HelmClusterAddonChart controller") - os.Exit(1) - } - if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { logger.Error(err, "unable to set up health check") os.Exit(1) diff --git a/images/operator-helm-artifact/internal/client/repository/helm.go b/images/operator-helm-artifact/internal/client/repository/helm.go index deec2a7..62012ac 100644 --- a/images/operator-helm-artifact/internal/client/repository/helm.go +++ b/images/operator-helm-artifact/internal/client/repository/helm.go @@ -96,7 +96,6 @@ func (c *helmRepositoryClient) FetchCharts(ctx context.Context, url string, auth charts[chartName] = append(charts[chartName], helmv1alpha1.HelmClusterAddonChartVersion{ Version: chartVersion.Version, - Digest: chartVersion.Digest, }) } } diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go index 4d963e5..8553971 100644 --- a/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go @@ -26,6 +26,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/manager/status" + reconcile "github.com/deckhouse/operator-helm/internal/reconcile/helmclusteraddon" "github.com/deckhouse/operator-helm/internal/services" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -37,14 +39,14 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - r := &reconciler{ - Client: mgr.GetClient(), - releaseService: services.NewReleaseService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - ociRepositoryService: services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - chartService: services.NewChartService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - maintenanceService: services.NewMaintenanceService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - statusManager: services.NewStatusManager(client, ControllerName), - } + r := reconcile.New( + mgr.GetClient(), + services.NewChartService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + services.NewReleaseService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + services.NewMaintenanceService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + status.NewManager(client, ControllerName), + ) return ctrl.NewControllerManagedBy(mgr). Named(ControllerName). @@ -52,7 +54,7 @@ func SetupWithManager(mgr ctrl.Manager) error { Watches( &sourcev1.HelmChart{}, handler.EnqueueRequestsFromMapFunc( - utils.MapInternalToFacade( + utils.MapInternalResources( helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, @@ -64,7 +66,7 @@ func SetupWithManager(mgr ctrl.Manager) error { Watches( &helmv2.HelmRelease{}, handler.EnqueueRequestsFromMapFunc( - utils.MapInternalToFacade( + utils.MapInternalResources( helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, @@ -76,7 +78,7 @@ func SetupWithManager(mgr ctrl.Manager) error { Watches( &sourcev1.OCIRepository{}, handler.EnqueueRequestsFromMapFunc( - utils.MapInternalToFacade( + utils.MapInternalResources( helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, @@ -87,7 +89,7 @@ func SetupWithManager(mgr ctrl.Manager) error { Watches( &corev1.Secret{}, handler.EnqueueRequestsFromMapFunc( - utils.MapInternalToFacade( + utils.MapInternalResources( helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/controller.go b/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/controller.go deleted file mode 100644 index 8e7eca2..0000000 --- a/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -/* -Copyright 2026 Flant JSC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package helmclusteraddonchart - -import ( - sourcev1 "github.com/werf/nelm-source-controller/api/v1" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/predicate" - - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/utils" -) - -const ( - // ControllerName is the name of this controller, used for leader election and logging. - ControllerName = "helmclusteraddonchart-controller" -) - -func SetupWithManager(mgr ctrl.Manager) error { - r := &reconciler{ - Client: mgr.GetClient(), - } - - return ctrl.NewControllerManagedBy(mgr). - Named(ControllerName). - For(&helmv1alpha1.HelmClusterAddonChart{}). - Watches( - &sourcev1.HelmChart{}, - handler.EnqueueRequestsFromMapFunc( - utils.MapInternalToFacade( - helmv1alpha1.TargetNamespace, - helmv1alpha1.LabelManagedBy, - helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonChartLabelSourceName)), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), - ). - Complete(r) -} diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/reconciler.go b/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/reconciler.go deleted file mode 100644 index 543c798..0000000 --- a/images/operator-helm-artifact/internal/controller/helmclusteraddonchart/reconciler.go +++ /dev/null @@ -1,80 +0,0 @@ -/* -Copyright 2026 Flant JSC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package helmclusteraddonchart - -import ( - "context" - "fmt" - - sourcev1 "github.com/werf/nelm-source-controller/api/v1" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" -) - -type reconciler struct { - client.Client -} - -func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { - logger := log.FromContext(ctx) - - chart := &helmv1alpha1.HelmClusterAddonChart{} - if err := r.Get(ctx, req.NamespacedName, chart); client.IgnoreNotFound(err) != nil { - return ctrl.Result{}, fmt.Errorf("failed to get helm cluster addon chart: %w", err) - } - - if !chart.DeletionTimestamp.IsZero() { - return reconcile.Result{}, nil - } - - base := chart.DeepCopy() - - internalCharts := &sourcev1.HelmChartList{} - if err := r.List(ctx, internalCharts, client.InNamespace(helmv1alpha1.TargetNamespace)); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to list internal helm charts: %w", err) - } - - updateRequired := false - for i, v := range chart.Status.Versions { - found := false - for _, child := range internalCharts.Items { - if child.Spec.Version == v.Version && child.Status.Artifact != nil { - found = true - break - } - } - - if chart.Status.Versions[i].Pulled != found { - chart.Status.Versions[i].Pulled = found - updateRequired = true - } - } - - if updateRequired { - if err := r.Status().Patch(ctx, chart, client.MergeFrom(base)); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to update helm cluster addon chart status: %w", err) - } - - logger.Info("HelmClusterAddonChart successfully reconciled") - } - - return reconcile.Result{}, nil -} diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go index 4d765fc..9c9d47f 100644 --- a/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go @@ -25,6 +25,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/manager/status" + reconcile "github.com/deckhouse/operator-helm/internal/reconcile/helmclusteraddonrepository" "github.com/deckhouse/operator-helm/internal/services" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -36,13 +38,13 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - r := &reconciler{ - Client: client, - helmRepositoryService: services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - ociRepositoryService: services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - chartSyncService: services.NewRepoSyncService(client, mgr.GetScheme()), - statusManager: services.NewStatusManager(client, helmv1alpha1.LabelManagedByValue), - } + r := reconcile.New( + client, + services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + services.NewRepoSyncService(client, mgr.GetScheme()), + status.NewManager(client, helmv1alpha1.LabelManagedByValue), + ) return ctrl.NewControllerManagedBy(mgr). Named(ControllerName). @@ -50,7 +52,7 @@ func SetupWithManager(mgr ctrl.Manager) error { Watches( &sourcev1.HelmRepository{}, handler.EnqueueRequestsFromMapFunc( - utils.MapInternalToFacade( + utils.MapInternalResources( helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, @@ -61,7 +63,7 @@ func SetupWithManager(mgr ctrl.Manager) error { Watches( &corev1.Secret{}, handler.EnqueueRequestsFromMapFunc( - utils.MapInternalToFacade( + utils.MapInternalResources( helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, diff --git a/images/operator-helm-artifact/internal/services/types.go b/images/operator-helm-artifact/internal/manager/status/helpers.go similarity index 52% rename from images/operator-helm-artifact/internal/services/types.go rename to images/operator-helm-artifact/internal/manager/status/helpers.go index 5830128..e7739aa 100644 --- a/images/operator-helm-artifact/internal/services/types.go +++ b/images/operator-helm-artifact/internal/manager/status/helpers.go @@ -14,74 +14,39 @@ See the License for the specific language governing permissions and limitations under the License. */ -package services +package status import ( - "context" - "fmt" - + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) -type BaseService struct { - Client client.Client - Scheme *runtime.Scheme -} - -func (s *BaseService) ensureResourceDeleted(ctx context.Context, nn types.NamespacedName, obj client.Object) error { - err := s.Client.Get(ctx, nn, obj) - if err != nil { - return client.IgnoreNotFound(err) - } - - if err := s.Client.Delete(ctx, obj); err != nil { - return fmt.Errorf("failed to delete resource %s/%s: %w", nn.Namespace, nn.Name, err) - } - - return nil -} - -type ResourceStatus struct { - ConditionType string - Observed bool - Status metav1.ConditionStatus - ObservedGeneration int64 - Reason string - Message string - NotReflectable bool - Err error -} - -func (s ResourceStatus) IsReady() bool { - return s.Status == metav1.ConditionTrue && s.Observed -} - type statusProxy struct { - StatusProvider + Provider newType string } func (p statusProxy) GetConditionType() string { return p.newType } -func AsCondition(res StatusProvider, conditionType string) StatusProvider { - return statusProxy{StatusProvider: res, newType: conditionType} +func AsCondition(res Provider, conditionType string) Provider { + return statusProxy{Provider: res, newType: conditionType} } -func Success(obj client.Object) ResourceStatus { - return ResourceStatus{ +func Success(obj client.Object) Status { + return Status{ + Observed: true, Status: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonSuccess, ObservedGeneration: obj.GetGeneration(), } } -func Failed(obj client.Object, reason, message string, err error) ResourceStatus { - return ResourceStatus{ +func Failed(obj client.Object, reason, message string, err error) Status { + return Status{ + Observed: true, Status: metav1.ConditionFalse, Reason: reason, ObservedGeneration: obj.GetGeneration(), @@ -90,10 +55,23 @@ func Failed(obj client.Object, reason, message string, err error) ResourceStatus } } -func Unknown(obj client.Object, reason string) ResourceStatus { - return ResourceStatus{ +func Unknown(obj client.Object, reason string) Status { + return Status{ Status: metav1.ConditionUnknown, Reason: reason, ObservedGeneration: obj.GetGeneration(), } } + +func Empty() Status { + return Status{} +} + +func IsConditionObserved(conditions []metav1.Condition, conditionType string, generation int64) (*metav1.Condition, bool) { + cond := meta.FindStatusCondition(conditions, conditionType) + if cond == nil || cond.ObservedGeneration != generation { + return cond, false + } + + return cond, true +} diff --git a/images/operator-helm-artifact/internal/services/status_manager.go b/images/operator-helm-artifact/internal/manager/status/manager.go similarity index 75% rename from images/operator-helm-artifact/internal/services/status_manager.go rename to images/operator-helm-artifact/internal/manager/status/manager.go index 7ea82a4..05d1160 100644 --- a/images/operator-helm-artifact/internal/services/status_manager.go +++ b/images/operator-helm-artifact/internal/manager/status/manager.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package services +package status import ( "context" @@ -37,8 +37,8 @@ type ObjectWithConditions interface { GetStatus() interface{} } -type StatusProvider interface { - GetStatus() ResourceStatus +type Provider interface { + GetStatus() Status GetConditionType() string } @@ -46,30 +46,30 @@ type GenerationProvider interface { GetObservedGeneration() int64 } -type StatusManager struct { +type Manager struct { client.Client FieldOwner string } -func NewStatusManager(c client.Client, fieldOwner string) *StatusManager { - return &StatusManager{ +func NewManager(c client.Client, fieldOwner string) *Manager { + return &Manager{ Client: c, FieldOwner: fieldOwner, } } -type StatusMutatorFunc func(ObjectWithConditions, []StatusProvider) (ObjectWithConditions, []StatusProvider) +type MutatorFunc func(ObjectWithConditions, []Provider) (ObjectWithConditions, []Provider) -var NoopStatusMutator = StatusMutatorFunc(func(o ObjectWithConditions, s []StatusProvider) (ObjectWithConditions, []StatusProvider) { return o, s }) +var NoopStatusMutator = MutatorFunc(func(o ObjectWithConditions, s []Provider) (ObjectWithConditions, []Provider) { return o, s }) -type StatusMapperFunc func(string, ResourceStatus) ResourceStatus +type MapperFunc func(string, Status) Status -var NoopStatusMapper = StatusMapperFunc(func(_ string, status ResourceStatus) ResourceStatus { +var NoopStatusMapper = MapperFunc(func(_ string, status Status) Status { return status }) -func (s *StatusManager) Update(ctx context.Context, obj ObjectWithConditions, mutatorFunc StatusMutatorFunc, statusMapperFunc StatusMapperFunc, results ...StatusProvider) error { +func (s *Manager) Update(ctx context.Context, obj ObjectWithConditions, mutatorFunc MutatorFunc, statusMapperFunc MapperFunc, results ...Provider) error { logger := log.FromContext(ctx) oldObj := obj.DeepCopyObject().(ObjectWithConditions) @@ -128,7 +128,7 @@ func (s *StatusManager) Update(ctx context.Context, obj ObjectWithConditions, mu return s.Status().Patch(ctx, obj, client.MergeFrom(oldObj)) } -func (s *StatusManager) InitializeConditions(ctx context.Context, obj ObjectWithConditions, conditionTypes ...string) error { +func (s *Manager) InitializeConditions(ctx context.Context, obj ObjectWithConditions, conditionTypes ...string) error { oldObj := obj.DeepCopyObject().(ObjectWithConditions) patchBase := client.MergeFrom(oldObj) @@ -158,15 +158,15 @@ func (s *StatusManager) InitializeConditions(ctx context.Context, obj ObjectWith return nil } -func ConsolidateConditions(obj ObjectWithConditions, results ...StatusProvider) []StatusProvider { - var result []StatusProvider +func DetermineConditions(obj ObjectWithConditions, results ...Provider) []Provider { + var result []Provider conditionTypes := obj.GetConditionTypesForUpdate() if len(results) == 0 { return result } - var decisionRes StatusProvider + var decisionRes Provider for _, res := range results { if res == nil { continue @@ -198,3 +198,18 @@ func ConsolidateConditions(obj ObjectWithConditions, results ...StatusProvider) return result } + +type Status struct { + ConditionType string + Observed bool + Status metav1.ConditionStatus + ObservedGeneration int64 + Reason string + Message string + NotReflectable bool + Err error +} + +func (s Status) IsReady() bool { + return s.Status == metav1.ConditionTrue && s.Observed +} diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddon/reconciler.go b/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go similarity index 70% rename from images/operator-helm-artifact/internal/controller/helmclusteraddon/reconciler.go rename to images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go index 32f6725..9f36f02 100644 --- a/images/operator-helm-artifact/internal/controller/helmclusteraddon/reconciler.go +++ b/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go @@ -30,21 +30,40 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/services" "github.com/deckhouse/operator-helm/internal/utils" ) -type reconciler struct { +func New( + client client.Client, + chartService *services.ChartService, + ociRepositoryService *services.OCIRepoService, + releaseService *services.ReleaseService, + maintenanceService *services.MaintenanceService, + statusManager *status.Manager, +) *Reconciler { + return &Reconciler{ + Client: client, + chartService: chartService, + ociRepositoryService: ociRepositoryService, + releaseService: releaseService, + maintenanceService: maintenanceService, + statusManager: statusManager, + } +} + +type Reconciler struct { client.Client chartService *services.ChartService ociRepositoryService *services.OCIRepoService releaseService *services.ReleaseService maintenanceService *services.MaintenanceService - statusManager *services.StatusManager + statusManager *status.Manager } -func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { logger := log.FromContext(ctx) ctx = log.IntoContext(ctx, logger) @@ -80,14 +99,14 @@ func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return reconcile.Result{}, err } - maintenanceRes := r.maintenanceService.EnsureMaintenanceMode(ctx, addon) - if maintenanceRes.StatusUpdateRequired { - return reconcile.Result{}, r.statusManager.Update(ctx, addon, services.NoopStatusMutator, services.NoopStatusMapper, maintenanceRes) + if r.maintenanceService.IsMaintenanceModeChangeRequired(addon) { + maintenanceRes := r.maintenanceService.EnsureMaintenanceMode(ctx, addon) + return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, maintenanceRes) } repo := &helmv1alpha1.HelmClusterAddonRepository{} if err := r.Get(ctx, types.NamespacedName{Name: addon.Spec.Chart.HelmClusterAddonRepository}, repo); err != nil { - return reconcile.Result{}, r.statusManager.Update(ctx, addon, services.NoopStatusMutator, services.NoopStatusMapper, services.ReleaseResult{Status: services.Failed( + return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( addon, helmv1alpha1.ReasonFailed, "Failed to get internal repository", @@ -97,7 +116,7 @@ func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco repoType, err := utils.GetRepositoryType(repo.Spec.URL) if err != nil { - return reconcile.Result{}, r.statusManager.Update(ctx, addon, services.NoopStatusMutator, services.NoopStatusMapper, services.ReleaseResult{Status: services.Failed( + return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( addon, helmv1alpha1.ReasonFailed, fmt.Sprintf("Failed to parse repository type: %s", err.Error()), @@ -111,6 +130,15 @@ func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco switch repoType { case utils.InternalHelmRepository: + // URL change in the HelmClusterAddonRepository may lead to repository type change. + // If repository type changed from OCI to Helm, we need to remove previously created OCI repository. + if err := r.ociRepositoryService.RemoveOCIRepository(ctx, addon); err != nil { + chartRes = services.ChartResult{ + Status: status.Failed(addon, helmv1alpha1.ReasonFailed, "Repository change failed", err), + } + break + } + chartRes = r.chartService.EnsureHelmChart(ctx, addon) if !chartRes.IsPartiallyDegraded() { apimeta.SetStatusCondition(&addon.Status.Conditions, metav1.Condition{ @@ -131,7 +159,7 @@ func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco }) } default: - return reconcile.Result{}, r.statusManager.Update(ctx, addon, services.NoopStatusMutator, services.NoopStatusMapper, services.ReleaseResult{Status: services.Failed( + return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( addon, helmv1alpha1.ReasonFailed, fmt.Sprintf("Unsupported repository type: %s", repoType), @@ -144,7 +172,7 @@ func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco } if !releaseRes.IsReady() { - releaseRes = services.ReleaseResult{Status: services.Failed( + releaseRes = services.ReleaseResult{Status: status.Failed( addon, releaseRes.Status.Reason, releaseRes.Status.Message, @@ -152,7 +180,7 @@ func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco )} } - return reconcile.Result{}, r.statusManager.Update( + if err := r.statusManager.Update( ctx, addon, setStatusAttrs(repoType, chartRes, repoRes, releaseRes), @@ -160,31 +188,41 @@ func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco chartRes, repoRes, releaseRes, - ) + ); client.IgnoreNotFound(err) != nil { + return reconcile.Result{}, fmt.Errorf("failed to update status: %w", err) + } + + return reconcile.Result{}, nil } -func (r *reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (reconcile.Result, error) { +func (r *Reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (reconcile.Result, error) { logger := log.FromContext(ctx) if !controllerutil.ContainsFinalizer(addon, helmv1alpha1.FinalizerName) { return reconcile.Result{}, nil } - if err := r.ociRepositoryService.RemoveOCIRepository(ctx, addon); err != nil && !apierrors.IsNotFound(err) { + if err := r.ociRepositoryService.RemoveOCIRepository(ctx, addon); client.IgnoreNotFound(err) != nil { return reconcile.Result{}, err } - if err := r.chartService.CleanupHelmChart(ctx, addon); err != nil && !apierrors.IsNotFound(err) { + if err := r.chartService.CleanupHelmChart(ctx, addon); client.IgnoreNotFound(err) != nil { return reconcile.Result{}, err } - if err := r.releaseService.CleanupHelmRelease(ctx, addon); err != nil && !apierrors.IsNotFound(err) { + if err := r.releaseService.CleanupHelmRelease(ctx, addon); client.IgnoreNotFound(err) != nil { return reconcile.Result{}, err } - controllerutil.RemoveFinalizer(addon, helmv1alpha1.FinalizerName) - if err := r.Update(ctx, addon); err != nil && !apierrors.IsNotFound(err) { - return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + latestAddon := &helmv1alpha1.HelmClusterAddon{} + if err := r.Get(ctx, client.ObjectKeyFromObject(addon), latestAddon); err != nil { + return reconcile.Result{}, client.IgnoreNotFound(err) + } + + if controllerutil.RemoveFinalizer(latestAddon, helmv1alpha1.FinalizerName) { + if err := r.Update(ctx, latestAddon); err != nil { + return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + } } logger.Info("Cleanup complete") @@ -192,9 +230,9 @@ func (r *reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.He return reconcile.Result{}, nil } -func setStatusAttrs(repoType utils.InternalRepositoryType, chartRes services.ChartResult, repoRes services.OCIRepoResult, releaseRes services.ReleaseResult) services.StatusMutatorFunc { - return func(obj services.ObjectWithConditions, results []services.StatusProvider) (services.ObjectWithConditions, []services.StatusProvider) { - results = services.ConsolidateConditions(obj, results...) +func setStatusAttrs(repoType utils.InternalRepositoryType, chartRes services.ChartResult, repoRes services.OCIRepoResult, releaseRes services.ReleaseResult) status.MutatorFunc { + return func(obj status.ObjectWithConditions, results []status.Provider) (status.ObjectWithConditions, []status.Provider) { + results = status.DetermineConditions(obj, results...) addon := obj.(*helmv1alpha1.HelmClusterAddon) var updateChart, updateValues bool @@ -250,8 +288,8 @@ func setStatusAttrs(repoType utils.InternalRepositoryType, chartRes services.Cha } } -func mapResourceStatus() services.StatusMapperFunc { - return func(conditionType string, status services.ResourceStatus) services.ResourceStatus { +func mapResourceStatus() status.MapperFunc { + return func(conditionType string, status status.Status) status.Status { if conditionType == helmv1alpha1.ConditionTypePartiallyDegraded { // ConditionTrue means that HelmChartSucceeded, resetting status would exclude it from result. if status.Status == metav1.ConditionTrue { diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/reconciler.go b/images/operator-helm-artifact/internal/reconcile/helmclusteraddonrepository/reconciler.go similarity index 63% rename from images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/reconciler.go rename to images/operator-helm-artifact/internal/reconcile/helmclusteraddonrepository/reconciler.go index e15ea0c..03fd9d8 100644 --- a/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/reconciler.go +++ b/images/operator-helm-artifact/internal/reconcile/helmclusteraddonrepository/reconciler.go @@ -21,30 +21,45 @@ import ( "fmt" "time" - "github.com/werf/3p-fluxcd-pkg/apis/meta" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/services" "github.com/deckhouse/operator-helm/internal/utils" ) -type reconciler struct { +func New( + client client.Client, + helmRepositoryService *services.HelmRepoService, + ociRepositoryService *services.OCIRepoService, + chartSyncService *services.RepoSyncService, + statusManager *status.Manager, +) *Reconciler { + return &Reconciler{ + Client: client, + helmRepositoryService: helmRepositoryService, + ociRepositoryService: ociRepositoryService, + chartSyncService: chartSyncService, + statusManager: statusManager, + } +} + +type Reconciler struct { client.Client helmRepositoryService *services.HelmRepoService ociRepositoryService *services.OCIRepoService chartSyncService *services.RepoSyncService - statusManager *services.StatusManager + statusManager *status.Manager } -func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { logger := log.FromContext(ctx) ctx = log.IntoContext(ctx, logger) @@ -90,37 +105,40 @@ func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco case utils.InternalHelmRepository: helmRepoRes = r.helmRepositoryService.EnsureInternalHelmRepository(ctx, &repo) case utils.InternalOCIRepository: - // TODO: need to add extra check to ensure that URL provided by user is valid OCI url and credentials are correct. - // Otherwise permanent ready status is invalid. - ociRepoRes = services.OCIRepoResult{ - Artifact: &meta.Artifact{}, - Status: services.ResourceStatus{ - ConditionType: helmv1alpha1.ConditionTypeReady, - Observed: true, - Status: metav1.ConditionTrue, - ObservedGeneration: repo.Generation, - Reason: helmv1alpha1.ReasonSuccess, - }, + if err := r.helmRepositoryService.CleanupHelmRepository(ctx, utils.GetInternalHelmRepositoryName(repo.Name)); err != nil { + ociRepoRes = services.OCIRepoResult{ + Status: status.Failed(&repo, helmv1alpha1.ReasonFailed, "Repository change failed", err), + } + break } + ociRepoRes = r.ociRepositoryService.EnsureRepositorySecrets(ctx, &repo) default: err := fmt.Errorf("unsupported repository type: %q", repoType) - helmRepoRes = services.HelmRepoResult{Status: services.Failed(&repo, "UnsupportedRepositoryType", err.Error(), err)} + helmRepoRes = services.HelmRepoResult{Status: status.Failed(&repo, "UnsupportedRepositoryType", err.Error(), err)} } if helmRepoRes.IsReady() || ociRepoRes.IsReady() { chartSyncRes = r.chartSyncService.EnsureAddonCharts(ctx, &repo, repoType) } else { - chartSyncRes = services.RepoSyncResult{Status: services.Failed(&repo, helmv1alpha1.ReasonRepositoryNotReady, helmRepoRes.Status.Message, err)} + chartSyncRes = services.RepoSyncResult{Status: status.Failed(&repo, helmv1alpha1.ReasonRepositoryNotReady, helmRepoRes.Status.Message, err)} } - if err := r.statusManager.Update(ctx, &repo, services.NoopStatusMutator, services.NoopStatusMapper, helmRepoRes, ociRepoRes, chartSyncRes); err != nil { + if err := r.statusManager.Update( + ctx, + &repo, + status.NoopStatusMutator, + status.NoopStatusMapper, + helmRepoRes, + ociRepoRes, + chartSyncRes, + ); client.IgnoreNotFound(err) != nil { return reconcile.Result{}, fmt.Errorf("failed to update status: %w", err) } return r.requeueAtSyncInterval(&repo) } -func (r *reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) (reconcile.Result, error) { +func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) (reconcile.Result, error) { logger := log.FromContext(ctx) if !controllerutil.ContainsFinalizer(repo, helmv1alpha1.FinalizerName) { @@ -130,23 +148,29 @@ func (r *reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.Hel switch repoType { case utils.InternalHelmRepository: if err := r.helmRepositoryService.CleanupHelmRepository(ctx, repo.Name); err != nil && !apierrors.IsNotFound(err) { - _ = r.statusManager.Update(ctx, repo, services.NoopStatusMutator, services.NoopStatusMapper, services.HelmRepoResult{ - Status: services.Failed(repo, helmv1alpha1.ReasonFailed, "Failed to remove dependencies", err), + _ = r.statusManager.Update(ctx, repo, status.NoopStatusMutator, status.NoopStatusMapper, services.HelmRepoResult{ + Status: status.Failed(repo, helmv1alpha1.ReasonFailed, "Failed to remove dependencies", err), }) return reconcile.Result{}, err } case utils.InternalOCIRepository: if err := r.ociRepositoryService.CleanupOCIRepository(ctx, repo.Name); err != nil && !apierrors.IsNotFound(err) { - _ = r.statusManager.Update(ctx, repo, services.NoopStatusMutator, services.NoopStatusMapper, services.HelmRepoResult{ - Status: services.Failed(repo, helmv1alpha1.ReasonFailed, "Failed to remove dependencies", err), + _ = r.statusManager.Update(ctx, repo, status.NoopStatusMutator, status.NoopStatusMapper, services.HelmRepoResult{ + Status: status.Failed(repo, helmv1alpha1.ReasonFailed, "Failed to remove dependencies", err), }) return reconcile.Result{}, err } } - controllerutil.RemoveFinalizer(repo, helmv1alpha1.FinalizerName) - if err := r.Update(ctx, repo); err != nil && !apierrors.IsNotFound(err) { - return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + latestRepo := &helmv1alpha1.HelmClusterAddonRepository{} + if err := r.Get(ctx, client.ObjectKeyFromObject(repo), latestRepo); err != nil { + return reconcile.Result{}, client.IgnoreNotFound(err) + } + + if controllerutil.RemoveFinalizer(latestRepo, helmv1alpha1.FinalizerName) { + if err := r.Update(ctx, latestRepo); err != nil { + return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + } } logger.Info("Cleanup complete") @@ -154,7 +178,7 @@ func (r *reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.Hel return reconcile.Result{}, nil } -func (r *reconciler) requeueAtSyncInterval(repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { +func (r *Reconciler) requeueAtSyncInterval(repo *helmv1alpha1.HelmClusterAddonRepository) (reconcile.Result, error) { repoSyncCond := apimeta.FindStatusCondition(repo.Status.Conditions, helmv1alpha1.ConditionTypeSynced) if repoSyncCond != nil { remaining := time.Until(repoSyncCond.LastTransitionTime.Add(services.ChartsSyncInterval)) diff --git a/images/operator-helm-artifact/internal/services/base_repo.go b/images/operator-helm-artifact/internal/services/base.go similarity index 78% rename from images/operator-helm-artifact/internal/services/base_repo.go rename to images/operator-helm-artifact/internal/services/base.go index 4e24474..7f45897 100644 --- a/images/operator-helm-artifact/internal/services/base_repo.go +++ b/images/operator-helm-artifact/internal/services/base.go @@ -22,21 +22,41 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/deckhouse/operator-helm/internal/utils" ) +type BaseService struct { + Client client.Client + Scheme *runtime.Scheme +} + +func (s *BaseService) ensureResourceDeleted(ctx context.Context, nn types.NamespacedName, obj client.Object) error { + err := s.Client.Get(ctx, nn, obj) + if err != nil { + return client.IgnoreNotFound(err) + } + + if err := s.Client.Delete(ctx, obj); client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to delete resource %s/%s: %w", nn.Namespace, nn.Name, err) + } + + return nil +} + type BaseRepoService struct { BaseService TargetNamespace string } -func (s *BaseRepoService) reconcileAuthSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) error { - secretName := utils.GetInternalRepositoryAuthSecretName(repoType, repo.Name) +func (s *BaseRepoService) reconcileAuthSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) error { + secretName := utils.GetInternalRepositoryAuthSecretName(repo.Name) if repo.Spec.Auth == nil { nn := types.NamespacedName{Name: secretName, Namespace: s.TargetNamespace} @@ -72,8 +92,8 @@ func (s *BaseRepoService) reconcileAuthSecret(ctx context.Context, repo *helmv1a return nil } -func (s *BaseRepoService) reconcileTLSSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) error { - secretName := utils.GetInternalRepositoryTLSSecretName(repoType, repo.Name) +func (s *BaseRepoService) reconcileTLSSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) error { + secretName := utils.GetInternalRepositoryTLSSecretName(repo.Name) if repo.Spec.CACertificate == "" { nn := types.NamespacedName{Name: secretName, Namespace: s.TargetNamespace} diff --git a/images/operator-helm-artifact/internal/services/chart.go b/images/operator-helm-artifact/internal/services/chart_service.go similarity index 88% rename from images/operator-helm-artifact/internal/services/chart.go rename to images/operator-helm-artifact/internal/services/chart_service.go index 7e76d2b..7dfb76a 100644 --- a/images/operator-helm-artifact/internal/services/chart.go +++ b/images/operator-helm-artifact/internal/services/chart_service.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -49,12 +50,14 @@ func NewChartService(client client.Client, scheme *runtime.Scheme, targetNamespa } } +var _ status.Provider = (*ChartResult)(nil) + type ChartResult struct { - Status ResourceStatus + Status status.Status Artifact *meta.Artifact } -func (r ChartResult) GetStatus() ResourceStatus { +func (r ChartResult) GetStatus() status.Status { return r.Status } @@ -90,7 +93,7 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, addon *helmv1alpha1. return nil }) if err != nil { - return ChartResult{Status: Failed( + return ChartResult{Status: status.Failed( addon, helmv1alpha1.ReasonHelmChartFailed, "Failed to create helm chart", @@ -102,11 +105,11 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, addon *helmv1alpha1. logger.Info("Reconciled helm chart", "operation", op) } - if cond, ok := utils.IsConditionObserved(existing.GetConditions(), helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + if cond, ok := status.IsConditionObserved(existing.GetConditions(), helmv1alpha1.ConditionTypeReady, existing.Generation); ok { logger.Info("Successfully reconciled helm chart", "operation", op, "chart", addon.Spec.Chart.HelmClusterAddonChartName) return ChartResult{ Artifact: existing.Status.Artifact, - Status: ResourceStatus{ + Status: status.Status{ Observed: ok, Status: cond.Status, ObservedGeneration: addon.Generation, @@ -117,7 +120,7 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, addon *helmv1alpha1. } } - return ChartResult{Status: Unknown(addon, helmv1alpha1.ReasonReconciling)} + return ChartResult{Status: status.Unknown(addon, helmv1alpha1.ReasonReconciling)} } func (s *ChartService) CleanupHelmChart(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { @@ -144,6 +147,6 @@ func applyHelmChartSpec(addon *helmv1alpha1.HelmClusterAddon, existing *sourcev1 existing.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ Kind: sourcev1.HelmRepositoryKind, - Name: addon.Spec.Chart.HelmClusterAddonRepository, + Name: utils.GetInternalHelmRepositoryName(addon.Spec.Chart.HelmClusterAddonRepository), } } diff --git a/images/operator-helm-artifact/internal/services/helm_repo.go b/images/operator-helm-artifact/internal/services/helm_repo_service.go similarity index 78% rename from images/operator-helm-artifact/internal/services/helm_repo.go rename to images/operator-helm-artifact/internal/services/helm_repo_service.go index ea2d003..6901e9a 100644 --- a/images/operator-helm-artifact/internal/services/helm_repo.go +++ b/images/operator-helm-artifact/internal/services/helm_repo_service.go @@ -32,6 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -64,11 +65,13 @@ func NewHelmRepoService(client client.Client, scheme *runtime.Scheme, namespace } } +var _ status.Provider = (*HelmRepoResult)(nil) + type HelmRepoResult struct { - Status ResourceStatus + Status status.Status } -func (r HelmRepoResult) GetStatus() ResourceStatus { +func (r HelmRepoResult) GetStatus() status.Status { return r.Status } @@ -83,17 +86,17 @@ func (r HelmRepoResult) GetConditionType() string { func (s *HelmRepoService) EnsureInternalHelmRepository(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) HelmRepoResult { logger := log.FromContext(ctx) - if err := s.reconcileAuthSecret(ctx, repo, utils.InternalHelmRepository); err != nil { - return HelmRepoResult{Status: Failed(repo, helmv1alpha1.ReasonFailed, "Failed to reconcile auth secret", err)} + if err := s.reconcileAuthSecret(ctx, repo); err != nil { + return HelmRepoResult{Status: status.Failed(repo, helmv1alpha1.ReasonFailed, "Failed to reconcile auth secret", err)} } - if err := s.reconcileTLSSecret(ctx, repo, utils.InternalHelmRepository); err != nil { - return HelmRepoResult{Status: Failed(repo, helmv1alpha1.ReasonFailed, "Failed to reconcile tls secret", err)} + if err := s.reconcileTLSSecret(ctx, repo); err != nil { + return HelmRepoResult{Status: status.Failed(repo, helmv1alpha1.ReasonFailed, "Failed to reconcile tls secret", err)} } existing := &sourcev1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ - Name: repo.Name, + Name: utils.GetInternalHelmRepositoryName(repo.Name), Namespace: s.TargetNamespace, }, } @@ -105,7 +108,7 @@ func (s *HelmRepoService) EnsureInternalHelmRepository(ctx context.Context, repo }) if err != nil { return HelmRepoResult{ - Status: Failed( + Status: status.Failed( repo, helmv1alpha1.ReasonFailed, "Failed to reconcile helm repository", @@ -117,8 +120,8 @@ func (s *HelmRepoService) EnsureInternalHelmRepository(ctx context.Context, repo logger.Info("Reconciled helm repository", "operation", op) } - if cond, ok := utils.IsConditionObserved(existing.Status.Conditions, helmv1alpha1.ConditionTypeReady, existing.Generation); ok { - return HelmRepoResult{Status: ResourceStatus{ + if cond, ok := status.IsConditionObserved(existing.Status.Conditions, helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + return HelmRepoResult{Status: status.Status{ Observed: ok, Status: cond.Status, ObservedGeneration: repo.Generation, @@ -127,7 +130,7 @@ func (s *HelmRepoService) EnsureInternalHelmRepository(ctx context.Context, repo }} } - return HelmRepoResult{Status: Unknown(repo, helmv1alpha1.ReasonReconciling)} + return HelmRepoResult{Status: status.Unknown(repo, helmv1alpha1.ReasonReconciling)} } func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, repoName string) error { @@ -136,11 +139,11 @@ func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, repoName st obj client.Object }{ { - name: utils.GetInternalRepositoryAuthSecretName(utils.InternalHelmRepository, repoName), + name: utils.GetInternalRepositoryAuthSecretName(repoName), obj: &corev1.Secret{}, }, { - name: utils.GetInternalRepositoryTLSSecretName(utils.InternalHelmRepository, repoName), + name: utils.GetInternalRepositoryTLSSecretName(repoName), obj: &corev1.Secret{}, }, { @@ -168,14 +171,14 @@ func applyHelmRepositorySpec(repo *helmv1alpha1.HelmClusterAddonRepository, exis if repo.Spec.Auth != nil { existing.Spec.SecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryAuthSecretName(utils.InternalHelmRepository, repo.Name), + Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), } existing.Spec.PassCredentials = true } if repo.Spec.CACertificate != "" { existing.Spec.CertSecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryTLSSecretName(utils.InternalHelmRepository, repo.Name), + Name: utils.GetInternalRepositoryTLSSecretName(repo.Name), } } diff --git a/images/operator-helm-artifact/internal/services/maintenance.go b/images/operator-helm-artifact/internal/services/maintenance_service.go similarity index 72% rename from images/operator-helm-artifact/internal/services/maintenance.go rename to images/operator-helm-artifact/internal/services/maintenance_service.go index 091c952..9c3fde4 100644 --- a/images/operator-helm-artifact/internal/services/maintenance.go +++ b/images/operator-helm-artifact/internal/services/maintenance_service.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + statusmgr "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -49,12 +50,13 @@ func NewMaintenanceService(client client.Client, scheme *runtime.Scheme, targetN } } +var _ statusmgr.Provider = (*MaintenanceResult)(nil) + type MaintenanceResult struct { - Status ResourceStatus - StatusUpdateRequired bool + Status statusmgr.Status } -func (r MaintenanceResult) GetStatus() ResourceStatus { +func (r MaintenanceResult) GetStatus() statusmgr.Status { return r.Status } @@ -69,40 +71,43 @@ func (r MaintenanceResult) GetConditionType() string { func (s *MaintenanceService) EnsureMaintenanceMode(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) MaintenanceResult { logger := log.FromContext(ctx) - if addon.MaintenanceModeActivated() && !addon.MaintenanceModeEnabled() { + suspendState := addon.MaintenanceModeActivated() + status := metav1.ConditionTrue + reason := helmv1alpha1.ReasonMaintenanceModeInactive + + if suspendState { logger.Info("Enabling maintenance mode") - err := s.updateHelmReleaseSuspendState(ctx, addon, true) - if err != nil { - return MaintenanceResult{StatusUpdateRequired: true, Status: Failed(addon, helmv1alpha1.ReasonFailed, "Failed to enable maintenance mode", err)} - } - return MaintenanceResult{ - StatusUpdateRequired: true, - Status: ResourceStatus{ - Status: metav1.ConditionFalse, - ObservedGeneration: addon.Generation, - Reason: helmv1alpha1.ReasonMaintenanceModeActive, - }, - } + status = metav1.ConditionFalse + reason = helmv1alpha1.ReasonMaintenanceModeActive + } else { + logger.Info("Disabling maintenance mode") + } + + err := s.updateHelmReleaseSuspendState(ctx, addon, suspendState) + if err != nil { + return MaintenanceResult{Status: statusmgr.Failed(addon, helmv1alpha1.ReasonFailed, "Failed to enable maintenance mode", err)} + } + return MaintenanceResult{ + Status: statusmgr.Status{ + Observed: true, + Status: status, + ObservedGeneration: addon.Generation, + Reason: reason, + }, + } +} + +func (s *MaintenanceService) IsMaintenanceModeChangeRequired(addon *helmv1alpha1.HelmClusterAddon) bool { + if addon.MaintenanceModeActivated() && !addon.MaintenanceModeEnabled() { + return true } if !addon.MaintenanceModeActivated() && (addon.MaintenanceModeEnabled() || apimeta.IsStatusConditionPresentAndEqual(addon.Status.Conditions, helmv1alpha1.ConditionTypeManaged, metav1.ConditionUnknown)) { - logger.Info("Disabling maintenance mode") - err := s.updateHelmReleaseSuspendState(ctx, addon, false) - if err != nil { - return MaintenanceResult{StatusUpdateRequired: true, Status: Failed(addon, helmv1alpha1.ReasonFailed, "Failed to disable maintenance mode", nil)} - } - return MaintenanceResult{ - StatusUpdateRequired: true, - Status: ResourceStatus{ - Status: metav1.ConditionTrue, - ObservedGeneration: addon.Generation, - Reason: helmv1alpha1.ReasonMaintenanceModeInactive, - }, - } + return true } - return MaintenanceResult{Status: Success(addon)} + return false } func (s *MaintenanceService) updateHelmReleaseSuspendState(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon, suspend bool) error { diff --git a/images/operator-helm-artifact/internal/services/oci_repo.go b/images/operator-helm-artifact/internal/services/oci_repo_service.go similarity index 72% rename from images/operator-helm-artifact/internal/services/oci_repo.go rename to images/operator-helm-artifact/internal/services/oci_repo_service.go index f4709e7..3e039e6 100644 --- a/images/operator-helm-artifact/internal/services/oci_repo.go +++ b/images/operator-helm-artifact/internal/services/oci_repo_service.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -58,12 +59,14 @@ func NewOCIRepoService(client client.Client, scheme *runtime.Scheme, namespace s } } +var _ status.Provider = (*OCIRepoResult)(nil) + type OCIRepoResult struct { - Status ResourceStatus + Status status.Status Artifact *meta.Artifact } -func (r OCIRepoResult) GetStatus() ResourceStatus { +func (r OCIRepoResult) GetStatus() status.Status { return r.Status } @@ -89,14 +92,6 @@ func (r OCIRepoResult) GetConditionType() string { func (s *OCIRepoService) EnsureInternalOCIRepository(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon, repo *helmv1alpha1.HelmClusterAddonRepository) OCIRepoResult { logger := log.FromContext(ctx) - if err := s.reconcileAuthSecret(ctx, repo, utils.InternalHelmRepository); err != nil { - return OCIRepoResult{Status: Failed(addon, helmv1alpha1.ReasonFailed, "Failed to reconcile auth secret", err)} - } - - if err := s.reconcileTLSSecret(ctx, repo, utils.InternalHelmRepository); err != nil { - return OCIRepoResult{Status: Failed(addon, helmv1alpha1.ReasonFailed, "Failed to reconcile tls secret", err)} - } - existing := &sourcev1.OCIRepository{ ObjectMeta: metav1.ObjectMeta{ Name: utils.GetInternalOCIRepositoryName(addon.Name), @@ -111,7 +106,7 @@ func (s *OCIRepoService) EnsureInternalOCIRepository(ctx context.Context, addon }) if err != nil { return OCIRepoResult{ - Status: Failed( + Status: status.Failed( addon, helmv1alpha1.ReasonFailed, "Failed to reconcile oci repository", @@ -123,10 +118,10 @@ func (s *OCIRepoService) EnsureInternalOCIRepository(ctx context.Context, addon logger.Info("Reconciled oci repository", "operation", op) } - if cond, ok := utils.IsConditionObserved(existing.Status.Conditions, helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + if cond, ok := status.IsConditionObserved(existing.Status.Conditions, helmv1alpha1.ConditionTypeReady, existing.Generation); ok { return OCIRepoResult{ Artifact: existing.Status.Artifact, - Status: ResourceStatus{ + Status: status.Status{ Observed: ok, Status: cond.Status, ObservedGeneration: addon.Generation, @@ -137,7 +132,48 @@ func (s *OCIRepoService) EnsureInternalOCIRepository(ctx context.Context, addon } } - return OCIRepoResult{Status: Unknown(addon, helmv1alpha1.ReasonReconciling)} + return OCIRepoResult{Status: status.Unknown(addon, helmv1alpha1.ReasonReconciling)} +} + +func (s *OCIRepoService) EnsureRepositorySecrets(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) OCIRepoResult { + if err := s.reconcileAuthSecret(ctx, repo); err != nil { + return OCIRepoResult{ + Status: status.Status{ + ConditionType: helmv1alpha1.ConditionTypeReady, + Observed: true, + Status: metav1.ConditionFalse, + ObservedGeneration: repo.Generation, + Reason: helmv1alpha1.ReasonFailed, + Message: "Failed to reconcile auth secret", + Err: err, + }, + } + } + + if err := s.reconcileTLSSecret(ctx, repo); err != nil { + return OCIRepoResult{ + Status: status.Status{ + ConditionType: helmv1alpha1.ConditionTypeReady, + Observed: true, + Status: metav1.ConditionFalse, + ObservedGeneration: repo.Generation, + Reason: helmv1alpha1.ReasonFailed, + Message: "Failed to reconcile tls secret", + Err: err, + }, + } + } + + return OCIRepoResult{ + Artifact: &meta.Artifact{}, + Status: status.Status{ + ConditionType: helmv1alpha1.ConditionTypeReady, + Observed: true, + Status: metav1.ConditionTrue, + ObservedGeneration: repo.Generation, + Reason: helmv1alpha1.ReasonSuccess, + }, + } } func (s *OCIRepoService) CleanupOCIRepository(ctx context.Context, repoName string) error { @@ -146,11 +182,11 @@ func (s *OCIRepoService) CleanupOCIRepository(ctx context.Context, repoName stri obj client.Object }{ { - name: utils.GetInternalRepositoryAuthSecretName(utils.InternalHelmRepository, repoName), + name: utils.GetInternalRepositoryAuthSecretName(repoName), obj: &corev1.Secret{}, }, { - name: utils.GetInternalRepositoryTLSSecretName(utils.InternalHelmRepository, repoName), + name: utils.GetInternalRepositoryTLSSecretName(repoName), obj: &corev1.Secret{}, }, } @@ -187,13 +223,13 @@ func applyOCIRepositorySpec(addon *helmv1alpha1.HelmClusterAddon, repo *helmv1al if repo.Spec.Auth != nil { existing.Spec.SecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryAuthSecretName(utils.InternalOCIRepository, repo.Name), + Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), } } if repo.Spec.CACertificate != "" { existing.Spec.CertSecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryTLSSecretName(utils.InternalOCIRepository, repo.Name), + Name: utils.GetInternalRepositoryTLSSecretName(repo.Name), } } diff --git a/images/operator-helm-artifact/internal/services/release.go b/images/operator-helm-artifact/internal/services/release_service.go similarity index 90% rename from images/operator-helm-artifact/internal/services/release.go rename to images/operator-helm-artifact/internal/services/release_service.go index a8ead2c..d1b349b 100644 --- a/images/operator-helm-artifact/internal/services/release.go +++ b/images/operator-helm-artifact/internal/services/release_service.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -49,12 +50,14 @@ func NewReleaseService(client client.Client, scheme *runtime.Scheme, targetNames } } +var _ status.Provider = (*ReleaseResult)(nil) + type ReleaseResult struct { - Status ResourceStatus + Status status.Status History helmv2.Snapshots } -func (r ReleaseResult) GetStatus() ResourceStatus { +func (r ReleaseResult) GetStatus() status.Status { return r.Status } @@ -80,7 +83,7 @@ func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, addon *helmv1alp return applyHelmReleaseSpec(addon, existing, repoType, s.TargetNamespace) }) if err != nil { - return ReleaseResult{Status: Failed( + return ReleaseResult{Status: status.Failed( addon, helmv1alpha1.ReasonHelmReleaseFailed, "Failed to create helm release", @@ -88,11 +91,11 @@ func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, addon *helmv1alp )} } - if cond, ok := utils.IsConditionObserved(existing.GetConditions(), helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + if cond, ok := status.IsConditionObserved(existing.GetConditions(), helmv1alpha1.ConditionTypeReady, existing.Generation); ok { logger.Info("Successfully reconciled helm release", "operation", op) return ReleaseResult{ History: existing.Status.History, - Status: ResourceStatus{ + Status: status.Status{ Observed: ok, Status: cond.Status, ObservedGeneration: addon.Generation, @@ -102,7 +105,7 @@ func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, addon *helmv1alp } } - return ReleaseResult{Status: Unknown(addon, helmv1alpha1.ReasonReconciling)} + return ReleaseResult{Status: status.Unknown(addon, helmv1alpha1.ReasonReconciling)} } func (s *ReleaseService) CleanupHelmRelease(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { diff --git a/images/operator-helm-artifact/internal/services/repo_sync.go b/images/operator-helm-artifact/internal/services/repo_sync_service.go similarity index 83% rename from images/operator-helm-artifact/internal/services/repo_sync.go rename to images/operator-helm-artifact/internal/services/repo_sync_service.go index 1edd1ba..8635e41 100644 --- a/images/operator-helm-artifact/internal/services/repo_sync.go +++ b/images/operator-helm-artifact/internal/services/repo_sync_service.go @@ -32,6 +32,7 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" repoclient "github.com/deckhouse/operator-helm/internal/client/repository" + "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -57,11 +58,13 @@ func NewRepoSyncService(client client.Client, scheme *runtime.Scheme) *RepoSyncS } } +var _ status.Provider = (*RepoSyncResult)(nil) + type RepoSyncResult struct { - Status ResourceStatus + Status status.Status } -func (r RepoSyncResult) GetStatus() ResourceStatus { +func (r RepoSyncResult) GetStatus() status.Status { return r.Status } @@ -77,15 +80,15 @@ func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alp logger := log.FromContext(ctx) if !isRepoSyncRequired(repo) { - return RepoSyncResult{Status: Success(repo)} + return RepoSyncResult{Status: status.Empty()} } else if !isRepoSyncInProgress(repo) { - return RepoSyncResult{Status: Unknown(repo, helmv1alpha1.ReasonReconciling)} + return RepoSyncResult{Status: status.Unknown(repo, helmv1alpha1.ReasonReconciling)} } repoClient, err := repoclient.NewClient(repoType) if err != nil { return RepoSyncResult{ - Status: Failed( + Status: status.Failed( repo, helmv1alpha1.ReasonSyncFailed, "Failed to get repository client on chart sync", @@ -105,7 +108,7 @@ func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alp charts, err := repoClient.FetchCharts(ctx, repo.Spec.URL, authConfig) if err != nil { return RepoSyncResult{ - Status: Failed( + Status: status.Failed( repo, helmv1alpha1.ReasonSyncFailed, "Failed to fetch charts from repository", @@ -146,7 +149,7 @@ func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alp }) if err != nil { return RepoSyncResult{ - Status: Failed( + Status: status.Failed( repo, helmv1alpha1.ReasonSyncFailed, fmt.Sprintf("Failed to create HelmClusterAddonChart %q", addonChartName), @@ -155,17 +158,6 @@ func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alp } } - existingVersionsMap := make(map[string]helmv1alpha1.HelmClusterAddonChartVersion) - for _, version := range existing.Status.Versions { - existingVersionsMap[version.Version] = version - } - - for i, version := range versions { - if existingVersion, found := existingVersionsMap[version.Version]; found && version.Digest == existingVersion.Digest { - versions[i].Pulled = existingVersion.Pulled - } - } - if op != controllerutil.OperationResultNone { logger.Info("Reconciled HelmClusterAddonChart", "operation", op, "addonChartName", addonChartName) } @@ -175,7 +167,7 @@ func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alp if err := s.Client.Status().Patch(ctx, existing, client.MergeFrom(base)); err != nil { return RepoSyncResult{ - Status: Failed( + Status: status.Failed( repo, helmv1alpha1.ReasonSyncFailed, fmt.Sprintf("Failed to update HelmClusterAddonChart %q versions", addonChartName), @@ -190,7 +182,7 @@ func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alp var existingCharts helmv1alpha1.HelmClusterAddonChartList if err := s.Client.List(ctx, &existingCharts, client.MatchingLabels{LabelRepositoryName: repo.Name}); err != nil { return RepoSyncResult{ - Status: Failed( + Status: status.Failed( repo, helmv1alpha1.ReasonSyncFailed, "Failed to list stale charts for pruning", @@ -199,15 +191,14 @@ func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alp } } - for i := range existingCharts.Items { - staleChart := &existingCharts.Items[i] - if _, wanted := desiredCharts[staleChart.Name]; wanted { + for _, chart := range existingCharts.Items { + if _, wanted := desiredCharts[chart.Name]; wanted { continue } - if err := s.ensureResourceDeleted(ctx, types.NamespacedName{Name: staleChart.Name}, staleChart); err != nil { + if err := s.ensureResourceDeleted(ctx, types.NamespacedName{Name: chart.Name}, &chart); err != nil { return RepoSyncResult{ - Status: Failed( + Status: status.Failed( repo, helmv1alpha1.ReasonSyncFailed, "Failed to delete stale charts", @@ -220,14 +211,7 @@ func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alp logger.Info(fmt.Sprintf("Scheduling next repo sync in %s", ChartsSyncInterval)) return RepoSyncResult{ - Status: ResourceStatus{ - Observed: true, - Status: metav1.ConditionTrue, - Reason: helmv1alpha1.ReasonSyncSucceeded, - ObservedGeneration: repo.Generation, - Message: "", - Err: nil, - }, + Status: status.Success(repo), } } diff --git a/images/operator-helm-artifact/internal/utils/conditions.go b/images/operator-helm-artifact/internal/utils/conditions.go deleted file mode 100644 index 3d9df65..0000000 --- a/images/operator-helm-artifact/internal/utils/conditions.go +++ /dev/null @@ -1,31 +0,0 @@ -/* -Copyright 2026 Flant JSC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package utils - -import ( - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func IsConditionObserved(conditions []metav1.Condition, conditionType string, generation int64) (*metav1.Condition, bool) { - cond := apimeta.FindStatusCondition(conditions, conditionType) - if cond == nil || cond.ObservedGeneration != generation { - return cond, false - } - - return cond, true -} diff --git a/images/operator-helm-artifact/internal/utils/mapper.go b/images/operator-helm-artifact/internal/utils/mapper.go index c3f390b..ef91475 100644 --- a/images/operator-helm-artifact/internal/utils/mapper.go +++ b/images/operator-helm-artifact/internal/utils/mapper.go @@ -26,7 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" ) -func MapInternalToFacade(targetNamespace, labelManagedBy, labelManagedByValue, labelSourceName string) handler.MapFunc { +func MapInternalResources(targetNamespace, labelManagedBy, labelManagedByValue, labelSourceName string) handler.MapFunc { return func(ctx context.Context, obj client.Object) []reconcile.Request { logger := log.FromContext(ctx) diff --git a/images/operator-helm-artifact/internal/utils/name.go b/images/operator-helm-artifact/internal/utils/name.go index f88995c..a703602 100644 --- a/images/operator-helm-artifact/internal/utils/name.go +++ b/images/operator-helm-artifact/internal/utils/name.go @@ -17,30 +17,29 @@ limitations under the License. package utils import ( + "crypto/sha256" "fmt" - "hash/fnv" "strings" ) func GetHash(s string) string { - h := fnv.New32a() + h := sha256.New() + h.Write([]byte(s)) - _, _ = h.Write([]byte(s)) - - return fmt.Sprintf("%x", h.Sum32()) + return fmt.Sprintf("%x", h.Sum(nil))[:12] } -func GetInternalRepositoryAuthSecretName(repoType InternalRepositoryType, internalRepoName string) string { - prefix := "auth" +func GetInternalRepositoryAuthSecretName(internalRepoName string) string { + prefix := "hcar-auth" - hash := GetHash(fmt.Sprintf("%s-%s-%s", prefix, repoType, internalRepoName)) + hash := GetHash(fmt.Sprintf("%s-%s", prefix, internalRepoName)) var result, postfix string - result = prefix + "-" + string(repoType) + "-" + result = prefix + "-" - if len(internalRepoName) > 35 { - result += internalRepoName[:35] + if len(internalRepoName) > 53 { + result += internalRepoName[:40] postfix = "-" + hash } else { result += internalRepoName @@ -49,17 +48,17 @@ func GetInternalRepositoryAuthSecretName(repoType InternalRepositoryType, intern return strings.TrimRight(result, "-") + postfix } -func GetInternalRepositoryTLSSecretName(repoType InternalRepositoryType, internalRepoName string) string { - prefix := "tls" +func GetInternalRepositoryTLSSecretName(internalRepoName string) string { + prefix := "hcar-tls" - hash := GetHash(fmt.Sprintf("%s-%s-%s", prefix, repoType, internalRepoName)) + hash := GetHash(fmt.Sprintf("%s-%s", prefix, internalRepoName)) var result, postfix string - result = prefix + "-" + string(repoType) + "-" + result = prefix + "-" - if len(internalRepoName) > 35 { - result += internalRepoName[:35] + if len(internalRepoName) > 54 { + result += internalRepoName[:41] postfix = "-" + hash } else { result += internalRepoName @@ -73,15 +72,15 @@ func GetHelmClusterAddonChartName(repoName, addonName string) string { var result, postfix string - if len(repoName) > 20 { - result += repoName[:20] + if len(repoName) > 24 { + result += repoName[:24] postfix = "-" + hash } else { result += repoName } - if len(addonName) > 20 { - result += "-" + addonName[:20] + if len(addonName) > 24 { + result += "-" + addonName[:24] postfix = "-" + hash } else { result += "-" + addonName @@ -91,14 +90,14 @@ func GetHelmClusterAddonChartName(repoName, addonName string) string { } func GetInternalHelmReleaseName(addonName string) string { - prefix := "addon" + prefix := "hca" hash := GetHash(fmt.Sprintf("%s-%s", prefix, addonName)) result := prefix + "-" postfix := "" - if len(addonName) > 40 { - result += addonName[:40] + if len(addonName) > 59 { + result += addonName[:46] postfix = "-" + hash } else { result += addonName @@ -112,14 +111,14 @@ func GetInternalHelmChartName(addonName string) string { } func GetInternalOCIRepositoryName(addonName string) string { - prefix := "addon" + prefix := "hca" hash := GetHash(fmt.Sprintf("%s-%s", prefix, addonName)) result := prefix + "-" postfix := "" - if len(addonName) > 40 { - result += addonName[:40] + if len(addonName) > 59 { + result += addonName[:46] postfix = "-" + hash } else { result += addonName @@ -127,3 +126,20 @@ func GetInternalOCIRepositoryName(addonName string) string { return strings.TrimRight(result, "-") + postfix } + +func GetInternalHelmRepositoryName(addonRepositoryName string) string { + prefix := "hcar" + hash := GetHash(fmt.Sprintf("%s-%s", prefix, addonRepositoryName)) + + result := prefix + "-" + postfix := "" + + if len(addonRepositoryName) > 58 { + result += addonRepositoryName[:45] + postfix = "-" + hash + } else { + result += addonRepositoryName + } + + return strings.TrimRight(result, "-") + postfix +} diff --git a/images/operator-helm-artifact/werf.inc.yaml b/images/operator-helm-artifact/werf.inc.yaml index 850fa01..4784ac4 100644 --- a/images/operator-helm-artifact/werf.inc.yaml +++ b/images/operator-helm-artifact/werf.inc.yaml @@ -28,17 +28,8 @@ import: add: /src to: /src before: install -# TODO: uncomment as soon as CI will be ready -# secrets: -# - id: GOPROXY -# value: {{ .GOPROXY }} -# mount: -# - fromPath: ~/go-pkg-cache -# to: /go/pkg shell: install: - # TODO: uncomment as soon as CI will be ready - # - export GOPROXY=$(cat /run/secrets/GOPROXY) - cd /src/images/operator-helm-artifact - go mod download setup: diff --git a/openapi/config-values.yaml b/openapi/config-values.yaml index 7097068..55c0b06 100644 --- a/openapi/config-values.yaml +++ b/openapi/config-values.yaml @@ -8,19 +8,3 @@ properties: By default, Deckhouse automatically decides whether to enable the HA mode. To learn more about the HA mode, refer to [High reliability and availability](/products/kubernetes-platform/documentation/v1/admin/configuration/high-reliability-and-availability/enable.html#enabling-ha-mode-for-individual-components). - logLevel: - type: string - default: info - description: | - Sets a logging level. - - Working for this components: - - `helm-controller` - - `nelm-source-controller` - - `kube-api-rewriter` - - `deckhouse-helm-controller` - enum: - - "debug" - - "info" - - "warn" - - "error" diff --git a/openapi/doc-ru-config-values.yaml b/openapi/doc-ru-config-values.yaml index 0bfe464..747bafb 100644 --- a/openapi/doc-ru-config-values.yaml +++ b/openapi/doc-ru-config-values.yaml @@ -6,12 +6,3 @@ properties: По умолчанию режим отказоустойчивости определяется автоматически. Подробнее про режим отказоустойчивости можно прочитать в разделе [Высокая надежность и доступность](/products/kubernetes-platform/documentation/v1/admin/configuration/high-reliability-and-availability/enable.html#включение-режима-ha-для-отдельных-компонентов). - logLevel: - description: | - Устанавливает уровень логирования. - - Работает для следующих компонентов: - - `helm-controller` - - `nelm-source-controller` - - `kube-api-rewriter` - - `deckhouse-helm-controller` diff --git a/templates/helm-controller/deployment.yaml b/templates/helm-controller/deployment.yaml index e333823..5add5f9 100644 --- a/templates/helm-controller/deployment.yaml +++ b/templates/helm-controller/deployment.yaml @@ -80,7 +80,6 @@ spec: imagePullPolicy: IfNotPresent args: - --watch-all-namespaces - - --log-level={{ include "moduleLogLevel" . }} - --log-encoding=json - --enable-leader-election volumeMounts: diff --git a/templates/nelm-source-controller/deployment.yaml b/templates/nelm-source-controller/deployment.yaml index f8eef88..d53b00b 100644 --- a/templates/nelm-source-controller/deployment.yaml +++ b/templates/nelm-source-controller/deployment.yaml @@ -75,7 +75,6 @@ spec: imagePullPolicy: IfNotPresent args: - --watch-all-namespaces - - --log-level={{ include "moduleLogLevel" . }} - --log-encoding=json - --enable-leader-election - --storage-path=/data diff --git a/templates/operator-helm-controller/deployment.yaml b/templates/operator-helm-controller/deployment.yaml index 7e01c31..521a640 100644 --- a/templates/operator-helm-controller/deployment.yaml +++ b/templates/operator-helm-controller/deployment.yaml @@ -79,7 +79,6 @@ spec: image: {{ include "helm_lib_module_image" (list . "operatorHelmController") }} imagePullPolicy: IfNotPresent args: - {{/* TODO: add log level option */}} - --leader-elect - --metrics-bind-address=:8080 - --health-probe-bind-address=:9440 From 86dfd3270bc6e1b5bbcd3b47261cb5bbc3f00cd6 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:18:25 +0300 Subject: [PATCH 15/27] refactor: apply code review recommendations (#9) --- CHAGELOG/v0.0.1.yaml | 5 +++ api/v1alpha1/constants.go | 2 +- api/v1alpha1/helm_cluster_addon.go | 8 ++-- api/v1alpha1/helm_cluster_addon_chart.go | 14 +------ api/v1alpha1/helm_cluster_addon_repository.go | 6 +-- api/v1alpha1/zz_generated.deepcopy.go | 16 -------- crds/helmclusteraddoncharts.yaml | 6 +-- crds/helmclusteraddonrepositories.yaml | 4 +- crds/helmclusteraddons.yaml | 8 ++-- .../cmd/operator-helm-controller/main.go | 11 ++++++ .../internal/client/repository/client.go | 29 ++++++++++++-- .../internal/client/repository/helm.go | 21 ++++++---- .../internal/client/repository/oci.go | 23 +++++++++-- .../controller/helmclusteraddon/controller.go | 21 ++++------ .../helmclusteraddonrepository/controller.go | 6 ++- .../internal/manager/status/manager.go | 15 +++---- .../reconcile/helmclusteraddon/reconciler.go | 22 +++++++---- .../helmclusteraddonrepository/reconciler.go | 24 +++++++----- .../internal/services/base.go | 4 +- .../internal/services/helm_repo_service.go | 20 +++++----- .../internal/services/maintenance_service.go | 2 +- .../internal/services/oci_repo_service.go | 8 ---- .../internal/services/repo_sync_service.go | 18 ++++++--- .../internal/utils/mapper.go | 39 ++++++++++++++++++- .../webhook/helmclusteraddon/webhook.go | 18 +++++++-- 25 files changed, 218 insertions(+), 132 deletions(-) create mode 100644 CHAGELOG/v0.0.1.yaml diff --git a/CHAGELOG/v0.0.1.yaml b/CHAGELOG/v0.0.1.yaml new file mode 100644 index 0000000..6508513 --- /dev/null +++ b/CHAGELOG/v0.0.1.yaml @@ -0,0 +1,5 @@ +features: + - initial release with basic capabilities +fixes: [] +security: [] +chore: [] diff --git a/api/v1alpha1/constants.go b/api/v1alpha1/constants.go index 9f46069..f72e1a9 100644 --- a/api/v1alpha1/constants.go +++ b/api/v1alpha1/constants.go @@ -20,7 +20,7 @@ const ( // TargetNamespace is the namespace where internal customer resources are created. TargetNamespace = "d8-operator-helm" - // FinalizerName is the finalizer added to HelmClusterRepository to ensure cleanup. + // FinalizerName is the finalizer used to ensure cleanup. FinalizerName = "helm.deckhouse.io/cleanup" // LabelManagedBy marks resources as managed by this controller. diff --git a/api/v1alpha1/helm_cluster_addon.go b/api/v1alpha1/helm_cluster_addon.go index 70b07ab..3346b83 100644 --- a/api/v1alpha1/helm_cluster_addon.go +++ b/api/v1alpha1/helm_cluster_addon.go @@ -40,7 +40,7 @@ const ( // +kubebuilder:resource:categories={all,operator-helm},singular=helmclusteraddon,scope=Cluster // +kubebuilder:printcolumn:name="Chart Name",type="string",JSONPath=".spec.chart.helmClusterAddonChart",description="Helm release chart name." // +kubebuilder:printcolumn:name="Chart Version",type="string",JSONPath=".spec.chart.version",description="Helm release chart version." -// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="The readiness status of the repository" +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="The readiness status of the addon" // +genclient // +genclient:nonNamespaced // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object @@ -157,10 +157,10 @@ type HelmClusterAddonStatus struct { // LastAppliedValues represents the latest values that triggered addon install or update. // +optional LastAppliedValues *apiextensionsv1.JSON `json:"lastAppliedValues,omitempty"` - // Conditions represent the latest available observations of the repository state. + // Conditions represent the latest available observations of the addon state. // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` - // Generating a resource that was last processed by the controller. + // Generation represents resource generation that was last processed by the controller. ObservedGeneration int64 `json:"observedGeneration,omitempty"` } @@ -190,7 +190,7 @@ type HelmClusterAddonList struct { Items []HelmClusterAddon `json:"items"` } -// HelmClusterAddonMaintenance describe HelmClusterAddon maintanance operation mode. +// HelmClusterAddonMaintenance describe HelmClusterAddon maintenance operation mode. // +kubebuilder:validation:Enum={"",NoResourceReconciliation} type HelmClusterAddonMaintenance string diff --git a/api/v1alpha1/helm_cluster_addon_chart.go b/api/v1alpha1/helm_cluster_addon_chart.go index 916833c..4c6ef1d 100644 --- a/api/v1alpha1/helm_cluster_addon_chart.go +++ b/api/v1alpha1/helm_cluster_addon_chart.go @@ -63,21 +63,11 @@ func (r *HelmClusterAddonChart) GetConditionTypesForUpdate() []string { return []string{"Ready"} } -type HelmClusterAddonChartSpec struct { - // Helm chart name - // +kubebuilder:validation:MinLength=1 - ChartName string `json:"chartName"` - // Name of HelmClusterAddonRepository where respective helm chart resides. - // +kubebuilder:validation:MinLength=3 - // +kubebuilder:validation:MaxLength=63 - RepositoryName string `json:"repositoryName"` -} - type HelmClusterAddonChartStatus struct { - // Conditions represent the latest available observations of the repository state. + // Conditions represent the latest available observations of the addon chart state. // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` - // Generating a resource that was last processed by the controller. + // Generation represents resource generation that was last processed by the controller. ObservedGeneration int64 `json:"observedGeneration,omitempty"` // Available helm chart versions // +optional diff --git a/api/v1alpha1/helm_cluster_addon_repository.go b/api/v1alpha1/helm_cluster_addon_repository.go index 52dd0c0..0c2da28 100644 --- a/api/v1alpha1/helm_cluster_addon_repository.go +++ b/api/v1alpha1/helm_cluster_addon_repository.go @@ -99,17 +99,17 @@ type HelmClusterAddonRepositoryStatus struct { // Conditions represent the latest available observations of the repository state. // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` - // Generating a resource that was last processed by the controller. + // Generation represents resource generation that was last processed by the controller. ObservedGeneration int64 `json:"observedGeneration,omitempty"` } -// HelmClusterAddonRepositoryList contains a list of HelmClusterRepositories. +// HelmClusterAddonRepositoryList contains a list of HelmClusterAddonRepositories. // +kubebuilder:object:root=true // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type HelmClusterAddonRepositoryList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata"` - // Items provides a list of HelmClusterRepositories. + // Items provides a list of HelmClusterAddonRepositories. Items []HelmClusterAddonRepository `json:"items"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 0348940..36a8065 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -131,22 +131,6 @@ func (in *HelmClusterAddonChartRef) DeepCopy() *HelmClusterAddonChartRef { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonChartSpec) DeepCopyInto(out *HelmClusterAddonChartSpec) { - *out = *in - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChartSpec. -func (in *HelmClusterAddonChartSpec) DeepCopy() *HelmClusterAddonChartSpec { - if in == nil { - return nil - } - out := new(HelmClusterAddonChartSpec) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HelmClusterAddonChartStatus) DeepCopyInto(out *HelmClusterAddonChartStatus) { *out = *in diff --git a/crds/helmclusteraddoncharts.yaml b/crds/helmclusteraddoncharts.yaml index fe6ac61..ab56175 100644 --- a/crds/helmclusteraddoncharts.yaml +++ b/crds/helmclusteraddoncharts.yaml @@ -49,7 +49,7 @@ spec: conditions: description: Conditions represent the latest available observations - of the repository state. + of the addon chart state. items: description: Condition contains details for one aspect of the current @@ -108,8 +108,8 @@ spec: type: array observedGeneration: description: - Generating a resource that was last processed by the - controller. + Generation represents resource generation that was last + processed by the controller. format: int64 type: integer versions: diff --git a/crds/helmclusteraddonrepositories.yaml b/crds/helmclusteraddonrepositories.yaml index 7a7b19e..4c69883 100644 --- a/crds/helmclusteraddonrepositories.yaml +++ b/crds/helmclusteraddonrepositories.yaml @@ -152,8 +152,8 @@ spec: type: array observedGeneration: description: - Generating a resource that was last processed by the - controller. + Generation represents resource generation that was last + processed by the controller. format: int64 type: integer type: object diff --git a/crds/helmclusteraddons.yaml b/crds/helmclusteraddons.yaml index 46bfe43..f976fca 100644 --- a/crds/helmclusteraddons.yaml +++ b/crds/helmclusteraddons.yaml @@ -29,7 +29,7 @@ spec: jsonPath: .spec.chart.version name: Chart Version type: string - - description: The readiness status of the repository + - description: The readiness status of the addon jsonPath: .status.conditions[?(@.type=='Ready')].status name: Status type: string @@ -110,7 +110,7 @@ spec: conditions: description: Conditions represent the latest available observations - of the repository state. + of the addon state. items: description: Condition contains details for one aspect of the current @@ -194,8 +194,8 @@ spec: x-kubernetes-preserve-unknown-fields: true observedGeneration: description: - Generating a resource that was last processed by the - controller. + Generation represents resource generation that was last + processed by the controller. format: int64 type: integer type: object diff --git a/images/operator-helm-artifact/cmd/operator-helm-controller/main.go b/images/operator-helm-artifact/cmd/operator-helm-controller/main.go index e8a6c8f..ee75a9a 100644 --- a/images/operator-helm-artifact/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-artifact/cmd/operator-helm-controller/main.go @@ -33,6 +33,7 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddon" "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddonrepository" + "github.com/deckhouse/operator-helm/internal/utils" helmclusteraddonwebhook "github.com/deckhouse/operator-helm/internal/webhook/helmclusteraddon" ) @@ -76,6 +77,11 @@ func main() { os.Exit(1) } + if err := utils.SetupAddonRepositoryIndex(mgr); err != nil { + logger.Error(err, "unable to setup addon repository index") + os.Exit(1) + } + if err := helmclusteraddonrepository.SetupWithManager(mgr); err != nil { logger.Error(err, "unable to setup HelmClusterAddonRepository controller") os.Exit(1) @@ -86,6 +92,11 @@ func main() { os.Exit(1) } + if err = helmclusteraddonwebhook.SetupIndexes(mgr); err != nil { + logger.Error(err, "unable to setup indexes", "webhook", "HelmClusterAddon") + os.Exit(1) + } + if err = helmclusteraddonwebhook.SetupWebhookWithManager(mgr); err != nil { logger.Error(err, "unable to create webhook", "webhook", "HelmClusterAddon") os.Exit(1) diff --git a/images/operator-helm-artifact/internal/client/repository/client.go b/images/operator-helm-artifact/internal/client/repository/client.go index 5a056ff..99a1ee7 100644 --- a/images/operator-helm-artifact/internal/client/repository/client.go +++ b/images/operator-helm-artifact/internal/client/repository/client.go @@ -18,14 +18,17 @@ package repository import ( "context" + "crypto/tls" + "crypto/x509" "fmt" + "net/http" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/deckhouse/operator-helm/internal/utils" ) type ClientInterface interface { - FetchCharts(ctx context.Context, url string, auth *AuthConfig) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) + FetchCharts(ctx context.Context, url string, config *RepoConfig) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) } func NewClient(repoType utils.InternalRepositoryType) (ClientInterface, error) { @@ -39,7 +42,25 @@ func NewClient(repoType utils.InternalRepositoryType) (ClientInterface, error) { } } -type AuthConfig struct { - Username string - Password string +type RepoConfig struct { + Username string + Password string + CACertificate string + Insecure bool +} + +func BuildTLSTransport(config *RepoConfig) *http.Transport { + tlsConfig := &tls.Config{} + + if config.Insecure { + tlsConfig.InsecureSkipVerify = true + } + + if config.CACertificate != "" { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM([]byte(config.CACertificate)) + tlsConfig.RootCAs = caCertPool + } + + return &http.Transport{TLSClientConfig: tlsConfig} } diff --git a/images/operator-helm-artifact/internal/client/repository/helm.go b/images/operator-helm-artifact/internal/client/repository/helm.go index 62012ac..ae6ce07 100644 --- a/images/operator-helm-artifact/internal/client/repository/helm.go +++ b/images/operator-helm-artifact/internal/client/repository/helm.go @@ -33,18 +33,23 @@ var HelmRepositoryDefaultClient ClientInterface = &helmRepositoryClient{} type helmRepositoryClient struct{} -func (c *helmRepositoryClient) FetchCharts(ctx context.Context, url string, auth *AuthConfig) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) { +func (c *helmRepositoryClient) FetchCharts(ctx context.Context, url string, config *RepoConfig) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) { if !strings.HasSuffix(url, "/index.yaml") { url += "/index.yaml" } var indexFile HelmRepositoryIndex + httpClient := http.DefaultClient + if config != nil && (config.CACertificate != "" || config.Insecure) { + httpClient = &http.Client{Transport: BuildTLSTransport(config)} + } + backoff := wait.Backoff{ - Duration: 1 * time.Second, // Initial delay - Factor: 2.0, // Double the delay each time - Jitter: 0.1, // Add 10% randomness to prevent the thundering herd problem - Steps: 3, // Maximum number of retries (1s, 2s, 4s, 8s, 16s) + Duration: 1 * time.Second, + Factor: 2.0, + Jitter: 0.1, + Steps: 3, } ctx, cancel := context.WithTimeout(ctx, 8*time.Second) @@ -56,11 +61,11 @@ func (c *helmRepositoryClient) FetchCharts(ctx context.Context, url string, auth return true, fmt.Errorf("creating request: %w", err) } - if auth != nil { - req.SetBasicAuth(auth.Username, auth.Password) + if config != nil && config.Username != "" { + req.SetBasicAuth(config.Username, config.Password) } - resp, err := http.DefaultClient.Do(req) + resp, err := httpClient.Do(req) if err != nil { return false, nil } diff --git a/images/operator-helm-artifact/internal/client/repository/oci.go b/images/operator-helm-artifact/internal/client/repository/oci.go index 67594ea..0a07a1b 100644 --- a/images/operator-helm-artifact/internal/client/repository/oci.go +++ b/images/operator-helm-artifact/internal/client/repository/oci.go @@ -18,6 +18,7 @@ package repository import ( "context" + "errors" "fmt" "strings" "time" @@ -34,11 +35,21 @@ var OCIRepositoryDefaultClient ClientInterface = &ociRepositoryClient{} type ociRepositoryClient struct{} -func (c *ociRepositoryClient) FetchCharts(ctx context.Context, url string, auth *AuthConfig) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) { +func (c *ociRepositoryClient) FetchCharts(ctx context.Context, url string, config *RepoConfig) (map[string][]helmv1alpha1.HelmClusterAddonChartVersion, error) { url = trimSchemaPrefixes(url) + url = strings.TrimSuffix(url, "/") + + if !strings.Contains(url, "/") { + return nil, errors.New("url must contain chart/image name") + } + urlParts := strings.Split(url, "/") chartName := urlParts[len(urlParts)-1] + if len(chartName) == 0 { + return nil, errors.New("failed to parse chart/image name from the url") + } + repo, err := name.NewRepository(url) if err != nil { return nil, fmt.Errorf("failed to parse repository url: %w", err) @@ -55,13 +66,17 @@ func (c *ociRepositoryClient) FetchCharts(ctx context.Context, url string, auth }), } - if auth != nil { + if config != nil && config.Username != "" { options = append(options, remote.WithAuth(authn.FromConfig(authn.AuthConfig{ - Username: auth.Username, - Password: auth.Password, + Username: config.Username, + Password: config.Password, }))) } + if config != nil && (config.CACertificate != "" || config.Insecure) { + options = append(options, remote.WithTransport(BuildTLSTransport(config))) + } + tags, err := remote.List(repo, options...) if err != nil { return nil, fmt.Errorf("listing image tags: %w", err) diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go index 8553971..852ffe8 100644 --- a/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddon/controller.go @@ -19,9 +19,9 @@ package helmclusteraddon import ( helmv2 "github.com/werf/3p-helm-controller/api/v2" sourcev1 "github.com/werf/nelm-source-controller/api/v1" - corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -45,16 +45,18 @@ func SetupWithManager(mgr ctrl.Manager) error { services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), services.NewReleaseService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), services.NewMaintenanceService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - status.NewManager(client, ControllerName), + status.NewManager(client), ) return ctrl.NewControllerManagedBy(mgr). Named(ControllerName). + WithOptions(controller.Options{MaxConcurrentReconciles: 2}). For(&helmv1alpha1.HelmClusterAddon{}). Watches( &sourcev1.HelmChart{}, handler.EnqueueRequestsFromMapFunc( utils.MapInternalResources( + ControllerName, helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, @@ -67,6 +69,7 @@ func SetupWithManager(mgr ctrl.Manager) error { &helmv2.HelmRelease{}, handler.EnqueueRequestsFromMapFunc( utils.MapInternalResources( + ControllerName, helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, @@ -79,6 +82,7 @@ func SetupWithManager(mgr ctrl.Manager) error { &sourcev1.OCIRepository{}, handler.EnqueueRequestsFromMapFunc( utils.MapInternalResources( + ControllerName, helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, @@ -86,20 +90,9 @@ func SetupWithManager(mgr ctrl.Manager) error { ), ), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). - Watches( - &corev1.Secret{}, - handler.EnqueueRequestsFromMapFunc( - utils.MapInternalResources( - helmv1alpha1.TargetNamespace, - helmv1alpha1.LabelManagedBy, - helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonLabelSourceName), - ), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), - ). Watches( &helmv1alpha1.HelmClusterAddonRepository{}, - &handler.EnqueueRequestForObject{}, + handler.EnqueueRequestsFromMapFunc(utils.MapRepositoryToAddons(client)), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). Complete(r) diff --git a/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go index 9c9d47f..17b8b1b 100644 --- a/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go +++ b/images/operator-helm-artifact/internal/controller/helmclusteraddonrepository/controller.go @@ -21,6 +21,7 @@ import ( corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -43,16 +44,18 @@ func SetupWithManager(mgr ctrl.Manager) error { services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), services.NewRepoSyncService(client, mgr.GetScheme()), - status.NewManager(client, helmv1alpha1.LabelManagedByValue), + status.NewManager(client), ) return ctrl.NewControllerManagedBy(mgr). Named(ControllerName). + WithOptions(controller.Options{MaxConcurrentReconciles: 2}). For(&helmv1alpha1.HelmClusterAddonRepository{}). Watches( &sourcev1.HelmRepository{}, handler.EnqueueRequestsFromMapFunc( utils.MapInternalResources( + ControllerName, helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, @@ -64,6 +67,7 @@ func SetupWithManager(mgr ctrl.Manager) error { &corev1.Secret{}, handler.EnqueueRequestsFromMapFunc( utils.MapInternalResources( + ControllerName, helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, diff --git a/images/operator-helm-artifact/internal/manager/status/manager.go b/images/operator-helm-artifact/internal/manager/status/manager.go index 05d1160..78f92b9 100644 --- a/images/operator-helm-artifact/internal/manager/status/manager.go +++ b/images/operator-helm-artifact/internal/manager/status/manager.go @@ -48,14 +48,11 @@ type GenerationProvider interface { type Manager struct { client.Client - - FieldOwner string } -func NewManager(c client.Client, fieldOwner string) *Manager { +func NewManager(c client.Client) *Manager { return &Manager{ - Client: c, - FieldOwner: fieldOwner, + Client: c, } } @@ -206,8 +203,12 @@ type Status struct { ObservedGeneration int64 Reason string Message string - NotReflectable bool - Err error + // NotReflectable marks a result that is appended as its own condition directly, + // bypassing the "decision result" logic in DetermineConditions. When true, the + // result does not participate in selecting the single decision result that gets + // projected across all condition types returned by GetConditionTypesForUpdate. + NotReflectable bool + Err error } func (s Status) IsReady() bool { diff --git a/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go b/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go index 9f36f02..6ec8e6d 100644 --- a/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go +++ b/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go @@ -24,6 +24,7 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" @@ -163,7 +164,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco addon, helmv1alpha1.ReasonFailed, fmt.Sprintf("Unsupported repository type: %s", repoType), - err, + fmt.Errorf("unsupported repository type: %s", repoType), )}) } @@ -214,15 +215,20 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.He return reconcile.Result{}, err } - latestAddon := &helmv1alpha1.HelmClusterAddon{} - if err := r.Get(ctx, client.ObjectKeyFromObject(addon), latestAddon); err != nil { - return reconcile.Result{}, client.IgnoreNotFound(err) - } + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latestAddon := &helmv1alpha1.HelmClusterAddon{} + if err := r.Get(ctx, client.ObjectKeyFromObject(addon), latestAddon); err != nil { + return client.IgnoreNotFound(err) + } - if controllerutil.RemoveFinalizer(latestAddon, helmv1alpha1.FinalizerName) { - if err := r.Update(ctx, latestAddon); err != nil { - return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + if controllerutil.RemoveFinalizer(latestAddon, helmv1alpha1.FinalizerName) { + if err := r.Update(ctx, latestAddon); err != nil { + return err + } } + return nil + }); err != nil { + return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) } logger.Info("Cleanup complete") diff --git a/images/operator-helm-artifact/internal/reconcile/helmclusteraddonrepository/reconciler.go b/images/operator-helm-artifact/internal/reconcile/helmclusteraddonrepository/reconciler.go index 03fd9d8..ba436da 100644 --- a/images/operator-helm-artifact/internal/reconcile/helmclusteraddonrepository/reconciler.go +++ b/images/operator-helm-artifact/internal/reconcile/helmclusteraddonrepository/reconciler.go @@ -23,6 +23,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" @@ -105,7 +106,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco case utils.InternalHelmRepository: helmRepoRes = r.helmRepositoryService.EnsureInternalHelmRepository(ctx, &repo) case utils.InternalOCIRepository: - if err := r.helmRepositoryService.CleanupHelmRepository(ctx, utils.GetInternalHelmRepositoryName(repo.Name)); err != nil { + if err := r.helmRepositoryService.RemoveHelmRepository(ctx, repo.Name); err != nil { ociRepoRes = services.OCIRepoResult{ Status: status.Failed(&repo, helmv1alpha1.ReasonFailed, "Repository change failed", err), } @@ -120,7 +121,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco if helmRepoRes.IsReady() || ociRepoRes.IsReady() { chartSyncRes = r.chartSyncService.EnsureAddonCharts(ctx, &repo, repoType) } else { - chartSyncRes = services.RepoSyncResult{Status: status.Failed(&repo, helmv1alpha1.ReasonRepositoryNotReady, helmRepoRes.Status.Message, err)} + chartSyncRes = services.RepoSyncResult{Status: status.Failed(&repo, helmv1alpha1.ReasonRepositoryNotReady, helmRepoRes.Status.Message, nil)} } if err := r.statusManager.Update( @@ -162,15 +163,20 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.Hel } } - latestRepo := &helmv1alpha1.HelmClusterAddonRepository{} - if err := r.Get(ctx, client.ObjectKeyFromObject(repo), latestRepo); err != nil { - return reconcile.Result{}, client.IgnoreNotFound(err) - } + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latestRepo := &helmv1alpha1.HelmClusterAddonRepository{} + if err := r.Get(ctx, client.ObjectKeyFromObject(repo), latestRepo); err != nil { + return client.IgnoreNotFound(err) + } - if controllerutil.RemoveFinalizer(latestRepo, helmv1alpha1.FinalizerName) { - if err := r.Update(ctx, latestRepo); err != nil { - return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + if controllerutil.RemoveFinalizer(latestRepo, helmv1alpha1.FinalizerName) { + if err := r.Update(ctx, latestRepo); err != nil { + return err // This will trigger a retry if it's a conflict + } } + return nil + }); err != nil { + return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) } logger.Info("Cleanup complete") diff --git a/images/operator-helm-artifact/internal/services/base.go b/images/operator-helm-artifact/internal/services/base.go index 7f45897..077ef80 100644 --- a/images/operator-helm-artifact/internal/services/base.go +++ b/images/operator-helm-artifact/internal/services/base.go @@ -114,8 +114,8 @@ func (s *BaseRepoService) reconcileTLSSecret(ctx context.Context, repo *helmv1al if _, err := controllerutil.CreateOrPatch(ctx, s.Client, tlsSecret, func() error { tlsSecret.Labels = map[string]string{ - helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonLabelSourceName: repo.Name, + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repo.Name, } tlsSecret.StringData = map[string]string{ diff --git a/images/operator-helm-artifact/internal/services/helm_repo_service.go b/images/operator-helm-artifact/internal/services/helm_repo_service.go index 6901e9a..0578c46 100644 --- a/images/operator-helm-artifact/internal/services/helm_repo_service.go +++ b/images/operator-helm-artifact/internal/services/helm_repo_service.go @@ -42,18 +42,11 @@ const ( ) type HelmRepoService struct { - BaseService BaseRepoService - - TargetNamespace string } func NewHelmRepoService(client client.Client, scheme *runtime.Scheme, namespace string) *HelmRepoService { return &HelmRepoService{ - BaseService: BaseService{ - Client: client, - Scheme: scheme, - }, BaseRepoService: BaseRepoService{ BaseService: BaseService{ Client: client, @@ -61,7 +54,6 @@ func NewHelmRepoService(client client.Client, scheme *runtime.Scheme, namespace }, TargetNamespace: namespace, }, - TargetNamespace: namespace, } } @@ -133,6 +125,16 @@ func (s *HelmRepoService) EnsureInternalHelmRepository(ctx context.Context, repo return HelmRepoResult{Status: status.Unknown(repo, helmv1alpha1.ReasonReconciling)} } +func (s *HelmRepoService) RemoveHelmRepository(ctx context.Context, repoName string) error { + name := utils.GetInternalHelmRepositoryName(repoName) + nn := types.NamespacedName{Name: name, Namespace: s.TargetNamespace} + if err := s.ensureResourceDeleted(ctx, nn, &sourcev1.HelmRepository{}); err != nil { + return fmt.Errorf("removing helm repository: %w", err) + } + + return nil +} + func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, repoName string) error { resources := []struct { name string @@ -147,7 +149,7 @@ func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, repoName st obj: &corev1.Secret{}, }, { - name: repoName, + name: utils.GetInternalHelmRepositoryName(repoName), obj: &sourcev1.HelmRepository{}, }, } diff --git a/images/operator-helm-artifact/internal/services/maintenance_service.go b/images/operator-helm-artifact/internal/services/maintenance_service.go index 9c3fde4..1ace641 100644 --- a/images/operator-helm-artifact/internal/services/maintenance_service.go +++ b/images/operator-helm-artifact/internal/services/maintenance_service.go @@ -85,7 +85,7 @@ func (s *MaintenanceService) EnsureMaintenanceMode(ctx context.Context, addon *h err := s.updateHelmReleaseSuspendState(ctx, addon, suspendState) if err != nil { - return MaintenanceResult{Status: statusmgr.Failed(addon, helmv1alpha1.ReasonFailed, "Failed to enable maintenance mode", err)} + return MaintenanceResult{Status: statusmgr.Failed(addon, helmv1alpha1.ReasonFailed, "Failed to change maintenance mode", err)} } return MaintenanceResult{ Status: statusmgr.Status{ diff --git a/images/operator-helm-artifact/internal/services/oci_repo_service.go b/images/operator-helm-artifact/internal/services/oci_repo_service.go index 3e039e6..4c6c46b 100644 --- a/images/operator-helm-artifact/internal/services/oci_repo_service.go +++ b/images/operator-helm-artifact/internal/services/oci_repo_service.go @@ -36,18 +36,11 @@ import ( ) type OCIRepoService struct { - BaseService BaseRepoService - - TargetNamespace string } func NewOCIRepoService(client client.Client, scheme *runtime.Scheme, namespace string) *OCIRepoService { return &OCIRepoService{ - BaseService: BaseService{ - Client: client, - Scheme: scheme, - }, BaseRepoService: BaseRepoService{ BaseService: BaseService{ Client: client, @@ -55,7 +48,6 @@ func NewOCIRepoService(client client.Client, scheme *runtime.Scheme, namespace s }, TargetNamespace: namespace, }, - TargetNamespace: namespace, } } diff --git a/images/operator-helm-artifact/internal/services/repo_sync_service.go b/images/operator-helm-artifact/internal/services/repo_sync_service.go index 8635e41..364b032 100644 --- a/images/operator-helm-artifact/internal/services/repo_sync_service.go +++ b/images/operator-helm-artifact/internal/services/repo_sync_service.go @@ -97,15 +97,21 @@ func (s *RepoSyncService) EnsureAddonCharts(ctx context.Context, repo *helmv1alp } } - var authConfig *repoclient.AuthConfig - if repo.Spec.Auth != nil { - authConfig = &repoclient.AuthConfig{ - Username: repo.Spec.Auth.Username, - Password: repo.Spec.Auth.Password, + var repoConfig *repoclient.RepoConfig + if repo.Spec.Auth != nil || repo.Spec.CACertificate != "" || !repo.Spec.TLSVerify { + repoConfig = &repoclient.RepoConfig{ + Insecure: !repo.Spec.TLSVerify, + } + if repo.Spec.Auth != nil { + repoConfig.Username = repo.Spec.Auth.Username + repoConfig.Password = repo.Spec.Auth.Password + } + if repo.Spec.CACertificate != "" { + repoConfig.CACertificate = repo.Spec.CACertificate } } - charts, err := repoClient.FetchCharts(ctx, repo.Spec.URL, authConfig) + charts, err := repoClient.FetchCharts(ctx, repo.Spec.URL, repoConfig) if err != nil { return RepoSyncResult{ Status: status.Failed( diff --git a/images/operator-helm-artifact/internal/utils/mapper.go b/images/operator-helm-artifact/internal/utils/mapper.go index ef91475..01255cd 100644 --- a/images/operator-helm-artifact/internal/utils/mapper.go +++ b/images/operator-helm-artifact/internal/utils/mapper.go @@ -20,13 +20,16 @@ import ( "context" "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) -func MapInternalResources(targetNamespace, labelManagedBy, labelManagedByValue, labelSourceName string) handler.MapFunc { +func MapInternalResources(controllerName, targetNamespace, labelManagedBy, labelManagedByValue, labelSourceName string) handler.MapFunc { return func(ctx context.Context, obj client.Object) []reconcile.Request { logger := log.FromContext(ctx) @@ -42,7 +45,7 @@ func MapInternalResources(targetNamespace, labelManagedBy, labelManagedByValue, sourceName := labels[labelSourceName] if sourceName == "" { logger.Info("resource missing source label, skipping", - "name", obj.GetName(), "namespace", obj.GetNamespace()) + "controller", controllerName, "name", obj.GetName(), "namespace", obj.GetNamespace()) return nil } @@ -57,3 +60,35 @@ func MapInternalResources(targetNamespace, labelManagedBy, labelManagedByValue, } } } + +const AddonRepositoryIndex = ".spec.chart.helmClusterAddonRepository" + +func SetupAddonRepositoryIndex(mgr ctrl.Manager) error { + return mgr.GetFieldIndexer().IndexField(context.Background(), &helmv1alpha1.HelmClusterAddon{}, AddonRepositoryIndex, + func(obj client.Object) []string { + addon := obj.(*helmv1alpha1.HelmClusterAddon) + if addon.Spec.Chart.HelmClusterAddonRepository == "" { + return nil + } + return []string{addon.Spec.Chart.HelmClusterAddonRepository} + }, + ) +} + +func MapRepositoryToAddons(c client.Client) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + addonList := &helmv1alpha1.HelmClusterAddonList{} + if err := c.List(ctx, addonList, client.MatchingFields{AddonRepositoryIndex: obj.GetName()}); err != nil { + log.FromContext(ctx).Error(err, "Failed to list HelmClusterAddons for repository mapping") + return nil + } + + requests := make([]reconcile.Request, 0, len(addonList.Items)) + for _, addon := range addonList.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: addon.Name}, + }) + } + return requests + } +} diff --git a/images/operator-helm-artifact/internal/webhook/helmclusteraddon/webhook.go b/images/operator-helm-artifact/internal/webhook/helmclusteraddon/webhook.go index 4870245..188fbc5 100644 --- a/images/operator-helm-artifact/internal/webhook/helmclusteraddon/webhook.go +++ b/images/operator-helm-artifact/internal/webhook/helmclusteraddon/webhook.go @@ -27,6 +27,17 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) +const addonChartIndex = ".spec.chart.repoAndChart" + +func SetupIndexes(mgr ctrl.Manager) error { + return mgr.GetFieldIndexer().IndexField(context.Background(), &helmv1alpha1.HelmClusterAddon{}, addonChartIndex, + func(obj client.Object) []string { + addon := obj.(*helmv1alpha1.HelmClusterAddon) + return []string{addon.Spec.Chart.HelmClusterAddonRepository + "/" + addon.Spec.Chart.HelmClusterAddonChartName} + }, + ) +} + func SetupWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr, &helmv1alpha1.HelmClusterAddon{}). WithValidator(&UniqRepositoryAndChartNameWebhookValidator{Client: mgr.GetClient()}). @@ -51,15 +62,14 @@ func (v *UniqRepositoryAndChartNameWebhookValidator) ValidateDelete(_ context.Co func (v *UniqRepositoryAndChartNameWebhookValidator) checkUniqueness(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { list := &helmv1alpha1.HelmClusterAddonList{} + indexValue := addon.Spec.Chart.HelmClusterAddonRepository + "/" + addon.Spec.Chart.HelmClusterAddonChartName - if err := v.Client.List(ctx, list); err != nil { + if err := v.Client.List(ctx, list, client.MatchingFields{addonChartIndex: indexValue}); err != nil { return err } for _, existing := range list.Items { - if existing.Name != addon.Name && - existing.Spec.Chart.HelmClusterAddonRepository == addon.Spec.Chart.HelmClusterAddonRepository && - existing.Spec.Chart.HelmClusterAddonChartName == addon.Spec.Chart.HelmClusterAddonChartName { + if existing.Name != addon.Name { return fmt.Errorf( "chart %s is already used by helmclusteraddon/%s", addon.Spec.Chart.HelmClusterAddonChartName, existing.Name, From 80e616d0731811632781f96d7a812c1e15f8087f Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:44:47 +0300 Subject: [PATCH 16/27] chore: remove stale docs (#10) Signed-off-by: Ilya Drey --- docs/USAGE.md | 53 --------------------------- docs/USAGE.ru.md | 51 -------------------------- docs/internal/components_placement.md | 3 -- 3 files changed, 107 deletions(-) delete mode 100644 docs/USAGE.md delete mode 100644 docs/USAGE.ru.md delete mode 100644 docs/internal/components_placement.md diff --git a/docs/USAGE.md b/docs/USAGE.md deleted file mode 100644 index d02ac16..0000000 --- a/docs/USAGE.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: "Usage" -description: Usage of the operator-helm Deckhouse module. -weight: 15 ---- - -## Enabling the module - -You can enable the module in one of the following ways: - -- **Using the [Deckhouse web interface](/products/kubernetes-platform/documentation/v1/user/web/ui.html).** - - In the "System" section → "System Management" → "Deckhouse" → "Modules", open the `operator-helm` module, enable the "Module enabled" switch. Save the changes. - -- **Using [Deckhouse CLI](/products/kubernetes-platform/documentation/v1/cli/d8/).** - - Execute the following command to enable the module: - - ```shell - d8 system module enable operator-helm - ``` - -- **Using ModuleConfig `operator-helm`.** - - Set `spec.enabled` to `true` or `false` in ModuleConfig `operator-helm` (create it if necessary). - - Example manifest to enable the module: - - ```yaml - apiVersion: deckhouse.io/v1alpha1 - kind: ModuleConfig - metadata: - name: operator-helm - spec: - enabled: true - ``` - -## Disabling the module - -You can disable the module using one of the following methods: - -- **Using the [Deckhouse web interface](/products/kubernetes-platform/documentation/v1/user/web/ui.html).** - - In the "System" → "System Management" → "Deckhouse" → "Modules" section, open the `operator-helm` module and turn off the "Module Enabled" switch. Save the changes. - -- **Using [Deckhouse CLI](/products/kubernetes-platform/documentation/v1/cli/d8/).** - - Execute the following commands to disable the module: - - ```shell - d8 k annotate mc operator-helm modules.deckhouse.io/allow-disabling=true - d8 system module disable operator-helm - ``` diff --git a/docs/USAGE.ru.md b/docs/USAGE.ru.md deleted file mode 100644 index 87fa0b6..0000000 --- a/docs/USAGE.ru.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: "Использование" -description: Использование модуля operator-helm. -weight: 15 ---- - -## Включение модуля - -Включить модуль можно одним из следующих способов: -- **С помощью [веб-интерфейса Deckhouse](/products/kubernetes-platform/documentation/v1/user/web/ui.html).** - - В разделе «Система» → «Управление системой» → «Deckhouse» → «Модули», откройте модуль `operator-helm`, включите переключатель «Модуль включен». Сохраните изменения. - -- **С помощью [Deckhouse CLI](/products/kubernetes-platform/documentation/v1/cli/d8/).** - - Выполните следующую команду для включения модуля: - - ```shell - d8 system module enable operator-helm - ``` - -- **С помощью ModuleConfig `operator-helm`.** - - Установите `spec.enabled` в `true` или `false` в ModuleConfig `operator-helm` (создайте его, при необходимости). - - Пример манифеста для включения модуля: - - ```yaml - apiVersion: deckhouse.io/v1alpha1 - kind: ModuleConfig - metadata: - name: operator-helm - spec: - enabled: true - ``` - -## Выключение модуля - -Выключить модуль можно одним из следующих способов: -- **С помощью [веб-интерфейса Deckhouse](/products/kubernetes-platform/documentation/v1/user/web/ui.html).** - - В разделе «Система» → «Управление системой» → «Deckhouse» → «Модули», откройте модуль `operator-helm`, выключите переключатель «Модуль включен». Сохраните изменения. - -- **С помощью [Deckhouse CLI](/products/kubernetes-platform/documentation/v1/cli/d8/).** - - Выполните следующие команды для выключения модуля: - - ```shell - d8 k annotate mc operator-helm modules.deckhouse.io/allow-disabling=true - d8 system module disable operator-helm - ``` diff --git a/docs/internal/components_placement.md b/docs/internal/components_placement.md deleted file mode 100644 index 17bc1d2..0000000 --- a/docs/internal/components_placement.md +++ /dev/null @@ -1,3 +0,0 @@ -## Placement strategies - - From afa2757e98103ab131c687b880922c5cb7492e5f Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:47:03 +0300 Subject: [PATCH 17/27] chore: remove unneeded files (#11) Signed-off-by: Ilya Drey --- enabled | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100755 enabled diff --git a/enabled b/enabled deleted file mode 100755 index b3012f9..0000000 --- a/enabled +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -source /deckhouse/shell_lib.sh - -function __main__() { - enabled::disable_module_if_cluster_is_not_bootstraped - enabled::disable_module_in_kubernetes_versions_less_than 1.23.0 - echo "true" > $MODULE_ENABLED_RESULT -} - -enabled::run $@ From 456c4ed6a25df6db7202e7eb6b48410b12f78c1d Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:51:39 +0300 Subject: [PATCH 18/27] feat: tune dmt lint config (#12) Signed-off-by: Ilya Drey --- .dmtlint.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.dmtlint.yaml b/.dmtlint.yaml index 7c2aab5..3e86481 100644 --- a/.dmtlint.yaml +++ b/.dmtlint.yaml @@ -1,7 +1,3 @@ -global: - linters-settings: - documentation: - impact: error linters-settings: openapi: exclude-rules: From e17ec951f6962ca5de8e92408725d5e64806e99e Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 18 Mar 2026 15:23:20 +0300 Subject: [PATCH 19/27] chore: correct changelog folder name Signed-off-by: Ilya Drey --- {CHAGELOG => CHANGELOG}/v0.0.1.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {CHAGELOG => CHANGELOG}/v0.0.1.yaml (100%) diff --git a/CHAGELOG/v0.0.1.yaml b/CHANGELOG/v0.0.1.yaml similarity index 100% rename from CHAGELOG/v0.0.1.yaml rename to CHANGELOG/v0.0.1.yaml From 60d59cf769b61046a447aefacd9f4e21ded62f3d Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 18 Mar 2026 16:37:24 +0300 Subject: [PATCH 20/27] chore: update MAINTAINERS.md Signed-off-by: Ilya Drey --- MAINTAINERS.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/MAINTAINERS.md b/MAINTAINERS.md index c80979e..0af3139 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,6 +1,5 @@ # Core maintainers -| Name | Email | GitHub | -| ---------------- | -------------------------- | ------------------------------------------------------- | -| Ilya Lesikov | ilya.lesikov@flant.com | [@ilya-lesikov](https://github.com/ilya-lesikov) | -| Aleksei Igrychev | aleksei.igrychev@flant.com | [@alexey-igrychev](https://github.com/alexey-igrychev) | +| Name | Email | GitHub | +|------------------|----------------------------|--------------------------------------------------------| +| Ilya Drey | ilya.drey@flant.com | [@drey](https://github.com/ilya-lesikov) | From dfbd94bdca60684b2d516e0996fe9bcbee2349c8 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 18 Mar 2026 16:38:56 +0300 Subject: [PATCH 21/27] chore: update deckhouse requirements to >= 1.74 Signed-off-by: Ilya Drey --- module.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module.yaml b/module.yaml index 82ba375..cca5d37 100644 --- a/module.yaml +++ b/module.yaml @@ -1,7 +1,7 @@ name: operator-helm stage: Experimental requirements: - deckhouse: ">= 1.69" + deckhouse: ">= 1.74" subsystems: - delivery namespace: d8-operator-helm From c906a1e08d22a1934f5238f0b0aad1c04e883acf Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 18 Mar 2026 16:45:48 +0300 Subject: [PATCH 22/27] chore: update year in copyright comments Signed-off-by: Ilya Drey --- api/scripts/update-codegen.sh | 2 +- images/hooks/pkg/hooks/tls-certificates-controller/hook.go | 2 +- images/hooks/pkg/settings/certificate.go | 2 +- images/hooks/pkg/settings/module.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/scripts/update-codegen.sh b/api/scripts/update-codegen.sh index 3c6e4da..d7be88a 100755 --- a/api/scripts/update-codegen.sh +++ b/api/scripts/update-codegen.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Copyright 2024 Flant JSC +# Copyright 2026 Flant JSC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/images/hooks/pkg/hooks/tls-certificates-controller/hook.go b/images/hooks/pkg/hooks/tls-certificates-controller/hook.go index 2eee888..b8ea981 100644 --- a/images/hooks/pkg/hooks/tls-certificates-controller/hook.go +++ b/images/hooks/pkg/hooks/tls-certificates-controller/hook.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +Copyright 2026 Flant JSC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/images/hooks/pkg/settings/certificate.go b/images/hooks/pkg/settings/certificate.go index 5d08591..7c437fc 100644 --- a/images/hooks/pkg/settings/certificate.go +++ b/images/hooks/pkg/settings/certificate.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +Copyright 2026 Flant JSC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/images/hooks/pkg/settings/module.go b/images/hooks/pkg/settings/module.go index 095d744..ecae271 100644 --- a/images/hooks/pkg/settings/module.go +++ b/images/hooks/pkg/settings/module.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +Copyright 2026 Flant JSC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From c7b9f38e2f26e9f988d97d736c5eae76359179ae Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 18 Mar 2026 16:50:34 +0300 Subject: [PATCH 23/27] chore: update MAINTAINERS.md Signed-off-by: Ilya Drey --- MAINTAINERS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 0af3139..c860715 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,5 +1,6 @@ # Core maintainers -| Name | Email | GitHub | -|------------------|----------------------------|--------------------------------------------------------| -| Ilya Drey | ilya.drey@flant.com | [@drey](https://github.com/ilya-lesikov) | +| Name | Email | GitHub | +|---------------|---------------------------|----------------------------------------| +| Ilya Drey | ilya.drey@flant.com | [@drey](https://github.com/drey) | +| Evgeniy Frolov | evgeniy.frolov@flant.com | [@Fral738](https://github.com/Fral738) | \ No newline at end of file From cd291fa5c3357e8f74365b19ac00459b0fe3fc45 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:07:50 +0300 Subject: [PATCH 24/27] feat: update helmlib (#13) Signed-off-by: Ilya Drey --- Chart.yaml | 2 +- Taskfile.yaml | 2 +- charts/deckhouse_lib_helm-1.55.1.tgz | Bin 26935 -> 0 bytes charts/deckhouse_lib_helm-1.71.2.tgz | Bin 0 -> 36459 bytes requirements.lock | 6 +++--- 5 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 charts/deckhouse_lib_helm-1.55.1.tgz create mode 100644 charts/deckhouse_lib_helm-1.71.2.tgz diff --git a/Chart.yaml b/Chart.yaml index 102d074..c8f166d 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -2,5 +2,5 @@ name: operator-helm version: 0.0.1 dependencies: - name: deckhouse_lib_helm - version: 1.55.1 + version: 1.71.2 repository: https://deckhouse.github.io/lib-helm diff --git a/Taskfile.yaml b/Taskfile.yaml index a091976..53ce037 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -3,7 +3,7 @@ version: "3" silent: true vars: - deckhouse_lib_helm_ver: 1.55.1 + deckhouse_lib_helm_ver: 1.71.2 target: "" VALIDATION_FILES: "tools/validation/{main,messages,diff,doc_changes}.go" diff --git a/charts/deckhouse_lib_helm-1.55.1.tgz b/charts/deckhouse_lib_helm-1.55.1.tgz deleted file mode 100644 index 73159b8f03a315ecc2a8f65fa75488f44e34298c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26935 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 Date: Wed, 18 Mar 2026 17:25:02 +0300 Subject: [PATCH 25/27] release: v0.0.2 (#14) Signed-off-by: Ilya Drey --- CHANGELOG/v0.0.2.yaml | 5 +++++ Chart.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG/v0.0.2.yaml diff --git a/CHANGELOG/v0.0.2.yaml b/CHANGELOG/v0.0.2.yaml new file mode 100644 index 0000000..7b55dca --- /dev/null +++ b/CHANGELOG/v0.0.2.yaml @@ -0,0 +1,5 @@ +features: + - apply deckhouse runtime time review recommendations +fixes: [] +security: [] +chore: [] diff --git a/Chart.yaml b/Chart.yaml index c8f166d..a279874 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -1,5 +1,5 @@ name: operator-helm -version: 0.0.1 +version: 0.0.2 dependencies: - name: deckhouse_lib_helm version: 1.71.2 From f9c3e825a66001f76bea6777f235477b720baa7e Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:16:40 +0300 Subject: [PATCH 26/27] feat: improve addon status observability (#16) Signed-off-by: Ilya Drey --- api/go.mod | 12 ---- api/go.sum | 17 ----- api/v1alpha1/conditions.go | 18 ++++- images/operator-helm-artifact/go.mod | 10 +-- images/operator-helm-artifact/go.sum | 16 ----- .../manager/status/condition_rules.go | 69 +++++++++++++++++++ .../reconcile/helmclusteraddon/reconciler.go | 9 --- .../internal/services/chart_service.go | 28 ++++---- .../internal/services/oci_repo_service.go | 29 ++++---- .../internal/services/release_service.go | 29 ++++---- 10 files changed, 134 insertions(+), 103 deletions(-) create mode 100644 images/operator-helm-artifact/internal/manager/status/condition_rules.go diff --git a/api/go.mod b/api/go.mod index c4ab374..63d1f0c 100644 --- a/api/go.mod +++ b/api/go.mod @@ -12,25 +12,18 @@ require ( k8s.io/apiextensions-apiserver v0.35.1 k8s.io/apimachinery v0.35.1 k8s.io/client-go v0.35.1 - sigs.k8s.io/controller-runtime v0.23.1 ) require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/evanphx/json-patch v5.9.11+incompatible // indirect - github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.18.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect github.com/gobuffalo/flect v1.0.3 // indirect - github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect @@ -46,10 +39,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/gomega v1.38.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -64,7 +53,6 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.40.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/api/go.sum b/api/go.sum index e4b8369..ab95caa 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,15 +1,11 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= -github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -18,7 +14,6 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= @@ -26,13 +21,11 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -42,12 +35,10 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -72,10 +63,6 @@ github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -98,8 +85,6 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -124,7 +109,6 @@ golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnps golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -158,7 +142,6 @@ k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= sigs.k8s.io/controller-tools v0.17.2 h1:jNFOKps8WnaRKZU2R+4vRCHnXyJanVmXBWqkuUPFyFg= sigs.k8s.io/controller-tools v0.17.2/go.mod h1:4q5tZG2JniS5M5bkiXY2/potOiXyhoZVw/U48vLkXk0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/conditions.go index ba12fc8..a314491 100644 --- a/api/v1alpha1/conditions.go +++ b/api/v1alpha1/conditions.go @@ -25,8 +25,6 @@ const ( ConditionTypeReady = "Ready" ConditionTypeSynced = "Synced" - ReasonHelmChartFailed = "HelmChartFailed" - ReasonHelmReleaseFailed = "HelmReleaseFailed" ReasonMaintenanceModeActive = "MaintenanceModeActive" ReasonMaintenanceModeInactive = "MaintenanceModeInactive" ReasonSyncFailed = "SyncFailed" @@ -34,4 +32,20 @@ const ( ReasonReconciling = "Reconciling" ReasonSuccess = "Success" ReasonFailed = "Failed" + + // HelmRelease error reasons + ReasonReleaseFailed = "ReleaseFailed" + ReasonTestFailed = "TestFailed" + ReasonRemediated = "Remediated" + + // HelmChart error reasons + ReasonHelmChartFailed = "HelmChartFailed" + ReasonChartFetchFailed = "ChartFetchFailed" + ReasonChartStorageFailed = "ChartStorageFailed" + + // OCIRepository error reasons + ReasonOCIFetchFailed = "OCIFetchFailed" + ReasonOCIIncludeUnavailable = "OCIIncludeUnavailable" + ReasonOCIStorageFailed = "OCIStorageFailed" + ReasonOCIVerificationFailed = "OCIVerificationFailed" ) diff --git a/images/operator-helm-artifact/go.mod b/images/operator-helm-artifact/go.mod index 175a19a..cad8d57 100644 --- a/images/operator-helm-artifact/go.mod +++ b/images/operator-helm-artifact/go.mod @@ -8,13 +8,11 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/deckhouse/operator-helm/api v0.0.0-00010101000000-000000000000 github.com/google/go-containerregistry v0.20.6 - github.com/opencontainers/go-digest v1.0.0 + github.com/stretchr/testify v1.11.1 github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 - github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1 github.com/werf/3p-helm-controller/api v0.1.4 github.com/werf/nelm-source-controller/api v0.1.4 go.yaml.in/yaml/v3 v3.0.4 - helm.sh/helm/v3 v3.19.2 k8s.io/api v0.35.1 k8s.io/apimachinery v0.35.1 k8s.io/client-go v0.35.1 @@ -26,12 +24,12 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect - github.com/cyphar/filepath-securejoin v0.6.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/docker/cli v28.2.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -48,12 +46,11 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -61,7 +58,6 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/vbatts/tar-split v0.12.1 // indirect diff --git a/images/operator-helm-artifact/go.sum b/images/operator-helm-artifact/go.sum index 64b26c1..e9866e4 100644 --- a/images/operator-helm-artifact/go.sum +++ b/images/operator-helm-artifact/go.sum @@ -1,5 +1,3 @@ -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -8,14 +6,10 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= -github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= -github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -74,12 +68,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -111,8 +101,6 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= @@ -132,8 +120,6 @@ github.com/werf/3p-fluxcd-pkg/apis/kustomize v1.14.0-nelm.1 h1:edZ5ugpeUvmjG+g9l github.com/werf/3p-fluxcd-pkg/apis/kustomize v1.14.0-nelm.1/go.mod h1:dAboSMVeohict/XrpXrqyZodq+8Qp6dwafzkBzoCHcU= github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 h1:rYX8cMeryBHH7sNPVSQm1IAVES08TiWvADaZsDj98Wk= github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1/go.mod h1:14co1+Ub5rW0Bp3Qo4IzCHwEcaw06StyMu7Rv5pMVCY= -github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1 h1:ua0xt66rxKptzbG1zxy3u96qfV8XsFT9Jd2PU8L6mc8= -github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1/go.mod h1:fodaCyMGXGxYYSIdWvokrjki8e+DAhgu6BtzHbH2VJ8= github.com/werf/3p-helm-controller/api v0.1.4 h1:s7g9UQOrDMUzVE+JtWOP2xApnPOKYlNe1tXkkWCisAw= github.com/werf/3p-helm-controller/api v0.1.4/go.mod h1:tiPvDerlc5SwKIDmXB8L3kIMJHse+wigueoEGQq+588= github.com/werf/nelm-source-controller/api v0.1.4 h1:/k3RT+hHdwKHntoebdcjhO+zboJIlljHJZlbcumoY08= @@ -208,8 +194,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= -helm.sh/helm/v3 v3.19.2 h1:psQjaM8aIWrSVEly6PgYtLu/y6MRSmok4ERiGhZmtUY= -helm.sh/helm/v3 v3.19.2/go.mod h1:gX10tB5ErM+8fr7bglUUS/UfTOO8UUTYWIBH1IYNnpE= k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= diff --git a/images/operator-helm-artifact/internal/manager/status/condition_rules.go b/images/operator-helm-artifact/internal/manager/status/condition_rules.go new file mode 100644 index 0000000..890eae9 --- /dev/null +++ b/images/operator-helm-artifact/internal/manager/status/condition_rules.go @@ -0,0 +1,69 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +// ErrorConditionRule defines how a specific child condition type should be +// treated as an error for the parent object. +type ErrorConditionRule struct { + Type string + TriggerStatus metav1.ConditionStatus + Reason string +} + +// ProcessChildConditions inspects a set of child conditions and returns a +// Status reflecting the aggregate state. Error rules are checked first (in +// order), then Reconciling, then Ready. If nothing matches, an Unknown status +// with ReasonReconciling is returned. +func ProcessChildConditions( + conditions []metav1.Condition, + generation int64, + parentObj client.Object, + errorRules []ErrorConditionRule, +) Status { + for _, rule := range errorRules { + cond := meta.FindStatusCondition(conditions, rule.Type) + if cond != nil && cond.Status == rule.TriggerStatus { + return Failed(parentObj, rule.Reason, cond.Message, nil) + } + } + + reconcilingCond := meta.FindStatusCondition(conditions, "Reconciling") + if reconcilingCond != nil && reconcilingCond.Status == metav1.ConditionTrue { + return Unknown(parentObj, helmv1alpha1.ReasonReconciling) + } + + cond, observed := IsConditionObserved(conditions, helmv1alpha1.ConditionTypeReady, generation) + if observed { + return Status{ + Observed: true, + Status: cond.Status, + ObservedGeneration: parentObj.GetGeneration(), + Reason: cond.Reason, + Message: cond.Message, + } + } + + return Unknown(parentObj, helmv1alpha1.ReasonReconciling) +} diff --git a/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go b/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go index 6ec8e6d..8991e10 100644 --- a/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go +++ b/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go @@ -172,15 +172,6 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco releaseRes = r.releaseService.EnsureHelmRelease(ctx, addon, repoType) } - if !releaseRes.IsReady() { - releaseRes = services.ReleaseResult{Status: status.Failed( - addon, - releaseRes.Status.Reason, - releaseRes.Status.Message, - nil, - )} - } - if err := r.statusManager.Update( ctx, addon, diff --git a/images/operator-helm-artifact/internal/services/chart_service.go b/images/operator-helm-artifact/internal/services/chart_service.go index 7dfb76a..23b3499 100644 --- a/images/operator-helm-artifact/internal/services/chart_service.go +++ b/images/operator-helm-artifact/internal/services/chart_service.go @@ -34,6 +34,11 @@ import ( "github.com/deckhouse/operator-helm/internal/utils" ) +var helmChartErrorRules = []status.ErrorConditionRule{ + {Type: "FetchFailed", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonChartFetchFailed}, + {Type: "StorageOperationFailed", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonChartStorageFailed}, +} + type ChartService struct { BaseService @@ -105,22 +110,19 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, addon *helmv1alpha1. logger.Info("Reconciled helm chart", "operation", op) } - if cond, ok := status.IsConditionObserved(existing.GetConditions(), helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + processedStatus := status.ProcessChildConditions( + existing.GetConditions(), existing.Generation, addon, helmChartErrorRules, + ) + processedStatus.NotReflectable = existing.Status.Artifact != nil + + if processedStatus.IsReady() { logger.Info("Successfully reconciled helm chart", "operation", op, "chart", addon.Spec.Chart.HelmClusterAddonChartName) - return ChartResult{ - Artifact: existing.Status.Artifact, - Status: status.Status{ - Observed: ok, - Status: cond.Status, - ObservedGeneration: addon.Generation, - Reason: cond.Reason, - Message: cond.Message, - NotReflectable: existing.Status.Artifact != nil, - }, - } } - return ChartResult{Status: status.Unknown(addon, helmv1alpha1.ReasonReconciling)} + return ChartResult{ + Artifact: existing.Status.Artifact, + Status: processedStatus, + } } func (s *ChartService) CleanupHelmChart(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { diff --git a/images/operator-helm-artifact/internal/services/oci_repo_service.go b/images/operator-helm-artifact/internal/services/oci_repo_service.go index 4c6c46b..f83042f 100644 --- a/images/operator-helm-artifact/internal/services/oci_repo_service.go +++ b/images/operator-helm-artifact/internal/services/oci_repo_service.go @@ -35,6 +35,13 @@ import ( "github.com/deckhouse/operator-helm/internal/utils" ) +var ociRepositoryErrorRules = []status.ErrorConditionRule{ + {Type: "FetchFailed", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonOCIFetchFailed}, + {Type: "IncludeUnavailable", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonOCIIncludeUnavailable}, + {Type: "StorageOperationFailed", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonOCIStorageFailed}, + {Type: "SourceVerified", TriggerStatus: metav1.ConditionFalse, Reason: helmv1alpha1.ReasonOCIVerificationFailed}, +} + type OCIRepoService struct { BaseRepoService } @@ -110,21 +117,15 @@ func (s *OCIRepoService) EnsureInternalOCIRepository(ctx context.Context, addon logger.Info("Reconciled oci repository", "operation", op) } - if cond, ok := status.IsConditionObserved(existing.Status.Conditions, helmv1alpha1.ConditionTypeReady, existing.Generation); ok { - return OCIRepoResult{ - Artifact: existing.Status.Artifact, - Status: status.Status{ - Observed: ok, - Status: cond.Status, - ObservedGeneration: addon.Generation, - Reason: cond.Reason, - Message: cond.Message, - NotReflectable: existing.Status.Artifact != nil, - }, - } - } + processedStatus := status.ProcessChildConditions( + existing.Status.Conditions, existing.Generation, addon, ociRepositoryErrorRules, + ) + processedStatus.NotReflectable = existing.Status.Artifact != nil - return OCIRepoResult{Status: status.Unknown(addon, helmv1alpha1.ReasonReconciling)} + return OCIRepoResult{ + Artifact: existing.Status.Artifact, + Status: processedStatus, + } } func (s *OCIRepoService) EnsureRepositorySecrets(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) OCIRepoResult { diff --git a/images/operator-helm-artifact/internal/services/release_service.go b/images/operator-helm-artifact/internal/services/release_service.go index d1b349b..62a0621 100644 --- a/images/operator-helm-artifact/internal/services/release_service.go +++ b/images/operator-helm-artifact/internal/services/release_service.go @@ -34,6 +34,12 @@ import ( "github.com/deckhouse/operator-helm/internal/utils" ) +var helmReleaseErrorRules = []status.ErrorConditionRule{ + {Type: "Released", TriggerStatus: metav1.ConditionFalse, Reason: helmv1alpha1.ReasonReleaseFailed}, + {Type: "TestSuccess", TriggerStatus: metav1.ConditionFalse, Reason: helmv1alpha1.ReasonTestFailed}, + {Type: "Remediated", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonRemediated}, +} + type ReleaseService struct { BaseService @@ -85,27 +91,24 @@ func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, addon *helmv1alp if err != nil { return ReleaseResult{Status: status.Failed( addon, - helmv1alpha1.ReasonHelmReleaseFailed, + helmv1alpha1.ReasonReleaseFailed, "Failed to create helm release", fmt.Errorf("reconciling helm release: %w", err), )} } - if cond, ok := status.IsConditionObserved(existing.GetConditions(), helmv1alpha1.ConditionTypeReady, existing.Generation); ok { + processedStatus := status.ProcessChildConditions( + existing.GetConditions(), existing.Generation, addon, helmReleaseErrorRules, + ) + + if processedStatus.IsReady() { logger.Info("Successfully reconciled helm release", "operation", op) - return ReleaseResult{ - History: existing.Status.History, - Status: status.Status{ - Observed: ok, - Status: cond.Status, - ObservedGeneration: addon.Generation, - Reason: cond.Reason, - Message: cond.Message, - }, - } } - return ReleaseResult{Status: status.Unknown(addon, helmv1alpha1.ReasonReconciling)} + return ReleaseResult{ + History: existing.Status.History, + Status: processedStatus, + } } func (s *ReleaseService) CleanupHelmRelease(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { From 0c7b0bfa9a02f4dc18d5c073981b7c728f4869b1 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:25:34 +0300 Subject: [PATCH 27/27] feat: add e2e tests (#17) --- .github/workflows/build_dev.yml | 39 ++ Taskfile.yaml | 5 + images/operator-helm-artifact/go.mod | 9 +- images/operator-helm-artifact/go.sum | 16 + .../reconcile/helmclusteraddon/reconciler.go | 57 +- tests/e2e/.golangci.yaml | 109 ++++ tests/e2e/Taskfile.dist.yaml | 38 ++ tests/e2e/default_config.yaml | 14 + tests/e2e/e2e_test.go | 44 ++ tests/e2e/go.mod | 81 +++ tests/e2e/go.sum | 214 +++++++ tests/e2e/helmclusteraddon/lifecycle.go | 353 +++++++++++ .../helmclusteraddonrepository/lifecycle.go | 151 +++++ tests/e2e/internal/controller/logwatch.go | 248 ++++++++ tests/e2e/internal/controller/restarts.go | 105 ++++ tests/e2e/internal/framework/cleanup.go | 25 + tests/e2e/internal/framework/client.go | 96 +++ tests/e2e/internal/framework/config.go | 156 +++++ tests/e2e/internal/framework/dump.go | 141 +++++ tests/e2e/internal/framework/framework.go | 164 +++++ tests/e2e/internal/framework/timeout.go | 48 ++ tests/e2e/internal/util/namespace.go | 97 +++ tests/e2e/internal/util/pod.go | 127 ++++ tests/e2e/internal/util/resource.go | 172 ++++++ tests/e2e/internal/util/update.go | 75 +++ tests/e2e/scripts/kind-d8-ci.sh | 574 ++++++++++++++++++ tmp/mc-operator-helm.yaml | 8 - tmp/modulepulloverride.yaml | 8 - tmp/modulesource.yaml | 8 - 29 files changed, 3131 insertions(+), 51 deletions(-) create mode 100644 tests/e2e/.golangci.yaml create mode 100644 tests/e2e/Taskfile.dist.yaml create mode 100644 tests/e2e/default_config.yaml create mode 100644 tests/e2e/e2e_test.go create mode 100644 tests/e2e/go.mod create mode 100644 tests/e2e/go.sum create mode 100644 tests/e2e/helmclusteraddon/lifecycle.go create mode 100644 tests/e2e/helmclusteraddonrepository/lifecycle.go create mode 100644 tests/e2e/internal/controller/logwatch.go create mode 100644 tests/e2e/internal/controller/restarts.go create mode 100644 tests/e2e/internal/framework/cleanup.go create mode 100644 tests/e2e/internal/framework/client.go create mode 100644 tests/e2e/internal/framework/config.go create mode 100644 tests/e2e/internal/framework/dump.go create mode 100644 tests/e2e/internal/framework/framework.go create mode 100644 tests/e2e/internal/framework/timeout.go create mode 100644 tests/e2e/internal/util/namespace.go create mode 100644 tests/e2e/internal/util/pod.go create mode 100644 tests/e2e/internal/util/resource.go create mode 100644 tests/e2e/internal/util/update.go create mode 100755 tests/e2e/scripts/kind-d8-ci.sh delete mode 100644 tmp/mc-operator-helm.yaml delete mode 100644 tmp/modulepulloverride.yaml delete mode 100644 tmp/modulesource.yaml diff --git a/.github/workflows/build_dev.yml b/.github/workflows/build_dev.yml index 1bd9fc9..552f77a 100644 --- a/.github/workflows/build_dev.yml +++ b/.github/workflows/build_dev.yml @@ -191,3 +191,42 @@ jobs: dev_registry_user: ${{ vars.DEV_MODULES_REGISTRY_LOGIN }} dev_registry_password: ${{ secrets.DEV_MODULES_REGISTRY_PASSWORD }} deckhouse_private_repo: ${{ secrets.DECKHOUSE_PRIVATE_REPO }} + + e2e_tests: + name: Run e2e tests + runs-on: [self-hosted, large] + needs: build_dev + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + cache: true + go-version-file: tests/e2e/go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install ginkgo + run: task --yes -p e2e:deps:install:ginkgo + + - name: Setup kind with module operator-helm enabled + run: task --yes -p e2e:kind:ci:setup + env: + KIND_CLUSTER_NAME: d8-operator-helm-${{ github.run_number }} + MODULE_TAG_NAME: ${{ needs.build_dev.outputs.modules_module_tag }} + DEV_REGISTRY_DOCKER_CONFIG: ${{ secrets.DEV_REGISTRY_DOCKER_CONFIG }} + + - name: Run e2e tests + run: task --yes -p e2e:tests + env: + KIND_CLUSTER_NAME: d8-operator-helm-${{ github.run_number }} + + - name: Delete kind cluster + run: task --yes -p e2e:kind:ci:cleanup + env: + KIND_CLUSTER_NAME: d8-operator-helm-${{ github.run_number }} + if: always() \ No newline at end of file diff --git a/Taskfile.yaml b/Taskfile.yaml index 53ce037..5b462cb 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -7,6 +7,11 @@ vars: target: "" VALIDATION_FILES: "tools/validation/{main,messages,diff,doc_changes}.go" +includes: + e2e: + taskfile: ./tests/e2e/Taskfile.dist.yaml + dir: ./tests/e2e + tasks: check-werf: cmds: diff --git a/images/operator-helm-artifact/go.mod b/images/operator-helm-artifact/go.mod index cad8d57..9936fb1 100644 --- a/images/operator-helm-artifact/go.mod +++ b/images/operator-helm-artifact/go.mod @@ -8,11 +8,14 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/deckhouse/operator-helm/api v0.0.0-00010101000000-000000000000 github.com/google/go-containerregistry v0.20.6 + github.com/opencontainers/go-digest v1.0.0 github.com/stretchr/testify v1.11.1 github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 + github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1 github.com/werf/3p-helm-controller/api v0.1.4 github.com/werf/nelm-source-controller/api v0.1.4 go.yaml.in/yaml/v3 v3.0.4 + helm.sh/helm/v3 v3.20.1 k8s.io/api v0.35.1 k8s.io/apimachinery v0.35.1 k8s.io/client-go v0.35.1 @@ -24,12 +27,12 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/docker/cli v28.2.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -46,11 +49,12 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -58,6 +62,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/vbatts/tar-split v0.12.1 // indirect diff --git a/images/operator-helm-artifact/go.sum b/images/operator-helm-artifact/go.sum index e9866e4..11bb0ed 100644 --- a/images/operator-helm-artifact/go.sum +++ b/images/operator-helm-artifact/go.sum @@ -1,3 +1,5 @@ +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -6,10 +8,14 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -68,8 +74,12 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -101,6 +111,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= @@ -120,6 +132,8 @@ github.com/werf/3p-fluxcd-pkg/apis/kustomize v1.14.0-nelm.1 h1:edZ5ugpeUvmjG+g9l github.com/werf/3p-fluxcd-pkg/apis/kustomize v1.14.0-nelm.1/go.mod h1:dAboSMVeohict/XrpXrqyZodq+8Qp6dwafzkBzoCHcU= github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 h1:rYX8cMeryBHH7sNPVSQm1IAVES08TiWvADaZsDj98Wk= github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1/go.mod h1:14co1+Ub5rW0Bp3Qo4IzCHwEcaw06StyMu7Rv5pMVCY= +github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1 h1:ua0xt66rxKptzbG1zxy3u96qfV8XsFT9Jd2PU8L6mc8= +github.com/werf/3p-fluxcd-pkg/chartutil v1.17.0-nelm.1/go.mod h1:fodaCyMGXGxYYSIdWvokrjki8e+DAhgu6BtzHbH2VJ8= github.com/werf/3p-helm-controller/api v0.1.4 h1:s7g9UQOrDMUzVE+JtWOP2xApnPOKYlNe1tXkkWCisAw= github.com/werf/3p-helm-controller/api v0.1.4/go.mod h1:tiPvDerlc5SwKIDmXB8L3kIMJHse+wigueoEGQq+588= github.com/werf/nelm-source-controller/api v0.1.4 h1:/k3RT+hHdwKHntoebdcjhO+zboJIlljHJZlbcumoY08= @@ -194,6 +208,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= +helm.sh/helm/v3 v3.20.1 h1:T8PodUaH1UwNvE+imUA2mIKjJItY8g7CVvLVP5g4NzI= +helm.sh/helm/v3 v3.20.1/go.mod h1:Fl1kBaWCpkUrM6IYXPjQ3bdZQfFrogKArqptvueZ6Ww= k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= diff --git a/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go b/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go index 8991e10..76f8b24 100644 --- a/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go +++ b/images/operator-helm-artifact/internal/reconcile/helmclusteraddon/reconciler.go @@ -20,6 +20,9 @@ import ( "context" "fmt" + "github.com/opencontainers/go-digest" + "github.com/werf/3p-fluxcd-pkg/chartutil" + helmchartutil "helm.sh/helm/v3/pkg/chartutil" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -150,6 +153,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco }) } case utils.InternalOCIRepository: + if err := r.chartService.CleanupHelmChart(ctx, addon); err != nil { + chartRes = services.ChartResult{ + Status: status.Failed(addon, helmv1alpha1.ReasonFailed, "Repository change failed", err), + } + break + } + repoRes = r.ociRepositoryService.EnsureInternalOCIRepository(ctx, addon, repo) if !repoRes.IsPartiallyDegraded() { apimeta.SetStatusCondition(&addon.Status.Conditions, metav1.Condition{ @@ -232,35 +242,19 @@ func setStatusAttrs(repoType utils.InternalRepositoryType, chartRes services.Cha results = status.DetermineConditions(obj, results...) addon := obj.(*helmv1alpha1.HelmClusterAddon) - var updateChart, updateValues bool + var updateChart bool switch repoType { case utils.InternalHelmRepository: if chartRes.HasArtifact() && releaseRes.IsReady() { - if addon.Status.LastAppliedChart == nil { + if addon.Status.LastAppliedChart == nil || (addon.IsChartStatusInfoOutdated() && chartRes.IsReady()) { updateChart = true - updateValues = true - } else { - if addon.IsChartStatusInfoOutdated() && chartRes.IsReady() { - updateChart = true - updateValues = true - } else { - updateValues = true - } } } case utils.InternalOCIRepository: if repoRes.HasArtifact() && releaseRes.IsReady() { - if addon.Status.LastAppliedChart == nil { + if addon.Status.LastAppliedChart == nil || (addon.IsChartStatusInfoOutdated() && repoRes.IsReady()) { updateChart = true - updateValues = true - } else { - if addon.IsChartStatusInfoOutdated() && repoRes.IsReady() { - updateChart = true - updateValues = true - } else { - updateValues = true - } } } } @@ -273,11 +267,20 @@ func setStatusAttrs(repoType utils.InternalRepositoryType, chartRes services.Cha } } - if updateValues { - if addon.Spec.Values == nil { - addon.Status.LastAppliedValues = nil - } else { - addon.Status.LastAppliedValues = addon.Spec.Values.DeepCopy() + latestRelease := releaseRes.History.Latest() + if releaseRes.IsReady() && latestRelease != nil { + rawValues := []byte(`{}`) + if addon.Spec.Values != nil { + rawValues = addon.Spec.Values.Raw + } + + addonValues, _ := helmchartutil.ReadValues(rawValues) + if latestRelease.Status == "deployed" && latestRelease.ConfigDigest == chartutil.DigestValues(digest.Canonical, addonValues).String() { + if addon.Spec.Values == nil { + addon.Status.LastAppliedValues = nil + } else { + addon.Status.LastAppliedValues = addon.Spec.Values.DeepCopy() + } } } @@ -288,9 +291,13 @@ func setStatusAttrs(repoType utils.InternalRepositoryType, chartRes services.Cha func mapResourceStatus() status.MapperFunc { return func(conditionType string, status status.Status) status.Status { if conditionType == helmv1alpha1.ConditionTypePartiallyDegraded { + switch status.Status { // ConditionTrue means that HelmChartSucceeded, resetting status would exclude it from result. - if status.Status == metav1.ConditionTrue { + case metav1.ConditionTrue: status.Status = "" + // ConditionFalse means that chart failed, change Status to True, to raise ConditionTypePartiallyDegraded condition. + case metav1.ConditionFalse: + status.Status = metav1.ConditionTrue } } diff --git a/tests/e2e/.golangci.yaml b/tests/e2e/.golangci.yaml new file mode 100644 index 0000000..4260e0d --- /dev/null +++ b/tests/e2e/.golangci.yaml @@ -0,0 +1,109 @@ +# https://golangci-lint.run/usage/configuration/ +version: "2" + +run: + concurrency: 4 + timeout: 10m + +issues: + # Show all errors. + max-issues-per-linter: 0 + max-same-issues: 0 + exclude: + - "don't use an underscore in package name" + +output: + sort-results: true + +exclusions: + paths: + - "^zz_generated.*" + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/deckhouse/) + gofumpt: + extra-rules: true + goimports: + local-prefixes: github.com/deckhouse/ + +linters: + default: none + enable: + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - contextcheck # [maybe too many false positives] checks the function whether use a non-inherited context + - dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - copyloopvar # detects places where loop variables are copied (Go 1.22+) + - gocritic # provides diagnostics that check for bugs, performance and style issues + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - ineffassign # detects when assignments to existing variables are not used + - misspell # finds commonly misspelled English words in comments + - nolintlint # reports ill-formed or insufficient nolint directives + - reassign # checks that package variables are not reassigned + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - testifylint # checks usage of github.com/stretchr/testify + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - unused # checks for unused constants, variables, functions and types + - usetesting # reports uses of functions with replacement inside the testing package + - testableexamples # checks if examples are testable (have an expected output) + - thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - whitespace # detects leading and trailing whitespace + - wastedassign # finds wasted assignment statements + - importas # checks import aliases against the configured convention + settings: + errcheck: + exclude-functions: + - "(*os.File).Close" + - "(*net.TCPConn).Close" + - "(io.ReadCloser).Close" + - "(net.Listener).Close" + - "(net.Conn).Close" + - "(net.Conn).Close" + - "(*golang.org/x/crypto/ssh.Session).Close" + - "(*github.com/fsnotify/fsnotify.Watcher).Close" + staticcheck: + dot-import-whitelist: + - github.com/onsi/ginkgo/v2 + - github.com/onsi/gomega + revive: + rules: + - name: dot-imports + disabled: true + - name: exported + disabled: true + - name: package-comments + disabled: true + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [funlen, gocognit, lll] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + importas: + # Do not allow unaliased imports of aliased packages. + # Default: false + no-unaliased: true + # Do not allow non-required aliases. + # Default: false + no-extra-aliases: false \ No newline at end of file diff --git a/tests/e2e/Taskfile.dist.yaml b/tests/e2e/Taskfile.dist.yaml new file mode 100644 index 0000000..3d1c93a --- /dev/null +++ b/tests/e2e/Taskfile.dist.yaml @@ -0,0 +1,38 @@ +version: '3' + +vars: + TIMEOUT: '{{.TIMEOUT | default "30m"}}' + KIND_CLUSTER_NAME: '{{.KIND_CLUSTER_NAME | default "d8-operator-helm"}}' + +tasks: + deps:install:ginkgo: + desc: 'Install ginkgo binary. Important vars: "paths".' + cmds: + - | + cd {{.paths | default "./"}} + version="$(go list -m -f '{{ printf `{{ .Version }}` }}' github.com/onsi/ginkgo/v2)" + go install {{.CLI_ARGS}} github.com/onsi/ginkgo/v2/ginkgo@${version} + + kind:ci:setup: + desc: Setup kind in CI + cmds: + - ./scripts/kind-d8-ci.sh --channel $KIND_DECKHOUSE_CHANNEL + env: + KIND_DECKHOUSE_CHANNEL: '{{.KIND_DECKHOUSE_CHANNEL | default "Stable"}}' + MODULE_TAG_NAME: '{{.MODULE_TAG_NAME | default "main"}}' + DEV_REGISTRY_URL: '{{.DEV_REGISTRY_URL | default "dev-registry.deckhouse.io/sys/deckhouse-oss/modules"}}' + DEV_REGISTRY_DOCKER_CONFIG: '{{.DEV_REGISTRY_DOCKER_CONFIG}}' + + kind:ci:cleanup: + desc: Delete kind cluster in CI + cmds: + - './kind/bin/kind delete cluster --name {{.KIND_CLUSTER_NAME}} || exit 0' + + tests: + desc: Run e2e tests + cmds: + - | + args="-v --race --timeout={{.TIMEOUT}}" + go tool ginkgo $args ./... + env: + E2E_CLUSTERTRANSPORT_KUBECONFIG: './kind/{{.KIND_CLUSTER_NAME}}/kubeconfig-external' diff --git a/tests/e2e/default_config.yaml b/tests/e2e/default_config.yaml new file mode 100644 index 0000000..bd6e2ba --- /dev/null +++ b/tests/e2e/default_config.yaml @@ -0,0 +1,14 @@ +clusterTransport: + kubeConfig: "" + +controllers: + - name: "operator-helm-controller" + namespace: "d8-operator-helm" + labelSelector: "app=operator-helm-controller" + containers: + - "operator-helm-controller" + logFilters: + exclude: + # On cascade deletion after tests, HelmClusterAddon reconcile can be triggered after repository removal. + - "Failed to get internal repository" + excludeRegexp: [] diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go new file mode 100644 index 0000000..d243f4e --- /dev/null +++ b/tests/e2e/e2e_test.go @@ -0,0 +1,44 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + _ "github.com/deckhouse/operator-helm/tests/e2e/helmclusteraddon" + _ "github.com/deckhouse/operator-helm/tests/e2e/helmclusteraddonrepository" + "github.com/deckhouse/operator-helm/tests/e2e/internal/controller" +) + +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "E2E Suite") +} + +var _ = SynchronizedBeforeSuite(func() { + controller.StartAll() + controller.SaveRestartCounts() +}, func() {}) + +var _ = SynchronizedAfterSuite(func() {}, func() { + controller.StopAll() + controller.AssertNoErrors() + controller.AssertNoRestarts() +}) diff --git a/tests/e2e/go.mod b/tests/e2e/go.mod new file mode 100644 index 0000000..fcc81cc --- /dev/null +++ b/tests/e2e/go.mod @@ -0,0 +1,81 @@ +module github.com/deckhouse/operator-helm/tests/e2e + +go 1.25.0 + +tool github.com/onsi/ginkgo/v2/ginkgo + +require ( + github.com/deckhouse/operator-helm/api v0.0.0 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.3 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.35.1 + k8s.io/apiextensions-apiserver v0.35.1 + k8s.io/apimachinery v0.35.1 + k8s.io/cli-runtime v0.35.1 + k8s.io/client-go v0.35.1 + sigs.k8s.io/controller-runtime v0.23.1 +) + +require ( + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/sergi/go-diff v1.3.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +replace github.com/deckhouse/operator-helm/api => ../../api diff --git a/tests/e2e/go.sum b/tests/e2e/go.sum new file mode 100644 index 0000000..b94bf4b --- /dev/null +++ b/tests/e2e/go.sum @@ -0,0 +1,214 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/cli-runtime v0.35.1 h1:uKcXFe8J7AMAM4Gm2JDK4mp198dBEq2nyeYtO+JfGJE= +k8s.io/cli-runtime v0.35.1/go.mod h1:55/hiXIq1C8qIJ3WBrWxEwDLdHQYhBNRdZOz9f7yvTw= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/tests/e2e/helmclusteraddon/lifecycle.go b/tests/e2e/helmclusteraddon/lifecycle.go new file mode 100644 index 0000000..7c04605 --- /dev/null +++ b/tests/e2e/helmclusteraddon/lifecycle.go @@ -0,0 +1,353 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddon + +import ( + "context" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/tests/e2e/internal/controller" + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" + "github.com/deckhouse/operator-helm/tests/e2e/internal/util" +) + +func DefineLifecycleTests(repoType, repoURL string) { + Describe(fmt.Sprintf("Using %s repository", repoType), Ordered, func() { + f := framework.NewFramework("addon-lifecycle") + cfg := framework.GetConfig() + + repoName := "e2e-test-repo-" + strings.ToLower(repoType) + addonName := "e2e-test-addon-" + strings.ToLower(repoType) + chartName := "podinfo" + + labelSelector := fmt.Sprintf("app.kubernetes.io/name=%s-%s", addonName, chartName) + + BeforeAll(func() { + DeferCleanup(f.After) + f.Before() + + By("Verifying all controllers are running") + for _, ctrl := range cfg.Controllers { + util.UntilControllerReady(ctrl.Namespace, ctrl.LabelSelector, framework.LongTimeout) + } + }) + + AfterEach(func() { + By("Verifying no errors in operator-helm-controller logs") + controller.AssertNoErrorsFor("operator-helm-controller") + }) + + It("should create HelmClusterAddonRepository and reach Ready", func() { + repo := &apiv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: repoName, + }, + Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + URL: repoURL, + TLSVerify: true, + }, + } + + created, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddonRepositories(). + Create(context.Background(), repo, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + f.DeferDeleteFunc(func() error { + return f.OperatorClient().HelmV1alpha1(). + HelmClusterAddonRepositories(). + Delete(context.Background(), created.Name, metav1.DeleteOptions{}) + }) + + By("Waiting for repository to become Ready") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypeReady, + framework.LongTimeout, + created, + ) + + By("Waiting for HelmClusterAddonRepository to become Synced") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypeSynced, + framework.LongTimeout, + created, + ) + }) + + It("should verify target namespace does not have addon pods yet", func() { + By(fmt.Sprintf("Checking namespace %q has no addon pods", f.NamespaceName())) + pods, err := f.KubeClient().CoreV1(). + Pods(f.NamespaceName()). + List(context.Background(), metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app.kubernetes.io/name=%s-%s", addonName, chartName), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(pods.Items).To(BeEmpty()) + }) + + It("should create HelmClusterAddon and wait for installation", func() { + addon := &apiv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{ + Name: addonName, + }, + Spec: apiv1alpha1.HelmClusterAddonSpec{ + Chart: apiv1alpha1.HelmClusterAddonChartRef{ + HelmClusterAddonChartName: chartName, + HelmClusterAddonRepository: repoName, + Version: "6.10.2", + }, + Namespace: f.NamespaceName(), + }, + } + + created, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Create(context.Background(), addon, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + f.DeferDeleteFunc(func() error { + return f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Delete(context.Background(), created.Name, metav1.DeleteOptions{}) + }) + + By("Waiting for addon to be installed") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypeReady, + framework.LongTimeout, + created, + ) + + By("Verifying Installed condition reason is Success") + util.UntilConditionReason( + apiv1alpha1.ConditionTypeInstalled, + "InstallSucceeded", + framework.ShortTimeout, + created, + ) + + By("Checking all pods are ready") + util.UntilAllPodsReady(f.NamespaceName(), labelSelector, 1, framework.LongTimeout) + }) + + It("should update chart version and apply changes", func() { + By("Updating addon chart version") + updated := util.UpdateHelmClusterAddon(addonName, func(addon *apiv1alpha1.HelmClusterAddon) { + addon.Spec.Chart.Version = "6.10.0" + }) + + By("Waiting for update to be applied") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypeUpdateInstalled, + framework.LongTimeout, + updated, + ) + + By("Verifying the version was applied") + updated, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Get(context.Background(), addonName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Status.LastAppliedChart).NotTo(BeNil()) + Expect(updated.Status.LastAppliedChart.Version).To(Equal("6.10.0")) + + By("Verifying pods are still running after update") + util.UntilPodCount(f.NamespaceName(), labelSelector, 1, framework.LongTimeout) + }) + + It("should update last applied values", func() { + expectedValues := `{"replicaCount": 2}` + + By("Updating last applied values") + updated := util.UpdateHelmClusterAddon(addonName, func(addon *apiv1alpha1.HelmClusterAddon) { + addon.Spec.Values = &apiextensionsv1.JSON{Raw: []byte(expectedValues)} + }) + + By("Waiting for update to be applied") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypeConfigurationApplied, + framework.LongTimeout, + updated, + ) + + By("Verifying that values are applied") + updated, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Get(context.Background(), addonName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Status.LastAppliedValues).NotTo(BeNil()) + Expect(updated.Status.LastAppliedValues.Raw).To(MatchJSON(expectedValues)) + + By("Verifying pods number changed after values update") + util.UntilPodCount(f.NamespaceName(), labelSelector, 2, framework.LongTimeout) + }) + + It("should not update chart version on invalid chart version", func() { + addon, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Get(context.Background(), addonName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + By("Should have PartiallyDegraded condition inactive") + util.UntilConditionStatus( + apiv1alpha1.ConditionTypePartiallyDegraded, + string(metav1.ConditionFalse), + framework.LongTimeout, + addon, + ) + + invalidChartVersion := "invalid-version" + + By("Updating addon chart to invalid version") + updated := util.UpdateHelmClusterAddon(addonName, func(addon *apiv1alpha1.HelmClusterAddon) { + addon.Spec.Chart.Version = invalidChartVersion + }) + + By("Should have PartiallyDegraded condition active") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypePartiallyDegraded, + framework.LongTimeout, + updated, + ) + + By("Should not update chart info") + updated, err = f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Get(context.Background(), addonName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Status.LastAppliedValues).NotTo(BeNil()) + Expect(updated.Status.LastAppliedChart.Version).NotTo(Equal(invalidChartVersion)) + + By("Verifying pods number changed after invalid chart info set") + util.UntilPodCount(f.NamespaceName(), labelSelector, 2, framework.LongTimeout) + }) + + It("Should redeem on reverting chart version", func() { + addon, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Get(context.Background(), addonName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + By("Should have PartiallyDegraded condition active") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypePartiallyDegraded, + framework.LongTimeout, + addon, + ) + + validChartVersion := "6.10.2" + + By("Updating addon chart to invalid version") + updated := util.UpdateHelmClusterAddon(addonName, func(addon *apiv1alpha1.HelmClusterAddon) { + addon.Spec.Chart.Version = validChartVersion + }) + + By("Waiting for addon to be upgraded") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypeReady, + framework.LongTimeout, + updated, + ) + + By("Should have PartiallyDegraded condition inactive") + util.UntilConditionStatus( + apiv1alpha1.ConditionTypePartiallyDegraded, + string(metav1.ConditionFalse), + framework.LongTimeout, + updated, + ) + + By("Should update chart info") + updated, err = f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Get(context.Background(), addonName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Status.LastAppliedValues).NotTo(BeNil()) + Expect(updated.Status.LastAppliedChart.Version).To(Equal(validChartVersion)) + + By("Verifying pods number changed after invalid chart info set") + util.UntilPodCount(f.NamespaceName(), labelSelector, 2, framework.LongTimeout) + }) + + It("Should fail on invalid values set", func() { + invalidValues := `{"replicaCount": "no"}` + + By("Updating addon chart version") + updated := util.UpdateHelmClusterAddon(addonName, func(addon *apiv1alpha1.HelmClusterAddon) { + addon.Spec.Values = &apiextensionsv1.JSON{Raw: []byte(invalidValues)} + }) + + By("Waiting for update to be applied") + util.UntilConditionStatus( + apiv1alpha1.ConditionTypeReady, + string(metav1.ConditionFalse), + framework.LongTimeout, + updated, + ) + + By("Verifying invalid values were not applied") + updated, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Get(context.Background(), addonName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Status.LastAppliedValues).NotTo(BeNil()) + Expect(updated.Status.LastAppliedValues.Raw).NotTo(MatchJSON(invalidValues)) + + By("Verifying pods are still running after update") + util.UntilPodCount(f.NamespaceName(), labelSelector, 2, framework.LongTimeout) + }) + + It("Should redeem on reverting values", func() { + validValues := `{"replicaCount": 3}` + + By("Updating addon chart version") + updated := util.UpdateHelmClusterAddon(addonName, func(addon *apiv1alpha1.HelmClusterAddon) { + addon.Spec.Values = &apiextensionsv1.JSON{Raw: []byte(validValues)} + }) + + By("Waiting for update to be applied") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypeConfigurationApplied, + framework.LongTimeout, + updated, + ) + + By("Verifying valid values were applied") + updated, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Get(context.Background(), addonName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Status.LastAppliedValues).NotTo(BeNil()) + Expect(updated.Status.LastAppliedValues.Raw).To(MatchJSON(validValues)) + + By("Verifying pods are running after update") + util.UntilPodCount(f.NamespaceName(), labelSelector, 3, framework.LongTimeout) + }) + }) +} + +var _ = Describe("HelmClusterAddon lifecycle", Ordered, func() { + DefineLifecycleTests("Helm", "https://stefanprodan.github.io/podinfo") + DefineLifecycleTests("OCI", "oci://ghcr.io/stefanprodan/charts/podinfo") +}) diff --git a/tests/e2e/helmclusteraddonrepository/lifecycle.go b/tests/e2e/helmclusteraddonrepository/lifecycle.go new file mode 100644 index 0000000..11d3411 --- /dev/null +++ b/tests/e2e/helmclusteraddonrepository/lifecycle.go @@ -0,0 +1,151 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helmclusteraddonrepository + +import ( + "context" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/tests/e2e/internal/controller" + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" + "github.com/deckhouse/operator-helm/tests/e2e/internal/util" +) + +func DefineLifecycleTests(repoType, repoURL string) { + Describe(fmt.Sprintf("Testing %s repository", repoType), Ordered, func() { + f := framework.NewFramework("repository-lifecycle") + cfg := framework.GetConfig() + + repoName := "e2e-test-repo-" + strings.ToLower(repoType) + + BeforeAll(func() { + DeferCleanup(f.After) + f.Before() + + By("Verifying all controllers are running") + for _, ctrl := range cfg.Controllers { + util.UntilControllerReady(ctrl.Namespace, ctrl.LabelSelector, framework.LongTimeout) + } + }) + + AfterEach(func() { + By("Verifying no errors in operator-helm-controller logs") + controller.AssertNoErrorsFor("operator-helm-controller") + }) + + It("should create HelmClusterAddonRepository", func() { + repo := &apiv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: repoName, + }, + Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + URL: repoURL, + TLSVerify: true, + }, + } + + created, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddonRepositories(). + Create(context.Background(), repo, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + f.DeferDeleteFunc(func() error { + return f.OperatorClient().HelmV1alpha1(). + HelmClusterAddonRepositories(). + Delete(context.Background(), created.Name, metav1.DeleteOptions{}) + }) + + By("Waiting for repository to become Ready") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypeReady, + framework.LongTimeout, + created, + ) + + By("Waiting for HelmClusterAddonRepository to become Synced") + util.UntilConditionTrue( + apiv1alpha1.ConditionTypeSynced, + framework.LongTimeout, + created, + ) + + By("Should have existing HelmClusterAddonChart") + labelSelector := fmt.Sprintf("repository=%s", repoName) + charts, err := f.OperatorClient(). + HelmV1alpha1(). + HelmClusterAddonCharts(). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + Expect(err).NotTo(HaveOccurred()) + Expect(len(charts.Items)).To(BeNumerically(">=", 1), + "waiting for >= %d charts, got %d", 1, len(charts.Items)) + + By("HelmClusterAddonChart should have versions") + for _, chart := range charts.Items { + Expect(chart.Status.Versions).NotTo(BeEmpty()) + } + }) + }) +} + +var _ = Describe("HelmClusterAddonRepository lifecycle", Ordered, func() { + DefineLifecycleTests("Helm", "https://stefanprodan.github.io/podinfo") + DefineLifecycleTests("OCI", "oci://ghcr.io/stefanprodan/charts/podinfo") +}) + +var _ = Describe("Create HelmClusterAddonRepository with invalid url", Ordered, func() { + f := framework.NewFramework("repository-lifecycle") + cfg := framework.GetConfig() + + BeforeAll(func() { + DeferCleanup(f.After) + f.Before() + + By("Verifying all controllers are running") + for _, ctrl := range cfg.Controllers { + util.UntilControllerReady(ctrl.Namespace, ctrl.LabelSelector, framework.LongTimeout) + } + }) + + AfterEach(func() { + By("Verifying no errors in operator-helm-controller logs") + controller.AssertNoErrorsFor("operator-helm-controller") + }) + + It("should create HelmClusterAddonRepository with invalid url", func() { + By("Creating HelmClusterAddonRepository with invalid url") + repo := &apiv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "repo-with-invalid-url", + }, + Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + URL: "invalid-url", + }, + } + + _, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterAddonRepositories(). + Create(context.Background(), repo, metav1.CreateOptions{}) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("is invalid: spec.url"))) + }) +}) diff --git a/tests/e2e/internal/controller/logwatch.go b/tests/e2e/internal/controller/logwatch.go new file mode 100644 index 0000000..a6855d9 --- /dev/null +++ b/tests/e2e/internal/controller/logwatch.go @@ -0,0 +1,248 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "bufio" + "context" + "fmt" + "strings" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" +) + +// LogError stores a single error line found in controller logs. +type LogError struct { + Controller string + Pod string + Container string + Line string + Timestamp time.Time +} + +func (e LogError) String() string { + return fmt.Sprintf("[%s] %s/%s: %s", e.Controller, e.Pod, e.Container, e.Line) +} + +// controllerWatcher monitors logs for a single controller. +type controllerWatcher struct { + config framework.ControllerConfig + errors []LogError + mu sync.Mutex + cancel context.CancelFunc + excludeStrings []string +} + +// LogWatchManager manages watchers for all controllers. +type LogWatchManager struct { + watchers map[string]*controllerWatcher +} + +var manager *LogWatchManager + +// StartAll begins streaming logs for all controllers defined in config. +func StartAll() { + cfg := framework.GetConfig() + manager = &LogWatchManager{ + watchers: make(map[string]*controllerWatcher, len(cfg.Controllers)), + } + + for _, ctrlCfg := range cfg.Controllers { + w := newControllerWatcher(ctrlCfg) + manager.watchers[ctrlCfg.Name] = w + w.start() + } +} + +// StopAll stops all log watchers. +func StopAll() { + if manager == nil { + return + } + for _, w := range manager.watchers { + w.stop() + } +} + +// AssertNoErrors fails the test if any controller logged errors. +func AssertNoErrors() { + GinkgoHelper() + if manager == nil { + return + } + + var allErrors []LogError + for _, w := range manager.watchers { + allErrors = append(allErrors, w.getErrors()...) + } + + if len(allErrors) > 0 { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d error(s) in controller logs:\n\n", len(allErrors))) + for i, e := range allErrors { + sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, e.String())) + } + Fail(sb.String()) + } +} + +// GetErrors returns collected errors for a specific controller. +func GetErrors(controllerName string) []LogError { + if manager == nil { + return nil + } + w, ok := manager.watchers[controllerName] + if !ok { + return nil + } + return w.getErrors() +} + +// AssertNoErrorsFor fails the test if the named controller has errors. +func AssertNoErrorsFor(controllerName string) { + GinkgoHelper() + errs := GetErrors(controllerName) + Expect(errs).To(BeEmpty(), + "controller %q has %d error(s) in logs:\n%v", controllerName, len(errs), errs) +} + +func newControllerWatcher(cfg framework.ControllerConfig) *controllerWatcher { + return &controllerWatcher{ + config: cfg, + excludeStrings: cfg.LogFilters.Exclude, + } +} + +func (w *controllerWatcher) start() { + ctx, cancel := context.WithCancel(context.Background()) + w.cancel = cancel + + pods, err := framework.GetClients().KubeClient().CoreV1(). + Pods(w.config.Namespace). + List(ctx, metav1.ListOptions{LabelSelector: w.config.LabelSelector}) + if err != nil { + GinkgoWriter.Printf("WARNING: cannot list pods for controller %q: %v\n", w.config.Name, err) + return + } + + for _, pod := range pods.Items { + containers := w.config.Containers + if len(containers) == 0 { + for _, c := range pod.Spec.Containers { + containers = append(containers, c.Name) + } + } + + for _, container := range containers { + go w.streamLogs(ctx, pod.Name, container) + } + } +} + +func (w *controllerWatcher) stop() { + if w.cancel != nil { + w.cancel() + } +} + +func (w *controllerWatcher) streamLogs(ctx context.Context, podName, containerName string) { + sinceTime := metav1.Now() + stream, err := framework.GetClients().KubeClient().CoreV1(). + Pods(w.config.Namespace). + GetLogs(podName, &corev1.PodLogOptions{ + Container: containerName, + Follow: true, + SinceTime: &sinceTime, + }).Stream(ctx) + if err != nil { + GinkgoWriter.Printf("WARNING: cannot stream logs for %s/%s/%s: %v\n", + w.config.Name, podName, containerName, err) + return + } + defer stream.Close() + + scanner := bufio.NewScanner(stream) + for scanner.Scan() { + line := scanner.Text() + if w.isError(line) && !w.isExcluded(line) { + w.addError(LogError{ + Controller: w.config.Name, + Pod: podName, + Container: containerName, + Line: line, + Timestamp: time.Now(), + }) + } + } +} + +func (w *controllerWatcher) isError(line string) bool { + lower := strings.ToLower(line) + + if strings.Contains(lower, `"level":"error"`) || + strings.Contains(lower, `"level":"fatal"`) || + strings.Contains(lower, `"level":"dpanic"`) { + return true + } + + // klog format: E0324 10:00:00.000000 ... + if len(line) > 1 && line[0] == 'E' && line[1] >= '0' && line[1] <= '9' { + return true + } + + if strings.Contains(lower, "level=error") || + strings.Contains(lower, "panic:") { + return true + } + + return false +} + +func (w *controllerWatcher) isExcluded(line string) bool { + for _, s := range w.excludeStrings { + if strings.Contains(line, s) { + return true + } + } + for _, re := range w.config.CompiledRegexps() { + if re.MatchString(line) { + return true + } + } + return false +} + +func (w *controllerWatcher) addError(err LogError) { + w.mu.Lock() + defer w.mu.Unlock() + w.errors = append(w.errors, err) +} + +func (w *controllerWatcher) getErrors() []LogError { + w.mu.Lock() + defer w.mu.Unlock() + copied := make([]LogError, len(w.errors)) + copy(copied, w.errors) + return copied +} diff --git a/tests/e2e/internal/controller/restarts.go b/tests/e2e/internal/controller/restarts.go new file mode 100644 index 0000000..0997848 --- /dev/null +++ b/tests/e2e/internal/controller/restarts.go @@ -0,0 +1,105 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" +) + +type restartSnapshot struct { + Controller string + Pod string + Container string + Count int32 +} + +var initialRestarts []restartSnapshot + +// SaveRestartCounts records the current restart count for all controller containers. +func SaveRestartCounts() { + cfg := framework.GetConfig() + initialRestarts = nil + + for _, ctrl := range cfg.Controllers { + pods, err := framework.GetClients().KubeClient().CoreV1(). + Pods(ctrl.Namespace). + List(context.Background(), metav1.ListOptions{LabelSelector: ctrl.LabelSelector}) + if err != nil { + GinkgoWriter.Printf("WARNING: cannot list pods for controller %q: %v\n", ctrl.Name, err) + continue + } + + for _, pod := range pods.Items { + for _, cs := range pod.Status.ContainerStatuses { + initialRestarts = append(initialRestarts, restartSnapshot{ + Controller: ctrl.Name, + Pod: pod.Name, + Container: cs.Name, + Count: cs.RestartCount, + }) + } + } + } +} + +// AssertNoRestarts fails the test if any controller container restarted during the suite. +func AssertNoRestarts() { + GinkgoHelper() + cfg := framework.GetConfig() + + var restartMessages []string + + for _, ctrl := range cfg.Controllers { + pods, err := framework.GetClients().KubeClient().CoreV1(). + Pods(ctrl.Namespace). + List(context.Background(), metav1.ListOptions{LabelSelector: ctrl.LabelSelector}) + Expect(err).NotTo(HaveOccurred(), "failed to list pods for controller %q", ctrl.Name) + + for _, pod := range pods.Items { + for _, cs := range pod.Status.ContainerStatuses { + initial := findInitialCount(ctrl.Name, pod.Name, cs.Name) + if cs.RestartCount > initial { + restartMessages = append(restartMessages, fmt.Sprintf( + "controller %q pod %s container %s: restarts before=%d after=%d", + ctrl.Name, pod.Name, cs.Name, initial, cs.RestartCount, + )) + } + } + } + } + + if len(restartMessages) > 0 { + Fail(fmt.Sprintf("Controller restarts detected:\n %s", strings.Join(restartMessages, "\n "))) + } +} + +func findInitialCount(controllerName, pod, container string) int32 { + for _, s := range initialRestarts { + if s.Controller == controllerName && s.Pod == pod && s.Container == container { + return s.Count + } + } + return 0 +} diff --git a/tests/e2e/internal/framework/cleanup.go b/tests/e2e/internal/framework/cleanup.go new file mode 100644 index 0000000..567417e --- /dev/null +++ b/tests/e2e/internal/framework/cleanup.go @@ -0,0 +1,25 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import "os" + +const PostCleanUpEnv = "POST_CLEANUP" + +func IsCleanUpNeeded() bool { + return os.Getenv(PostCleanUpEnv) != "no" +} diff --git a/tests/e2e/internal/framework/client.go b/tests/e2e/internal/framework/client.go new file mode 100644 index 0000000..cfec64f --- /dev/null +++ b/tests/e2e/internal/framework/client.go @@ -0,0 +1,96 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + + operatorhelmclient "github.com/deckhouse/operator-helm/api/client/generated/clientset/versioned" + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +var clients Clients + +func GetClients() Clients { + return clients +} + +type Clients struct { + kubeClient kubernetes.Interface + operatorClient operatorhelmclient.Interface + generic client.Client + dynamic dynamic.Interface +} + +func (c Clients) KubeClient() kubernetes.Interface { + return c.kubeClient +} + +func (c Clients) OperatorClient() operatorhelmclient.Interface { + return c.operatorClient +} + +func (c Clients) GenericClient() client.Client { + return c.generic +} + +func (c Clients) DynamicClient() dynamic.Interface { + return c.dynamic +} + +func init() { + onceLoadConfig() + + restConfig, err := conf.ClusterTransport.RestConfig() + if err != nil { + panic(err) + } + + clients.kubeClient, err = kubernetes.NewForConfig(restConfig) + if err != nil { + panic(err) + } + + clients.operatorClient, err = operatorhelmclient.NewForConfig(restConfig) + if err != nil { + panic(err) + } + + clients.dynamic, err = dynamic.NewForConfig(restConfig) + if err != nil { + panic(err) + } + + scheme := apiruntime.NewScheme() + for _, addToScheme := range []func(*apiruntime.Scheme) error{ + clientgoscheme.AddToScheme, + apiv1alpha1.AddToScheme, + } { + if err := addToScheme(scheme); err != nil { + panic(err) + } + } + + clients.generic, err = client.New(restConfig, client.Options{Scheme: scheme}) + if err != nil { + panic(err) + } +} diff --git a/tests/e2e/internal/framework/config.go b/tests/e2e/internal/framework/config.go new file mode 100644 index 0000000..4d81f0a --- /dev/null +++ b/tests/e2e/internal/framework/config.go @@ -0,0 +1,156 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + "os" + "regexp" + "strconv" + "sync" + + yamlv3 "gopkg.in/yaml.v3" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" +) + +var ( + conf *Config + once sync.Once +) + +func onceLoadConfig() { + once.Do(func() { + c, err := loadConfig() + if err != nil { + panic(err) + } + conf = c + }) +} + +func GetConfig() *Config { + onceLoadConfig() + copied := *conf + return &copied +} + +func loadConfig() (*Config, error) { + cfgPath := "./default_config.yaml" + if e, ok := os.LookupEnv("E2E_CONFIG"); ok { + cfgPath = e + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + return nil, err + } + + var cfg Config + if err := yamlv3.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + cfg.applyEnvOverrides() + cfg.compileRegexps() + + return &cfg, nil +} + +type Config struct { + ClusterTransport ClusterTransport `yaml:"clusterTransport"` + Controllers []ControllerConfig `yaml:"controllers"` +} + +type ControllerConfig struct { + Name string `yaml:"name"` + Namespace string `yaml:"namespace"` + LabelSelector string `yaml:"labelSelector"` + Containers []string `yaml:"containers"` + LogFilters LogFilters `yaml:"logFilters"` + + compiledRegexps []*regexp.Regexp +} + +func (c *ControllerConfig) CompiledRegexps() []*regexp.Regexp { + return c.compiledRegexps +} + +type LogFilters struct { + Exclude []string `yaml:"exclude"` + ExcludeRegexp []string `yaml:"excludeRegexp"` +} + +type ClusterTransport struct { + KubeConfig string `yaml:"kubeConfig"` + Token string `yaml:"token"` + Endpoint string `yaml:"endpoint"` + CertificateAuthority string `yaml:"certificateAuthority"` + InsecureTLS bool `yaml:"insecureTls"` +} + +func (c ClusterTransport) RestConfig() (*rest.Config, error) { + flags := genericclioptions.ConfigFlags{} + if c.KubeConfig != "" { + flags.KubeConfig = &c.KubeConfig + } + if c.Token != "" { + flags.BearerToken = &c.Token + } + if c.InsecureTLS { + flags.Insecure = &c.InsecureTLS + } + if c.CertificateAuthority != "" { + flags.CAFile = &c.CertificateAuthority + } + if c.Endpoint != "" { + flags.APIServer = &c.Endpoint + } + return flags.ToRESTConfig() +} + +func (c *Config) applyEnvOverrides() { + if e, ok := os.LookupEnv("E2E_CLUSTERTRANSPORT_KUBECONFIG"); ok { + c.ClusterTransport.KubeConfig = e + } + if e, ok := os.LookupEnv("E2E_CLUSTERTRANSPORT_TOKEN"); ok { + c.ClusterTransport.Token = e + } + if e, ok := os.LookupEnv("E2E_CLUSTERTRANSPORT_ENDPOINT"); ok { + c.ClusterTransport.Endpoint = e + } + if e, ok := os.LookupEnv("E2E_CLUSTERTRANSPORT_CERTIFICATEAUTHORITY"); ok { + c.ClusterTransport.CertificateAuthority = e + } + if e, ok := os.LookupEnv("E2E_CLUSTERTRANSPORT_INSECURETLS"); ok { + v, err := strconv.ParseBool(e) + if err == nil { + c.ClusterTransport.InsecureTLS = v + } + } +} + +func (c *Config) compileRegexps() { + for i := range c.Controllers { + ctrl := &c.Controllers[i] + for _, pattern := range ctrl.LogFilters.ExcludeRegexp { + re, err := regexp.Compile(pattern) + if err == nil { + ctrl.compiledRegexps = append(ctrl.compiledRegexps, re) + } + } + } +} diff --git a/tests/e2e/internal/framework/dump.go b/tests/e2e/internal/framework/dump.go new file mode 100644 index 0000000..2d5e4e1 --- /dev/null +++ b/tests/e2e/internal/framework/dump.go @@ -0,0 +1,141 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + . "github.com/onsi/ginkgo/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func (f *Framework) saveDump() { + testName := sanitizeTestName(CurrentSpecReport().FullText()) + dir := getTmpDir() + + f.dumpNamespaceResources(testName, dir) + f.dumpAllControllerLogs(testName, dir) +} + +func (f *Framework) dumpNamespaceResources(testName, dir string) { + if f.namespace == nil { + return + } + + type gvr struct { + group, version, resource string + } + resources := []gvr{ + {"", "v1", "pods"}, + {"", "v1", "services"}, + {"", "v1", "configmaps"}, + {"", "v1", "events"}, + {"apps", "v1", "deployments"}, + } + for _, r := range resources { + list, err := f.dynamic.Resource( + schema.GroupVersionResource{Group: r.group, Version: r.version, Resource: r.resource}, + ).Namespace(f.namespace.Name).List(context.Background(), metav1.ListOptions{}) + if err != nil { + GinkgoWriter.Printf("Failed to list %s in namespace %s: %v\n", r.resource, f.namespace.Name, err) + continue + } + if len(list.Items) == 0 { + continue + } + + fileName := fmt.Sprintf("%s/e2e_failed__%s__%s.yaml", dir, testName, r.resource) + data, err := list.MarshalJSON() + if err != nil { + GinkgoWriter.Printf("Failed to marshal %s: %v\n", r.resource, err) + continue + } + if err := os.WriteFile(fileName, data, 0o644); err != nil { + GinkgoWriter.Printf("Failed to write %s dump: %v\n", r.resource, err) + } + } +} + +func (f *Framework) dumpAllControllerLogs(testName, dir string) { + cfg := GetConfig() + for _, ctrl := range cfg.Controllers { + pods, err := f.kubeClient.CoreV1(). + Pods(ctrl.Namespace). + List(context.Background(), metav1.ListOptions{LabelSelector: ctrl.LabelSelector}) + if err != nil { + GinkgoWriter.Printf("WARNING: cannot list pods for %s: %v\n", ctrl.Name, err) + continue + } + + for _, pod := range pods.Items { + containers := ctrl.Containers + if len(containers) == 0 { + for _, c := range pod.Spec.Containers { + containers = append(containers, c.Name) + } + } + + for _, container := range containers { + f.dumpContainerLogs(testName, dir, ctrl.Name, pod.Name, pod.Namespace, container) + } + } + } +} + +func (f *Framework) dumpContainerLogs(testName, dir, controllerName, podName, namespace, container string) { + stream, err := f.kubeClient.CoreV1(). + Pods(namespace). + GetLogs(podName, &corev1.PodLogOptions{Container: container}). + Stream(context.Background()) + if err != nil { + GinkgoWriter.Printf("Failed to get logs for %s/%s/%s: %v\n", controllerName, podName, container, err) + return + } + defer stream.Close() + + data, err := io.ReadAll(stream) + if err != nil { + GinkgoWriter.Printf("Failed to read logs for %s/%s/%s: %v\n", controllerName, podName, container, err) + return + } + + fileName := fmt.Sprintf("%s/e2e_failed__%s__%s__%s__%s.log", dir, testName, controllerName, podName, container) + if err := os.WriteFile(fileName, data, 0o644); err != nil { + GinkgoWriter.Printf("Failed to save logs for %s/%s/%s: %v\n", controllerName, podName, container, err) + } +} + +func sanitizeTestName(name string) string { + r := strings.NewReplacer( + " ", "_", ":", "_", "[", "_", "]", "_", + "(", "_", ")", "_", "|", "_", "`", "", "'", "", + ) + return r.Replace(strings.ToLower(name)) +} + +func getTmpDir() string { + if dir := os.Getenv("RUNNER_TEMP"); dir != "" { + return dir + } + return "/tmp" +} diff --git a/tests/e2e/internal/framework/framework.go b/tests/e2e/internal/framework/framework.go new file mode 100644 index 0000000..0eef062 --- /dev/null +++ b/tests/e2e/internal/framework/framework.go @@ -0,0 +1,164 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + "context" + "fmt" + "maps" + "slices" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + NamespacePrefix = "e2e" + E2ELabel = "e2e-test" +) + +type Framework struct { + Clients + + namespacePrefix string + namespace *corev1.Namespace + objectsToDelete []client.Object + deferredDeletes []func() error +} + +func NewFramework(prefix string) *Framework { + return &Framework{ + Clients: GetClients(), + namespacePrefix: prefix, + } +} + +// Before creates an isolated namespace for the test. +// Pass empty prefix to NewFramework to skip namespace creation. +func (f *Framework) Before() { + GinkgoHelper() + if f.namespacePrefix == "" { + return + } + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-%s-", NamespacePrefix, f.namespacePrefix), + Labels: map[string]string{E2ELabel: "true"}, + }, + } + err := f.generic.Create(context.Background(), ns) + Expect(err).NotTo(HaveOccurred()) + By(fmt.Sprintf("Namespace %q has been created", ns.Name)) + f.namespace = ns +} + +// After handles cleanup and dump on failure. +func (f *Framework) After() { + GinkgoHelper() + + if CurrentSpecReport().Failed() { + f.saveDump() + } + + if !IsCleanUpNeeded() { + return + } + + for _, fn := range f.deferredDeletes { + _ = fn() + } + + slices.Reverse(f.objectsToDelete) + + for _, obj := range f.objectsToDelete { + _ = f.generic.Delete(context.Background(), obj) + } + f.waitDeleted(f.objectsToDelete) + + if f.namespace != nil { + By("Cleanup: delete namespace") + err := f.generic.Delete(context.Background(), f.namespace) + if err != nil && !k8serrors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } +} + +func (f *Framework) Namespace() *corev1.Namespace { + return f.namespace +} + +func (f *Framework) NamespaceName() string { + if f.namespace == nil { + return "" + } + return f.namespace.Name +} + +// Create creates resources via the generic client and registers them for cleanup. +func (f *Framework) Create(ctx context.Context, objs ...client.Object) error { + for _, obj := range objs { + labels := obj.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + maps.Copy(labels, map[string]string{E2ELabel: f.namespacePrefix}) + obj.SetLabels(labels) + + if err := f.generic.Create(ctx, obj); err != nil { + return err + } + f.objectsToDelete = append(f.objectsToDelete, obj) + } + return nil +} + +// DeferDelete registers objects for cleanup in After(). +func (f *Framework) DeferDelete(objs ...client.Object) { + f.objectsToDelete = append(f.objectsToDelete, objs...) +} + +// DeferDeleteFunc registers a custom cleanup function. +func (f *Framework) DeferDeleteFunc(fn func() error) { + f.deferredDeletes = append(f.deferredDeletes, fn) +} + +func (f *Framework) waitDeleted(objs []client.Object) { + for _, obj := range objs { + key := types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + } + _ = wait.PollUntilContextTimeout( + context.Background(), time.Second, LongTimeout, true, + func(ctx context.Context) (bool, error) { + err := f.generic.Get(ctx, key, obj) + if k8serrors.IsNotFound(err) { + return true, nil + } + return false, nil + }, + ) + } +} diff --git a/tests/e2e/internal/framework/timeout.go b/tests/e2e/internal/framework/timeout.go new file mode 100644 index 0000000..310c773 --- /dev/null +++ b/tests/e2e/internal/framework/timeout.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + "os" + "time" +) + +const ( + shortTimeoutEnv = "E2E_SHORT_TIMEOUT" + middleTimeoutEnv = "E2E_MIDDLE_TIMEOUT" + longTimeoutEnv = "E2E_LONG_TIMEOUT" + maxTimeoutEnv = "E2E_MAX_TIMEOUT" +) + +var ( + ShortTimeout = getTimeout(shortTimeoutEnv, 30*time.Second) + MiddleTimeout = getTimeout(middleTimeoutEnv, 60*time.Second) + LongTimeout = getTimeout(longTimeoutEnv, 300*time.Second) + MaxTimeout = getTimeout(maxTimeoutEnv, 600*time.Second) + PollingInterval = 1 * time.Second +) + +func getTimeout(env string, defaultTimeout time.Duration) time.Duration { + if e, ok := os.LookupEnv(env); ok { + t, err := time.ParseDuration(e) + if err != nil { + return defaultTimeout + } + return t + } + return defaultTimeout +} diff --git a/tests/e2e/internal/util/namespace.go b/tests/e2e/internal/util/namespace.go new file mode 100644 index 0000000..9f43627 --- /dev/null +++ b/tests/e2e/internal/util/namespace.go @@ -0,0 +1,97 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" +) + +// AssertNamespaceAbsent verifies the namespace does not exist. +func AssertNamespaceAbsent(name string) { + GinkgoHelper() + _, err := framework.GetClients().KubeClient().CoreV1(). + Namespaces().Get(context.Background(), name, metav1.GetOptions{}) + Expect(k8serrors.IsNotFound(err)).To(BeTrue(), + "namespace %q should not exist, but it does", name) +} + +// UntilNamespaceAbsent waits for the namespace to be fully deleted. +func UntilNamespaceAbsent(name string, timeout time.Duration) { + GinkgoHelper() + Eventually(func(g Gomega) { + _, err := framework.GetClients().KubeClient().CoreV1(). + Namespaces().Get(context.Background(), name, metav1.GetOptions{}) + g.Expect(k8serrors.IsNotFound(err)).To(BeTrue(), + "namespace %q still exists", name) + }).WithTimeout(timeout).WithPolling(time.Second).Should(Succeed()) +} + +// AssertNamespaceExists verifies the namespace exists. +func AssertNamespaceExists(name string) { + GinkgoHelper() + _, err := framework.GetClients().KubeClient().CoreV1(). + Namespaces().Get(context.Background(), name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred(), "namespace %q should exist", name) +} + +// EnsureNamespace creates a namespace if it does not already exist. +func EnsureNamespace(name string, labels map[string]string) *corev1.Namespace { + GinkgoHelper() + + existing, err := framework.GetClients().KubeClient().CoreV1(). + Namespaces().Get(context.Background(), name, metav1.GetOptions{}) + if err == nil { + return existing + } + Expect(k8serrors.IsNotFound(err)).To(BeTrue(), + "unexpected error checking namespace %q: %v", name, err) + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + } + created, err := framework.GetClients().KubeClient().CoreV1(). + Namespaces().Create(context.Background(), ns, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to create namespace %q", name) + return created +} + +// DeleteNamespace deletes a namespace and optionally waits for it to disappear. +func DeleteNamespace(name string, wait bool, timeout time.Duration) { + GinkgoHelper() + err := framework.GetClients().KubeClient().CoreV1(). + Namespaces().Delete(context.Background(), name, metav1.DeleteOptions{}) + if k8serrors.IsNotFound(err) { + return + } + Expect(err).NotTo(HaveOccurred(), "failed to delete namespace %q", name) + + if wait { + UntilNamespaceAbsent(name, timeout) + } +} diff --git a/tests/e2e/internal/util/pod.go b/tests/e2e/internal/util/pod.go new file mode 100644 index 0000000..ee4cf21 --- /dev/null +++ b/tests/e2e/internal/util/pod.go @@ -0,0 +1,127 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" +) + +// UntilControllerReady waits for all controller pods to be Running with all +// containers Ready and zero restarts. +func UntilControllerReady(namespace, labelSelector string, timeout time.Duration) { + GinkgoHelper() + Eventually(func(g Gomega) { + pods, err := framework.GetClients().KubeClient().CoreV1(). + Pods(namespace). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(pods.Items).NotTo(BeEmpty(), + "no controller pods found with selector %s in namespace %s", labelSelector, namespace) + + for _, pod := range pods.Items { + g.Expect(pod.Status.Phase).To(Equal(corev1.PodRunning), + "pod %s is %s, not Running", pod.Name, pod.Status.Phase) + + for _, cs := range pod.Status.ContainerStatuses { + g.Expect(cs.Ready).To(BeTrue(), + "container %s in pod %s is not ready", cs.Name, pod.Name) + g.Expect(cs.RestartCount).To(BeZero(), + "container %s in pod %s has %d restarts", cs.Name, pod.Name, cs.RestartCount) + } + } + }).WithTimeout(timeout).WithPolling(time.Second).Should(Succeed()) +} + +// AssertPodsExist verifies that at least minCount pods matching the selector +// exist in the namespace. +func AssertPodsExist(namespace, labelSelector string, minCount int) { + GinkgoHelper() + pods, err := framework.GetClients().KubeClient().CoreV1(). + Pods(namespace). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + Expect(err).NotTo(HaveOccurred()) + Expect(len(pods.Items)).To(BeNumerically(">=", minCount), + "expected >= %d pods in %s with selector %s, got %d", + minCount, namespace, labelSelector, len(pods.Items)) +} + +// UntilPodsExist waits until at least minCount pods appear. +func UntilPodsExist(namespace, labelSelector string, minCount int, timeout time.Duration) { + GinkgoHelper() + Eventually(func(g Gomega) { + pods, err := framework.GetClients().KubeClient().CoreV1(). + Pods(namespace). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(len(pods.Items)).To(BeNumerically(">=", minCount), + "waiting for >= %d pods, got %d", minCount, len(pods.Items)) + }).WithTimeout(timeout).WithPolling(time.Second).Should(Succeed()) +} + +// UntilPodCount waits for exactly expectedCount Running pods (excluding +// pods being deleted). +func UntilPodCount(namespace, labelSelector string, expectedCount int, timeout time.Duration) { + GinkgoHelper() + Eventually(func(g Gomega) { + pods, err := framework.GetClients().KubeClient().CoreV1(). + Pods(namespace). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + g.Expect(err).NotTo(HaveOccurred()) + + runningCount := 0 + for _, pod := range pods.Items { + if pod.Status.Phase == corev1.PodRunning && pod.DeletionTimestamp == nil { + runningCount++ + } + } + + g.Expect(runningCount).To(Equal(expectedCount), + "expected %d running pods, got %d (total listed: %d)", + expectedCount, runningCount, len(pods.Items)) + }).WithTimeout(timeout).WithPolling(time.Second).Should(Succeed()) +} + +// UntilAllPodsReady waits for exactly expectedCount pods to be Running and +// all their containers Ready. +func UntilAllPodsReady(namespace, labelSelector string, expectedCount int, timeout time.Duration) { + GinkgoHelper() + Eventually(func(g Gomega) { + pods, err := framework.GetClients().KubeClient().CoreV1(). + Pods(namespace). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(len(pods.Items)).To(Equal(expectedCount), + "expected %d pods, got %d", expectedCount, len(pods.Items)) + + for _, pod := range pods.Items { + g.Expect(pod.Status.Phase).To(Equal(corev1.PodRunning), + "pod %s phase: %s", pod.Name, pod.Status.Phase) + for _, cs := range pod.Status.ContainerStatuses { + g.Expect(cs.Ready).To(BeTrue(), + "pod %s container %s not ready", pod.Name, cs.Name) + } + } + }).WithTimeout(timeout).WithPolling(time.Second).Should(Succeed()) +} diff --git a/tests/e2e/internal/util/resource.go b/tests/e2e/internal/util/resource.go new file mode 100644 index 0000000..b1369cf --- /dev/null +++ b/tests/e2e/internal/util/resource.go @@ -0,0 +1,172 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "context" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" +) + +// UntilObjectPhase waits for all objects to reach the expected status.phase. +func UntilObjectPhase(expectedPhase string, timeout time.Duration, objs ...client.Object) { + GinkgoHelper() + untilObjectField("status.phase", expectedPhase, timeout, objs...) +} + +// UntilObjectState waits for all objects to reach the expected status.state. +func UntilObjectState(expectedState string, timeout time.Duration, objs ...client.Object) { + GinkgoHelper() + untilObjectField("status.state", expectedState, timeout, objs...) +} + +// UntilConditionTrue waits for the specified condition type to become True +// on all provided objects. +func UntilConditionTrue(conditionType string, timeout time.Duration, objs ...client.Object) { + GinkgoHelper() + UntilConditionStatus(conditionType, string(metav1.ConditionTrue), timeout, objs...) +} + +// UntilConditionStatus waits for the specified condition to reach the given status. +func UntilConditionStatus(conditionType, expectedStatus string, timeout time.Duration, objs ...client.Object) { + GinkgoHelper() + Eventually(func(g Gomega) { + for _, obj := range objs { + u := toUnstructured(obj) + err := framework.GetClients().GenericClient().Get( + context.Background(), client.ObjectKeyFromObject(obj), u, + ) + g.Expect(err).NotTo(HaveOccurred()) + + conditions, found, err := unstructured.NestedSlice(u.Object, "status", "conditions") + g.Expect(err).NotTo(HaveOccurred(), + "failed to access status.conditions of %s", u.GetName()) + g.Expect(found).To(BeTrue(), + "no status.conditions found on %s", u.GetName()) + + var matched bool + for _, c := range conditions { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + if t, _ := m["type"].(string); t == conditionType { + observedGeneration, _ := m["observedGeneration"].(int64) + g.Expect(observedGeneration).To(BeNumerically("==", obj.GetGeneration())) + + status, _ := m["status"].(string) + g.Expect(status).To(Equal(expectedStatus), + "object %s condition %s status is %q, expected %q", + u.GetName(), conditionType, status, expectedStatus) + matched = true + break + } + } + g.Expect(matched).To(BeTrue(), + "condition %s not found on %s", conditionType, u.GetName()) + } + }).WithTimeout(timeout).WithPolling(time.Second).Should(Succeed()) +} + +// UntilConditionReason waits for the specified condition to have the expected reason. +func UntilConditionReason(conditionType, expectedReason string, timeout time.Duration, objs ...client.Object) { + GinkgoHelper() + Eventually(func(g Gomega) { + for _, obj := range objs { + u := toUnstructured(obj) + err := framework.GetClients().GenericClient().Get( + context.Background(), client.ObjectKeyFromObject(obj), u, + ) + g.Expect(err).NotTo(HaveOccurred()) + + conditions, found, _ := unstructured.NestedSlice(u.Object, "status", "conditions") + g.Expect(found).To(BeTrue()) + + var matched bool + for _, c := range conditions { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + if t, _ := m["type"].(string); t == conditionType { + reason, _ := m["reason"].(string) + g.Expect(reason).To(Equal(expectedReason), + "object %s condition %s reason is %q, expected %q", + u.GetName(), conditionType, reason, expectedReason) + matched = true + break + } + } + g.Expect(matched).To(BeTrue(), + "condition %s not found on %s", conditionType, u.GetName()) + } + }).WithTimeout(timeout).WithPolling(time.Second).Should(Succeed()) +} + +func untilObjectField(fieldPath, expected string, timeout time.Duration, objs ...client.Object) { + GinkgoHelper() + Eventually(func(g Gomega) { + for _, obj := range objs { + u := toUnstructured(obj) + err := framework.GetClients().GenericClient().Get( + context.Background(), client.ObjectKeyFromObject(obj), u, + ) + g.Expect(err).NotTo(HaveOccurred(), + "failed to get %s", obj.GetName()) + + path := strings.Split(fieldPath, ".") + value, found, _ := unstructured.NestedString(u.Object, path...) + actual := "Unknown" + if found { + actual = value + } + g.Expect(actual).To(Equal(expected), + "object %s %s is %q, expected %q", + u.GetName(), fieldPath, actual, expected) + } + }).WithTimeout(timeout).WithPolling(time.Second).Should(Succeed()) +} + +func toUnstructured(obj client.Object) *unstructured.Unstructured { + if u, ok := obj.(*unstructured.Unstructured); ok { + return u.DeepCopy() + } + + objMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + Expect(err).NotTo(HaveOccurred(), "failed to convert object to unstructured") + u := &unstructured.Unstructured{Object: objMap} + + c := framework.GetClients().GenericClient() + gvks, _, err := c.Scheme().ObjectKinds(obj) + if err == nil && len(gvks) > 0 { + u.SetGroupVersionKind(gvks[0]) + } else { + u.SetGroupVersionKind(schema.GroupVersionKind{}) + } + + return u +} diff --git a/tests/e2e/internal/util/update.go b/tests/e2e/internal/util/update.go new file mode 100644 index 0000000..ea163b5 --- /dev/null +++ b/tests/e2e/internal/util/update.go @@ -0,0 +1,75 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" +) + +// UpdateHelmClusterAddon performs a read-modify-write cycle on a HelmClusterAddon +// with automatic retry on conflict. +func UpdateHelmClusterAddon(name string, mutate func(*apiv1alpha1.HelmClusterAddon)) *apiv1alpha1.HelmClusterAddon { + GinkgoHelper() + + var updated *apiv1alpha1.HelmClusterAddon + Eventually(func(g Gomega) { + current, err := framework.GetClients().OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Get(context.Background(), name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + + mutate(current) + + updated, err = framework.GetClients().OperatorClient().HelmV1alpha1(). + HelmClusterAddons(). + Update(context.Background(), current, metav1.UpdateOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + }).WithTimeout(framework.ShortTimeout).WithPolling(time.Second).Should(Succeed()) + + return updated +} + +// UpdateHelmClusterAddonRepository performs a read-modify-write cycle on a HelmClusterAddonRepository +// with automatic retry on conflict. +func UpdateHelmClusterAddonRepository(name string, mutate func(*apiv1alpha1.HelmClusterAddonRepository)) *apiv1alpha1.HelmClusterAddonRepository { + GinkgoHelper() + + var updated *apiv1alpha1.HelmClusterAddonRepository + Eventually(func(g Gomega) { + current, err := framework.GetClients().OperatorClient().HelmV1alpha1(). + HelmClusterAddonRepositories(). + Get(context.Background(), name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + + mutate(current) + + updated, err = framework.GetClients().OperatorClient().HelmV1alpha1(). + HelmClusterAddonRepositories(). + Update(context.Background(), current, metav1.UpdateOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + }).WithTimeout(framework.ShortTimeout).WithPolling(time.Second).Should(Succeed()) + + return updated +} diff --git a/tests/e2e/scripts/kind-d8-ci.sh b/tests/e2e/scripts/kind-d8-ci.sh new file mode 100755 index 0000000..293219e --- /dev/null +++ b/tests/e2e/scripts/kind-d8-ci.sh @@ -0,0 +1,574 @@ +#!/bin/bash + +# Copyright 2022 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Colors to identify the chip +BOLD='\033[1m' +GREEN='\033[0;32m' +PURPLE='\033[0;35m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Checking OS and getting a chip name +if uname -s | grep -q "Darwin"; then + chip_info=$(sysctl -n machdep.cpu.brand_string) + if [[ "$chip_info" == *"Apple M"* ]]; then + # Retrieving the processor generation for Apple on the M + chip_model=$(echo "$chip_info" | awk -F'Apple ' '{print $2}' | cut -d' ' -f1-2 | sed 's/ / /') + # Display an alert for Apple on M + echo -e "${BOLD}${PURPLE}Warning. ${CYAN}Your computer has been identified as: ${GREEN}Apple $chip_model ${NC} + ${YELLOW}Disable Rosetta support in Docker Desktop before installation. + To do this, in Docker Desktop go to ${CYAN}Settings > General > Virtual Machine Options ${YELLOW}and uncheck the ${CYAN}Use Rosetta for x86_64/amd64 emulation on Apple Silicon ${YELLOW}option.${NC}" + fi +fi + +PARENT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd) + +KIND_CLUSTER_NAME=${KIND_CLUSTER_NAME:-d8-operator-helm} +KIND_CONFIG_DIR=${KIND_CONFIG_DIR:-$PARENT_DIR/kind}/$KIND_CLUSTER_NAME +KIND_IMAGE=kindest/node:v1.31.6@sha256:28b7cbb993dfe093c76641a0c95807637213c9109b761f1d422c2400e22b8e87 + +D8_RELEASE_CHANNEL_TAG=stable +D8_RELEASE_CHANNEL_NAME=Stable +D8_REGISTRY_ADDRESS=registry.deckhouse.io +D8_REGISTRY_PATH=${D8_REGISTRY_ADDRESS}/deckhouse/ce +D8_LICENSE_KEY= + +KIND_INSTALL_DIRECTORY=$PARENT_DIR/kind/bin +KIND_PATH=kind +KIND_VERSION=v0.27.0 + +KUBECTL_INSTALL_DIRECTORY=$PARENT_DIR/kind/bin +KUBECTL_PATH=kubectl +KUBECTL_VERSION=v1.31.6 + +REQUIRE_MEMORY_MIN_BYTES=4000000000 # 4GB + +usage() { + printf " + Usage: %s [--channel ] [--key ] [--os ] + + --channel + Deckhouse Kubernetes Platform release channel name. + Possible values: Alpha, Beta, EarlyAccess, Stable, RockSolid. + Default: Stable. + + --key + Deckhouse Kubernetes Platform Enterprise Edition license key. + If no license key specified, Deckhouse Kubernetes Platform Community Edition will be installed. + + --os + Override the OS detection. + + --help|-h + Print this message. + +" "$0" +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --channel) + case "$2" in + "") + echo "Release channel is empty. Please specify the release channel name." + usage + exit 1 + ;; + *) + if [[ "$2" =~ ^(Alpha|Beta|EarlyAccess|Stable|RockSolid)$ ]]; then + D8_RELEASE_CHANNEL_NAME="$2" + D8_RELEASE_CHANNEL_TAG=$(echo ${D8_RELEASE_CHANNEL_NAME} | sed 's/EarlyAccess/early-access/; s/RockSolid/rock-solid/' | tr '[:upper:]' '[:lower:]') + else + echo "Incorrect release channel. Use Alpha, Beta, EarlyAccess, Stable or RockSolid." + usage + exit 1 + fi + shift + ;; + esac + ;; + --key) + case "$2" in + "") + echo "License key is empty. Please specify the license key or don't use the --key parameter to install Deckhouse Kubernetes Platform Community Edition." + usage + exit 1 + ;; + *) + D8_LICENSE_KEY="$2" + D8_REGISTRY_PATH=${D8_REGISTRY_ADDRESS}/deckhouse/ee + shift + ;; + esac + ;; + --os) + case "$2" in + "") + echo "Please specify 'linux' or 'mac' for the --os parameter." + usage + exit 1 + ;; + *) + OS_NAME="$2" + shift + ;; + esac + ;; + --help | -h) + usage + exit 1 + ;; + --*) + echo "Illegal option $1" + usage + exit 1 + ;; + esac + shift $(($# > 0 ? 1 : 0)) + done +} + +os_detect() { + if [[ (-z "$OS_NAME") ]]; then + # some systems dont have lsb-release yet have the lsb_release binary and + # vice-versa + if [ -e /etc/lsb-release ]; then + . /etc/lsb-release + + OS_NAME=${DISTRIB_ID} + + elif [ "$(which lsb_release 2>/dev/null)" ]; then + OS_NAME=$(lsb_release -i | cut -f2 | awk '{ print tolower($1) }') + + elif [ -e /etc/debian_version ]; then + # some Debians have jessie/sid in their /etc/debian_version + # while others have '6.0.7' + OS_NAME=$(cat /etc/issue | head -1 | awk '{ print tolower($1) }') + + elif [[ "$OSTYPE" == 'darwin'* ]]; then + OS_NAME=mac + + else + noop # Unknown OS + fi + fi + + OS_NAME="${OS_NAME// /}" + + # Supported on ... + if [[ ("$OS_NAME" == "Ubuntu") || ("$OS_NAME" == "ubuntu") || ("$OS_NAME" == "Debian") || ("$OS_NAME" == "debian") ]]; then + OS_NAME=linux + elif [[ ("$OS_NAME" != "mac") && ("$OS_NAME" != "linux") ]]; then + OS_NAME= + fi + + if [ -z "$OS_NAME" ]; then + printf "Your operating system distribution and version might not supported by this script. + +You can override the OS detection by setting the --os parameter to running this script. + +E.g, to force Linux: --os linux +" + + exit 1 + fi + + MACHINE_ARCH=$(uname -m) + + echo "Detected operating system as $OS_NAME (${MACHINE_ARCH:-unknown})." +} + +prerequisites_check() { + echo "Checking for docker..." + if command -v docker >/dev/null; then + echo "Detected docker..." + else + echo "docker is not installed. Please install docker. You may go to https://docs.docker.com/engine/install/ for details." + exit 1 + fi + + memory_check + kubectl_check + kind_check + preinstall_checks +} + +memory_check() { + if [[ "$OS_NAME" == "linux" ]]; then + MEMORY_TOTAL_BYTES=$(free --bytes 2>/dev/null | grep -i mem | awk '{print $2}' 2>/dev/null) + else + MEMORY_TOTAL_BYTES=$(sysctl -n hw.memsize 2>/dev/null) + fi + + if [[ ("$MEMORY_TOTAL_BYTES" -gt "0") && ("$MEMORY_TOTAL_BYTES" -lt "$REQUIRE_MEMORY_MIN_BYTES") ]]; then + echo "Insufficient memory to install Deckhouse Kubernetes Platform." + echo "Deckhouse Kubernetes Platform requires at least 4 gigabytes of memory." + exit 1 + fi + + if [[ ("$MEMORY_TOTAL_BYTES" -eq "0") || (-z "$MEMORY_TOTAL_BYTES") ]]; then + echo "Can't get the total memory value." + echo "Note, that Deckhouse Kubernetes Platform requires at least 4 gigabytes of memory." + echo "Press enter to continue..." + read + fi +} + +kubectl_check() { + echo "Checking for kubectl..." + if command -v kubectl >/dev/null; then + echo "Detected kubectl..." + elif command -v ${KUBECTL_INSTALL_DIRECTORY}/kubectl >/dev/null; then + echo "Detected ${KUBECTL_INSTALL_DIRECTORY}/kubectl..." + KUBECTL_PATH=${KUBECTL_INSTALL_DIRECTORY}/kubectl + else + echo "kubectl is not installed." + echo "Installing the latest stable kubectl version to ${KUBECTL_INSTALL_DIRECTORY}/kubectl ..." + + mkdir -p $KUBECTL_INSTALL_DIRECTORY + curl -sLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/${OS_NAME/mac/darwin}/${MACHINE_ARCH/x86_64/amd64}/kubectl" + + if [ "$?" -ne "0" ]; then + echo "Unable to download kubectl." + exit 1 + fi + + install -m 0755 kubectl "${KUBECTL_INSTALL_DIRECTORY}"/kubectl + if [ "$?" -ne "0" ]; then + echo "Insufficient permissions to install kubectl. Trying again with sudo..." + sudo install -m 0755 kubectl "${KUBECTL_INSTALL_DIRECTORY}"/kubectl + if [ "$?" -ne "0" ]; then + echo "Unable to install kubectl. Check installation path and permissions." + exit 1 + fi + fi + + KUBECTL_PATH=${KUBECTL_INSTALL_DIRECTORY}/kubectl + fi +} + +kind_check() { + echo "Checking for kind $KIND_VERSION..." + if [[ "v$(kind version -q 2>/dev/null)" == "$KIND_VERSION" ]]; then + echo "Detected kind $KIND_VERSION..." + elif [[ "v$(${KIND_INSTALL_DIRECTORY}/kind version -q 2>/dev/null)" == "$KIND_VERSION" ]]; then + echo "Detected ${KIND_INSTALL_DIRECTORY}/kind..." + KIND_PATH=${KIND_INSTALL_DIRECTORY}/kind + else + echo "Installing kind to ${KIND_INSTALL_DIRECTORY}/kind ..." + + mkdir -p ${KIND_INSTALL_DIRECTORY} + + curl -sLo ./kind-binary "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-${OS_NAME/mac/darwin}-${MACHINE_ARCH/x86_64/amd64}" + + if [ "$?" -ne "0" ]; then + echo "Unable to download kind." + exit 1 + fi + + install -m 0755 kind-binary "${KIND_INSTALL_DIRECTORY}"/kind + + if [ "$?" -ne "0" ]; then + echo "Insufficient permissions to install kind. Trying again with sudo..." + sudo install -m 0755 kind-binary "${KIND_INSTALL_DIRECTORY}"/kind + if [ "$?" -ne "0" ]; then + echo "Unable to install kind. Check installation path and permissions." + exit 1 + fi + fi + + KIND_PATH=${KIND_INSTALL_DIRECTORY}/kind + fi +} + +preinstall_checks() { + local cluster_exist=true + + while [[ "$cluster_exist" == "true" ]]; do + + # Check if a kind cluster with the name `d8` exist + ${KIND_PATH} get clusters | grep -q "^${KIND_CLUSTER_NAME}$" &>/dev/null + + if [ "$?" -eq "0" ]; then + cluster_exist=true + else + cluster_exist=false + fi + + if [[ "$cluster_exist" == "true" ]]; then + ${KIND_PATH} delete cluster --name "${KIND_CLUSTER_NAME}" + sleep 3 + fi + done +} + +configs_create() { + mkdir -p ${KIND_CONFIG_DIR} + + echo "Creating kind config file (${KIND_CONFIG_DIR}/kind.cfg)..." + cat <${KIND_CONFIG_DIR}/kind.cfg +apiVersion: kind.x-k8s.io/v1alpha4 +kind: Cluster +featureGates: + "ValidatingAdmissionPolicy": true +runtimeConfig: + "admissionregistration.k8s.io/v1alpha1": true +nodes: +- role: control-plane +EOF + + echo "Creating Deckhouse Kubernetes Platform installation config file (${KIND_CONFIG_DIR}/config.yml)..." + cat <${KIND_CONFIG_DIR}/config.yml +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: deckhouse +spec: + version: 1 + enabled: true + settings: + bundle: Minimal + releaseChannel: EarlyAccess + logLevel: Info +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: global +spec: + version: 2 + settings: + modules: + publicDomainTemplate: "%s.127.0.0.1.sslip.io" + https: + mode: Disabled +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: cert-manager +spec: + version: 1 + enabled: true +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: operator-prometheus-crd +spec: + enabled: true +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: prometheus-crd +spec: + enabled: true +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: prometheus +spec: + version: 2 + enabled: true + settings: + longtermRetentionDays: 0 +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: ingress-nginx +spec: + enabled: true +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: operator-prometheus +spec: + enabled: true +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: monitoring-kubernetes +spec: + enabled: true +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: monitoring-deckhouse +spec: + enabled: true +--- +apiVersion: deckhouse.io/v1alpha1 +kind: ModuleConfig +metadata: + name: monitoring-kubernetes-control-plane +spec: + enabled: true +EOF + + if [[ -n "$D8_LICENSE_KEY" ]]; then + generate_ee_access_string "$D8_LICENSE_KEY" + cat <>${KIND_CONFIG_DIR}/config.yml +--- +apiVersion: deckhouse.io/v1 +kind: InitConfiguration +deckhouse: + imagesRepo: $D8_REGISTRY_PATH + registryDockerCfg: $D8_EE_ACCESS_STRING +EOF + fi + + echo "Creating Deckhouse Kubernetes Platform resource file (${KIND_CONFIG_DIR}/resources.yml)..." + cat <${KIND_CONFIG_DIR}/resources.yml +apiVersion: deckhouse.io/v1 +kind: IngressNginxController +metadata: + name: nginx +spec: + ingressClass: nginx + inlet: HostPort +EOF +} + +cluster_deletion_info() { + + printf " +To delete created cluster use the following command: + + ${KIND_PATH} delete cluster --name "${KIND_CLUSTER_NAME}" + +" +} + +cluster_create() { + + ${KIND_PATH} create cluster --name "${KIND_CLUSTER_NAME}" --image "${KIND_IMAGE}" --config "${KIND_CONFIG_DIR}/kind.cfg" + + if [ "$?" -ne "0" ]; then + printf " +Error creating cluster. If error is like '...port is already allocated' or '... address already in use', then you need to free ports 80 and 443. +E.g., you can find programs that use these ports using the following command: + + sudo lsof -n -i TCP@0.0.0.0:80,443 -s TCP:LISTEN + +" + cluster_deletion_info + exit 1 + fi + + ${KIND_PATH} get kubeconfig --internal --name "${KIND_CLUSTER_NAME}" >${KIND_CONFIG_DIR}/kubeconfig + +} + +deckhouse_install() { + echo "Running Deckhouse installation..." + + # Use the --debug flag to see exactly why it's failing + docker run --pull=always --rm --network kind \ + -v "${KIND_CONFIG_DIR}/config.yml:/config.yml" \ + -v "${KIND_CONFIG_DIR}/resources.yml:/resources.yml" \ + -v "${KIND_CONFIG_DIR}/kubeconfig:/kubeconfig" \ + ${D8_REGISTRY_PATH}/install:${D8_RELEASE_CHANNEL_TAG} \ + bash -c "dhctl bootstrap-phase install-deckhouse --kubeconfig=/kubeconfig --kubeconfig-context=kind-${KIND_CLUSTER_NAME} --config=/config.yml" + + # If that fails with the CRD error, we might need to wait 30s and try the second phase manually + if [ "$?" -ne "0" ]; then + echo "First phase might have timed out. Waiting 30s for CRDs to settle..." + sleep 30 + # Try the resource creation phase separately + docker run --rm --network kind -v "${KIND_CONFIG_DIR}/resources.yml:/resources.yml" -v "${KIND_CONFIG_DIR}/kubeconfig:/kubeconfig" \ + ${D8_REGISTRY_PATH}/install:${D8_RELEASE_CHANNEL_TAG} \ + dhctl bootstrap-phase create-resources --kubeconfig=/kubeconfig --kubeconfig-context=kind-${KIND_CLUSTER_NAME} --resources=/resources.yml + fi +} + +macos_force_qemu() { + if [ "$OS_NAME" = "mac" ] + then ${KUBECTL_PATH} --context kind-"${KIND_CLUSTER_NAME}" patch daemonset node-exporter -n d8-monitoring --type='json' -p='[{"op": "add", "path": "/spec/template/spec/containers/1/env/-", "value": {"name": "EXPERIMENTAL_DOCKER_DESKTOP_FORCE_QEMU", "value": "1"}}]' 2>/dev/null + fi +} + +generate_ee_access_string() { + if [ "$OS_NAME" != "mac" ]; then B64_ARG="-w0"; else B64_ARG=""; fi + auth_part=$(echo -n "license-token:$1" | base64 $B64_ARG) + D8_EE_ACCESS_STRING=$(echo -n "{\"auths\": { \"$D8_REGISTRY_ADDRESS\": { \"username\": \"license-token\", \"password\": \"$1\", \"auth\": \"$auth_part\"}}}" | base64 $B64_ARG) + + if [ "$?" -ne "0" ]; then + echo "Error generation container registry access string for Deckhouse Kubernetes Platform Enterprise Edition" + exit 1 + fi +} + +setup_operator_helm() { + echo "Enabling operator-helm module..." + + ${KUBECTL_PATH} --context "kind-${KIND_CLUSTER_NAME}" create -f - < "${KIND_CONFIG_DIR}/kubeconfig-external" +} + +main() { + parse_args "$@" + + os_detect + prerequisites_check + configs_create + cluster_create + deckhouse_install + macos_force_qemu + setup_operator_helm + extract_kubectl_context +} + +main "$@" diff --git a/tmp/mc-operator-helm.yaml b/tmp/mc-operator-helm.yaml deleted file mode 100644 index 24275b2..0000000 --- a/tmp/mc-operator-helm.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: deckhouse.io/v1alpha1 -kind: ModuleConfig -metadata: - name: operator-helm -spec: - enabled: false - source: operator-helm - version: 1 diff --git a/tmp/modulepulloverride.yaml b/tmp/modulepulloverride.yaml deleted file mode 100644 index 9f2f086..0000000 --- a/tmp/modulepulloverride.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: deckhouse.io/v1alpha2 -kind: ModulePullOverride -metadata: - name: operator-helm -spec: - imageTag: mvp - rollback: true - scanInterval: 15s diff --git a/tmp/modulesource.yaml b/tmp/modulesource.yaml deleted file mode 100644 index 9bf6aa5..0000000 --- a/tmp/modulesource.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: deckhouse.io/v1alpha1 -kind: ModuleSource -metadata: - name: operator-helm -spec: - registry: - repo: ghcr.io/deckhouse/operator-helm - scheme: HTTPS