diff --git a/.dmtlint.yaml b/.dmtlint.yaml index 3e864812..4502d8d3 100644 --- a/.dmtlint.yaml +++ b/.dmtlint.yaml @@ -4,20 +4,16 @@ linters-settings: 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" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.upgrade.properties.serverSideApply" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.rollback.properties.serverSideApply" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.waitStrategy.properties.name" + - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.postRenderStrategy" - "properties.logLevel" - "properties.logFormat" rbac: diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 7364760e..560f8ee0 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -23,25 +23,53 @@ jobs: env: DMT_METRICS_URL: ${{ secrets.DMT_METRICS_URL }} DMT_METRICS_TOKEN: ${{ secrets.DMT_METRICS_TOKEN }} -# waiting for golangci-lint for go 1.26 - # lint: - # runs-on: [self-hosted, large] - # name: Lint - # steps: - # - name: Set up Go ${{ vars.GO_VERSION }} - # uses: actions/setup-go@v6 - # with: - # go-version: "${{ vars.GO_VERSION }}" - - # - uses: actions/checkout@v6 - - # - name: Install Task - # uses: arduino/setup-task@v2 - # with: - # repo-token: ${{ secrets.GITHUB_TOKEN }} - - # - name: Install golangci-lint - # run: task --yes deps:install:golangci-lint - - # - name: Run linters - # run: task --yes lint + + generated_files: + runs-on: [self-hosted, large] + name: Generated files + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + cache: true + go-version-file: api/go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # Regenerates the clients and the CRDs, then fails if anything moved. The + # prettier pass over the CRDs runs in a container, so the runner needs Docker. + - name: Verify the generated files are committed + run: task --yes ci:generate:api + + - name: Verify the internal definitions are committed + run: task --yes ci:generate:internal-crds + + lint: + runs-on: [self-hosted, large] + name: Lint + steps: + - uses: actions/checkout@v6 + + # The modules do not all declare the same Go version; the controllers ask + # for the newest one, and a toolchain that satisfies them satisfies the rest. + - name: Set up Go + uses: actions/setup-go@v6 + with: + cache: true + go-version-file: images/operator-helm-controller/go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # The linter is built from source by the lint task itself, so there is no + # separate install step: the shared one could not fetch a release new enough + # to read a module targeting this repository's Go version. + - name: Run linters + run: task --yes lint diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 00000000..994e657c --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,36 @@ +name: Unit tests + +on: + workflow_dispatch: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] + push: + branches: + - main + - release-* + +env: + TASK_X_REMOTE_TASKFILES: 1 + +jobs: + unit_tests: + runs-on: [self-hosted, large] + name: Unit tests + steps: + # The modules do not all declare the same Go version; the controllers ask + # for the newest one, and a toolchain that satisfies them satisfies the rest. + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + cache: true + go-version-file: images/operator-helm-controller/go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run unit tests + run: task --yes test:unit diff --git a/.helmignore b/.helmignore index 94a1ea99..4e4628f6 100644 --- a/.helmignore +++ b/.helmignore @@ -21,5 +21,6 @@ LICENSE tests/ Taskfile.yaml CHANGELOG/ +bin/ build/ requirements.lock diff --git a/Taskfile.yaml b/Taskfile.yaml index 6f928055..98d9ae61 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -6,8 +6,14 @@ vars: deckhouse_lib_helm_ver: 1.71.2 target: "" VALIDATION_FILES: "tools/validation/{main,messages,diff,doc_changes}.go" - golangciLintVersion: "v2.8.0" - + golangciLintVersion: "v2.13.2" + +# Only the modules this repository authors are listed, including tools/internalcrds. +# images/kube-api-rewriter is a separate upstream module vendored in for the build, and +# images/helm-controller and images/source-controller carry nothing but their werf +# files, so none of them is ours to lint, format or test here. The one exception is +# the rewriter's operatornelm package, which is this module's own rule table: its +# tests run from test:rewrite-rules below. includes: api: taskfile: ./api/Taskfile.dist.yaml @@ -18,12 +24,15 @@ includes: hooks: taskfile: ./images/hooks/Taskfile.dist.yaml dir: ./images/hooks - artifact: + operator-helm-controller: taskfile: ./images/operator-helm-controller/Taskfile.dist.yaml dir: ./images/operator-helm-controller - chart-values-artifact: + chart-values-controller: taskfile: ./images/chart-values-controller/Taskfile.dist.yaml dir: ./images/chart-values-controller + internalcrds: + taskfile: ./tools/internalcrds/Taskfile.dist.yaml + dir: ./tools/internalcrds deps: taskfile: https://raw.githubusercontent.com/werf/common-ci/refs/heads/main/Taskfile.deps.yml @@ -55,6 +64,94 @@ tasks: cmds: - task: api:ci:generate + generate:internal-crds: + desc: "Regenerate the internal CustomResourceDefinitions from upstream flux." + cmds: + - | + set -eu + helmControllerTag=$(sed -n 's/^ *helm-controller: *//p' build/components/versions.yml) + sourceControllerTag=$(sed -n 's/^ *source-controller: *//p' build/components/versions.yml) + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + git clone --quiet --depth 1 --branch "$helmControllerTag" \ + https://github.com/fluxcd/helm-controller "$work/helm-controller" + git clone --quiet --depth 1 --branch "$sourceControllerTag" \ + https://github.com/fluxcd/source-controller "$work/source-controller" + cd tools/internalcrds + go run . -out ../../crds/embedded/helm-controller.yaml \ + "$work/helm-controller/config/crd/bases" + go run . -out ../../crds/embedded/source-controller.yaml \ + "$work/source-controller/config/crd/bases" + + check-flux-tags: + desc: "Verify the upstream flux tags pinned in werf.inc.yaml match build/components/versions.yml." + cmds: + - | + set -eu + helmControllerTag=$(sed -n 's/^ *helm-controller: *//p' build/components/versions.yml) + sourceControllerTag=$(sed -n 's/^ *source-controller: *//p' build/components/versions.yml) + helmWerfTag=$(sed -n 's/.*\$helmControllerTag := "\(.*\)".*/\1/p' images/helm-controller/werf.inc.yaml) + sourceWerfTag=$(sed -n 's/.*\$sourceControllerTag := "\(.*\)".*/\1/p' images/source-controller/werf.inc.yaml) + for pin in "$helmControllerTag" "$sourceControllerTag" "$helmWerfTag" "$sourceWerfTag"; do + if [ -z "$pin" ]; then + echo "a version pin came out empty: build/components/versions.yml has '$helmControllerTag' and '$sourceControllerTag', the werf files have '$helmWerfTag' and '$sourceWerfTag'" >&2 + exit 1 + fi + done + if [ "$helmControllerTag" != "$helmWerfTag" ]; then + echo "images/helm-controller/werf.inc.yaml pins helm-controller $helmWerfTag but build/components/versions.yml pins $helmControllerTag" >&2 + exit 1 + fi + if [ "$sourceControllerTag" != "$sourceWerfTag" ]; then + echo "images/source-controller/werf.inc.yaml pins source-controller $sourceWerfTag but build/components/versions.yml pins $sourceControllerTag" >&2 + exit 1 + fi + silent: true + + ci:generate:internal-crds: + desc: "Regenerate the internal CustomResourceDefinitions and verify they are committed." + cmds: + - task: check-flux-tags + - task: generate:internal-crds + - | + git diff --exit-code crds/embedded || (echo "Please run task generate:internal-crds and commit changes" && exit 1) + + # The shared deps task installs golangci-lint through the upstream install.sh, + # whose checksum lookup is an unanchored grep: releases now ship an SBOM entry + # next to the archive, both lines match, and the verification fails for every + # version past v2.8.0. Building the binary with the toolchain already present + # sidesteps the download and keeps the linter new enough to read a module that + # targets this repository's Go version. + install:golangci-lint: + desc: "Install golangci-lint binary to bin/golangci-lint-." + status: + - test -x bin/golangci-lint-{{.golangciLintVersion}} + cmds: + - mkdir -p bin + - GOBIN="$(pwd)/bin" go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@{{.golangciLintVersion}} + - mv bin/golangci-lint bin/golangci-lint-{{.golangciLintVersion}} + + test:unit: + desc: "Run the unit tests of every module." + cmds: + - task: api:test:unit + - task: hooks:test:unit + - task: operator-helm-controller:test:unit + - task: chart-values-controller:test:unit + - task: e2e:test:unit + - task: internalcrds:test:unit + - task: test:rewrite-rules + + # The rule table and the generated definitions are two halves of one + # agreement, and only a live cluster reports a disagreement: a request the + # proxy renames to a group, kind or resource type no definition declares is + # answered "not found". The test compares the two, and reads the definitions + # from outside its own module, which the test cache does not track. + test:rewrite-rules: + desc: "Check the proxy rewrite rules against the generated definitions." + cmds: + - cd images/kube-api-rewriter && go test -count=1 ./pkg/operatornelm/... + test:e2e:setup: desc: "Setup environment for e2e tests." cmds: @@ -116,17 +213,21 @@ tasks: cmds: - task: api:format - task: hooks:format - - task: artifact:format - - task: chart-values-artifact:format + - task: operator-helm-controller:format + - task: chart-values-controller:format - task: e2e:format + - task: internalcrds:format lint: + deps: + - install:golangci-lint cmds: - task: api:lint - task: hooks:lint - - task: artifact:lint - - task: chart-values-artifact:lint + - task: operator-helm-controller:lint + - task: chart-values-controller:lint - task: e2e:lint + - task: internalcrds:lint - task: lint:doc-ru lint:doc-ru: diff --git a/api/Taskfile.dist.yaml b/api/Taskfile.dist.yaml index 04a95841..b7eb60e6 100644 --- a/api/Taskfile.dist.yaml +++ b/api/Taskfile.dist.yaml @@ -10,12 +10,17 @@ includes: gciPrefix: '{{.gciPrefix | default "github.com/deckhouse/"}}' golangciConfigPath: '{{.golangciConfigPath | default "./.golangci.yaml"}}' golangciLintBinDir: '{{.golangciLintBinDir | default "../bin"}}' - golangciLintVersion: '{{.golangciLintVersion | default "v2.8.0"}}' + golangciLintVersion: '{{.golangciLintVersion | default "v2.13.2"}}' golangciPaths: '{{.golangciPaths | default "./..."}}' paths: '{{.paths | default "."}}' prettierPattern: '../crds/*.yaml' tasks: + test:unit: + desc: "Run the unit tests of this module." + cmds: + - go test ./... + generate: desc: "Regenerate all" cmds: 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 index 29edccb3..cbbd9b31 100644 --- a/api/client/generated/clientset/versioned/typed/api/v1alpha1/api_client.go +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/api_client.go @@ -28,9 +28,14 @@ import ( type HelmV1alpha1Interface interface { RESTClient() rest.Interface + HelmApplicationsGetter + HelmApplicationChartsGetter + HelmApplicationRepositoriesGetter HelmClusterAddonsGetter HelmClusterAddonChartsGetter HelmClusterAddonRepositoriesGetter + HelmClusterApplicationChartsGetter + HelmClusterApplicationRepositoriesGetter } // HelmV1alpha1Client is used to interact with features provided by the helm.deckhouse.io group. @@ -38,6 +43,18 @@ type HelmV1alpha1Client struct { restClient rest.Interface } +func (c *HelmV1alpha1Client) HelmApplications(namespace string) HelmApplicationInterface { + return newHelmApplications(c, namespace) +} + +func (c *HelmV1alpha1Client) HelmApplicationCharts(namespace string) HelmApplicationChartInterface { + return newHelmApplicationCharts(c, namespace) +} + +func (c *HelmV1alpha1Client) HelmApplicationRepositories(namespace string) HelmApplicationRepositoryInterface { + return newHelmApplicationRepositories(c, namespace) +} + func (c *HelmV1alpha1Client) HelmClusterAddons() HelmClusterAddonInterface { return newHelmClusterAddons(c) } @@ -50,6 +67,14 @@ func (c *HelmV1alpha1Client) HelmClusterAddonRepositories() HelmClusterAddonRepo return newHelmClusterAddonRepositories(c) } +func (c *HelmV1alpha1Client) HelmClusterApplicationCharts() HelmClusterApplicationChartInterface { + return newHelmClusterApplicationCharts(c) +} + +func (c *HelmV1alpha1Client) HelmClusterApplicationRepositories() HelmClusterApplicationRepositoryInterface { + return newHelmClusterApplicationRepositories(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). 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 index 5b3bbb8c..9668e6d4 100644 --- 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 @@ -28,6 +28,18 @@ type FakeHelmV1alpha1 struct { *testing.Fake } +func (c *FakeHelmV1alpha1) HelmApplications(namespace string) v1alpha1.HelmApplicationInterface { + return newFakeHelmApplications(c, namespace) +} + +func (c *FakeHelmV1alpha1) HelmApplicationCharts(namespace string) v1alpha1.HelmApplicationChartInterface { + return newFakeHelmApplicationCharts(c, namespace) +} + +func (c *FakeHelmV1alpha1) HelmApplicationRepositories(namespace string) v1alpha1.HelmApplicationRepositoryInterface { + return newFakeHelmApplicationRepositories(c, namespace) +} + func (c *FakeHelmV1alpha1) HelmClusterAddons() v1alpha1.HelmClusterAddonInterface { return newFakeHelmClusterAddons(c) } @@ -40,6 +52,14 @@ func (c *FakeHelmV1alpha1) HelmClusterAddonRepositories() v1alpha1.HelmClusterAd return newFakeHelmClusterAddonRepositories(c) } +func (c *FakeHelmV1alpha1) HelmClusterApplicationCharts() v1alpha1.HelmClusterApplicationChartInterface { + return newFakeHelmClusterApplicationCharts(c) +} + +func (c *FakeHelmV1alpha1) HelmClusterApplicationRepositories() v1alpha1.HelmClusterApplicationRepositoryInterface { + return newFakeHelmClusterApplicationRepositories(c) +} + // RESTClient returns a RESTClient that is used to communicate // with API server by this client implementation. func (c *FakeHelmV1alpha1) RESTClient() rest.Interface { diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplication.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplication.go new file mode 100644 index 00000000..e6412e00 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplication.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" +) + +// fakeHelmApplications implements HelmApplicationInterface +type fakeHelmApplications struct { + *gentype.FakeClientWithList[*v1alpha1.HelmApplication, *v1alpha1.HelmApplicationList] + Fake *FakeHelmV1alpha1 +} + +func newFakeHelmApplications(fake *FakeHelmV1alpha1, namespace string) apiv1alpha1.HelmApplicationInterface { + return &fakeHelmApplications{ + gentype.NewFakeClientWithList[*v1alpha1.HelmApplication, *v1alpha1.HelmApplicationList]( + fake.Fake, + namespace, + v1alpha1.SchemeGroupVersion.WithResource("helmapplications"), + v1alpha1.SchemeGroupVersion.WithKind("HelmApplication"), + func() *v1alpha1.HelmApplication { return &v1alpha1.HelmApplication{} }, + func() *v1alpha1.HelmApplicationList { return &v1alpha1.HelmApplicationList{} }, + func(dst, src *v1alpha1.HelmApplicationList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.HelmApplicationList) []*v1alpha1.HelmApplication { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.HelmApplicationList, items []*v1alpha1.HelmApplication) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplicationchart.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplicationchart.go new file mode 100644 index 00000000..0c0ac65b --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplicationchart.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" +) + +// fakeHelmApplicationCharts implements HelmApplicationChartInterface +type fakeHelmApplicationCharts struct { + *gentype.FakeClientWithList[*v1alpha1.HelmApplicationChart, *v1alpha1.HelmApplicationChartList] + Fake *FakeHelmV1alpha1 +} + +func newFakeHelmApplicationCharts(fake *FakeHelmV1alpha1, namespace string) apiv1alpha1.HelmApplicationChartInterface { + return &fakeHelmApplicationCharts{ + gentype.NewFakeClientWithList[*v1alpha1.HelmApplicationChart, *v1alpha1.HelmApplicationChartList]( + fake.Fake, + namespace, + v1alpha1.SchemeGroupVersion.WithResource("helmapplicationcharts"), + v1alpha1.SchemeGroupVersion.WithKind("HelmApplicationChart"), + func() *v1alpha1.HelmApplicationChart { return &v1alpha1.HelmApplicationChart{} }, + func() *v1alpha1.HelmApplicationChartList { return &v1alpha1.HelmApplicationChartList{} }, + func(dst, src *v1alpha1.HelmApplicationChartList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.HelmApplicationChartList) []*v1alpha1.HelmApplicationChart { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.HelmApplicationChartList, items []*v1alpha1.HelmApplicationChart) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplicationrepository.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplicationrepository.go new file mode 100644 index 00000000..33fc518d --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplicationrepository.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" +) + +// fakeHelmApplicationRepositories implements HelmApplicationRepositoryInterface +type fakeHelmApplicationRepositories struct { + *gentype.FakeClientWithList[*v1alpha1.HelmApplicationRepository, *v1alpha1.HelmApplicationRepositoryList] + Fake *FakeHelmV1alpha1 +} + +func newFakeHelmApplicationRepositories(fake *FakeHelmV1alpha1, namespace string) apiv1alpha1.HelmApplicationRepositoryInterface { + return &fakeHelmApplicationRepositories{ + gentype.NewFakeClientWithList[*v1alpha1.HelmApplicationRepository, *v1alpha1.HelmApplicationRepositoryList]( + fake.Fake, + namespace, + v1alpha1.SchemeGroupVersion.WithResource("helmapplicationrepositories"), + v1alpha1.SchemeGroupVersion.WithKind("HelmApplicationRepository"), + func() *v1alpha1.HelmApplicationRepository { return &v1alpha1.HelmApplicationRepository{} }, + func() *v1alpha1.HelmApplicationRepositoryList { return &v1alpha1.HelmApplicationRepositoryList{} }, + func(dst, src *v1alpha1.HelmApplicationRepositoryList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.HelmApplicationRepositoryList) []*v1alpha1.HelmApplicationRepository { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.HelmApplicationRepositoryList, items []*v1alpha1.HelmApplicationRepository) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusterapplicationchart.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusterapplicationchart.go new file mode 100644 index 00000000..c05bdb65 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusterapplicationchart.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" +) + +// fakeHelmClusterApplicationCharts implements HelmClusterApplicationChartInterface +type fakeHelmClusterApplicationCharts struct { + *gentype.FakeClientWithList[*v1alpha1.HelmClusterApplicationChart, *v1alpha1.HelmClusterApplicationChartList] + Fake *FakeHelmV1alpha1 +} + +func newFakeHelmClusterApplicationCharts(fake *FakeHelmV1alpha1) apiv1alpha1.HelmClusterApplicationChartInterface { + return &fakeHelmClusterApplicationCharts{ + gentype.NewFakeClientWithList[*v1alpha1.HelmClusterApplicationChart, *v1alpha1.HelmClusterApplicationChartList]( + fake.Fake, + "", + v1alpha1.SchemeGroupVersion.WithResource("helmclusterapplicationcharts"), + v1alpha1.SchemeGroupVersion.WithKind("HelmClusterApplicationChart"), + func() *v1alpha1.HelmClusterApplicationChart { return &v1alpha1.HelmClusterApplicationChart{} }, + func() *v1alpha1.HelmClusterApplicationChartList { return &v1alpha1.HelmClusterApplicationChartList{} }, + func(dst, src *v1alpha1.HelmClusterApplicationChartList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.HelmClusterApplicationChartList) []*v1alpha1.HelmClusterApplicationChart { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.HelmClusterApplicationChartList, items []*v1alpha1.HelmClusterApplicationChart) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusterapplicationrepository.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusterapplicationrepository.go new file mode 100644 index 00000000..209669e5 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusterapplicationrepository.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. +*/ + +// 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" +) + +// fakeHelmClusterApplicationRepositories implements HelmClusterApplicationRepositoryInterface +type fakeHelmClusterApplicationRepositories struct { + *gentype.FakeClientWithList[*v1alpha1.HelmClusterApplicationRepository, *v1alpha1.HelmClusterApplicationRepositoryList] + Fake *FakeHelmV1alpha1 +} + +func newFakeHelmClusterApplicationRepositories(fake *FakeHelmV1alpha1) apiv1alpha1.HelmClusterApplicationRepositoryInterface { + return &fakeHelmClusterApplicationRepositories{ + gentype.NewFakeClientWithList[*v1alpha1.HelmClusterApplicationRepository, *v1alpha1.HelmClusterApplicationRepositoryList]( + fake.Fake, + "", + v1alpha1.SchemeGroupVersion.WithResource("helmclusterapplicationrepositories"), + v1alpha1.SchemeGroupVersion.WithKind("HelmClusterApplicationRepository"), + func() *v1alpha1.HelmClusterApplicationRepository { return &v1alpha1.HelmClusterApplicationRepository{} }, + func() *v1alpha1.HelmClusterApplicationRepositoryList { + return &v1alpha1.HelmClusterApplicationRepositoryList{} + }, + func(dst, src *v1alpha1.HelmClusterApplicationRepositoryList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.HelmClusterApplicationRepositoryList) []*v1alpha1.HelmClusterApplicationRepository { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.HelmClusterApplicationRepositoryList, items []*v1alpha1.HelmClusterApplicationRepository) { + 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 index 911c8ea4..1f6c6f31 100644 --- a/api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go @@ -18,8 +18,18 @@ limitations under the License. package v1alpha1 +type HelmApplicationExpansion interface{} + +type HelmApplicationChartExpansion interface{} + +type HelmApplicationRepositoryExpansion interface{} + type HelmClusterAddonExpansion interface{} type HelmClusterAddonChartExpansion interface{} type HelmClusterAddonRepositoryExpansion interface{} + +type HelmClusterApplicationChartExpansion interface{} + +type HelmClusterApplicationRepositoryExpansion interface{} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplication.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplication.go new file mode 100644 index 00000000..1527b678 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplication.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" +) + +// HelmApplicationsGetter has a method to return a HelmApplicationInterface. +// A group's client should implement this interface. +type HelmApplicationsGetter interface { + HelmApplications(namespace string) HelmApplicationInterface +} + +// HelmApplicationInterface has methods to work with HelmApplication resources. +type HelmApplicationInterface interface { + Create(ctx context.Context, helmApplication *apiv1alpha1.HelmApplication, opts v1.CreateOptions) (*apiv1alpha1.HelmApplication, error) + Update(ctx context.Context, helmApplication *apiv1alpha1.HelmApplication, opts v1.UpdateOptions) (*apiv1alpha1.HelmApplication, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, helmApplication *apiv1alpha1.HelmApplication, opts v1.UpdateOptions) (*apiv1alpha1.HelmApplication, 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.HelmApplication, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.HelmApplicationList, 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.HelmApplication, err error) + HelmApplicationExpansion +} + +// helmApplications implements HelmApplicationInterface +type helmApplications struct { + *gentype.ClientWithList[*apiv1alpha1.HelmApplication, *apiv1alpha1.HelmApplicationList] +} + +// newHelmApplications returns a HelmApplications +func newHelmApplications(c *HelmV1alpha1Client, namespace string) *helmApplications { + return &helmApplications{ + gentype.NewClientWithList[*apiv1alpha1.HelmApplication, *apiv1alpha1.HelmApplicationList]( + "helmapplications", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *apiv1alpha1.HelmApplication { return &apiv1alpha1.HelmApplication{} }, + func() *apiv1alpha1.HelmApplicationList { return &apiv1alpha1.HelmApplicationList{} }, + ), + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplicationchart.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplicationchart.go new file mode 100644 index 00000000..095695aa --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplicationchart.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" +) + +// HelmApplicationChartsGetter has a method to return a HelmApplicationChartInterface. +// A group's client should implement this interface. +type HelmApplicationChartsGetter interface { + HelmApplicationCharts(namespace string) HelmApplicationChartInterface +} + +// HelmApplicationChartInterface has methods to work with HelmApplicationChart resources. +type HelmApplicationChartInterface interface { + Create(ctx context.Context, helmApplicationChart *apiv1alpha1.HelmApplicationChart, opts v1.CreateOptions) (*apiv1alpha1.HelmApplicationChart, error) + Update(ctx context.Context, helmApplicationChart *apiv1alpha1.HelmApplicationChart, opts v1.UpdateOptions) (*apiv1alpha1.HelmApplicationChart, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, helmApplicationChart *apiv1alpha1.HelmApplicationChart, opts v1.UpdateOptions) (*apiv1alpha1.HelmApplicationChart, 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.HelmApplicationChart, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.HelmApplicationChartList, 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.HelmApplicationChart, err error) + HelmApplicationChartExpansion +} + +// helmApplicationCharts implements HelmApplicationChartInterface +type helmApplicationCharts struct { + *gentype.ClientWithList[*apiv1alpha1.HelmApplicationChart, *apiv1alpha1.HelmApplicationChartList] +} + +// newHelmApplicationCharts returns a HelmApplicationCharts +func newHelmApplicationCharts(c *HelmV1alpha1Client, namespace string) *helmApplicationCharts { + return &helmApplicationCharts{ + gentype.NewClientWithList[*apiv1alpha1.HelmApplicationChart, *apiv1alpha1.HelmApplicationChartList]( + "helmapplicationcharts", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *apiv1alpha1.HelmApplicationChart { return &apiv1alpha1.HelmApplicationChart{} }, + func() *apiv1alpha1.HelmApplicationChartList { return &apiv1alpha1.HelmApplicationChartList{} }, + ), + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplicationrepository.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplicationrepository.go new file mode 100644 index 00000000..3520b636 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplicationrepository.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" +) + +// HelmApplicationRepositoriesGetter has a method to return a HelmApplicationRepositoryInterface. +// A group's client should implement this interface. +type HelmApplicationRepositoriesGetter interface { + HelmApplicationRepositories(namespace string) HelmApplicationRepositoryInterface +} + +// HelmApplicationRepositoryInterface has methods to work with HelmApplicationRepository resources. +type HelmApplicationRepositoryInterface interface { + Create(ctx context.Context, helmApplicationRepository *apiv1alpha1.HelmApplicationRepository, opts v1.CreateOptions) (*apiv1alpha1.HelmApplicationRepository, error) + Update(ctx context.Context, helmApplicationRepository *apiv1alpha1.HelmApplicationRepository, opts v1.UpdateOptions) (*apiv1alpha1.HelmApplicationRepository, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, helmApplicationRepository *apiv1alpha1.HelmApplicationRepository, opts v1.UpdateOptions) (*apiv1alpha1.HelmApplicationRepository, 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.HelmApplicationRepository, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.HelmApplicationRepositoryList, 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.HelmApplicationRepository, err error) + HelmApplicationRepositoryExpansion +} + +// helmApplicationRepositories implements HelmApplicationRepositoryInterface +type helmApplicationRepositories struct { + *gentype.ClientWithList[*apiv1alpha1.HelmApplicationRepository, *apiv1alpha1.HelmApplicationRepositoryList] +} + +// newHelmApplicationRepositories returns a HelmApplicationRepositories +func newHelmApplicationRepositories(c *HelmV1alpha1Client, namespace string) *helmApplicationRepositories { + return &helmApplicationRepositories{ + gentype.NewClientWithList[*apiv1alpha1.HelmApplicationRepository, *apiv1alpha1.HelmApplicationRepositoryList]( + "helmapplicationrepositories", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *apiv1alpha1.HelmApplicationRepository { return &apiv1alpha1.HelmApplicationRepository{} }, + func() *apiv1alpha1.HelmApplicationRepositoryList { return &apiv1alpha1.HelmApplicationRepositoryList{} }, + ), + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusterapplicationchart.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusterapplicationchart.go new file mode 100644 index 00000000..bc8ea2fc --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusterapplicationchart.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" +) + +// HelmClusterApplicationChartsGetter has a method to return a HelmClusterApplicationChartInterface. +// A group's client should implement this interface. +type HelmClusterApplicationChartsGetter interface { + HelmClusterApplicationCharts() HelmClusterApplicationChartInterface +} + +// HelmClusterApplicationChartInterface has methods to work with HelmClusterApplicationChart resources. +type HelmClusterApplicationChartInterface interface { + Create(ctx context.Context, helmClusterApplicationChart *apiv1alpha1.HelmClusterApplicationChart, opts v1.CreateOptions) (*apiv1alpha1.HelmClusterApplicationChart, error) + Update(ctx context.Context, helmClusterApplicationChart *apiv1alpha1.HelmClusterApplicationChart, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterApplicationChart, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, helmClusterApplicationChart *apiv1alpha1.HelmClusterApplicationChart, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterApplicationChart, 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.HelmClusterApplicationChart, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.HelmClusterApplicationChartList, 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.HelmClusterApplicationChart, err error) + HelmClusterApplicationChartExpansion +} + +// helmClusterApplicationCharts implements HelmClusterApplicationChartInterface +type helmClusterApplicationCharts struct { + *gentype.ClientWithList[*apiv1alpha1.HelmClusterApplicationChart, *apiv1alpha1.HelmClusterApplicationChartList] +} + +// newHelmClusterApplicationCharts returns a HelmClusterApplicationCharts +func newHelmClusterApplicationCharts(c *HelmV1alpha1Client) *helmClusterApplicationCharts { + return &helmClusterApplicationCharts{ + gentype.NewClientWithList[*apiv1alpha1.HelmClusterApplicationChart, *apiv1alpha1.HelmClusterApplicationChartList]( + "helmclusterapplicationcharts", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *apiv1alpha1.HelmClusterApplicationChart { return &apiv1alpha1.HelmClusterApplicationChart{} }, + func() *apiv1alpha1.HelmClusterApplicationChartList { + return &apiv1alpha1.HelmClusterApplicationChartList{} + }, + ), + } +} diff --git a/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusterapplicationrepository.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusterapplicationrepository.go new file mode 100644 index 00000000..9baf4e31 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusterapplicationrepository.go @@ -0,0 +1,74 @@ +/* +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" +) + +// HelmClusterApplicationRepositoriesGetter has a method to return a HelmClusterApplicationRepositoryInterface. +// A group's client should implement this interface. +type HelmClusterApplicationRepositoriesGetter interface { + HelmClusterApplicationRepositories() HelmClusterApplicationRepositoryInterface +} + +// HelmClusterApplicationRepositoryInterface has methods to work with HelmClusterApplicationRepository resources. +type HelmClusterApplicationRepositoryInterface interface { + Create(ctx context.Context, helmClusterApplicationRepository *apiv1alpha1.HelmClusterApplicationRepository, opts v1.CreateOptions) (*apiv1alpha1.HelmClusterApplicationRepository, error) + Update(ctx context.Context, helmClusterApplicationRepository *apiv1alpha1.HelmClusterApplicationRepository, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterApplicationRepository, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, helmClusterApplicationRepository *apiv1alpha1.HelmClusterApplicationRepository, opts v1.UpdateOptions) (*apiv1alpha1.HelmClusterApplicationRepository, 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.HelmClusterApplicationRepository, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.HelmClusterApplicationRepositoryList, 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.HelmClusterApplicationRepository, err error) + HelmClusterApplicationRepositoryExpansion +} + +// helmClusterApplicationRepositories implements HelmClusterApplicationRepositoryInterface +type helmClusterApplicationRepositories struct { + *gentype.ClientWithList[*apiv1alpha1.HelmClusterApplicationRepository, *apiv1alpha1.HelmClusterApplicationRepositoryList] +} + +// newHelmClusterApplicationRepositories returns a HelmClusterApplicationRepositories +func newHelmClusterApplicationRepositories(c *HelmV1alpha1Client) *helmClusterApplicationRepositories { + return &helmClusterApplicationRepositories{ + gentype.NewClientWithList[*apiv1alpha1.HelmClusterApplicationRepository, *apiv1alpha1.HelmClusterApplicationRepositoryList]( + "helmclusterapplicationrepositories", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *apiv1alpha1.HelmClusterApplicationRepository { + return &apiv1alpha1.HelmClusterApplicationRepository{} + }, + func() *apiv1alpha1.HelmClusterApplicationRepositoryList { + return &apiv1alpha1.HelmClusterApplicationRepositoryList{} + }, + ), + } +} diff --git a/api/client/generated/informers/externalversions/api/v1alpha1/helmapplication.go b/api/client/generated/informers/externalversions/api/v1alpha1/helmapplication.go new file mode 100644 index 00000000..b2318ca5 --- /dev/null +++ b/api/client/generated/informers/externalversions/api/v1alpha1/helmapplication.go @@ -0,0 +1,102 @@ +/* +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" +) + +// HelmApplicationInformer provides access to a shared informer and lister for +// HelmApplications. +type HelmApplicationInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.HelmApplicationLister +} + +type helmApplicationInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewHelmApplicationInformer constructs a new informer for HelmApplication 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 NewHelmApplicationInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHelmApplicationInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredHelmApplicationInformer constructs a new informer for HelmApplication 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 NewFilteredHelmApplicationInformer(client versioned.Interface, namespace string, 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().HelmApplications(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmApplications(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmApplications(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmApplications(namespace).Watch(ctx, options) + }, + }, client), + &operatorhelmapiv1alpha1.HelmApplication{}, + resyncPeriod, + indexers, + ) +} + +func (f *helmApplicationInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHelmApplicationInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *helmApplicationInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&operatorhelmapiv1alpha1.HelmApplication{}, f.defaultInformer) +} + +func (f *helmApplicationInformer) Lister() apiv1alpha1.HelmApplicationLister { + return apiv1alpha1.NewHelmApplicationLister(f.Informer().GetIndexer()) +} diff --git a/api/client/generated/informers/externalversions/api/v1alpha1/helmapplicationchart.go b/api/client/generated/informers/externalversions/api/v1alpha1/helmapplicationchart.go new file mode 100644 index 00000000..76526c7d --- /dev/null +++ b/api/client/generated/informers/externalversions/api/v1alpha1/helmapplicationchart.go @@ -0,0 +1,102 @@ +/* +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" +) + +// HelmApplicationChartInformer provides access to a shared informer and lister for +// HelmApplicationCharts. +type HelmApplicationChartInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.HelmApplicationChartLister +} + +type helmApplicationChartInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewHelmApplicationChartInformer constructs a new informer for HelmApplicationChart 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 NewHelmApplicationChartInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHelmApplicationChartInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredHelmApplicationChartInformer constructs a new informer for HelmApplicationChart 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 NewFilteredHelmApplicationChartInformer(client versioned.Interface, namespace string, 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().HelmApplicationCharts(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmApplicationCharts(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmApplicationCharts(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmApplicationCharts(namespace).Watch(ctx, options) + }, + }, client), + &operatorhelmapiv1alpha1.HelmApplicationChart{}, + resyncPeriod, + indexers, + ) +} + +func (f *helmApplicationChartInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHelmApplicationChartInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *helmApplicationChartInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&operatorhelmapiv1alpha1.HelmApplicationChart{}, f.defaultInformer) +} + +func (f *helmApplicationChartInformer) Lister() apiv1alpha1.HelmApplicationChartLister { + return apiv1alpha1.NewHelmApplicationChartLister(f.Informer().GetIndexer()) +} diff --git a/api/client/generated/informers/externalversions/api/v1alpha1/helmapplicationrepository.go b/api/client/generated/informers/externalversions/api/v1alpha1/helmapplicationrepository.go new file mode 100644 index 00000000..e181f0a9 --- /dev/null +++ b/api/client/generated/informers/externalversions/api/v1alpha1/helmapplicationrepository.go @@ -0,0 +1,102 @@ +/* +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" +) + +// HelmApplicationRepositoryInformer provides access to a shared informer and lister for +// HelmApplicationRepositories. +type HelmApplicationRepositoryInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.HelmApplicationRepositoryLister +} + +type helmApplicationRepositoryInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewHelmApplicationRepositoryInformer constructs a new informer for HelmApplicationRepository 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 NewHelmApplicationRepositoryInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHelmApplicationRepositoryInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredHelmApplicationRepositoryInformer constructs a new informer for HelmApplicationRepository 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 NewFilteredHelmApplicationRepositoryInformer(client versioned.Interface, namespace string, 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().HelmApplicationRepositories(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmApplicationRepositories(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmApplicationRepositories(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmApplicationRepositories(namespace).Watch(ctx, options) + }, + }, client), + &operatorhelmapiv1alpha1.HelmApplicationRepository{}, + resyncPeriod, + indexers, + ) +} + +func (f *helmApplicationRepositoryInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHelmApplicationRepositoryInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *helmApplicationRepositoryInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&operatorhelmapiv1alpha1.HelmApplicationRepository{}, f.defaultInformer) +} + +func (f *helmApplicationRepositoryInformer) Lister() apiv1alpha1.HelmApplicationRepositoryLister { + return apiv1alpha1.NewHelmApplicationRepositoryLister(f.Informer().GetIndexer()) +} diff --git a/api/client/generated/informers/externalversions/api/v1alpha1/helmclusterapplicationchart.go b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusterapplicationchart.go new file mode 100644 index 00000000..ce60ced9 --- /dev/null +++ b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusterapplicationchart.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" +) + +// HelmClusterApplicationChartInformer provides access to a shared informer and lister for +// HelmClusterApplicationCharts. +type HelmClusterApplicationChartInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.HelmClusterApplicationChartLister +} + +type helmClusterApplicationChartInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewHelmClusterApplicationChartInformer constructs a new informer for HelmClusterApplicationChart 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 NewHelmClusterApplicationChartInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHelmClusterApplicationChartInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredHelmClusterApplicationChartInformer constructs a new informer for HelmClusterApplicationChart 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 NewFilteredHelmClusterApplicationChartInformer(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().HelmClusterApplicationCharts().List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterApplicationCharts().Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterApplicationCharts().List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterApplicationCharts().Watch(ctx, options) + }, + }, client), + &operatorhelmapiv1alpha1.HelmClusterApplicationChart{}, + resyncPeriod, + indexers, + ) +} + +func (f *helmClusterApplicationChartInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHelmClusterApplicationChartInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *helmClusterApplicationChartInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&operatorhelmapiv1alpha1.HelmClusterApplicationChart{}, f.defaultInformer) +} + +func (f *helmClusterApplicationChartInformer) Lister() apiv1alpha1.HelmClusterApplicationChartLister { + return apiv1alpha1.NewHelmClusterApplicationChartLister(f.Informer().GetIndexer()) +} diff --git a/api/client/generated/informers/externalversions/api/v1alpha1/helmclusterapplicationrepository.go b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusterapplicationrepository.go new file mode 100644 index 00000000..fc1dfef8 --- /dev/null +++ b/api/client/generated/informers/externalversions/api/v1alpha1/helmclusterapplicationrepository.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" +) + +// HelmClusterApplicationRepositoryInformer provides access to a shared informer and lister for +// HelmClusterApplicationRepositories. +type HelmClusterApplicationRepositoryInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.HelmClusterApplicationRepositoryLister +} + +type helmClusterApplicationRepositoryInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewHelmClusterApplicationRepositoryInformer constructs a new informer for HelmClusterApplicationRepository 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 NewHelmClusterApplicationRepositoryInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHelmClusterApplicationRepositoryInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredHelmClusterApplicationRepositoryInformer constructs a new informer for HelmClusterApplicationRepository 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 NewFilteredHelmClusterApplicationRepositoryInformer(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().HelmClusterApplicationRepositories().List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterApplicationRepositories().Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterApplicationRepositories().List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.HelmV1alpha1().HelmClusterApplicationRepositories().Watch(ctx, options) + }, + }, client), + &operatorhelmapiv1alpha1.HelmClusterApplicationRepository{}, + resyncPeriod, + indexers, + ) +} + +func (f *helmClusterApplicationRepositoryInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHelmClusterApplicationRepositoryInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *helmClusterApplicationRepositoryInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&operatorhelmapiv1alpha1.HelmClusterApplicationRepository{}, f.defaultInformer) +} + +func (f *helmClusterApplicationRepositoryInformer) Lister() apiv1alpha1.HelmClusterApplicationRepositoryLister { + return apiv1alpha1.NewHelmClusterApplicationRepositoryLister(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 index e8deecc9..09e4800b 100644 --- a/api/client/generated/informers/externalversions/api/v1alpha1/interface.go +++ b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go @@ -24,12 +24,22 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // HelmApplications returns a HelmApplicationInformer. + HelmApplications() HelmApplicationInformer + // HelmApplicationCharts returns a HelmApplicationChartInformer. + HelmApplicationCharts() HelmApplicationChartInformer + // HelmApplicationRepositories returns a HelmApplicationRepositoryInformer. + HelmApplicationRepositories() HelmApplicationRepositoryInformer // HelmClusterAddons returns a HelmClusterAddonInformer. HelmClusterAddons() HelmClusterAddonInformer // HelmClusterAddonCharts returns a HelmClusterAddonChartInformer. HelmClusterAddonCharts() HelmClusterAddonChartInformer // HelmClusterAddonRepositories returns a HelmClusterAddonRepositoryInformer. HelmClusterAddonRepositories() HelmClusterAddonRepositoryInformer + // HelmClusterApplicationCharts returns a HelmClusterApplicationChartInformer. + HelmClusterApplicationCharts() HelmClusterApplicationChartInformer + // HelmClusterApplicationRepositories returns a HelmClusterApplicationRepositoryInformer. + HelmClusterApplicationRepositories() HelmClusterApplicationRepositoryInformer } type version struct { @@ -43,6 +53,21 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } +// HelmApplications returns a HelmApplicationInformer. +func (v *version) HelmApplications() HelmApplicationInformer { + return &helmApplicationInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + +// HelmApplicationCharts returns a HelmApplicationChartInformer. +func (v *version) HelmApplicationCharts() HelmApplicationChartInformer { + return &helmApplicationChartInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + +// HelmApplicationRepositories returns a HelmApplicationRepositoryInformer. +func (v *version) HelmApplicationRepositories() HelmApplicationRepositoryInformer { + return &helmApplicationRepositoryInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // HelmClusterAddons returns a HelmClusterAddonInformer. func (v *version) HelmClusterAddons() HelmClusterAddonInformer { return &helmClusterAddonInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} @@ -57,3 +82,13 @@ func (v *version) HelmClusterAddonCharts() HelmClusterAddonChartInformer { func (v *version) HelmClusterAddonRepositories() HelmClusterAddonRepositoryInformer { return &helmClusterAddonRepositoryInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} } + +// HelmClusterApplicationCharts returns a HelmClusterApplicationChartInformer. +func (v *version) HelmClusterApplicationCharts() HelmClusterApplicationChartInformer { + return &helmClusterApplicationChartInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + +// HelmClusterApplicationRepositories returns a HelmClusterApplicationRepositoryInformer. +func (v *version) HelmClusterApplicationRepositories() HelmClusterApplicationRepositoryInformer { + return &helmClusterApplicationRepositoryInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} diff --git a/api/client/generated/informers/externalversions/generic.go b/api/client/generated/informers/externalversions/generic.go index aca9bbbb..45ec4194 100644 --- a/api/client/generated/informers/externalversions/generic.go +++ b/api/client/generated/informers/externalversions/generic.go @@ -53,12 +53,22 @@ func (f *genericInformer) Lister() cache.GenericLister { func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { switch resource { // Group=helm.deckhouse.io, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("helmapplications"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Helm().V1alpha1().HelmApplications().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("helmapplicationcharts"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Helm().V1alpha1().HelmApplicationCharts().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("helmapplicationrepositories"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Helm().V1alpha1().HelmApplicationRepositories().Informer()}, nil 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 + case v1alpha1.SchemeGroupVersion.WithResource("helmclusterapplicationcharts"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Helm().V1alpha1().HelmClusterApplicationCharts().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("helmclusterapplicationrepositories"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Helm().V1alpha1().HelmClusterApplicationRepositories().Informer()}, nil } diff --git a/api/client/generated/listers/api/v1alpha1/expansion_generated.go b/api/client/generated/listers/api/v1alpha1/expansion_generated.go index 8e4f30ff..eb9d1a46 100644 --- a/api/client/generated/listers/api/v1alpha1/expansion_generated.go +++ b/api/client/generated/listers/api/v1alpha1/expansion_generated.go @@ -18,6 +18,30 @@ limitations under the License. package v1alpha1 +// HelmApplicationListerExpansion allows custom methods to be added to +// HelmApplicationLister. +type HelmApplicationListerExpansion interface{} + +// HelmApplicationNamespaceListerExpansion allows custom methods to be added to +// HelmApplicationNamespaceLister. +type HelmApplicationNamespaceListerExpansion interface{} + +// HelmApplicationChartListerExpansion allows custom methods to be added to +// HelmApplicationChartLister. +type HelmApplicationChartListerExpansion interface{} + +// HelmApplicationChartNamespaceListerExpansion allows custom methods to be added to +// HelmApplicationChartNamespaceLister. +type HelmApplicationChartNamespaceListerExpansion interface{} + +// HelmApplicationRepositoryListerExpansion allows custom methods to be added to +// HelmApplicationRepositoryLister. +type HelmApplicationRepositoryListerExpansion interface{} + +// HelmApplicationRepositoryNamespaceListerExpansion allows custom methods to be added to +// HelmApplicationRepositoryNamespaceLister. +type HelmApplicationRepositoryNamespaceListerExpansion interface{} + // HelmClusterAddonListerExpansion allows custom methods to be added to // HelmClusterAddonLister. type HelmClusterAddonListerExpansion interface{} @@ -29,3 +53,11 @@ type HelmClusterAddonChartListerExpansion interface{} // HelmClusterAddonRepositoryListerExpansion allows custom methods to be added to // HelmClusterAddonRepositoryLister. type HelmClusterAddonRepositoryListerExpansion interface{} + +// HelmClusterApplicationChartListerExpansion allows custom methods to be added to +// HelmClusterApplicationChartLister. +type HelmClusterApplicationChartListerExpansion interface{} + +// HelmClusterApplicationRepositoryListerExpansion allows custom methods to be added to +// HelmClusterApplicationRepositoryLister. +type HelmClusterApplicationRepositoryListerExpansion interface{} diff --git a/api/client/generated/listers/api/v1alpha1/helmapplication.go b/api/client/generated/listers/api/v1alpha1/helmapplication.go new file mode 100644 index 00000000..c815418f --- /dev/null +++ b/api/client/generated/listers/api/v1alpha1/helmapplication.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 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" +) + +// HelmApplicationLister helps list HelmApplications. +// All objects returned here must be treated as read-only. +type HelmApplicationLister interface { + // List lists all HelmApplications in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmApplication, err error) + // HelmApplications returns an object that can list and get HelmApplications. + HelmApplications(namespace string) HelmApplicationNamespaceLister + HelmApplicationListerExpansion +} + +// helmApplicationLister implements the HelmApplicationLister interface. +type helmApplicationLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmApplication] +} + +// NewHelmApplicationLister returns a new HelmApplicationLister. +func NewHelmApplicationLister(indexer cache.Indexer) HelmApplicationLister { + return &helmApplicationLister{listers.New[*apiv1alpha1.HelmApplication](indexer, apiv1alpha1.Resource("helmapplication"))} +} + +// HelmApplications returns an object that can list and get HelmApplications. +func (s *helmApplicationLister) HelmApplications(namespace string) HelmApplicationNamespaceLister { + return helmApplicationNamespaceLister{listers.NewNamespaced[*apiv1alpha1.HelmApplication](s.ResourceIndexer, namespace)} +} + +// HelmApplicationNamespaceLister helps list and get HelmApplications. +// All objects returned here must be treated as read-only. +type HelmApplicationNamespaceLister interface { + // List lists all HelmApplications in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmApplication, err error) + // Get retrieves the HelmApplication from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.HelmApplication, error) + HelmApplicationNamespaceListerExpansion +} + +// helmApplicationNamespaceLister implements the HelmApplicationNamespaceLister +// interface. +type helmApplicationNamespaceLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmApplication] +} diff --git a/api/client/generated/listers/api/v1alpha1/helmapplicationchart.go b/api/client/generated/listers/api/v1alpha1/helmapplicationchart.go new file mode 100644 index 00000000..78c9f053 --- /dev/null +++ b/api/client/generated/listers/api/v1alpha1/helmapplicationchart.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 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" +) + +// HelmApplicationChartLister helps list HelmApplicationCharts. +// All objects returned here must be treated as read-only. +type HelmApplicationChartLister interface { + // List lists all HelmApplicationCharts in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmApplicationChart, err error) + // HelmApplicationCharts returns an object that can list and get HelmApplicationCharts. + HelmApplicationCharts(namespace string) HelmApplicationChartNamespaceLister + HelmApplicationChartListerExpansion +} + +// helmApplicationChartLister implements the HelmApplicationChartLister interface. +type helmApplicationChartLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmApplicationChart] +} + +// NewHelmApplicationChartLister returns a new HelmApplicationChartLister. +func NewHelmApplicationChartLister(indexer cache.Indexer) HelmApplicationChartLister { + return &helmApplicationChartLister{listers.New[*apiv1alpha1.HelmApplicationChart](indexer, apiv1alpha1.Resource("helmapplicationchart"))} +} + +// HelmApplicationCharts returns an object that can list and get HelmApplicationCharts. +func (s *helmApplicationChartLister) HelmApplicationCharts(namespace string) HelmApplicationChartNamespaceLister { + return helmApplicationChartNamespaceLister{listers.NewNamespaced[*apiv1alpha1.HelmApplicationChart](s.ResourceIndexer, namespace)} +} + +// HelmApplicationChartNamespaceLister helps list and get HelmApplicationCharts. +// All objects returned here must be treated as read-only. +type HelmApplicationChartNamespaceLister interface { + // List lists all HelmApplicationCharts in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmApplicationChart, err error) + // Get retrieves the HelmApplicationChart from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.HelmApplicationChart, error) + HelmApplicationChartNamespaceListerExpansion +} + +// helmApplicationChartNamespaceLister implements the HelmApplicationChartNamespaceLister +// interface. +type helmApplicationChartNamespaceLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmApplicationChart] +} diff --git a/api/client/generated/listers/api/v1alpha1/helmapplicationrepository.go b/api/client/generated/listers/api/v1alpha1/helmapplicationrepository.go new file mode 100644 index 00000000..faef4805 --- /dev/null +++ b/api/client/generated/listers/api/v1alpha1/helmapplicationrepository.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 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" +) + +// HelmApplicationRepositoryLister helps list HelmApplicationRepositories. +// All objects returned here must be treated as read-only. +type HelmApplicationRepositoryLister interface { + // List lists all HelmApplicationRepositories in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmApplicationRepository, err error) + // HelmApplicationRepositories returns an object that can list and get HelmApplicationRepositories. + HelmApplicationRepositories(namespace string) HelmApplicationRepositoryNamespaceLister + HelmApplicationRepositoryListerExpansion +} + +// helmApplicationRepositoryLister implements the HelmApplicationRepositoryLister interface. +type helmApplicationRepositoryLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmApplicationRepository] +} + +// NewHelmApplicationRepositoryLister returns a new HelmApplicationRepositoryLister. +func NewHelmApplicationRepositoryLister(indexer cache.Indexer) HelmApplicationRepositoryLister { + return &helmApplicationRepositoryLister{listers.New[*apiv1alpha1.HelmApplicationRepository](indexer, apiv1alpha1.Resource("helmapplicationrepository"))} +} + +// HelmApplicationRepositories returns an object that can list and get HelmApplicationRepositories. +func (s *helmApplicationRepositoryLister) HelmApplicationRepositories(namespace string) HelmApplicationRepositoryNamespaceLister { + return helmApplicationRepositoryNamespaceLister{listers.NewNamespaced[*apiv1alpha1.HelmApplicationRepository](s.ResourceIndexer, namespace)} +} + +// HelmApplicationRepositoryNamespaceLister helps list and get HelmApplicationRepositories. +// All objects returned here must be treated as read-only. +type HelmApplicationRepositoryNamespaceLister interface { + // List lists all HelmApplicationRepositories in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmApplicationRepository, err error) + // Get retrieves the HelmApplicationRepository from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.HelmApplicationRepository, error) + HelmApplicationRepositoryNamespaceListerExpansion +} + +// helmApplicationRepositoryNamespaceLister implements the HelmApplicationRepositoryNamespaceLister +// interface. +type helmApplicationRepositoryNamespaceLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmApplicationRepository] +} diff --git a/api/client/generated/listers/api/v1alpha1/helmclusterapplicationchart.go b/api/client/generated/listers/api/v1alpha1/helmclusterapplicationchart.go new file mode 100644 index 00000000..01b63829 --- /dev/null +++ b/api/client/generated/listers/api/v1alpha1/helmclusterapplicationchart.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" +) + +// HelmClusterApplicationChartLister helps list HelmClusterApplicationCharts. +// All objects returned here must be treated as read-only. +type HelmClusterApplicationChartLister interface { + // List lists all HelmClusterApplicationCharts in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmClusterApplicationChart, err error) + // Get retrieves the HelmClusterApplicationChart from the index for a given name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.HelmClusterApplicationChart, error) + HelmClusterApplicationChartListerExpansion +} + +// helmClusterApplicationChartLister implements the HelmClusterApplicationChartLister interface. +type helmClusterApplicationChartLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmClusterApplicationChart] +} + +// NewHelmClusterApplicationChartLister returns a new HelmClusterApplicationChartLister. +func NewHelmClusterApplicationChartLister(indexer cache.Indexer) HelmClusterApplicationChartLister { + return &helmClusterApplicationChartLister{listers.New[*apiv1alpha1.HelmClusterApplicationChart](indexer, apiv1alpha1.Resource("helmclusterapplicationchart"))} +} diff --git a/api/client/generated/listers/api/v1alpha1/helmclusterapplicationrepository.go b/api/client/generated/listers/api/v1alpha1/helmclusterapplicationrepository.go new file mode 100644 index 00000000..16fe9cf7 --- /dev/null +++ b/api/client/generated/listers/api/v1alpha1/helmclusterapplicationrepository.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" +) + +// HelmClusterApplicationRepositoryLister helps list HelmClusterApplicationRepositories. +// All objects returned here must be treated as read-only. +type HelmClusterApplicationRepositoryLister interface { + // List lists all HelmClusterApplicationRepositories in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.HelmClusterApplicationRepository, err error) + // Get retrieves the HelmClusterApplicationRepository from the index for a given name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.HelmClusterApplicationRepository, error) + HelmClusterApplicationRepositoryListerExpansion +} + +// helmClusterApplicationRepositoryLister implements the HelmClusterApplicationRepositoryLister interface. +type helmClusterApplicationRepositoryLister struct { + listers.ResourceIndexer[*apiv1alpha1.HelmClusterApplicationRepository] +} + +// NewHelmClusterApplicationRepositoryLister returns a new HelmClusterApplicationRepositoryLister. +func NewHelmClusterApplicationRepositoryLister(indexer cache.Indexer) HelmClusterApplicationRepositoryLister { + return &helmClusterApplicationRepositoryLister{listers.New[*apiv1alpha1.HelmClusterApplicationRepository](indexer, apiv1alpha1.Resource("helmclusterapplicationrepository"))} +} diff --git a/api/go.mod b/api/go.mod index 63d1f0c3..029c0a9b 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,6 +1,6 @@ module github.com/deckhouse/operator-helm/api -go 1.25.0 +go 1.26.3 tool ( k8s.io/code-generator diff --git a/api/naming/naming.go b/api/naming/naming.go index b6c82865..fe77e215 100644 --- a/api/naming/naming.go +++ b/api/naming/naming.go @@ -23,29 +23,86 @@ import ( ) // HelmClusterAddonChartName derives the name of the HelmClusterAddonChart object -// that mirrors one chart of a repository. It lives in the api module because -// operator-helm-controller writes those objects while chart-values-controller reads -// them: the name is a truncated hash, so both must derive it identically. +// that mirrors one chart of a repository. func HelmClusterAddonChartName(repoName, chartName string) string { - hash := hash(fmt.Sprintf("%s-chart-%s", repoName, chartName)) + return chartObjectName(repoName, chartName) +} + +// ApplicationChartName derives the name of the HelmApplicationChart object that +// mirrors one chart of a HelmApplicationRepository. The object is namespaced, so +// the name only has to be unique inside the repository's namespace. +func ApplicationChartName(repoName, chartName string) string { + return chartObjectName(repoName, chartName) +} + +// ClusterApplicationChartName derives the name of the HelmClusterApplicationChart +// object that mirrors one chart of a HelmClusterApplicationRepository. +func ClusterApplicationChartName(repoName, chartName string) string { + return chartObjectName(repoName, chartName) +} + +// chartObjectName is the naming scheme behind every chart catalog kind. It lives in +// the api module because operator-helm-controller writes those objects while +// chart-values-controller reads them, so both must derive the name identically. +// +// Joining the two parts with a separator that may itself appear inside them is not +// injective: "abc" + "def-chart-ghi" and "abc-chart-def" + "ghi" produce the same +// readable part, and the two repositories then fight over one catalog object. The +// hash is what separates them, so every family always carries it — and it is taken +// over the two parts joined by a byte no object name can hold, because hashing the +// readable join would reproduce the very ambiguity it is there to resolve. The hash +// is taken over the raw inputs, not the sanitized readable parts below, so it stays +// injective even when sanitizing two different inputs happens to yield the same +// readable part. +func chartObjectName(repoName, chartName string) string { + hash := hash(repoName + "\x00" + chartName) - var result, postfix string + repoPart := sanitize(repoName) + chartPart := sanitize(chartName) - if len(repoName) > 20 { - result += repoName[:20] + "-chart-" - postfix = "-" + hash + var result string + + if len(repoPart) > 20 { + // The truncated part is followed by a separator, so a dash or a dot the + // cut left behind has to go here: the final trim only reaches the end of + // the whole name. + result += strings.TrimRight(repoPart[:20], "-.") + "-chart-" } else { - result += repoName + "-chart-" + // Same reasoning as the truncated branch above: repoPart is followed by + // a separator here too, so a trailing dash or dot has to be trimmed + // before it, not left for the final trim to reach. + result += strings.TrimRight(repoPart, "-.") + "-chart-" } - if len(chartName) > 20 { - result += chartName[:20] - postfix = "-" + hash + if len(chartPart) > 20 { + result += chartPart[:20] } else { - result += chartName + result += chartPart + } + + // A repoPart that sanitizes to empty (or to only separators) leaves the fixed + // "-chart-" literal leading the name, so the trim has to reach the front too. + return strings.Trim(result, "-.") + "-" + hash +} + +// sanitize lower-cases s and replaces every character that cannot appear in a +// DNS-1123 subdomain (anything outside [a-z0-9.-]) with a dash, so a chart or +// repository name coming from an index or an OCI tag — "MyChart", "ch art" — always +// contributes a valid object name segment. It does not trim or truncate: that is +// left to the caller, which needs to do both around the fixed "-chart-" separator. +func sanitize(s string) string { + s = strings.ToLower(s) + + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' { + b.WriteRune(r) + } else { + b.WriteRune('-') + } } - return strings.TrimRight(result, "-") + postfix + return b.String() } func hash(s string) string { diff --git a/api/naming/naming_test.go b/api/naming/naming_test.go index 2b5f8df9..2878c461 100644 --- a/api/naming/naming_test.go +++ b/api/naming/naming_test.go @@ -16,7 +16,11 @@ limitations under the License. package naming -import "testing" +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/validation" +) func TestHelmClusterAddonChartName(t *testing.T) { cases := []struct { @@ -26,30 +30,245 @@ func TestHelmClusterAddonChartName(t *testing.T) { want string }{ { - name: "short names are joined verbatim", + name: "short names are joined and hashed", repo: "example", chart: "podinfo", - want: "example-chart-podinfo", + want: "example-chart-podinfo-015bdf9886f6", }, { name: "long names are truncated and suffixed with a hash", repo: "yandex-cloud-marketplace-mirror", chart: "cert-manager-webhook-yandex", - want: "yandex-cloud-marketp-chart-cert-manager-webhook-a3ee4a8a584e", + want: "yandex-cloud-marketp-chart-cert-manager-webhook-cb0f7a51035d", }, { - name: "an empty chart name leaves no trailing dash", + name: "an empty chart name leaves no trailing dash before the hash", repo: "repo", chart: "", - want: "repo-chart", + want: "repo-chart-8549288388a9", + }, + { + // A repository name is a DNS subdomain and a chart name comes from + // the index, so either may carry a dot at the truncation boundary. + name: "a truncation that ends in a dot drops it", + repo: "abcdefghijklmnopqrs.x", + chart: "podinfo", + want: "abcdefghijklmnopqrs-chart-podinfo-0fe4a214e986", + }, + { + // A repository name at or under the length limit is not truncated, + // but a trailing dot still has to be dropped before the separator: + // the final trim only reaches the end of the whole name. + name: "an untruncated name ending in a dot still drops it", + repo: "abcdefghijklmnopqrs.", + chart: "podinfo", + want: "abcdefghijklmnopqrs-chart-podinfo-b5579464eede", + }, + { + // Chart and repository names can carry upper case, e.g. from an OCI + // tag or a repository index entry. + name: "upper case is lowered", + repo: "REPO", + chart: "CHART", + want: "repo-chart-chart-e5db4c98cda1", + }, + { + name: "a space is replaced, not dropped, so the parts stay separated", + repo: "repo", + chart: "ch art", + want: "repo-chart-ch-art-2e144a9bd47b", + }, + { + name: "two empty parts still start with a letter, not a dash", + repo: "", + chart: "", + want: "chart-6e340b9cffb3", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := HelmClusterAddonChartName(tc.repo, tc.chart); got != tc.want { + got := HelmClusterAddonChartName(tc.repo, tc.chart) + if got != tc.want { t.Fatalf("HelmClusterAddonChartName(%q, %q) = %q, want %q", tc.repo, tc.chart, got, tc.want) } + if errs := validation.IsDNS1123Subdomain(got); len(errs) > 0 { + t.Fatalf("%q is not a valid DNS-1123 subdomain: %v", got, errs) + } }) } } + +func TestApplicationChartName(t *testing.T) { + cases := []struct { + name string + repo string + chart string + want string + }{ + { + name: "short names are joined and hashed", + repo: "example", + chart: "podinfo", + want: "example-chart-podinfo-015bdf9886f6", + }, + { + name: "long names are truncated and suffixed with a hash", + repo: "yandex-cloud-marketplace-mirror", + chart: "cert-manager-webhook-yandex", + want: "yandex-cloud-marketp-chart-cert-manager-webhook-cb0f7a51035d", + }, + { + name: "an empty chart name leaves no trailing dash before the hash", + repo: "repo", + chart: "", + want: "repo-chart-8549288388a9", + }, + { + // A repository name is a DNS subdomain and a chart name comes from + // the index, so either may carry a dot at the truncation boundary. + name: "a truncation that ends in a dot drops it", + repo: "abcdefghijklmnopqrs.x", + chart: "podinfo", + want: "abcdefghijklmnopqrs-chart-podinfo-0fe4a214e986", + }, + { + // A repository name at or under the length limit is not truncated, + // but a trailing dot still has to be dropped before the separator: + // the final trim only reaches the end of the whole name. + name: "an untruncated name ending in a dot still drops it", + repo: "abcdefghijklmnopqrs.", + chart: "podinfo", + want: "abcdefghijklmnopqrs-chart-podinfo-b5579464eede", + }, + { + name: "upper case is lowered", + repo: "REPO", + chart: "CHART", + want: "repo-chart-chart-e5db4c98cda1", + }, + { + name: "a space is replaced, not dropped, so the parts stay separated", + repo: "repo", + chart: "ch art", + want: "repo-chart-ch-art-2e144a9bd47b", + }, + { + name: "two empty parts still start with a letter, not a dash", + repo: "", + chart: "", + want: "chart-6e340b9cffb3", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ApplicationChartName(tc.repo, tc.chart) + if got != tc.want { + t.Fatalf("ApplicationChartName(%q, %q) = %q, want %q", tc.repo, tc.chart, got, tc.want) + } + if errs := validation.IsDNS1123Subdomain(got); len(errs) > 0 { + t.Fatalf("%q is not a valid DNS-1123 subdomain: %v", got, errs) + } + }) + } +} + +func TestClusterApplicationChartName(t *testing.T) { + cases := []struct { + name string + repo string + chart string + want string + }{ + { + name: "short names are joined and hashed", + repo: "shared", + chart: "nginx", + want: "shared-chart-nginx-a2f6f72110ff", + }, + { + name: "long names are truncated and suffixed with a hash", + repo: "yandex-cloud-marketplace-mirror", + chart: "cert-manager-webhook-yandex", + want: "yandex-cloud-marketp-chart-cert-manager-webhook-cb0f7a51035d", + }, + { + name: "upper case is lowered", + repo: "REPO", + chart: "CHART", + want: "repo-chart-chart-e5db4c98cda1", + }, + { + name: "a space is replaced, not dropped, so the parts stay separated", + repo: "repo", + chart: "ch art", + want: "repo-chart-ch-art-2e144a9bd47b", + }, + { + name: "two empty parts still start with a letter, not a dash", + repo: "", + chart: "", + want: "chart-6e340b9cffb3", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ClusterApplicationChartName(tc.repo, tc.chart) + if got != tc.want { + t.Fatalf("ClusterApplicationChartName(%q, %q) = %q, want %q", tc.repo, tc.chart, got, tc.want) + } + if errs := validation.IsDNS1123Subdomain(got); len(errs) > 0 { + t.Fatalf("%q is not a valid DNS-1123 subdomain: %v", got, errs) + } + }) + } +} + +// TestChartNameSchemeIsShared pins the decision that every chart catalog kind is +// named by one scheme. The objects differ in kind, and the namespaced and cluster +// variants live in different scopes, so identical names cannot collide — while a +// scheme that silently diverged per family would break the round trip from an +// object name back to the repository/chart pair. +func TestChartNameSchemeIsShared(t *testing.T) { + const ( + repo = "yandex-cloud-marketplace-mirror" + chart = "cert-manager-webhook-yandex" + ) + + addon := HelmClusterAddonChartName(repo, chart) + + if got := ApplicationChartName(repo, chart); got != addon { + t.Fatalf("ApplicationChartName = %q, want the shared scheme result %q", got, addon) + } + + if got := ClusterApplicationChartName(repo, chart); got != addon { + t.Fatalf("ClusterApplicationChartName = %q, want the shared scheme result %q", got, addon) + } +} + +// TestChartNameSeparatesAnAmbiguousPair pins the reason the hash is taken over the +// two parts joined by a NUL rather than over the readable name: the readable join +// is ambiguous, so hashing it would reproduce exactly the collision the hash exists +// to resolve. Both pairs below build the same readable part. +func TestChartNameSeparatesAnAmbiguousPair(t *testing.T) { + left := HelmClusterAddonChartName("abc", "def-chart-ghi") + right := HelmClusterAddonChartName("abc-chart-def", "ghi") + + if left == right { + t.Fatalf("(%q, %q) and (%q, %q) both produce %q", "abc", "def-chart-ghi", "abc-chart-def", "ghi", left) + } +} + +// TestChartNameSeparatesATrailingDot pins a second ambiguity the join alone cannot +// carry: a part ending in a dot is trimmed inside the readable name, so two charts +// that differ only by it would otherwise share an object. +func TestChartNameSeparatesATrailingDot(t *testing.T) { + withDot := HelmClusterAddonChartName("foo", "bar.") + withoutDot := HelmClusterAddonChartName("foo", "bar") + + if withDot == withoutDot { + t.Fatalf("(%q, %q) and (%q, %q) both produce %q", "foo", "bar.", "foo", "bar", withDot) + } +} diff --git a/api/v1alpha1/chart_artifact.go b/api/v1alpha1/chart_artifact.go index cadf98e5..1ba27068 100644 --- a/api/v1alpha1/chart_artifact.go +++ b/api/v1alpha1/chart_artifact.go @@ -21,7 +21,7 @@ import ( "strings" ) -// The media types below are the value domain of HelmClusterAddonChartVersion.MediaType +// The media types below are the value domain of ChartVersion.MediaType // and the rule for recognizing a packaged Helm chart inside an OCI artifact. They live // in the API module because more than one component has to agree on them: the operator // records a verdict against them, and the chart-values service examines the same @@ -54,7 +54,7 @@ func IsChartConfigMediaType(mediaType string) bool { return false } -// SplitOCIRef splits the value of HelmClusterAddonChartVersion.OCIRef into the +// SplitOCIRef splits the value of ChartVersion.OCIRef into the // repository address and the tag. fallbackTag is used when the reference carries no // tag of its own, which is how an index entry that relies on its own version field // spells the reference; a reference read back from the catalog always carries one, so diff --git a/api/v1alpha1/chart_catalog_types.go b/api/v1alpha1/chart_catalog_types.go new file mode 100644 index 00000000..ef82513d --- /dev/null +++ b/api/v1alpha1/chart_catalog_types.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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ChartCatalogStatus and ChartVersion are shared by every chart catalog kind: +// HelmClusterAddonChart, HelmApplicationChart and HelmClusterApplicationChart are +// all projections of a repository index and differ only in scope. One declaration +// lets the controller write all three catalogs through one generic code path and +// lets chart-values-controller read them the same way. +// +// This note is outside every doc comment on purpose: a doc comment on a Status type +// becomes the description of the status field in the CRD. + +type ChartCatalogStatus struct { + // IconURL is the URL to the Helm chart icon (applicable to Helm Chart repository charts only). + IconURL string `json:"iconURL,omitempty"` + // Conditions represent the latest available observations of the chart state. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + // Generation represents resource generation that was last processed by the controller. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Versions lists every chart version the controller has examined. A version is + // usable when it has no unavailableReason; for an OCI repository a usable version + // also carries the media type of the layer that holds it. + // +optional + Versions []ChartVersion `json:"versions"` +} + +type ChartVersion struct { + // Helm chart version + // +kubebuilder:validation:MinLength=1 + Version string `json:"version"` + // OCIRef is the OCI reference this version is published at, as recorded from + // the repository index. It is set only for a version of a helm repository whose + // index entry points at a registry instead of a chart archive; such a version is + // deployed through an internal OCIRepository even though its repository is a helm + // one. + // +optional + OCIRef string `json:"ociRef,omitempty"` + // MediaType is the OCI media type of the layer that holds this chart version. It + // is set only for a version of an oci:// repository, and only when the layer is + // supported: an empty value there means the version cannot be deployed. + // +optional + MediaType string `json:"mediaType,omitempty"` + // UnavailableReason explains why this version cannot be deployed. Its absence means + // the version is usable. + // +optional + // +kubebuilder:validation:Enum=RemovedFromRepository;UnsupportedMediaType;ResolvePending;InvalidChartReference + UnavailableReason string `json:"unavailableReason,omitempty"` + // UnavailableMessage carries human readable detail for UnavailableReason. + // +optional + UnavailableMessage string `json:"unavailableMessage,omitempty"` +} diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/conditions.go index 8e625f2b..16a60a5f 100644 --- a/api/v1alpha1/conditions.go +++ b/api/v1alpha1/conditions.go @@ -39,6 +39,14 @@ const ( ReasonFailed = "Failed" ReasonUninstallFailed = "UninstallFailed" ReasonChartClaimConflict = "ChartClaimConflict" + // ReasonAccessSetupFailed marks a release whose identity — the ServiceAccount, + // Role and RoleBinding the chart is applied with — could not be reconciled. + ReasonAccessSetupFailed = "AccessSetupFailed" + // ReasonForeignAccessObject marks a release whose identity cannot be built + // because a Role or RoleBinding already occupies the name it needs and does not + // carry the operator's managed-by label. Such an object belongs to whoever + // created it and is never adopted, patched or deleted. + ReasonForeignAccessObject = "ForeignAccessObject" // ReasonForceReconcile marks the Reconciling condition raised for a pass that // was requested through the force reconcile annotation. ReasonForceReconcile = "ForceReconcile" diff --git a/api/v1alpha1/constants.go b/api/v1alpha1/constants.go index 2b836330..3cab49c2 100644 --- a/api/v1alpha1/constants.go +++ b/api/v1alpha1/constants.go @@ -29,8 +29,46 @@ const ( // LabelManagedByValue is the value for the managed-by label. LabelManagedByValue = "operator-helm" + // LabelSourceNamespace carries the namespace of the namespaced source resource an + // internal object was derived from. Internal objects of every family live in + // TargetNamespace, so the source-name label alone cannot identify a namespaced + // source; the two labels are kept separate because a joined "namespace/name" can + // exceed the 63-character limit of a label value while each part fits. + LabelSourceNamespace = "helm.deckhouse.io/source-namespace" + LabelDeckhouseHeritage = "heritage" LabelDeckhouseHeritageValue = "deckhouse" AnnotationForceReconcile = "reconcile.helm.deckhouse.io/force" + + // LabelRepositoryName and LabelChartName are set on every chart catalog object — + // HelmClusterAddonChart, HelmApplicationChart and HelmClusterApplicationChart — + // and carry the repository/chart pair the object mirrors. They are the only way + // back from the object name — a truncated hash — to the pair it belongs to, which + // is why both the catalog synchronization and the watch that maps a chart to the + // resources using it read them. + LabelRepositoryName = "repository" + LabelChartName = "chart" + + // UnavailableReason* are the values of the UnavailableReason field of a chart + // catalog version, in every family. They are field values rather than condition + // reasons, and they describe OCI artifacts rather than resource kinds, so they + // live here instead of conditions.go or next to one family's chart type. + // + // UnavailableReasonRemovedFromRepository means the tag is no longer offered by the + // repository. The entry is retained only because a resource still references it, and + // the marker is dropped automatically once the tag is listed again. + UnavailableReasonRemovedFromRepository = "RemovedFromRepository" + // UnavailableReasonUnsupportedMediaType means the manifest was read but the artifact + // is not a packaged Helm chart. It is a verdict about the artifact, so it is kept + // until a force reconcile re-examines every tag. + UnavailableReasonUnsupportedMediaType = "UnsupportedMediaType" + // UnavailableReasonResolvePending means the manifest request failed and no verdict + // was reached. Such a tag is re-examined on every normal synchronization. + UnavailableReasonResolvePending = "ResolvePending" + // UnavailableReasonInvalidChartReference means the repository index points this + // version at a registry, but the reference it gives is not a valid tagged + // reference. It is a verdict about the index entry rather than about the + // artifact, so it is kept until the repository publishes a usable reference. + UnavailableReasonInvalidChartReference = "InvalidChartReference" ) diff --git a/api/v1alpha1/helm_application.go b/api/v1alpha1/helm_application.go new file mode 100644 index 00000000..efc5d678 --- /dev/null +++ b/api/v1alpha1/helm_application.go @@ -0,0 +1,293 @@ +/* +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 + +import ( + "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" +) + +const ( + HelmApplicationKind = "HelmApplication" + HelmApplicationResource = "helmapplications" + + // HelmApplicationLabelSourceName stores the name of the source facade resource. + HelmApplicationLabelSourceName = "helm.deckhouse.io/application" +) + +// HelmApplication represents an installation of a Helm chart inside a single namespace. The release is deployed into the namespace of the resource itself. The chart is applied with a ServiceAccount bound to a Role that grants every permission inside that namespace, so the right to create a HelmApplication is equivalent to administrator rights in its namespace; the Role and the binding belong to the module and are reconciled, so an edit to either does not outlast the application that needs it. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:labels={heritage=deckhouse,module=operator-helm} +// +kubebuilder:resource:singular=helmapplication,scope=Namespaced +// +kubebuilder:validation:XValidation:rule="self.metadata.name.size() <= 63",message="application name must be at most 63 characters long" +// +kubebuilder:printcolumn:name="Chart",type="string",JSONPath=".spec.chart.name",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 application" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Repository",type="string",JSONPath=".spec.chart.repository",priority=1,description="The namespaced repository the chart is taken from" +// +kubebuilder:printcolumn:name="Cluster Repository",type="string",JSONPath=".spec.chart.clusterRepository",priority=1,description="The cluster-wide repository the chart is taken from" +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmApplication struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec HelmApplicationSpec `json:"spec"` + Status HelmApplicationStatus `json:"status,omitempty"` +} + +func (r *HelmApplication) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *HelmApplication) SetObservedGeneration(generation int64) { + r.Status.ObservedGeneration = generation +} + +func (r *HelmApplication) GetObservedGeneration() int64 { + return r.Status.ObservedGeneration +} + +// RepositoryName returns the name of the repository the chart is taken from, +// whichever of the two mutually exclusive reference fields is set. +func (r *HelmApplication) RepositoryName() string { + if r.Spec.Chart.Repository != "" { + return r.Spec.Chart.Repository + } + + return r.Spec.Chart.ClusterRepository +} + +// RepositoryKind returns the kind of the repository the chart is taken from, or an +// empty string when neither reference is set. The CEL rule on spec.chart keeps +// exactly one of them set on any persisted object, so an empty result means the +// object was built in memory and never validated by the API server. +func (r *HelmApplication) RepositoryKind() string { + switch { + case r.Spec.Chart.Repository != "": + return HelmApplicationRepositoryKind + case r.Spec.Chart.ClusterRepository != "": + return HelmClusterApplicationRepositoryKind + default: + return "" + } +} + +func (r *HelmApplication) MaintenanceModeActivated() bool { + return r.Spec.Maintenance == string(NoResourceReconciliation) +} + +func (r *HelmApplication) MaintenanceModeEnabled() bool { + return apimeta.IsStatusConditionPresentAndEqual(r.Status.Conditions, ConditionTypeManaged, metav1.ConditionFalse) +} + +func (r *HelmApplication) GetConditionTypesForUpdate() []string { + conditionTypes := []string{ConditionTypeReady} + + if r.Status.LastAppliedChart == nil || !apimeta.IsStatusConditionPresentAndEqual(r.Status.Conditions, ConditionTypeInstalled, metav1.ConditionTrue) { + return append(conditionTypes, ConditionTypeInstalled) + } + + if r.IsChartStatusInfoOutdated() || + apimeta.IsStatusConditionFalse(r.Status.Conditions, ConditionTypeUpdateInstalled) || + r.UpdateInstallInProgress() { + conditionTypes = append(conditionTypes, ConditionTypeUpdateInstalled) + } + + if !reflect.DeepEqual(r.Spec.Values, r.Status.LastAppliedValues) || + apimeta.IsStatusConditionFalse(r.Status.Conditions, ConditionTypeConfigurationApplied) || + r.ConfigurationApplyInProgress() { + conditionTypes = append(conditionTypes, ConditionTypeConfigurationApplied) + } + + return conditionTypes +} + +func (r *HelmApplication) ConfigurationApplyInProgress() bool { + cond := apimeta.FindStatusCondition(r.Status.Conditions, ConditionTypeConfigurationApplied) + if cond == nil { + return false + } + + return cond.Status == metav1.ConditionUnknown && cond.Reason == ReasonReconciling +} + +func (r *HelmApplication) UpdateInstallInProgress() bool { + cond := apimeta.FindStatusCondition(r.Status.Conditions, ConditionTypeUpdateInstalled) + if cond == nil { + return false + } + + return cond.Status == metav1.ConditionUnknown && cond.Reason == ReasonReconciling +} + +// IsChartStatusInfoOutdated compares the whole chart reference, both repository +// fields included: moving a chart of the same name from a namespaced repository to +// a cluster one, or back, is a chart change even though the repository name and the +// version stay the same. +func (r *HelmApplication) IsChartStatusInfoOutdated() bool { + if r.Status.LastAppliedChart == nil { + return true + } + + return r.Spec.Chart.Name != r.Status.LastAppliedChart.Name || + r.Spec.Chart.Repository != r.Status.LastAppliedChart.Repository || + r.Spec.Chart.ClusterRepository != r.Status.LastAppliedChart.ClusterRepository || + r.Spec.Chart.Version != r.Status.LastAppliedChart.Version +} + +func (r *HelmApplication) ForceReconcileRequired() bool { + annotations := r.GetAnnotations() + if annotations == nil { + return false + } + + _, found := annotations[AnnotationForceReconcile] + + return found +} + +type HelmApplicationSpec struct { + Chart HelmApplicationChartRef `json:"chart"` + // Values holds the values for this HelmApplication release. + // +kubebuilder:pruning:PreserveUnknownFields + // +optional + Values *apiextensionsv1.JSON `json:"values"` + // 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. + // +kubebuilder:validation:Enum="";NoResourceReconciliation + // +optional + Maintenance string `json:"maintenance,omitempty"` +} + +// The XValidation rule below states the relationship between the two reference +// fields, which is why it is declared on the object rather than on either field. +// A value sent as an empty string passes has() and is rejected by MinLength. + +// +kubebuilder:validation:XValidation:rule="has(self.repository) != has(self.clusterRepository)",message="exactly one of spec.chart.repository or spec.chart.clusterRepository must be set" +type HelmApplicationChartRef struct { + // Specifies the name of the Helm chart to be installed + // from the referenced repository (e.g., "nginx" or "redis"). + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + // Specifies the name of the HelmApplicationRepository custom resource in the same + // namespace that contains the connection details and credentials for the + // repository where the chart is located. + // +optional + // +kubebuilder:validation:MinLength=3 + // +kubebuilder:validation:MaxLength=63 + Repository string `json:"repository,omitempty"` + // Specifies the name of the cluster-wide HelmClusterApplicationRepository custom + // resource that contains the connection details and credentials for the + // repository where the chart is located. + // +optional + // +kubebuilder:validation:MinLength=3 + // +kubebuilder:validation:MaxLength=63 + ClusterRepository string `json:"clusterRepository,omitempty"` + // Version holds the HelmApplication chart version. + // +kubebuilder:validation:MinLength=1 + Version string `json:"version"` +} + +type HelmApplicationStatus struct { + // LastAppliedChart represents the latest chart that triggered application install or update. + // +optional + LastAppliedChart *HelmApplicationLastAppliedChartRef `json:"lastAppliedChart,omitempty"` + // LastAppliedValues represents the latest values that triggered application install or update. + // +optional + LastAppliedValues *apiextensionsv1.JSON `json:"lastAppliedValues,omitempty"` + // Conditions represent the latest available observations of the application state. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + // Generation represents resource generation that was last processed by the controller. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // LastForceReconcileTime is the time the most recent force reconcile request was + // processed. It records that the request was acted on, not that it succeeded: + // the outcome is reported by Ready. + // +optional + LastForceReconcileTime *metav1.Time `json:"lastForceReconcileTime,omitempty"` +} + +// HelmApplicationLastAppliedChartRef mirrors HelmApplicationChartRef field for +// field, both repository references included. Which of the two is filled records +// the kind of the repository the release was last deployed from, so no separate +// kind field is needed. No validation is declared here: the status is written by +// the controller, and a rule would only be able to block a write. +// +// This note is outside the doc comment on purpose, as in repository_types.go: a +// doc comment attached to a type used as a status field becomes that field's +// description in the CRD. + +type HelmApplicationLastAppliedChartRef struct { + // Specifies the name of the Helm chart the release was last deployed from. + // +optional + Name string `json:"name,omitempty"` + // Specifies the name of the HelmApplicationRepository custom resource the chart + // was last taken from. + // +optional + Repository string `json:"repository,omitempty"` + // Specifies the name of the HelmClusterApplicationRepository custom resource the + // chart was last taken from. + // +optional + ClusterRepository string `json:"clusterRepository,omitempty"` + // Version holds the chart version the release was last deployed from. + // +optional + Version string `json:"version,omitempty"` +} + +// RepositoryName returns the name of the repository the release was last deployed +// from, whichever of the two mutually exclusive reference fields is set. +func (r *HelmApplicationLastAppliedChartRef) RepositoryName() string { + if r.Repository != "" { + return r.Repository + } + + return r.ClusterRepository +} + +// RepositoryKind returns the kind of the repository the release was last deployed +// from, or an empty string when neither reference is set — which is what the status +// holds before the first successful deployment. +func (r *HelmApplicationLastAppliedChartRef) RepositoryKind() string { + switch { + case r.Repository != "": + return HelmApplicationRepositoryKind + case r.ClusterRepository != "": + return HelmClusterApplicationRepositoryKind + default: + return "" + } +} + +// HelmApplicationList contains a list of HelmApplications. +// +kubebuilder:object:root=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmApplicationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + // Items provides a list of HelmApplications. + Items []HelmApplication `json:"items"` +} diff --git a/api/v1alpha1/helm_application_chart.go b/api/v1alpha1/helm_application_chart.go new file mode 100644 index 00000000..142d7fa7 --- /dev/null +++ b/api/v1alpha1/helm_application_chart.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. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + HelmApplicationChartKind = "HelmApplicationChart" + HelmApplicationChartResource = "helmapplicationcharts" + + HelmApplicationChartLabelSourceName = "helm.deckhouse.io/application-chart" +) + +// HelmApplicationChart represents a specific Helm chart discovered within a HelmApplicationRepository. These resources are automatically managed during repository synchronization and are immutable to user modifications. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:labels={heritage=deckhouse,module=operator-helm} +// +kubebuilder:resource:singular=helmapplicationchart,scope=Namespaced +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmApplicationChart struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Status ChartCatalogStatus `json:"status,omitempty"` +} + +func (r *HelmApplicationChart) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *HelmApplicationChart) SetObservedGeneration(generation int64) { + r.Status.ObservedGeneration = generation +} + +func (r *HelmApplicationChart) GetObservedGeneration() int64 { + return r.Status.ObservedGeneration +} + +func (r *HelmApplicationChart) GetConditionTypesForUpdate() []string { + return []string{ConditionTypeReady} +} + +// HelmApplicationChartList contains a list of HelmApplicationCharts. +// +kubebuilder:object:root=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmApplicationChartList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + // Items provides a list of HelmApplicationCharts. + Items []HelmApplicationChart `json:"items"` +} diff --git a/api/v1alpha1/helm_application_chart_test.go b/api/v1alpha1/helm_application_chart_test.go new file mode 100644 index 00000000..b680edab --- /dev/null +++ b/api/v1alpha1/helm_application_chart_test.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 v1alpha1 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestHelmApplicationChartGetConditions(t *testing.T) { + chart := &HelmApplicationChart{} + + conditions := chart.GetConditions() + *conditions = append(*conditions, metav1.Condition{Type: ConditionTypeReady, Status: metav1.ConditionTrue}) + + if len(chart.Status.Conditions) != 1 { + t.Fatalf("appending through GetConditions did not reach the status: got %d conditions, want 1", len(chart.Status.Conditions)) + } +} + +func TestHelmApplicationChartObservedGeneration(t *testing.T) { + chart := &HelmApplicationChart{} + + chart.SetObservedGeneration(2) + + if got := chart.GetObservedGeneration(); got != 2 { + t.Fatalf("GetObservedGeneration() = %d, want 2", got) + } +} + +func TestHelmApplicationChartGetConditionTypesForUpdate(t *testing.T) { + chart := &HelmApplicationChart{} + + got := chart.GetConditionTypesForUpdate() + + if len(got) != 1 || got[0] != ConditionTypeReady { + t.Fatalf("GetConditionTypesForUpdate() = %v, want [%s]", got, ConditionTypeReady) + } +} diff --git a/api/v1alpha1/helm_application_repository.go b/api/v1alpha1/helm_application_repository.go new file mode 100644 index 00000000..96978f5a --- /dev/null +++ b/api/v1alpha1/helm_application_repository.go @@ -0,0 +1,90 @@ +/* +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 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + HelmApplicationRepositoryKind = "HelmApplicationRepository" + HelmApplicationRepositoryResource = "helmapplicationrepositories" + + // HelmApplicationRepositoryLabelSourceName stores the name of the source facade resource. + HelmApplicationRepositoryLabelSourceName = "helm.deckhouse.io/application-repository" +) + +// HelmApplicationRepository represents a Helm or OCI-compliant repository containing Helm charts that can be referenced by HelmApplication resources from the same namespace. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:labels={heritage=deckhouse,module=operator-helm} +// +kubebuilder:resource:singular=helmapplicationrepository,scope=Namespaced +// +kubebuilder:validation:XValidation:rule="self.metadata.name.size() >= 3 && self.metadata.name.size() <= 63",message="repository name must be between 3 and 63 characters long" +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="The readiness status of the repository" +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status",description="Repository synchronization status" +// +kubebuilder:printcolumn:name="Last Sync",type="date",JSONPath=".status.lastSuccessfulSyncTime",description="Time of the last successful catalog synchronization" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Next Sync",type="string",JSONPath=".status.nextSyncTime",priority=1,description="Scheduled time of the next synchronization attempt" +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message",priority=1 +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmApplicationRepository struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec RepositorySpec `json:"spec"` + Status RepositoryStatus `json:"status,omitempty"` +} + +func (r *HelmApplicationRepository) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *HelmApplicationRepository) SetObservedGeneration(generation int64) { + r.Status.ObservedGeneration = generation +} + +func (r *HelmApplicationRepository) GetObservedGeneration() int64 { + return r.Status.ObservedGeneration +} + +func (r *HelmApplicationRepository) GetConditionTypesForUpdate() []string { + return []string{ConditionTypeReady} +} + +func (r *HelmApplicationRepository) ForceReconcileRequired() bool { + annotations := r.GetAnnotations() + if annotations == nil { + return false + } + + _, found := annotations[AnnotationForceReconcile] + + return found +} + +// HelmApplicationRepositoryList contains a list of HelmApplicationRepositories. +// +kubebuilder:object:root=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmApplicationRepositoryList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + // Items provides a list of HelmApplicationRepositories. + Items []HelmApplicationRepository `json:"items"` +} diff --git a/api/v1alpha1/helm_application_repository_test.go b/api/v1alpha1/helm_application_repository_test.go new file mode 100644 index 00000000..37909d25 --- /dev/null +++ b/api/v1alpha1/helm_application_repository_test.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 v1alpha1 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestHelmApplicationRepositoryGetConditions guards the contract the status manager +// relies on: GetConditions must hand out a pointer into the status, not a copy, so +// appending through it is visible on the object. +func TestHelmApplicationRepositoryGetConditions(t *testing.T) { + repo := &HelmApplicationRepository{} + + conditions := repo.GetConditions() + *conditions = append(*conditions, metav1.Condition{Type: ConditionTypeReady, Status: metav1.ConditionTrue}) + + if len(repo.Status.Conditions) != 1 { + t.Fatalf("appending through GetConditions did not reach the status: got %d conditions, want 1", len(repo.Status.Conditions)) + } +} + +func TestHelmApplicationRepositoryObservedGeneration(t *testing.T) { + repo := &HelmApplicationRepository{} + + repo.SetObservedGeneration(7) + + if got := repo.GetObservedGeneration(); got != 7 { + t.Fatalf("GetObservedGeneration() = %d, want 7", got) + } + if repo.Status.ObservedGeneration != 7 { + t.Fatalf("status.observedGeneration = %d, want 7", repo.Status.ObservedGeneration) + } +} + +func TestHelmApplicationRepositoryGetConditionTypesForUpdate(t *testing.T) { + repo := &HelmApplicationRepository{} + + got := repo.GetConditionTypesForUpdate() + + if len(got) != 1 || got[0] != ConditionTypeReady { + t.Fatalf("GetConditionTypesForUpdate() = %v, want [%s]", got, ConditionTypeReady) + } +} + +func TestHelmApplicationRepositoryForceReconcileRequired(t *testing.T) { + cases := []struct { + name string + annotations map[string]string + want bool + }{ + { + name: "no annotations at all", + annotations: nil, + want: false, + }, + { + name: "an unrelated annotation", + annotations: map[string]string{"example.com/other": ""}, + want: false, + }, + { + name: "the force reconcile annotation with an empty value", + annotations: map[string]string{AnnotationForceReconcile: ""}, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Annotations: tc.annotations}, + } + + if got := repo.ForceReconcileRequired(); got != tc.want { + t.Fatalf("ForceReconcileRequired() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/api/v1alpha1/helm_application_test.go b/api/v1alpha1/helm_application_test.go new file mode 100644 index 00000000..f65080ae --- /dev/null +++ b/api/v1alpha1/helm_application_test.go @@ -0,0 +1,474 @@ +/* +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 + +import ( + "slices" + "testing" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestHelmApplicationRepositoryName(t *testing.T) { + cases := []struct { + name string + ref HelmApplicationChartRef + want string + }{ + { + name: "a namespaced repository", + ref: HelmApplicationChartRef{Repository: "myapp-repo"}, + want: "myapp-repo", + }, + { + name: "a cluster repository", + ref: HelmApplicationChartRef{ClusterRepository: "shared-repo"}, + want: "shared-repo", + }, + { + name: "neither reference set", + ref: HelmApplicationChartRef{}, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &HelmApplication{Spec: HelmApplicationSpec{Chart: tc.ref}} + + if got := app.RepositoryName(); got != tc.want { + t.Fatalf("RepositoryName() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestHelmApplicationRepositoryKind(t *testing.T) { + cases := []struct { + name string + ref HelmApplicationChartRef + want string + }{ + { + name: "a namespaced repository", + ref: HelmApplicationChartRef{Repository: "myapp-repo"}, + want: HelmApplicationRepositoryKind, + }, + { + name: "a cluster repository", + ref: HelmApplicationChartRef{ClusterRepository: "shared-repo"}, + want: HelmClusterApplicationRepositoryKind, + }, + { + name: "neither reference set is reported as unknown, not as a cluster repository", + ref: HelmApplicationChartRef{}, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &HelmApplication{Spec: HelmApplicationSpec{Chart: tc.ref}} + + if got := app.RepositoryKind(); got != tc.want { + t.Fatalf("RepositoryKind() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestHelmApplicationLastAppliedChartRefRepositoryName(t *testing.T) { + cases := []struct { + name string + ref HelmApplicationLastAppliedChartRef + want string + }{ + { + name: "a namespaced repository", + ref: HelmApplicationLastAppliedChartRef{Repository: "myapp-repo"}, + want: "myapp-repo", + }, + { + name: "a cluster repository", + ref: HelmApplicationLastAppliedChartRef{ClusterRepository: "shared-repo"}, + want: "shared-repo", + }, + { + name: "neither reference set", + ref: HelmApplicationLastAppliedChartRef{}, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ref := tc.ref + + if got := ref.RepositoryName(); got != tc.want { + t.Fatalf("RepositoryName() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestHelmApplicationLastAppliedChartRefRepositoryKind(t *testing.T) { + cases := []struct { + name string + ref HelmApplicationLastAppliedChartRef + want string + }{ + { + name: "a namespaced repository", + ref: HelmApplicationLastAppliedChartRef{Repository: "myapp-repo"}, + want: HelmApplicationRepositoryKind, + }, + { + name: "a cluster repository", + ref: HelmApplicationLastAppliedChartRef{ClusterRepository: "shared-repo"}, + want: HelmClusterApplicationRepositoryKind, + }, + { + name: "neither reference set is reported as unknown, not as a cluster repository", + ref: HelmApplicationLastAppliedChartRef{}, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ref := tc.ref + + if got := ref.RepositoryKind(); got != tc.want { + t.Fatalf("RepositoryKind() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestHelmApplicationIsChartStatusInfoOutdated(t *testing.T) { + spec := HelmApplicationChartRef{ + Name: "nginx", + Repository: "myapp-repo", + Version: "1.0.0", + } + + cases := []struct { + name string + lastAppliedChart *HelmApplicationLastAppliedChartRef + want bool + }{ + { + name: "nothing applied yet", + lastAppliedChart: nil, + want: true, + }, + { + name: "the applied chart matches the spec", + lastAppliedChart: &HelmApplicationLastAppliedChartRef{ + Name: "nginx", + Repository: "myapp-repo", + Version: "1.0.0", + }, + want: false, + }, + { + name: "the version changed", + lastAppliedChart: &HelmApplicationLastAppliedChartRef{ + Name: "nginx", + Repository: "myapp-repo", + Version: "0.9.0", + }, + want: true, + }, + { + name: "the same repository name, but it is now a cluster repository", + lastAppliedChart: &HelmApplicationLastAppliedChartRef{ + Name: "nginx", + ClusterRepository: "myapp-repo", + Version: "1.0.0", + }, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &HelmApplication{ + Spec: HelmApplicationSpec{Chart: spec}, + Status: HelmApplicationStatus{LastAppliedChart: tc.lastAppliedChart}, + } + + if got := app.IsChartStatusInfoOutdated(); got != tc.want { + t.Fatalf("IsChartStatusInfoOutdated() = %v, want %v", got, tc.want) + } + }) + } + + // The cases above all differ in a field the addon's three-field comparison + // already covers, so none of them would fail if clusterRepository were dropped + // from the comparison. This one isolates it: the namespaced reference is unset + // on both sides, so clusterRepository is the only field left that differs. + t.Run("only the cluster repository changed, with the namespaced reference unset on both sides", func(t *testing.T) { + app := &HelmApplication{ + Spec: HelmApplicationSpec{Chart: HelmApplicationChartRef{ + Name: "nginx", + ClusterRepository: "shared-a", + Version: "1.0.0", + }}, + Status: HelmApplicationStatus{LastAppliedChart: &HelmApplicationLastAppliedChartRef{ + Name: "nginx", + ClusterRepository: "shared-b", + Version: "1.0.0", + }}, + } + + if !app.IsChartStatusInfoOutdated() { + t.Fatal("IsChartStatusInfoOutdated() = false, want true: only clusterRepository differs") + } + }) +} + +func TestHelmApplicationGetConditionTypesForUpdate(t *testing.T) { + chart := HelmApplicationChartRef{Name: "nginx", Repository: "myapp-repo", Version: "1.0.0"} + applied := &HelmApplicationLastAppliedChartRef{Name: "nginx", Repository: "myapp-repo", Version: "1.0.0"} + installed := []metav1.Condition{{Type: ConditionTypeInstalled, Status: metav1.ConditionTrue}} + + t.Run("nothing installed yet asks about Installed and not about UpdateInstalled", func(t *testing.T) { + app := &HelmApplication{Spec: HelmApplicationSpec{Chart: chart}} + + got := app.GetConditionTypesForUpdate() + + if !slices.Contains(got, ConditionTypeInstalled) { + t.Fatalf("GetConditionTypesForUpdate() = %v, want it to contain %s", got, ConditionTypeInstalled) + } + if slices.Contains(got, ConditionTypeUpdateInstalled) { + t.Fatalf("GetConditionTypesForUpdate() = %v, want it not to contain %s", got, ConditionTypeUpdateInstalled) + } + }) + + t.Run("an installed application in sync asks only about Ready", func(t *testing.T) { + app := &HelmApplication{ + Spec: HelmApplicationSpec{Chart: chart}, + Status: HelmApplicationStatus{LastAppliedChart: applied, Conditions: installed}, + } + + got := app.GetConditionTypesForUpdate() + + if len(got) != 1 || got[0] != ConditionTypeReady { + t.Fatalf("GetConditionTypesForUpdate() = %v, want [%s]", got, ConditionTypeReady) + } + }) + + t.Run("a changed chart version asks about UpdateInstalled", func(t *testing.T) { + app := &HelmApplication{ + Spec: HelmApplicationSpec{Chart: HelmApplicationChartRef{Name: "nginx", Repository: "myapp-repo", Version: "2.0.0"}}, + Status: HelmApplicationStatus{ + LastAppliedChart: applied, + Conditions: installed, + }, + } + + got := app.GetConditionTypesForUpdate() + + if !slices.Contains(got, ConditionTypeUpdateInstalled) { + t.Fatalf("GetConditionTypesForUpdate() = %v, want it to contain %s", got, ConditionTypeUpdateInstalled) + } + }) + + t.Run("changed values ask about ConfigurationApplied", func(t *testing.T) { + app := &HelmApplication{ + Spec: HelmApplicationSpec{ + Chart: chart, + Values: &apiextensionsv1.JSON{Raw: []byte(`{"replicaCount":2}`)}, + }, + Status: HelmApplicationStatus{LastAppliedChart: applied, Conditions: installed}, + } + + got := app.GetConditionTypesForUpdate() + + if !slices.Contains(got, ConditionTypeConfigurationApplied) { + t.Fatalf("GetConditionTypesForUpdate() = %v, want it to contain %s", got, ConditionTypeConfigurationApplied) + } + }) +} + +func TestHelmApplicationConfigurationApplyInProgress(t *testing.T) { + cases := []struct { + name string + conditions []metav1.Condition + want bool + }{ + { + name: "no conditions at all", + conditions: nil, + want: false, + }, + { + name: "the condition is unknown but for a different reason", + conditions: []metav1.Condition{{Type: ConditionTypeConfigurationApplied, Status: metav1.ConditionUnknown, Reason: ReasonFailed}}, + want: false, + }, + { + name: "the condition is unknown while reconciling", + conditions: []metav1.Condition{{Type: ConditionTypeConfigurationApplied, Status: metav1.ConditionUnknown, Reason: ReasonReconciling}}, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &HelmApplication{Status: HelmApplicationStatus{Conditions: tc.conditions}} + + if got := app.ConfigurationApplyInProgress(); got != tc.want { + t.Fatalf("ConfigurationApplyInProgress() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestHelmApplicationUpdateInstallInProgress(t *testing.T) { + cases := []struct { + name string + conditions []metav1.Condition + want bool + }{ + { + name: "no conditions at all", + conditions: nil, + want: false, + }, + { + name: "the condition is unknown but for a different reason", + conditions: []metav1.Condition{{Type: ConditionTypeUpdateInstalled, Status: metav1.ConditionUnknown, Reason: ReasonFailed}}, + want: false, + }, + { + name: "the condition is unknown while reconciling", + conditions: []metav1.Condition{{Type: ConditionTypeUpdateInstalled, Status: metav1.ConditionUnknown, Reason: ReasonReconciling}}, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &HelmApplication{Status: HelmApplicationStatus{Conditions: tc.conditions}} + + if got := app.UpdateInstallInProgress(); got != tc.want { + t.Fatalf("UpdateInstallInProgress() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestHelmApplicationMaintenanceModeActivated(t *testing.T) { + cases := []struct { + name string + maintenance string + want bool + }{ + {name: "standard reconciliation", maintenance: "", want: false}, + {name: "maintenance requested", maintenance: string(NoResourceReconciliation), want: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &HelmApplication{Spec: HelmApplicationSpec{Maintenance: tc.maintenance}} + + if got := app.MaintenanceModeActivated(); got != tc.want { + t.Fatalf("MaintenanceModeActivated() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestHelmApplicationMaintenanceModeEnabled(t *testing.T) { + cases := []struct { + name string + conditions []metav1.Condition + want bool + }{ + { + name: "no conditions at all", + conditions: nil, + want: false, + }, + { + name: "still managed", + conditions: []metav1.Condition{{Type: ConditionTypeManaged, Status: metav1.ConditionTrue}}, + want: false, + }, + { + name: "no longer managed", + conditions: []metav1.Condition{{Type: ConditionTypeManaged, Status: metav1.ConditionFalse}}, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &HelmApplication{Status: HelmApplicationStatus{Conditions: tc.conditions}} + + if got := app.MaintenanceModeEnabled(); got != tc.want { + t.Fatalf("MaintenanceModeEnabled() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestHelmApplicationForceReconcileRequired(t *testing.T) { + cases := []struct { + name string + annotations map[string]string + want bool + }{ + {name: "no annotations at all", annotations: nil, want: false}, + {name: "an unrelated annotation", annotations: map[string]string{"example.com/other": ""}, want: false}, + {name: "the force reconcile annotation with an empty value", annotations: map[string]string{AnnotationForceReconcile: ""}, want: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := &HelmApplication{ObjectMeta: metav1.ObjectMeta{Annotations: tc.annotations}} + + if got := app.ForceReconcileRequired(); got != tc.want { + t.Fatalf("ForceReconcileRequired() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestHelmApplicationGetConditions(t *testing.T) { + app := &HelmApplication{} + + conditions := app.GetConditions() + *conditions = append(*conditions, metav1.Condition{Type: ConditionTypeReady, Status: metav1.ConditionTrue}) + + if len(app.Status.Conditions) != 1 { + t.Fatalf("appending through GetConditions did not reach the status: got %d conditions, want 1", len(app.Status.Conditions)) + } +} + +func TestHelmApplicationObservedGeneration(t *testing.T) { + app := &HelmApplication{} + + app.SetObservedGeneration(11) + + if got := app.GetObservedGeneration(); got != 11 { + t.Fatalf("GetObservedGeneration() = %d, want 11", got) + } +} diff --git a/api/v1alpha1/helm_cluster_addon.go b/api/v1alpha1/helm_cluster_addon.go index 86df948f..81ebef93 100644 --- a/api/v1alpha1/helm_cluster_addon.go +++ b/api/v1alpha1/helm_cluster_addon.go @@ -20,7 +20,6 @@ import ( "reflect" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/api/meta" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -65,10 +64,6 @@ func (r *HelmClusterAddon) GetObservedGeneration() int64 { return r.Status.ObservedGeneration } -func (r *HelmClusterAddon) GetStatus() any { - return r.Status -} - func (r *HelmClusterAddon) MaintenanceModeActivated() bool { return r.Spec.Maintenance == string(NoResourceReconciliation) } @@ -78,20 +73,20 @@ func (r *HelmClusterAddon) MaintenanceModeEnabled() bool { } func (r *HelmClusterAddon) GetConditionTypesForUpdate() []string { - conditionTypes := []string{"Ready"} + conditionTypes := []string{ConditionTypeReady} - if r.Status.LastAppliedChart == nil || !meta.IsStatusConditionPresentAndEqual(r.Status.Conditions, ConditionTypeInstalled, metav1.ConditionTrue) { + if r.Status.LastAppliedChart == nil || !apimeta.IsStatusConditionPresentAndEqual(r.Status.Conditions, ConditionTypeInstalled, metav1.ConditionTrue) { return append(conditionTypes, ConditionTypeInstalled) } if r.IsChartStatusInfoOutdated() || - meta.IsStatusConditionFalse(r.Status.Conditions, ConditionTypeUpdateInstalled) || + apimeta.IsStatusConditionFalse(r.Status.Conditions, ConditionTypeUpdateInstalled) || r.UpdateInstallInProgress() { conditionTypes = append(conditionTypes, ConditionTypeUpdateInstalled) } if !reflect.DeepEqual(r.Spec.Values, r.Status.LastAppliedValues) || - meta.IsStatusConditionFalse(r.Status.Conditions, ConditionTypeConfigurationApplied) || + apimeta.IsStatusConditionFalse(r.Status.Conditions, ConditionTypeConfigurationApplied) || r.ConfigurationApplyInProgress() { conditionTypes = append(conditionTypes, ConditionTypeConfigurationApplied) } @@ -100,21 +95,21 @@ func (r *HelmClusterAddon) GetConditionTypesForUpdate() []string { } func (r *HelmClusterAddon) ConfigurationApplyInProgress() bool { - cond := meta.FindStatusCondition(r.Status.Conditions, ConditionTypeConfigurationApplied) + cond := apimeta.FindStatusCondition(r.Status.Conditions, ConditionTypeConfigurationApplied) if cond == nil { return false } - return cond.Status == metav1.ConditionUnknown && cond.Reason == "Reconciling" + return cond.Status == metav1.ConditionUnknown && cond.Reason == ReasonReconciling } func (r *HelmClusterAddon) UpdateInstallInProgress() bool { - cond := meta.FindStatusCondition(r.Status.Conditions, ConditionTypeUpdateInstalled) + cond := apimeta.FindStatusCondition(r.Status.Conditions, ConditionTypeUpdateInstalled) if cond == nil { return false } - return cond.Status == metav1.ConditionUnknown && cond.Reason == "Reconciling" + return cond.Status == metav1.ConditionUnknown && cond.Reason == ReasonReconciling } func (r *HelmClusterAddon) IsChartStatusInfoOutdated() bool { @@ -183,10 +178,6 @@ type HelmClusterAddonStatus struct { // +optional LastAppliedValues *apiextensionsv1.JSON `json:"lastAppliedValues,omitempty"` // Conditions represent the latest available observations of the addon state. - // - // Reconciling is present only while applicable, following the kstatus convention. - // It carries the reason ForceReconcile while a reconciliation requested through - // the force reconcile annotation is running. // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` // Generation represents resource generation that was last processed by the controller. diff --git a/api/v1alpha1/helm_cluster_addon_chart.go b/api/v1alpha1/helm_cluster_addon_chart.go index 3c042c1d..3e005760 100644 --- a/api/v1alpha1/helm_cluster_addon_chart.go +++ b/api/v1alpha1/helm_cluster_addon_chart.go @@ -25,35 +25,6 @@ const ( HelmClusterAddonChartResource = "helmclusteraddoncharts" HelmClusterAddonChartLabelSourceName = "helm.deckhouse.io/cluster-addon-chart" - - // LabelRepositoryName and LabelChartName are set on every HelmClusterAddonChart and - // carry the repository/chart pair the object mirrors. They are the only way back - // from the object name — a truncated hash — to the pair it belongs to, which is why - // both the catalog synchronization and the watch that maps a chart to the addons - // using it read them. - LabelRepositoryName = "repository" - LabelChartName = "chart" - - // UnavailableReason* are the values of HelmClusterAddonChartVersion.UnavailableReason. - // They are field values rather than condition reasons, so they live next to the - // type that carries them instead of conditions.go. - // - // UnavailableReasonRemovedFromRepository means the tag is no longer offered by the - // repository. The entry is retained only because an addon still references it, and - // the marker is dropped automatically once the tag is listed again. - UnavailableReasonRemovedFromRepository = "RemovedFromRepository" - // UnavailableReasonUnsupportedMediaType means the manifest was read but the artifact - // is not a packaged Helm chart. It is a verdict about the artifact, so it is kept - // until a force reconcile re-examines every tag. - UnavailableReasonUnsupportedMediaType = "UnsupportedMediaType" - // UnavailableReasonResolvePending means the manifest request failed and no verdict - // was reached. Such a tag is re-examined on every normal synchronization. - UnavailableReasonResolvePending = "ResolvePending" - // UnavailableReasonInvalidChartReference means the repository index points this - // version at a registry, but the reference it gives is not a valid tagged - // reference. It is a verdict about the index entry rather than about the - // artifact, so it is kept until the repository publishes a usable reference. - UnavailableReasonInvalidChartReference = "InvalidChartReference" ) // HelmClusterAddonChart represents a specific Helm chart discovered within a HelmClusterAddonRepository. These resources are automatically managed during repository synchronization and are immutable to user modifications. @@ -69,7 +40,7 @@ type HelmClusterAddonChart struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Status HelmClusterAddonChartStatus `json:"status,omitempty"` + Status ChartCatalogStatus `json:"status,omitempty"` } func (r *HelmClusterAddonChart) GetConditions() *[]metav1.Condition { @@ -84,59 +55,10 @@ 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 HelmClusterAddonChartStatus struct { - // IconURL is the URL to the Helm chart icon (applicable to Helm Chart repository charts only). - IconURL string `json:"iconURL,omitempty"` - // Conditions represent the latest available observations of the addon chart state. - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` - // Generation represents resource generation that was last processed by the controller. - ObservedGeneration int64 `json:"observedGeneration,omitempty"` - // Versions lists every chart version the controller has examined. A version is - // usable when it has no unavailableReason; for an OCI repository a usable version - // also carries the media type of the layer that holds it. - // +optional - Versions []HelmClusterAddonChartVersion `json:"versions"` -} - -type HelmClusterAddonChartVersion struct { - // Helm chart version - // +kubebuilder:validation:MinLength=1 - Version string `json:"version"` - // OCIRef is the OCI reference this version is published at, as recorded from - // the repository index. It is set only for a version of a helm repository whose - // index entry points at a registry instead of a chart archive; such a version is - // deployed through an internal OCIRepository even though its repository is a helm - // one. The registry host and path keep the spelling the index used, and the tag is - // always explicit: an index entry without one is recorded with its own version as - // the tag. - // +optional - OCIRef string `json:"ociRef,omitempty"` - // MediaType is the OCI media type of the layer that holds this chart version. It - // is set only for a version of an oci:// repository, and only when the layer is - // supported: an empty value there means the version cannot be deployed. It stays - // empty for a version carrying OCIRef — the layer of such an artifact is examined - // at deploy time and is not recorded here. - // +optional - MediaType string `json:"mediaType,omitempty"` - // UnavailableReason explains why this version cannot be deployed. Its absence means - // the version is usable. - // +optional - // +kubebuilder:validation:Enum=RemovedFromRepository;UnsupportedMediaType;ResolvePending;InvalidChartReference - UnavailableReason string `json:"unavailableReason,omitempty"` - // UnavailableMessage carries human readable detail for UnavailableReason. - // +optional - UnavailableMessage string `json:"unavailableMessage,omitempty"` -} - // HelmClusterAddonChartList contains a list of HelmClusterAddonCharts. // +kubebuilder:object:root=true // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/api/v1alpha1/helm_cluster_addon_repository.go b/api/v1alpha1/helm_cluster_addon_repository.go index 7e24b148..d636f1fa 100644 --- a/api/v1alpha1/helm_cluster_addon_repository.go +++ b/api/v1alpha1/helm_cluster_addon_repository.go @@ -28,19 +28,13 @@ const ( HelmClusterAddonRepositoryLabelSourceName = "helm.deckhouse.io/cluster-addon-repository" ) -// The "Next Sync" print column below is a string, not a date, on purpose: a date -// column prints how long ago its value was, and kubectl renders any instant more -// than a second in the future as . nextSyncTime is always in the future. -// -// This note is deliberately outside the doc comment below — controller-gen folds -// every non-marker line of that block into the resource's API description. - // HelmClusterAddonRepository represents a Helm or OCI-compliant repository containing Helm charts that can be referenced by HelmClusterAddon resources. // // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:metadata:labels={heritage=deckhouse,module=operator-helm} // +kubebuilder:resource:singular=helmclusteraddonrepository,scope=Cluster +// +kubebuilder:validation:XValidation:rule="self.metadata.name.size() >= 3 && self.metadata.name.size() <= 63",message="repository name must be between 3 and 63 characters long" // +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="The readiness status of the repository" // +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status",description="Repository synchronization status" // +kubebuilder:printcolumn:name="Last Sync",type="date",JSONPath=".status.lastSuccessfulSyncTime",description="Time of the last successful catalog synchronization" @@ -54,8 +48,8 @@ type HelmClusterAddonRepository struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec HelmClusterAddonRepositorySpec `json:"spec"` - Status HelmClusterAddonRepositoryStatus `json:"status,omitempty"` + Spec RepositorySpec `json:"spec"` + Status RepositoryStatus `json:"status,omitempty"` } func (r *HelmClusterAddonRepository) GetConditions() *[]metav1.Condition { @@ -70,10 +64,6 @@ 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"} } @@ -89,70 +79,6 @@ func (r *HelmClusterAddonRepository) ForceReconcileRequired() bool { return found } -type HelmClusterAddonRepositorySpec struct { - // URL of the Helm repository. Supports http(s):// and oci:// protocols. - // +kubebuilder:validation:Required - // +kubebuilder:validation:XValidation:rule="self.matches('^(https?|oci)://.+$')",message="URL must have a valid protocol (http, https, oci) and a non-empty path" - URL string `json:"url"` - - // Auth contains authentication credentials for the repository. - // +optional - Auth *HelmClusterAddonRepositoryAuth `json:"auth,omitempty"` - - // CACertificate is the PEM encoded CA certificate for TLS verification. - // +optional - CACertificate string `json:"caCertificate,omitempty"` - - // InsecureSkipVerify disable TLS certificate verification. - // +optional - InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` -} - -type HelmClusterAddonRepositoryAuth struct { - // Repository authentication username. - // +kubebuilder:validation:MinLength=1 - Username string `json:"username"` - // Repository authentication password. - // +kubebuilder:validation:MinLength=1 - Password string `json:"password"` -} - -type HelmClusterAddonRepositoryStatus struct { - // Conditions represent the latest available observations of the repository state. - // - // Ready reports whether the repository is usable: auxiliary resources are in place, - // the internal source object is healthy and the repository has responded to a catalog - // read on the current spec. A transient read failure does not flip Ready to False. - // - // Synced reports whether the chart catalog is up to date. - // - // Reconciling and Stalled follow the kstatus convention: they are present only while - // applicable. Reconciling means work is in progress or a retry is scheduled; Stalled - // means the repository will not recover without a change. While a synchronization is - // running Reconciling carries the reason Synchronization, or ForceReconcile when the - // pass was requested through the force reconcile annotation. - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` - // Generation represents resource generation that was last processed by the controller. - ObservedGeneration int64 `json:"observedGeneration,omitempty"` - // LastSuccessfulSyncTime is the last time the chart catalog was fully brought up to date, - // including creating and pruning chart resources. - // +optional - LastSuccessfulSyncTime *metav1.Time `json:"lastSuccessfulSyncTime,omitempty"` - // NextSyncTime is the scheduled time of the next synchronization attempt. - // +optional - NextSyncTime *metav1.Time `json:"nextSyncTime,omitempty"` - // LastForceReconcileTime is the time the most recent force reconcile request was - // processed. It records that the request was acted on, not that it succeeded: - // the outcome is reported by Ready and Synced. - // +optional - LastForceReconcileTime *metav1.Time `json:"lastForceReconcileTime,omitempty"` - // ConsecutiveFetchFailures counts consecutive failures to read from the repository. - // It drives the retry backoff and resets on the first success. - // +optional - ConsecutiveFetchFailures int32 `json:"consecutiveFetchFailures,omitempty"` -} - // HelmClusterAddonRepositoryList contains a list of HelmClusterAddonRepositories. // +kubebuilder:object:root=true // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/api/v1alpha1/helm_cluster_application_chart.go b/api/v1alpha1/helm_cluster_application_chart.go new file mode 100644 index 00000000..c2d8fba4 --- /dev/null +++ b/api/v1alpha1/helm_cluster_application_chart.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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + HelmClusterApplicationChartKind = "HelmClusterApplicationChart" + HelmClusterApplicationChartResource = "helmclusterapplicationcharts" + + HelmClusterApplicationChartLabelSourceName = "helm.deckhouse.io/cluster-application-chart" +) + +// HelmClusterApplicationChart represents a specific Helm chart discovered within a HelmClusterApplicationRepository. These resources are automatically managed during repository synchronization and are immutable to user modifications. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:labels={heritage=deckhouse,module=operator-helm} +// +kubebuilder:resource:singular=helmclusterapplicationchart,scope=Cluster +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmClusterApplicationChart struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Status ChartCatalogStatus `json:"status,omitempty"` +} + +func (r *HelmClusterApplicationChart) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *HelmClusterApplicationChart) SetObservedGeneration(generation int64) { + r.Status.ObservedGeneration = generation +} + +func (r *HelmClusterApplicationChart) GetObservedGeneration() int64 { + return r.Status.ObservedGeneration +} + +func (r *HelmClusterApplicationChart) GetConditionTypesForUpdate() []string { + return []string{ConditionTypeReady} +} + +// HelmClusterApplicationChartList contains a list of HelmClusterApplicationCharts. +// +kubebuilder:object:root=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmClusterApplicationChartList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + // Items provides a list of HelmClusterApplicationCharts. + Items []HelmClusterApplicationChart `json:"items"` +} diff --git a/api/v1alpha1/helm_cluster_application_chart_test.go b/api/v1alpha1/helm_cluster_application_chart_test.go new file mode 100644 index 00000000..c3878e56 --- /dev/null +++ b/api/v1alpha1/helm_cluster_application_chart_test.go @@ -0,0 +1,73 @@ +/* +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 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestHelmClusterApplicationChartGetConditions(t *testing.T) { + chart := &HelmClusterApplicationChart{} + + conditions := chart.GetConditions() + *conditions = append(*conditions, metav1.Condition{Type: ConditionTypeReady, Status: metav1.ConditionTrue}) + + if len(chart.Status.Conditions) != 1 { + t.Fatalf("appending through GetConditions did not reach the status: got %d conditions, want 1", len(chart.Status.Conditions)) + } +} + +func TestHelmClusterApplicationChartObservedGeneration(t *testing.T) { + chart := &HelmClusterApplicationChart{} + + chart.SetObservedGeneration(9) + + if got := chart.GetObservedGeneration(); got != 9 { + t.Fatalf("GetObservedGeneration() = %d, want 9", got) + } +} + +func TestHelmClusterApplicationChartGetConditionTypesForUpdate(t *testing.T) { + chart := &HelmClusterApplicationChart{} + + got := chart.GetConditionTypesForUpdate() + + if len(got) != 1 || got[0] != ConditionTypeReady { + t.Fatalf("GetConditionTypesForUpdate() = %v, want [%s]", got, ConditionTypeReady) + } +} + +// TestChartCatalogStatusIsShared pins that every chart catalog kind is built on +// one status type. If someone later splits them into per-kind copies, the +// schemas start drifting apart silently; this assignment stops compiling instead. +func TestChartCatalogStatusIsShared(t *testing.T) { + namespaced := &HelmApplicationChart{} + cluster := &HelmClusterApplicationChart{} + addon := &HelmClusterAddonChart{} + + namespaced.Status = cluster.Status + addon.Status = namespaced.Status + + if namespaced.Status.IconURL != "" { + t.Fatalf("unexpected iconURL after the assignment: %q", namespaced.Status.IconURL) + } + if addon.Status.IconURL != "" { + t.Fatalf("unexpected iconURL after the assignment: %q", addon.Status.IconURL) + } +} diff --git a/api/v1alpha1/helm_cluster_application_repository.go b/api/v1alpha1/helm_cluster_application_repository.go new file mode 100644 index 00000000..56bbcaad --- /dev/null +++ b/api/v1alpha1/helm_cluster_application_repository.go @@ -0,0 +1,91 @@ +/* +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 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + HelmClusterApplicationRepositoryKind = "HelmClusterApplicationRepository" + HelmClusterApplicationRepositoryResource = "helmclusterapplicationrepositories" + + // HelmClusterApplicationRepositoryLabelSourceName stores the name of the source facade resource. + HelmClusterApplicationRepositoryLabelSourceName = "helm.deckhouse.io/cluster-application-repository" +) + +// HelmClusterApplicationRepository represents a cluster-wide Helm or OCI-compliant repository containing Helm charts that can be referenced by HelmApplication resources from any namespace. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:labels={heritage=deckhouse,module=operator-helm} +// +kubebuilder:resource:singular=helmclusterapplicationrepository,scope=Cluster +// +kubebuilder:validation:XValidation:rule="self.metadata.name.size() >= 3 && self.metadata.name.size() <= 63",message="repository name must be between 3 and 63 characters long" +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="The readiness status of the repository" +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status",description="Repository synchronization status" +// +kubebuilder:printcolumn:name="Last Sync",type="date",JSONPath=".status.lastSuccessfulSyncTime",description="Time of the last successful catalog synchronization" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Next Sync",type="string",JSONPath=".status.nextSyncTime",priority=1,description="Scheduled time of the next synchronization attempt" +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message",priority=1 +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmClusterApplicationRepository struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec RepositorySpec `json:"spec"` + Status RepositoryStatus `json:"status,omitempty"` +} + +func (r *HelmClusterApplicationRepository) GetConditions() *[]metav1.Condition { + return &r.Status.Conditions +} + +func (r *HelmClusterApplicationRepository) SetObservedGeneration(generation int64) { + r.Status.ObservedGeneration = generation +} + +func (r *HelmClusterApplicationRepository) GetObservedGeneration() int64 { + return r.Status.ObservedGeneration +} + +func (r *HelmClusterApplicationRepository) GetConditionTypesForUpdate() []string { + return []string{ConditionTypeReady} +} + +func (r *HelmClusterApplicationRepository) ForceReconcileRequired() bool { + annotations := r.GetAnnotations() + if annotations == nil { + return false + } + + _, found := annotations[AnnotationForceReconcile] + + return found +} + +// HelmClusterApplicationRepositoryList contains a list of HelmClusterApplicationRepositories. +// +kubebuilder:object:root=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HelmClusterApplicationRepositoryList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + // Items provides a list of HelmClusterApplicationRepositories. + Items []HelmClusterApplicationRepository `json:"items"` +} diff --git a/api/v1alpha1/helm_cluster_application_repository_test.go b/api/v1alpha1/helm_cluster_application_repository_test.go new file mode 100644 index 00000000..55be0b34 --- /dev/null +++ b/api/v1alpha1/helm_cluster_application_repository_test.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 v1alpha1 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestHelmClusterApplicationRepositoryGetConditions(t *testing.T) { + repo := &HelmClusterApplicationRepository{} + + conditions := repo.GetConditions() + *conditions = append(*conditions, metav1.Condition{Type: ConditionTypeReady, Status: metav1.ConditionTrue}) + + if len(repo.Status.Conditions) != 1 { + t.Fatalf("appending through GetConditions did not reach the status: got %d conditions, want 1", len(repo.Status.Conditions)) + } +} + +func TestHelmClusterApplicationRepositoryObservedGeneration(t *testing.T) { + repo := &HelmClusterApplicationRepository{} + + repo.SetObservedGeneration(4) + + if got := repo.GetObservedGeneration(); got != 4 { + t.Fatalf("GetObservedGeneration() = %d, want 4", got) + } + if repo.Status.ObservedGeneration != 4 { + t.Fatalf("status.observedGeneration = %d, want 4", repo.Status.ObservedGeneration) + } +} + +func TestHelmClusterApplicationRepositoryGetConditionTypesForUpdate(t *testing.T) { + repo := &HelmClusterApplicationRepository{} + + got := repo.GetConditionTypesForUpdate() + + if len(got) != 1 || got[0] != ConditionTypeReady { + t.Fatalf("GetConditionTypesForUpdate() = %v, want [%s]", got, ConditionTypeReady) + } +} + +func TestHelmClusterApplicationRepositoryForceReconcileRequired(t *testing.T) { + cases := []struct { + name string + annotations map[string]string + want bool + }{ + { + name: "no annotations at all", + annotations: nil, + want: false, + }, + { + name: "an unrelated annotation", + annotations: map[string]string{"example.com/other": ""}, + want: false, + }, + { + name: "the force reconcile annotation with an empty value", + annotations: map[string]string{AnnotationForceReconcile: ""}, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &HelmClusterApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Annotations: tc.annotations}, + } + + if got := repo.ForceReconcileRequired(); got != tc.want { + t.Fatalf("ForceReconcileRequired() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestApplicationRepositoryTypesAreShared pins that both repository kinds are built +// on one pair of spec and status types. If someone later splits them into per-kind +// copies, the two schemas start drifting apart silently; this assignment stops +// compiling instead. +func TestApplicationRepositoryTypesAreShared(t *testing.T) { + namespaced := &HelmApplicationRepository{} + cluster := &HelmClusterApplicationRepository{} + + namespaced.Spec = cluster.Spec + namespaced.Status = cluster.Status + + if namespaced.Spec.URL != "" { + t.Fatalf("unexpected URL after the assignment: %q", namespaced.Spec.URL) + } +} diff --git a/api/v1alpha1/register.go b/api/v1alpha1/register.go index 1e7fd460..79b0d258 100644 --- a/api/v1alpha1/register.go +++ b/api/v1alpha1/register.go @@ -30,9 +30,14 @@ const ( var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: Version} var ( - HelmClusterAddonGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmClusterAddonKind} - HelmClusterAddonRepositoryGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmClusterAddonRepositoryKind} - HelmClusterAddonChartGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmClusterAddonChartKind} + HelmClusterAddonGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmClusterAddonKind} + HelmClusterAddonRepositoryGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmClusterAddonRepositoryKind} + HelmClusterAddonChartGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmClusterAddonChartKind} + HelmApplicationRepositoryGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmApplicationRepositoryKind} + HelmClusterApplicationRepositoryGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmClusterApplicationRepositoryKind} + HelmApplicationChartGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmApplicationChartKind} + HelmClusterApplicationChartGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmClusterApplicationChartKind} + HelmApplicationGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmApplicationKind} ) func Kind(kind string) schema.GroupKind { @@ -60,6 +65,16 @@ func addKnownTypes(scheme *runtime.Scheme) error { &HelmClusterAddonRepositoryList{}, &HelmClusterAddonChart{}, &HelmClusterAddonChartList{}, + &HelmApplicationRepository{}, + &HelmApplicationRepositoryList{}, + &HelmClusterApplicationRepository{}, + &HelmClusterApplicationRepositoryList{}, + &HelmApplicationChart{}, + &HelmApplicationChartList{}, + &HelmClusterApplicationChart{}, + &HelmClusterApplicationChartList{}, + &HelmApplication{}, + &HelmApplicationList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/api/v1alpha1/repository_types.go b/api/v1alpha1/repository_types.go new file mode 100644 index 00000000..06b18399 --- /dev/null +++ b/api/v1alpha1/repository_types.go @@ -0,0 +1,89 @@ +/* +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 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RepositorySpec, RepositoryAuth and RepositoryStatus are shared by every +// repository kind: HelmClusterAddonRepository, HelmApplicationRepository and +// HelmClusterApplicationRepository differ only in scope and in who may reference +// them. Declaring the shape once makes a divergence between the schemas impossible +// by construction, and lets the controller reconcile all three through one code +// path. The field descriptions are the ones the released HelmClusterAddonRepository +// CRD already carries: sharing them changes no generated schema. +// +// This note is outside every doc comment on purpose: a doc comment on a Spec or +// Status type becomes the description of the spec or status field in the CRD. + +type RepositorySpec struct { + // URL of the Helm repository. Supports http(s):// and oci:// protocols. + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self.matches('^(https?|oci)://.+$')",message="URL must have a valid protocol (http, https, oci) and a non-empty path" + URL string `json:"url"` + + // Auth contains authentication credentials for the repository. + // +optional + Auth *RepositoryAuth `json:"auth,omitempty"` + + // CACertificate is the PEM encoded CA certificate for TLS verification. + // +optional + CACertificate string `json:"caCertificate,omitempty"` + + // InsecureSkipVerify disable TLS certificate verification. + // +optional + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` +} + +type RepositoryAuth struct { + // Repository authentication username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + // Repository authentication password. + // +kubebuilder:validation:MinLength=1 + Password string `json:"password"` +} + +type RepositoryStatus struct { + // Conditions represent the latest available observations of the repository state. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + // Generation represents resource generation that was last processed by the controller. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // LastSuccessfulSyncTime is the last time the chart catalog was fully brought up to date, + // including creating and pruning chart resources. + // +optional + LastSuccessfulSyncTime *metav1.Time `json:"lastSuccessfulSyncTime,omitempty"` + // NextSyncTime is the scheduled time of the next synchronization attempt. + // +optional + NextSyncTime *metav1.Time `json:"nextSyncTime,omitempty"` + // LastForceReconcileTime is the time the most recent force reconcile request was + // processed. It records that the request was acted on, not that it succeeded: + // the outcome is reported by Ready and Synced. + // +optional + LastForceReconcileTime *metav1.Time `json:"lastForceReconcileTime,omitempty"` + // ConsecutiveFetchFailures counts consecutive failures to read from the repository. + // It drives the retry backoff and resets on the first success. + // +optional + ConsecutiveFetchFailures int32 `json:"consecutiveFetchFailures,omitempty"` + // ChartCount is the number of charts the repository offered when it was last read + // successfully. It is absent until the first successful read, so a repository that + // has never been read is distinguishable from one that offers no charts. + // +optional + ChartCount *int32 `json:"chartCount,omitempty"` +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index e02b8b17..4a24d4ad 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -28,7 +28,51 @@ import ( ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddon) DeepCopyInto(out *HelmClusterAddon) { +func (in *ChartCatalogStatus) DeepCopyInto(out *ChartCatalogStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Versions != nil { + in, out := &in.Versions, &out.Versions + *out = make([]ChartVersion, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChartCatalogStatus. +func (in *ChartCatalogStatus) DeepCopy() *ChartCatalogStatus { + if in == nil { + return nil + } + out := new(ChartCatalogStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ChartVersion) DeepCopyInto(out *ChartVersion) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChartVersion. +func (in *ChartVersion) DeepCopy() *ChartVersion { + if in == nil { + return nil + } + out := new(ChartVersion) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmApplication) DeepCopyInto(out *HelmApplication) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) @@ -37,18 +81,18 @@ func (in *HelmClusterAddon) DeepCopyInto(out *HelmClusterAddon) { return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddon. -func (in *HelmClusterAddon) DeepCopy() *HelmClusterAddon { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplication. +func (in *HelmApplication) DeepCopy() *HelmApplication { if in == nil { return nil } - out := new(HelmClusterAddon) + out := new(HelmApplication) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *HelmClusterAddon) DeepCopyObject() runtime.Object { +func (in *HelmApplication) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -56,7 +100,7 @@ func (in *HelmClusterAddon) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonChart) DeepCopyInto(out *HelmClusterAddonChart) { +func (in *HelmApplicationChart) DeepCopyInto(out *HelmApplicationChart) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) @@ -64,18 +108,18 @@ func (in *HelmClusterAddonChart) DeepCopyInto(out *HelmClusterAddonChart) { return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChart. -func (in *HelmClusterAddonChart) DeepCopy() *HelmClusterAddonChart { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplicationChart. +func (in *HelmApplicationChart) DeepCopy() *HelmApplicationChart { if in == nil { return nil } - out := new(HelmClusterAddonChart) + out := new(HelmApplicationChart) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *HelmClusterAddonChart) DeepCopyObject() runtime.Object { +func (in *HelmApplicationChart) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -83,13 +127,13 @@ func (in *HelmClusterAddonChart) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonChartList) DeepCopyInto(out *HelmClusterAddonChartList) { +func (in *HelmApplicationChartList) DeepCopyInto(out *HelmApplicationChartList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items - *out = make([]HelmClusterAddonChart, len(*in)) + *out = make([]HelmApplicationChart, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -97,18 +141,18 @@ func (in *HelmClusterAddonChartList) DeepCopyInto(out *HelmClusterAddonChartList return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChartList. -func (in *HelmClusterAddonChartList) DeepCopy() *HelmClusterAddonChartList { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplicationChartList. +func (in *HelmApplicationChartList) DeepCopy() *HelmApplicationChartList { if in == nil { return nil } - out := new(HelmClusterAddonChartList) + out := new(HelmApplicationChartList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *HelmClusterAddonChartList) DeepCopyObject() runtime.Object { +func (in *HelmApplicationChartList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -116,24 +160,166 @@ func (in *HelmClusterAddonChartList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonChartRef) DeepCopyInto(out *HelmClusterAddonChartRef) { +func (in *HelmApplicationChartRef) DeepCopyInto(out *HelmApplicationChartRef) { *out = *in return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChartRef. -func (in *HelmClusterAddonChartRef) DeepCopy() *HelmClusterAddonChartRef { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplicationChartRef. +func (in *HelmApplicationChartRef) DeepCopy() *HelmApplicationChartRef { if in == nil { return nil } - out := new(HelmClusterAddonChartRef) + out := new(HelmApplicationChartRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmApplicationLastAppliedChartRef) DeepCopyInto(out *HelmApplicationLastAppliedChartRef) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplicationLastAppliedChartRef. +func (in *HelmApplicationLastAppliedChartRef) DeepCopy() *HelmApplicationLastAppliedChartRef { + if in == nil { + return nil + } + out := new(HelmApplicationLastAppliedChartRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmApplicationList) DeepCopyInto(out *HelmApplicationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]HelmApplication, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplicationList. +func (in *HelmApplicationList) DeepCopy() *HelmApplicationList { + if in == nil { + return nil + } + out := new(HelmApplicationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmApplicationList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmApplicationRepository) DeepCopyInto(out *HelmApplicationRepository) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplicationRepository. +func (in *HelmApplicationRepository) DeepCopy() *HelmApplicationRepository { + if in == nil { + return nil + } + out := new(HelmApplicationRepository) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmApplicationRepository) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmApplicationRepositoryList) DeepCopyInto(out *HelmApplicationRepositoryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]HelmApplicationRepository, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplicationRepositoryList. +func (in *HelmApplicationRepositoryList) DeepCopy() *HelmApplicationRepositoryList { + if in == nil { + return nil + } + out := new(HelmApplicationRepositoryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmApplicationRepositoryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmApplicationSpec) DeepCopyInto(out *HelmApplicationSpec) { + *out = *in + out.Chart = in.Chart + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplicationSpec. +func (in *HelmApplicationSpec) DeepCopy() *HelmApplicationSpec { + if in == nil { + return nil + } + out := new(HelmApplicationSpec) 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) { +func (in *HelmApplicationStatus) DeepCopyInto(out *HelmApplicationStatus) { *out = *in + if in.LastAppliedChart != nil { + in, out := &in.LastAppliedChart, &out.LastAppliedChart + *out = new(HelmApplicationLastAppliedChartRef) + **out = **in + } + if in.LastAppliedValues != nil { + in, out := &in.LastAppliedValues, &out.LastAppliedValues + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) @@ -141,36 +327,123 @@ func (in *HelmClusterAddonChartStatus) DeepCopyInto(out *HelmClusterAddonChartSt (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.Versions != nil { - in, out := &in.Versions, &out.Versions - *out = make([]HelmClusterAddonChartVersion, len(*in)) - copy(*out, *in) + if in.LastForceReconcileTime != nil { + in, out := &in.LastForceReconcileTime, &out.LastForceReconcileTime + *out = (*in).DeepCopy() + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmApplicationStatus. +func (in *HelmApplicationStatus) DeepCopy() *HelmApplicationStatus { + if in == nil { + return nil + } + out := new(HelmApplicationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmClusterAddon) DeepCopyInto(out *HelmClusterAddon) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddon. +func (in *HelmClusterAddon) DeepCopy() *HelmClusterAddon { + if in == nil { + return nil + } + out := new(HelmClusterAddon) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmClusterAddon) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmClusterAddonChart) DeepCopyInto(out *HelmClusterAddonChart) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChart. +func (in *HelmClusterAddonChart) DeepCopy() *HelmClusterAddonChart { + if in == nil { + return nil + } + out := new(HelmClusterAddonChart) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmClusterAddonChart) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmClusterAddonChartList) DeepCopyInto(out *HelmClusterAddonChartList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]HelmClusterAddonChart, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChartStatus. -func (in *HelmClusterAddonChartStatus) DeepCopy() *HelmClusterAddonChartStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChartList. +func (in *HelmClusterAddonChartList) DeepCopy() *HelmClusterAddonChartList { if in == nil { return nil } - out := new(HelmClusterAddonChartStatus) + out := new(HelmClusterAddonChartList) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmClusterAddonChartList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonChartVersion) DeepCopyInto(out *HelmClusterAddonChartVersion) { +func (in *HelmClusterAddonChartRef) DeepCopyInto(out *HelmClusterAddonChartRef) { *out = *in return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChartVersion. -func (in *HelmClusterAddonChartVersion) DeepCopy() *HelmClusterAddonChartVersion { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChartRef. +func (in *HelmClusterAddonChartRef) DeepCopy() *HelmClusterAddonChartRef { if in == nil { return nil } - out := new(HelmClusterAddonChartVersion) + out := new(HelmClusterAddonChartRef) in.DeepCopyInto(out) return out } @@ -252,22 +525,6 @@ func (in *HelmClusterAddonRepository) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonRepositoryAuth) DeepCopyInto(out *HelmClusterAddonRepositoryAuth) { - *out = *in - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonRepositoryAuth. -func (in *HelmClusterAddonRepositoryAuth) DeepCopy() *HelmClusterAddonRepositoryAuth { - if in == nil { - return nil - } - out := new(HelmClusterAddonRepositoryAuth) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HelmClusterAddonRepositoryList) DeepCopyInto(out *HelmClusterAddonRepositoryList) { *out = *in @@ -302,29 +559,40 @@ func (in *HelmClusterAddonRepositoryList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonRepositorySpec) DeepCopyInto(out *HelmClusterAddonRepositorySpec) { +func (in *HelmClusterAddonSpec) DeepCopyInto(out *HelmClusterAddonSpec) { *out = *in - if in.Auth != nil { - in, out := &in.Auth, &out.Auth - *out = new(HelmClusterAddonRepositoryAuth) - **out = **in + out.Chart = in.Chart + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) } return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonRepositorySpec. -func (in *HelmClusterAddonRepositorySpec) DeepCopy() *HelmClusterAddonRepositorySpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonSpec. +func (in *HelmClusterAddonSpec) DeepCopy() *HelmClusterAddonSpec { if in == nil { return nil } - out := new(HelmClusterAddonRepositorySpec) + out := new(HelmClusterAddonSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonRepositoryStatus) DeepCopyInto(out *HelmClusterAddonRepositoryStatus) { +func (in *HelmClusterAddonStatus) DeepCopyInto(out *HelmClusterAddonStatus) { *out = *in + if in.LastAppliedChart != nil { + in, out := &in.LastAppliedChart, &out.LastAppliedChart + *out = new(HelmClusterAddonLastAppliedChartRef) + **out = **in + } + if in.LastAppliedValues != nil { + in, out := &in.LastAppliedValues, &out.LastAppliedValues + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) @@ -332,14 +600,6 @@ func (in *HelmClusterAddonRepositoryStatus) DeepCopyInto(out *HelmClusterAddonRe (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.LastSuccessfulSyncTime != nil { - in, out := &in.LastSuccessfulSyncTime, &out.LastSuccessfulSyncTime - *out = (*in).DeepCopy() - } - if in.NextSyncTime != nil { - in, out := &in.NextSyncTime, &out.NextSyncTime - *out = (*in).DeepCopy() - } if in.LastForceReconcileTime != nil { in, out := &in.LastForceReconcileTime, &out.LastForceReconcileTime *out = (*in).DeepCopy() @@ -347,51 +607,177 @@ func (in *HelmClusterAddonRepositoryStatus) DeepCopyInto(out *HelmClusterAddonRe return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonRepositoryStatus. -func (in *HelmClusterAddonRepositoryStatus) DeepCopy() *HelmClusterAddonRepositoryStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonStatus. +func (in *HelmClusterAddonStatus) DeepCopy() *HelmClusterAddonStatus { if in == nil { return nil } - out := new(HelmClusterAddonRepositoryStatus) + out := new(HelmClusterAddonStatus) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonSpec) DeepCopyInto(out *HelmClusterAddonSpec) { +func (in *HelmClusterApplicationChart) DeepCopyInto(out *HelmClusterApplicationChart) { *out = *in - out.Chart = in.Chart - if in.Values != nil { - in, out := &in.Values, &out.Values - *out = new(apiextensionsv1.JSON) - (*in).DeepCopyInto(*out) + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterApplicationChart. +func (in *HelmClusterApplicationChart) DeepCopy() *HelmClusterApplicationChart { + if in == nil { + return nil + } + out := new(HelmClusterApplicationChart) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmClusterApplicationChart) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmClusterApplicationChartList) DeepCopyInto(out *HelmClusterApplicationChartList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]HelmClusterApplicationChart, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonSpec. -func (in *HelmClusterAddonSpec) DeepCopy() *HelmClusterAddonSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterApplicationChartList. +func (in *HelmClusterApplicationChartList) DeepCopy() *HelmClusterApplicationChartList { if in == nil { return nil } - out := new(HelmClusterAddonSpec) + out := new(HelmClusterApplicationChartList) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmClusterApplicationChartList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonStatus) DeepCopyInto(out *HelmClusterAddonStatus) { +func (in *HelmClusterApplicationRepository) DeepCopyInto(out *HelmClusterApplicationRepository) { *out = *in - if in.LastAppliedChart != nil { - in, out := &in.LastAppliedChart, &out.LastAppliedChart - *out = new(HelmClusterAddonLastAppliedChartRef) + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterApplicationRepository. +func (in *HelmClusterApplicationRepository) DeepCopy() *HelmClusterApplicationRepository { + if in == nil { + return nil + } + out := new(HelmClusterApplicationRepository) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmClusterApplicationRepository) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmClusterApplicationRepositoryList) DeepCopyInto(out *HelmClusterApplicationRepositoryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]HelmClusterApplicationRepository, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterApplicationRepositoryList. +func (in *HelmClusterApplicationRepositoryList) DeepCopy() *HelmClusterApplicationRepositoryList { + if in == nil { + return nil + } + out := new(HelmClusterApplicationRepositoryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmClusterApplicationRepositoryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryAuth) DeepCopyInto(out *RepositoryAuth) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryAuth. +func (in *RepositoryAuth) DeepCopy() *RepositoryAuth { + if in == nil { + return nil + } + out := new(RepositoryAuth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositorySpec) DeepCopyInto(out *RepositorySpec) { + *out = *in + if in.Auth != nil { + in, out := &in.Auth, &out.Auth + *out = new(RepositoryAuth) **out = **in } - if in.LastAppliedValues != nil { - in, out := &in.LastAppliedValues, &out.LastAppliedValues - *out = new(apiextensionsv1.JSON) - (*in).DeepCopyInto(*out) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositorySpec. +func (in *RepositorySpec) DeepCopy() *RepositorySpec { + if in == nil { + return nil } + out := new(RepositorySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryStatus) DeepCopyInto(out *RepositoryStatus) { + *out = *in if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) @@ -399,19 +785,32 @@ func (in *HelmClusterAddonStatus) DeepCopyInto(out *HelmClusterAddonStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.LastSuccessfulSyncTime != nil { + in, out := &in.LastSuccessfulSyncTime, &out.LastSuccessfulSyncTime + *out = (*in).DeepCopy() + } + if in.NextSyncTime != nil { + in, out := &in.NextSyncTime, &out.NextSyncTime + *out = (*in).DeepCopy() + } if in.LastForceReconcileTime != nil { in, out := &in.LastForceReconcileTime, &out.LastForceReconcileTime *out = (*in).DeepCopy() } + if in.ChartCount != nil { + in, out := &in.ChartCount, &out.ChartCount + *out = new(int32) + **out = **in + } return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonStatus. -func (in *HelmClusterAddonStatus) DeepCopy() *HelmClusterAddonStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryStatus. +func (in *RepositoryStatus) DeepCopy() *RepositoryStatus { if in == nil { return nil } - out := new(HelmClusterAddonStatus) + out := new(RepositoryStatus) in.DeepCopyInto(out) return out } diff --git a/build/components/versions.yml b/build/components/versions.yml index 06ab7266..06cb73da 100644 --- a/build/components/versions.yml +++ b/build/components/versions.yml @@ -1,4 +1,4 @@ core: - 3p-helm-controller: v0.1.3 - nelm-source-controller: v0.1.4 + helm-controller: v1.6.4 + source-controller: v1.9.5 package: diff --git a/crds/doc-ru-helmapplicationcharts.yaml b/crds/doc-ru-helmapplicationcharts.yaml new file mode 100644 index 00000000..1af34170 --- /dev/null +++ b/crds/doc-ru-helmapplicationcharts.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: helmapplicationcharts.helm.deckhouse.io +spec: + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmApplicationChart представляет собой Helm-чарт, обнаруженный в HelmApplicationRepository. Эти ресурсы создаются автоматически во время синхронизации репозитория и защищены от изменений. + properties: + status: + properties: + iconURL: + description: URL-адрес иконки Helm-чарта. Применимо только для чартов из Helm-репозиториев. + conditions: + description: Условия отражают последние наблюдения за состоянием чарта. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. + versions: + description: Список всех версий Helm-чарта, изученных контроллером. Версия пригодна к использованию, если у неё нет unavailableReason; для OCI-репозитория у пригодной версии также заполнен media type слоя, который её содержит. + items: + properties: + version: + description: Версия Helm-чарта. + ociRef: + description: "OCI-ссылка, по которой опубликована эта версия, записанная из индекса репозитория. Заполняется только для версии helm-репозитория, чья запись в индексе указывает на OCI-репозиторий вместо архива чарта: такая версия раскатывается через внутренний OCIRepository, хотя её репозиторий — helm." + mediaType: + description: "OCI media type слоя, содержащего эту версию чарта. Заполняется только для версии oci://-репозитория и только когда слой поддерживается: пустое значение там означает, что версию нельзя задеплоить." + unavailableReason: + description: Причина, по которой версию нельзя задеплоить. Отсутствие поля означает, что версия пригодна. + unavailableMessage: + description: Человекочитаемые подробности к unavailableReason. diff --git a/crds/doc-ru-helmapplicationrepositories.yaml b/crds/doc-ru-helmapplicationrepositories.yaml new file mode 100644 index 00000000..2c5c5c51 --- /dev/null +++ b/crds/doc-ru-helmapplicationrepositories.yaml @@ -0,0 +1,48 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: helmapplicationrepositories.helm.deckhouse.io +spec: + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmApplicationRepository представляет собой Helm- или OCI-совместимый репозиторий, содержащий Helm-чарты, на которые могут ссылаться ресурсы HelmApplication из того же пространства имён. + properties: + spec: + properties: + auth: + description: Учётные данные для аутентификации в репозитории. + properties: + password: + description: Пароль для аутентификации в репозитории. + username: + description: Имя пользователя для аутентификации в репозитории. + caCertificate: + description: CA-сертификат в формате PEM для проверки TLS. + insecureSkipVerify: + description: Выключить проверку TLS-сертификата. + url: + description: | + URL Helm-репозитория. + + Поддерживаются протоколы `http(s)://` и `oci://`. + status: + properties: + conditions: + description: Условия отражают последние наблюдения за состоянием репозитория. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. + lastSuccessfulSyncTime: + description: Время последнего успешного приведения каталога чартов в актуальное состояние. + nextSyncTime: + description: Запланированное время следующей попытки синхронизации. + lastForceReconcileTime: + description: | + Время обработки последнего запроса принудительной реконсиляции. Фиксирует, что запрос был обработан, а не что он завершился успешно — результат отражают `Ready` и `Synced`. + consecutiveFetchFailures: + description: Число подряд идущих неудачных обращений к репозиторию. Определяет задержку повтора и обнуляется при первом успехе. + chartCount: + description: | + Число чартов, которые репозиторий предлагал при последнем успешном чтении. Отсутствует, пока успешного чтения не было, поэтому репозиторий, который ещё не читали, отличим от репозитория без чартов. diff --git a/crds/doc-ru-helmapplications.yaml b/crds/doc-ru-helmapplications.yaml new file mode 100644 index 00000000..308b5986 --- /dev/null +++ b/crds/doc-ru-helmapplications.yaml @@ -0,0 +1,57 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: helmapplications.helm.deckhouse.io +spec: + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmApplication представляет собой установку Helm-чарта в пределах одного пространства имён. Релиз развёртывается в том же пространстве имён, где создан ресурс. Чарт применяется от имени ServiceAccount, связанного с Role, дающей все права внутри этого пространства имён, поэтому право создавать HelmApplication эквивалентно правам администратора пространства имён; Role и RoleBinding принадлежат модулю и реконсилируются, поэтому правка любого из них не переживает приложение, которому он нужен. + properties: + spec: + properties: + chart: + properties: + name: + description: | + Имя Helm-чарта для установки из указанного репозитория (например, «nginx» или «redis»). + repository: + description: | + Имя ресурса HelmApplicationRepository в том же пространстве имён, содержащего параметры подключения и учётные данные для доступа к репозиторию, в котором расположен чарт. + clusterRepository: + description: | + Имя кластерного ресурса HelmClusterApplicationRepository, содержащего параметры подключения и учётные данные для доступа к репозиторию, в котором расположен чарт. + version: + description: Версия Helm-чарта для HelmApplication. + maintenance: + description: | + Стратегия согласования ресурса. + + При значении `NoResourceReconciliation` контроллер прекращает обновление управляемых ресурсов, что позволяет выполнять ручное вмешательство или обслуживание без перезаписи изменений оператором. + При пустом значении (`""`) используется стандартное согласование. + values: + description: Пользовательские значения для релиза HelmApplication. + status: + properties: + conditions: + description: Условия отражают последние наблюдения за состоянием ресурса. + lastAppliedChart: + description: Последний применённый чарт, инициировавший установку или обновление приложения. + properties: + name: + description: Имя Helm-чарта, из которого последний раз развёртывался релиз. + repository: + description: Имя ресурса HelmApplicationRepository, из которого последний раз был взят чарт. + clusterRepository: + description: Имя ресурса HelmClusterApplicationRepository, из которого последний раз был взят чарт. + version: + description: Версия Helm-чарта, из которой последний раз развёртывался релиз. + lastAppliedValues: + description: Последние применённые значения, инициировавшие установку или обновление приложения. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. + lastForceReconcileTime: + description: | + Время обработки последнего запроса принудительной реконсиляции. Фиксирует, что запрос был обработан, а не что он завершился успешно — результат отражает `Ready`. diff --git a/crds/doc-ru-helmclusteraddoncharts.yaml b/crds/doc-ru-helmclusteraddoncharts.yaml index 34da4420..ab763f74 100644 --- a/crds/doc-ru-helmclusteraddoncharts.yaml +++ b/crds/doc-ru-helmclusteraddoncharts.yaml @@ -15,7 +15,7 @@ spec: iconURL: description: URL-адрес иконки Helm-чарта. Применимо только для чартов из Helm-репозиториев. conditions: - description: Условия отражают последние наблюдения за состоянием репозитория. + description: Условия отражают последние наблюдения за состоянием чарта. observedGeneration: description: Поколение ресурса, обработанное контроллером последним. versions: @@ -25,9 +25,9 @@ spec: version: description: Версия Helm-чарта. ociRef: - description: "OCI-ссылка, по которой опубликована эта версия, записанная из индекса репозитория. Заполняется только для версии helm-репозитория, чья запись в индексе указывает на OCI-репозиторий вместо архива чарта: такая версия раскатывается через внутренний OCIRepository, хотя её репозиторий — helm. Хост и путь сохраняются в написании индекса, тег всегда указан явно." + description: "OCI-ссылка, по которой опубликована эта версия, записанная из индекса репозитория. Заполняется только для версии helm-репозитория, чья запись в индексе указывает на OCI-репозиторий вместо архива чарта: такая версия раскатывается через внутренний OCIRepository, хотя её репозиторий — helm." mediaType: - description: "OCI media type слоя, содержащего эту версию чарта. Заполняется только для версии oci://-репозитория и только когда слой поддерживается: пустое значение там означает, что версию нельзя задеплоить. Для версии с ociRef остаётся пустым — слой такого артефакта проверяется в момент раскатки и здесь не записывается." + description: "OCI media type слоя, содержащего эту версию чарта. Заполняется только для версии oci://-репозитория и только когда слой поддерживается: пустое значение там означает, что версию нельзя задеплоить." unavailableReason: description: Причина, по которой версию нельзя задеплоить. Отсутствие поля означает, что версия пригодна. unavailableMessage: diff --git a/crds/doc-ru-helmclusteraddonrepositories.yaml b/crds/doc-ru-helmclusteraddonrepositories.yaml index 5030c23d..d0073763 100644 --- a/crds/doc-ru-helmclusteraddonrepositories.yaml +++ b/crds/doc-ru-helmclusteraddonrepositories.yaml @@ -31,14 +31,7 @@ spec: status: properties: conditions: - description: | - Условия отражают последние наблюдения за состоянием репозитория. - - `Ready` сообщает, пригоден ли репозиторий: вспомогательные ресурсы на месте, внутренний объект источника исправен, и репозиторий ответил на чтение каталога на текущей спецификации. Транзиентная ошибка чтения не переводит `Ready` в `False`. - - `Synced` сообщает, актуален ли каталог чартов. - - `Reconciling` и `Stalled` следуют соглашению kstatus: они присутствуют, только когда применимы. `Reconciling` означает, что работа выполняется или запланирован повтор; `Stalled` — что репозиторий не восстановится без вмешательства. Пока выполняется синхронизация, `Reconciling` имеет причину `Synchronization`, либо `ForceReconcile`, если проход был запрошен аннотацией принудительной реконсиляции. + description: Условия отражают последние наблюдения за состоянием репозитория. observedGeneration: description: Поколение ресурса, обработанное контроллером последним. lastSuccessfulSyncTime: @@ -50,3 +43,6 @@ spec: Время обработки последнего запроса принудительной реконсиляции. Фиксирует, что запрос был обработан, а не что он завершился успешно — результат отражают `Ready` и `Synced`. consecutiveFetchFailures: description: Число подряд идущих неудачных обращений к репозиторию. Определяет задержку повтора и обнуляется при первом успехе. + chartCount: + description: | + Число чартов, которые репозиторий предлагал при последнем успешном чтении. Отсутствует, пока успешного чтения не было, поэтому репозиторий, который ещё не читали, отличим от репозитория без чартов. diff --git a/crds/doc-ru-helmclusteraddons.yaml b/crds/doc-ru-helmclusteraddons.yaml index cb8c16ad..e9ef696e 100644 --- a/crds/doc-ru-helmclusteraddons.yaml +++ b/crds/doc-ru-helmclusteraddons.yaml @@ -35,10 +35,7 @@ spec: status: properties: conditions: - description: | - Условия отражают последние наблюдения за состоянием ресурса. - - `Reconciling` присутствует, только когда применимо, следуя соглашению kstatus. Пока выполняется реконсиляция, запрошенная аннотацией принудительной реконсиляции, это условие имеет причину `ForceReconcile`. + description: Условия отражают последние наблюдения за состоянием ресурса. lastAppliedChart: description: Последний применённый чарт, инициировавший установку или обновление аддона. properties: diff --git a/crds/doc-ru-helmclusterapplicationcharts.yaml b/crds/doc-ru-helmclusterapplicationcharts.yaml new file mode 100644 index 00000000..4e616efd --- /dev/null +++ b/crds/doc-ru-helmclusterapplicationcharts.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: helmclusterapplicationcharts.helm.deckhouse.io +spec: + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmClusterApplicationChart представляет собой Helm-чарт, обнаруженный в HelmClusterApplicationRepository. Эти ресурсы создаются автоматически во время синхронизации репозитория и защищены от изменений. + properties: + status: + properties: + iconURL: + description: URL-адрес иконки Helm-чарта. Применимо только для чартов из Helm-репозиториев. + conditions: + description: Условия отражают последние наблюдения за состоянием чарта. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. + versions: + description: Список всех версий Helm-чарта, изученных контроллером. Версия пригодна к использованию, если у неё нет unavailableReason; для OCI-репозитория у пригодной версии также заполнен media type слоя, который её содержит. + items: + properties: + version: + description: Версия Helm-чарта. + ociRef: + description: "OCI-ссылка, по которой опубликована эта версия, записанная из индекса репозитория. Заполняется только для версии helm-репозитория, чья запись в индексе указывает на OCI-репозиторий вместо архива чарта: такая версия раскатывается через внутренний OCIRepository, хотя её репозиторий — helm." + mediaType: + description: "OCI media type слоя, содержащего эту версию чарта. Заполняется только для версии oci://-репозитория и только когда слой поддерживается: пустое значение там означает, что версию нельзя задеплоить." + unavailableReason: + description: Причина, по которой версию нельзя задеплоить. Отсутствие поля означает, что версия пригодна. + unavailableMessage: + description: Человекочитаемые подробности к unavailableReason. diff --git a/crds/doc-ru-helmclusterapplicationrepositories.yaml b/crds/doc-ru-helmclusterapplicationrepositories.yaml new file mode 100644 index 00000000..3fbaf1b5 --- /dev/null +++ b/crds/doc-ru-helmclusterapplicationrepositories.yaml @@ -0,0 +1,48 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: helmclusterapplicationrepositories.helm.deckhouse.io +spec: + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmClusterApplicationRepository представляет собой кластерный Helm- или OCI-совместимый репозиторий, содержащий Helm-чарты, на которые могут ссылаться ресурсы HelmApplication из любого пространства имён. + properties: + spec: + properties: + auth: + description: Учётные данные для аутентификации в репозитории. + properties: + password: + description: Пароль для аутентификации в репозитории. + username: + description: Имя пользователя для аутентификации в репозитории. + caCertificate: + description: CA-сертификат в формате PEM для проверки TLS. + insecureSkipVerify: + description: Выключить проверку TLS-сертификата. + url: + description: | + URL Helm-репозитория. + + Поддерживаются протоколы `http(s)://` и `oci://`. + status: + properties: + conditions: + description: Условия отражают последние наблюдения за состоянием репозитория. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. + lastSuccessfulSyncTime: + description: Время последнего успешного приведения каталога чартов в актуальное состояние. + nextSyncTime: + description: Запланированное время следующей попытки синхронизации. + lastForceReconcileTime: + description: | + Время обработки последнего запроса принудительной реконсиляции. Фиксирует, что запрос был обработан, а не что он завершился успешно — результат отражают `Ready` и `Synced`. + consecutiveFetchFailures: + description: Число подряд идущих неудачных обращений к репозиторию. Определяет задержку повтора и обнуляется при первом успехе. + chartCount: + description: | + Число чартов, которые репозиторий предлагал при последнем успешном чтении. Отсутствует, пока успешного чтения не было, поэтому репозиторий, который ещё не читали, отличим от репозитория без чартов. diff --git a/crds/embedded/helm-controller.yaml b/crds/embedded/helm-controller.yaml index 20bd22c6..eb0e03ec 100644 --- a/crds/embedded/helm-controller.yaml +++ b/crds/embedded/helm-controller.yaml @@ -1,8 +1,9 @@ +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.19.0 + controller-gen.kubebuilder.io/version: v0.21.0 labels: backup.deckhouse.io/cluster-config: "true" heritage: deckhouse @@ -30,7 +31,7 @@ spec: name: v2 schema: openAPIV3Schema: - description: HelmRelease is the Schema for the helmreleases API + description: InternalNelmOperatorHelmRelease is the Schema for the helmreleases API properties: apiVersion: description: |- @@ -54,13 +55,11 @@ spec: properties: chart: description: |- - Chart defines the template of the v1.HelmChart that should be created - for this HelmRelease. + Chart defines the template of the v1.InternalNelmOperatorHelmChart that should be created + for this InternalNelmOperatorHelmRelease. properties: metadata: - description: - ObjectMeta holds the template for metadata like labels - and annotations. + description: ObjectMeta holds the template for metadata like labels and annotations. properties: annotations: additionalProperties: @@ -81,21 +80,15 @@ spec: type: object type: object spec: - description: - Spec holds the template for the v1.HelmChartSpec - for this HelmRelease. + description: Spec holds the template for the v1.HelmChartSpec for this InternalNelmOperatorHelmRelease. properties: chart: - description: - The name or path the Helm chart is available - at in the SourceRef. + 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. + description: IgnoreMissingValuesFiles controls whether to silently ignore missing values files rather than failing. type: boolean interval: description: |- @@ -115,9 +108,7 @@ spec: - Revision type: string sourceRef: - description: - The name and namespace of the v1.Source the chart - is available at. + description: The name and namespace of the v1.Source the chart is available at. properties: apiVersion: description: APIVersion of the referent. @@ -163,9 +154,7 @@ spec: properties: provider: default: cosign - description: - Provider specifies the technology used to - sign the OCI Helm chart. + description: Provider specifies the technology used to sign the OCI Helm chart. enum: - cosign - notation @@ -185,10 +174,10 @@ spec: - provider type: object version: - default: "*" + default: '*' description: |- - Version semver expression, ignored for charts from v1.GitRepository and - v1beta2.Bucket sources. Defaults to latest when omitted. + Version semver expression, ignored for charts from v1.InternalNelmOperatorGitRepository and + v1beta2.InternalNelmOperatorBucket sources. Defaults to latest when omitted. type: string required: - chart @@ -248,20 +237,20 @@ spec: dependsOn: description: |- DependsOn may contain a DependencyReference slice with - references to HelmRelease resources that must be ready before this HelmRelease + references to InternalNelmOperatorHelmRelease resources that must be ready before this InternalNelmOperatorHelmRelease can be reconciled. items: - description: - DependencyReference defines a HelmRelease dependency - on another HelmRelease resource. + description: |- + DependencyReference contains enough information to locate the referenced Kubernetes resource object + and optional CEL expression to assess its readiness. properties: name: description: Name of the referent. type: string namespace: description: |- - Namespace of the referent, defaults to the namespace of the HelmRelease - resource object that contains the reference. + Namespace of the referent, defaults to the namespace of the resource + object that contains the reference. type: string readyExpr: description: |- @@ -357,10 +346,44 @@ spec: - disabled type: string type: object + healthCheckExprs: + description: |- + HealthCheckExprs is a list of healthcheck expressions for evaluating the + health of custom resources using Common Expression Language (CEL). + The expressions are evaluated only when the specific Helm action + taking place has wait enabled, i.e. DisableWait is false, and the + 'poller' WaitStrategy is used. + items: + description: CustomHealthCheck defines the health check for custom resources. + properties: + apiVersion: + description: APIVersion of the custom resource under evaluation. + type: string + current: + description: |- + Current is the CEL expression that determines if the status + of the custom resource has reached the desired state. + type: string + failed: + description: |- + Failed is the CEL expression that determines if the status + of the custom resource has failed to reach the desired state. + type: string + inProgress: + description: |- + InProgress is the CEL expression that determines if the status + of the custom resource has not yet reached the desired state. + type: string + kind: + description: Kind of the custom resource under evaluation. + type: string + required: + - apiVersion + - current + type: object + type: array install: - description: - Install holds the configuration for Helm install actions - for this HelmRelease. + description: Install holds the configuration for Helm install actions for this InternalNelmOperatorHelmRelease. properties: crds: description: |- @@ -392,9 +415,7 @@ spec: On uninstall, the namespace will not be garbage collected. type: boolean disableHooks: - description: - DisableHooks prevents hooks from running during the - Helm install action. + description: DisableHooks prevents hooks from running during the Helm install action. type: boolean disableOpenAPIValidation: description: |- @@ -424,7 +445,7 @@ spec: 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. + action for the InternalNelmOperatorHelmRelease fails. The default is to not perform any action. properties: ignoreTestFailures: description: |- @@ -449,6 +470,11 @@ spec: 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 + serverSideApply: + description: |- + ServerSideApply enables server-side apply for resources during install. + Defaults to true (or false when UseHelm3Defaults feature gate is enabled). + type: boolean skipCRDs: description: |- SkipCRDs tells the Helm install action to not install any CRDs. By default, @@ -458,8 +484,9 @@ spec: type: boolean strategy: description: |- - Strategy defines the install strategy to use for this HelmRelease. - Defaults to 'RemediateOnFailure'. + Strategy defines the install strategy to use for this InternalNelmOperatorHelmRelease. + Defaults to 'RemediateOnFailure', or 'RetryOnFailure' when the + DefaultToRetryOnFailure feature gate is enabled. properties: name: description: Name of the install strategy. @@ -479,7 +506,7 @@ spec: type: object x-kubernetes-validations: - message: .retryInterval cannot be set when .name is 'RemediateOnFailure' - rule: "!has(self.retryInterval) || self.name != 'RemediateOnFailure'" + rule: '!has(self.retryInterval) || self.name != ''RemediateOnFailure''' timeout: description: |- Timeout is the time to wait for any individual Kubernetes operation (like @@ -494,7 +521,7 @@ spec: type: string kubeConfig: description: |- - KubeConfig for reconciling the HelmRelease on a remote cluster. + KubeConfig for reconciling the InternalNelmOperatorHelmRelease 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. @@ -552,9 +579,7 @@ spec: 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. + description: Key in the Secret, when not specified an implementation-specific default key is used. type: string name: description: Name of the Secret. @@ -564,17 +589,13 @@ spec: type: object type: object x-kubernetes-validations: - - message: - exactly one of spec.kubeConfig.configMapRef or spec.kubeConfig.secretRef - must be specified + - 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)" + - 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. + MaxHistory is the number of revisions saved by Helm for this InternalNelmOperatorHelmRelease. Use '0' for an unlimited number of revisions; defaults to '5'. type: integer persistentClient: @@ -591,6 +612,18 @@ spec: If not set, it defaults to true. type: boolean + postRenderStrategy: + description: |- + PostRenderStrategy defines the strategy for sending hooks to post-renderers. + Valid values are 'nohooks' (hooks not sent to post-renderers, Helm 3 behavior), + 'combined' (hooks and templates sent together, Helm 4 default), and 'separate' + (hooks and templates sent in separate streams, Helm 4.2 opt-in). + Defaults to 'combined', or 'nohooks' when the UseHelm3Defaults feature gate is enabled. + enum: + - nohooks + - combined + - separate + type: string postRenderers: description: |- PostRenderers holds an array of Helm PostRenderers, which will be applied in order @@ -607,10 +640,7 @@ spec: 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. + 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: |- @@ -621,14 +651,10 @@ spec: description: Name is a tag-less image name. type: string newName: - description: - NewName is the value used to replace - the original name. + 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. + description: NewTag is the value used to replace the original tag. type: string required: - name @@ -649,9 +675,7 @@ spec: an array of operation objects. type: string target: - description: - Target points to the resources that the - patch document should be applied to. + description: Target points to the resources that the patch document should be applied to. properties: annotationSelector: description: |- @@ -706,9 +730,7 @@ spec: minLength: 1 type: string rollback: - description: - Rollback holds the configuration for Helm rollback actions - for this HelmRelease. + description: Rollback holds the configuration for Helm rollback actions for this InternalNelmOperatorHelmRelease. properties: cleanupOnFail: description: |- @@ -716,9 +738,7 @@ spec: rollback action when it fails. type: boolean disableHooks: - description: - DisableHooks prevents hooks from running during the - Helm rollback action. + description: DisableHooks prevents hooks from running during the Helm rollback action. type: boolean disableWait: description: |- @@ -731,15 +751,35 @@ spec: rollback has been performed. type: boolean force: - description: - Force forces resource updates through a replacement - strategy. + description: |- + Force forces resource updates through a replacement strategy + that avoids 3-way merge conflicts on client-side apply. + This field is ignored for server-side apply (which always + forces conflicts with other field managers). type: boolean recreate: - description: - Recreate performs pod restarts for the resource if - applicable. + description: |- + Recreate performs pod restarts for any managed workloads. + + Deprecated: This behavior was deprecated in Helm 3: + - Deprecation: https://github.com/helm/helm/pull/6463 + - Removal: https://github.com/helm/helm/pull/31023 + After helm-controller was upgraded to the Helm 4 SDK, + this field is no longer functional and will print a + warning if set to true. It will also be removed in a + future release. type: boolean + serverSideApply: + description: |- + ServerSideApply enables server-side apply for resources during rollback. + Can be "enabled", "disabled", or "auto". + When "auto", server-side apply usage will be based on the release's previous usage. + Defaults to "auto". + enum: + - enabled + - disabled + - auto + type: string timeout: description: |- Timeout is the time to wait for any individual Kubernetes operation (like @@ -751,52 +791,44 @@ spec: serviceAccountName: description: |- The name of the Kubernetes service account to impersonate - when reconciling this HelmRelease. + when reconciling this InternalNelmOperatorHelmRelease. maxLength: 253 minLength: 1 type: string storageNamespace: description: |- StorageNamespace used for the Helm storage. - Defaults to the namespace of the HelmRelease. + Defaults to the namespace of the InternalNelmOperatorHelmRelease. maxLength: 63 minLength: 1 type: string suspend: description: |- - Suspend tells the controller to suspend reconciliation for this HelmRelease, + Suspend tells the controller to suspend reconciliation for this InternalNelmOperatorHelmRelease, 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. + TargetNamespace to target when performing operations for the InternalNelmOperatorHelmRelease. + Defaults to the namespace of the InternalNelmOperatorHelmRelease. maxLength: 63 minLength: 1 type: string test: - description: - Test holds the configuration for Helm test actions for - this HelmRelease. + description: Test holds the configuration for Helm test actions for this InternalNelmOperatorHelmRelease. properties: enable: description: |- - Enable enables Helm test actions for this HelmRelease after an Helm install + Enable enables Helm test actions for this InternalNelmOperatorHelmRelease 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. + description: Filters is a list of tests to run or exclude from running. items: - description: - Filter holds the configuration for individual Helm - test filters. + description: Filter holds the configuration for individual Helm test filters. properties: exclude: - description: - Exclude specifies whether the named test should - be excluded. + description: Exclude specifies whether the named test should be excluded. type: boolean name: description: Name is the name of the test. @@ -827,9 +859,7 @@ spec: pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ type: string uninstall: - description: - Uninstall holds the configuration for Helm uninstall - actions for this HelmRelease. + description: Uninstall holds the configuration for Helm uninstall actions for this InternalNelmOperatorHelmRelease. properties: deletionPropagation: default: background @@ -842,9 +872,7 @@ spec: - orphan type: string disableHooks: - description: - DisableHooks prevents hooks from running during the - Helm rollback action. + description: DisableHooks prevents hooks from running during the Helm rollback action. type: boolean disableWait: description: |- @@ -865,10 +893,20 @@ spec: type: string type: object upgrade: - description: - Upgrade holds the configuration for Helm upgrade actions - for this HelmRelease. + description: Upgrade holds the configuration for Helm upgrade actions for this InternalNelmOperatorHelmRelease. properties: + chartNameChangeStrategy: + description: |- + ChartNameChangeStrategy defines the strategy to use when a Helm chart name changes. + Valid values are 'Reinstall' or 'InPlaceUpdate'. Defaults to 'Reinstall' if omitted. + + Reinstall: Reinstall the Helm release, uninstalling the existing Helm release. + + InPlaceUpdate: Update the Helm release in place. + enum: + - InPlaceUpdate + - Reinstall + type: string cleanupOnFail: description: |- CleanupOnFail allows deletion of new resources created during the Helm @@ -897,9 +935,7 @@ spec: - CreateReplace type: string disableHooks: - description: - DisableHooks prevents hooks from running during the - Helm upgrade action. + description: DisableHooks prevents hooks from running during the Helm upgrade action. type: boolean disableOpenAPIValidation: description: |- @@ -927,20 +963,22 @@ spec: upgrade has been performed. type: boolean force: - description: - Force forces resource updates through a replacement - strategy. + description: |- + Force forces resource updates through a replacement strategy + that avoids 3-way merge conflicts on client-side apply. + This field is ignored for server-side apply (which always + forces conflicts with other field managers). 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 + overrides from 'Values'. Setting this flag makes the InternalNelmOperatorHelmRelease 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. + action for the InternalNelmOperatorHelmRelease fails. The default is to not perform any action. properties: ignoreTestFailures: description: |- @@ -960,18 +998,28 @@ spec: Defaults to '0', a negative integer equals to unlimited retries. type: integer strategy: - description: - Strategy to use for failure remediation. Defaults - to 'rollback'. + description: Strategy to use for failure remediation. Defaults to 'rollback'. enum: - rollback - uninstall type: string type: object + serverSideApply: + description: |- + ServerSideApply enables server-side apply for resources during upgrade. + Can be "enabled", "disabled", or "auto". + When "auto", server-side apply usage will be based on the release's previous usage. + Defaults to "auto". + enum: + - enabled + - disabled + - auto + type: string strategy: description: |- - Strategy defines the upgrade strategy to use for this HelmRelease. - Defaults to 'RemediateOnFailure'. + Strategy defines the upgrade strategy to use for this InternalNelmOperatorHelmRelease. + Defaults to 'RemediateOnFailure', or 'RetryOnFailure' when the + DefaultToRetryOnFailure feature gate is enabled. properties: name: description: Name of the upgrade strategy. @@ -991,7 +1039,7 @@ spec: type: object x-kubernetes-validations: - message: .retryInterval can only be set when .name is 'RetryOnFailure' - rule: "!has(self.retryInterval) || self.name == 'RetryOnFailure'" + rule: '!has(self.retryInterval) || self.name == ''RetryOnFailure''' timeout: description: |- Timeout is the time to wait for any individual Kubernetes operation (like @@ -1005,7 +1053,7 @@ spec: x-kubernetes-preserve-unknown-fields: true valuesFrom: description: |- - ValuesFrom holds references to resources containing Helm values for this HelmRelease, + ValuesFrom holds references to resources containing Helm values for this InternalNelmOperatorHelmRelease, and information about how they should be merged. items: description: |- @@ -1013,13 +1061,22 @@ spec: and optionally the key they can be found at. properties: kind: - description: - Kind of the values referent, valid values are ('Secret', - 'ConfigMap'). + description: Kind of the values referent, valid values are ('Secret', 'ConfigMap'). enum: - Secret - ConfigMap type: string + literal: + description: |- + Literal marks this ValuesReference as a literal value. When set in + combination with TargetPath, the referenced value is merged at the target + path without interpreting Helm's `--set` syntax (commas, brackets, dots, + equal signs, etc.), mirroring the behavior of `helm --set-literal`. This + is the only safe way to inject arbitrary file content (config files, JSON + blobs, multi-line strings containing special characters) through + `valuesFrom`. Has no effect when TargetPath is empty: in that mode the + referenced value is always YAML-merged at the root. + type: boolean name: description: |- Name of the values referent. Should reside in the same namespace as the @@ -1053,25 +1110,41 @@ spec: - name type: object type: array + waitStrategy: + description: |- + WaitStrategy defines Helm's wait strategy for waiting for applied + resources to become ready. + properties: + name: + description: |- + Name is Helm's wait strategy for waiting for applied resources to + become ready. One of 'poller' or 'legacy'. The 'poller' strategy uses + kstatus to poll resource statuses, while the 'legacy' strategy uses + Helm v3's waiting logic. + Defaults to 'poller', or to 'legacy' when UseHelm3Defaults feature + gate is enabled. + enum: + - poller + - legacy + type: string + required: + - name + type: object 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)) + 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. + description: HelmReleaseStatus defines the observed state of a InternalNelmOperatorHelmRelease. properties: conditions: - description: Conditions holds the conditions for the HelmRelease. + description: Conditions holds the conditions for the InternalNelmOperatorHelmRelease. items: - description: - Condition contains details for one aspect of the current - state of this API Resource. + description: Condition contains details for one aspect of the current state of this API Resource. properties: lastTransitionTime: description: |- @@ -1132,33 +1205,32 @@ spec: type: integer helmChart: description: |- - HelmChart is the namespaced name of the HelmChart resource created by - the controller for the HelmRelease. + InternalNelmOperatorHelmChart is the namespaced name of the InternalNelmOperatorHelmChart resource created by + the controller for the InternalNelmOperatorHelmRelease. type: string history: description: |- - History holds the history of Helm releases performed for this HelmRelease + History holds the history of Helm releases performed for this InternalNelmOperatorHelmRelease 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: + action: + description: Action is the action that resulted in this snapshot being created. + type: string 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. + 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. + 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. + description: ChartName is the chart name of the release object in storage. type: string chartVersion: description: |- @@ -1192,14 +1264,10 @@ spec: description: Name is the name of the release. type: string namespace: - description: - Namespace is the namespace the release is deployed - to. + 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. + 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. @@ -1211,15 +1279,11 @@ spec: to be run by the controller. properties: lastCompleted: - description: - LastCompleted is the time the test hook last - completed. + 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. + description: LastStarted is the time the test hook was last started. format: date-time type: string phase: @@ -1231,9 +1295,7 @@ spec: run by the controller. type: object version: - description: - Version is the version of the release object in - storage. + description: Version is the version of the release object in storage. type: integer required: - chartName @@ -1254,6 +1316,32 @@ spec: state. It is reset after a successful reconciliation. format: int64 type: integer + inventory: + description: |- + Inventory contains the list of Kubernetes resource object references + that have been applied for this release. + properties: + entries: + description: Entries of Kubernetes resource object references. + items: + description: ResourceRef contains the information necessary to locate a resource within a cluster. + properties: + id: + description: |- + ID is the string representation of the Kubernetes resource object's metadata, + in the format '___'. + type: string + v: + description: Version is the API version of the Kubernetes resource object's kind. + type: string + required: + - id + - v + type: object + type: array + required: + - entries + type: object lastAttemptedConfigDigest: description: |- LastAttemptedConfigDigest is the digest for the config (better known as @@ -1268,7 +1356,7 @@ spec: lastAttemptedReleaseAction: description: |- LastAttemptedReleaseAction is the last release action performed for this - HelmRelease. It is used to determine the active retry or remediation + InternalNelmOperatorHelmRelease. It is used to determine the active retry or remediation strategy. enum: - install @@ -1277,18 +1365,18 @@ spec: lastAttemptedReleaseActionDuration: description: |- LastAttemptedReleaseActionDuration is the duration of the last - release action performed for this HelmRelease. + release action performed for this InternalNelmOperatorHelmRelease. 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 + attempt. For InternalNelmOperatorOCIRepository 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. + This is only set for InternalNelmOperatorOCIRepository sources. type: string lastAttemptedValuesChecksum: description: |- @@ -1353,1360 +1441,3 @@ spec: 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 deleted file mode 100644 index 08f3e509..00000000 --- a/crds/embedded/nelm-source-controller.yaml +++ /dev/null @@ -1,4165 +0,0 @@ -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 - 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 - 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 - 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 - 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/embedded/source-controller.yaml b/crds/embedded/source-controller.yaml new file mode 100644 index 00000000..2d5f8a6f --- /dev/null +++ b/crds/embedded/source-controller.yaml @@ -0,0 +1,2134 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.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: InternalNelmOperatorBucket 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 InternalNelmOperatorBucket 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 InternalNelmOperatorBucket. + 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 InternalNelmOperatorBucket 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 InternalNelmOperatorBucket. + 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 + InternalNelmOperatorBucket 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 + InternalNelmOperatorBucket. + 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' InternalNelmOperatorBucket providers + rule: self.provider == 'aws' || self.provider == 'generic' || !has(self.sts) + - message: '''aws'' is the only supported STS provider for the ''aws'' InternalNelmOperatorBucket provider' + rule: self.provider != 'aws' || !has(self.sts) || self.sts.provider == 'aws' + - message: '''ldap'' is the only supported STS provider for the ''generic'' InternalNelmOperatorBucket 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' InternalNelmOperatorBucket 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 InternalNelmOperatorBucket. + properties: + artifact: + description: Artifact represents the last successful InternalNelmOperatorBucket 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 InternalNelmOperatorBucket. + 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 InternalNelmOperatorBucket 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: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.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: InternalNelmOperatorExternalArtifact 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 InternalNelmOperatorExternalArtifact + 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 InternalNelmOperatorExternalArtifact + properties: + artifact: + description: Artifact represents the output of an InternalNelmOperatorExternalArtifact 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 InternalNelmOperatorExternalArtifact. + 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.21.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 + 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: InternalNelmOperatorGitRepository 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 InternalNelmOperatorGitRepository resources which Artifacts + should be included in the Artifact produced for this InternalNelmOperatorGitRepository. + items: + description: |- + GitRepositoryInclude specifies a local reference to a InternalNelmOperatorGitRepository 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 InternalNelmOperatorGitRepository 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 InternalNelmOperatorGitRepository 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 'aws', 'azure', 'github', 'generic'. + When not specified, defaults to 'generic'. + enum: + - generic + - aws + - 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 InternalNelmOperatorGitRepository 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 InternalNelmOperatorGitRepository. + 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 InternalNelmOperatorGitRepository. This field is only supported for 'azure' and 'aws' providers. + 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 InternalNelmOperatorGitRepository. + items: + type: string + type: array + suspend: + description: |- + Suspend tells the controller to suspend the reconciliation of this + InternalNelmOperatorGitRepository. + 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. PGP public keys must be stored under keys with the .asc suffix, + and SSH public keys must be stored under keys with the .sshpub suffix. + 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' or 'aws' + rule: '!has(self.serviceAccountName) || (has(self.provider) && (self.provider == ''azure'' || self.provider == ''aws''))' + status: + default: + observedGeneration: -1 + description: GitRepositoryStatus records the observed state of a Git repository. + properties: + artifact: + description: Artifact represents the last successful InternalNelmOperatorGitRepository 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 InternalNelmOperatorGitRepository. + 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 InternalNelmOperatorGitRepository + 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 InternalNelmOperatorGitRepository resources used to + produce the current Artifact. + items: + description: |- + GitRepositoryInclude specifies a local reference to a InternalNelmOperatorGitRepository 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 InternalNelmOperatorGitRepository 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: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.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 + 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: InternalNelmOperatorHelmChart 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 InternalNelmOperatorHelmChart 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 ('InternalNelmOperatorHelmRepository', 'InternalNelmOperatorGitRepository', + 'InternalNelmOperatorBucket'). + 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 InternalNelmOperatorHelmRepository 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 + InternalNelmOperatorGitRepository and InternalNelmOperatorBucket 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 'InternalNelmOperatorHelmRepository' + rule: '!has(self.verify) || self.sourceRef.kind == ''InternalNelmOperatorHelmRepository''' + status: + default: + observedGeneration: -1 + description: HelmChartStatus records the observed state of the InternalNelmOperatorHelmChart. + 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 InternalNelmOperatorHelmChart. + 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 InternalNelmOperatorHelmChart + 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 + HelmChartStatus.Artifact data is recommended. + type: string + 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.21.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 + 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: InternalNelmOperatorHelmRepository 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 InternalNelmOperatorHelmRepository 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 InternalNelmOperatorHelmRepository. + 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 + InternalNelmOperatorHelmRepository. + 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 InternalNelmOperatorHelmChart. + Its default value is 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + type: + description: |- + Type of the InternalNelmOperatorHelmRepository. + 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 InternalNelmOperatorHelmRepository. + properties: + artifact: + description: Artifact represents the last successful InternalNelmOperatorHelmRepository 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 InternalNelmOperatorHelmRepository. + 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 InternalNelmOperatorHelmRepository + 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: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.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 + 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: InternalNelmOperatorOCIRepository 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 InternalNelmOperatorOCIRepository + 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 InternalNelmOperatorOCIRepository 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 + trustedRootSecretRef: + description: |- + TrustedRootSecretRef specifies the Kubernetes Secret containing a + Sigstore trusted_root.json file. This enables verification against + self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted + Rekor instance). The Secret must contain a key named "trusted_root.json". + 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 InternalNelmOperatorOCIRepository + 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 InternalNelmOperatorOCIRepository. + 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: {} diff --git a/crds/helmapplicationcharts.yaml b/crds/helmapplicationcharts.yaml new file mode 100644 index 00000000..fc78c06f --- /dev/null +++ b/crds/helmapplicationcharts.yaml @@ -0,0 +1,161 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + heritage: deckhouse + module: operator-helm + name: helmapplicationcharts.helm.deckhouse.io +spec: + group: helm.deckhouse.io + names: + kind: HelmApplicationChart + listKind: HelmApplicationChartList + plural: helmapplicationcharts + singular: helmapplicationchart + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmApplicationChart represents a specific Helm chart discovered + within a HelmApplicationRepository. These resources are automatically managed + during repository synchronization and are immutable to user modifications. + 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 chart 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 + iconURL: + description: IconURL is the URL to the Helm chart icon (applicable + to Helm Chart repository charts only). + type: string + observedGeneration: + description: Generation represents resource generation that was last + processed by the controller. + format: int64 + type: integer + versions: + description: |- + Versions lists every chart version the controller has examined. A version is + usable when it has no unavailableReason; for an OCI repository a usable version + also carries the media type of the layer that holds it. + items: + properties: + mediaType: + description: |- + MediaType is the OCI media type of the layer that holds this chart version. It + is set only for a version of an oci:// repository, and only when the layer is + supported: an empty value there means the version cannot be deployed. + type: string + ociRef: + description: |- + OCIRef is the OCI reference this version is published at, as recorded from + the repository index. It is set only for a version of a helm repository whose + index entry points at a registry instead of a chart archive; such a version is + deployed through an internal OCIRepository even though its repository is a helm + one. + type: string + unavailableMessage: + description: UnavailableMessage carries human readable detail + for UnavailableReason. + type: string + unavailableReason: + description: |- + UnavailableReason explains why this version cannot be deployed. Its absence means + the version is usable. + enum: + - RemovedFromRepository + - UnsupportedMediaType + - ResolvePending + - InvalidChartReference + type: string + version: + description: Helm chart version + minLength: 1 + type: string + required: + - version + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/crds/helmapplicationrepositories.yaml b/crds/helmapplicationrepositories.yaml new file mode 100644 index 00000000..13086c9d --- /dev/null +++ b/crds/helmapplicationrepositories.yaml @@ -0,0 +1,210 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + heritage: deckhouse + module: operator-helm + name: helmapplicationrepositories.helm.deckhouse.io +spec: + group: helm.deckhouse.io + names: + kind: HelmApplicationRepository + listKind: HelmApplicationRepositoryList + plural: helmapplicationrepositories + singular: helmapplicationrepository + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The readiness status of the repository + jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Status + type: string + - description: Repository synchronization status + jsonPath: .status.conditions[?(@.type=='Synced')].status + name: Synced + type: string + - description: Time of the last successful catalog synchronization + jsonPath: .status.lastSuccessfulSyncTime + name: Last Sync + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - description: Scheduled time of the next synchronization attempt + jsonPath: .status.nextSyncTime + name: Next Sync + priority: 1 + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].message + name: Message + priority: 1 + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmApplicationRepository represents a Helm or OCI-compliant + repository containing Helm charts that can be referenced by HelmApplication + resources from the same namespace. + 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 + insecureSkipVerify: + description: InsecureSkipVerify disable 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: + chartCount: + description: |- + ChartCount is the number of charts the repository offered when it was last read + successfully. It is absent until the first successful read, so a repository that + has never been read is distinguishable from one that offers no charts. + format: int32 + type: integer + 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 + consecutiveFetchFailures: + description: |- + ConsecutiveFetchFailures counts consecutive failures to read from the repository. + It drives the retry backoff and resets on the first success. + format: int32 + type: integer + lastForceReconcileTime: + description: |- + LastForceReconcileTime is the time the most recent force reconcile request was + processed. It records that the request was acted on, not that it succeeded: + the outcome is reported by Ready and Synced. + format: date-time + type: string + lastSuccessfulSyncTime: + description: |- + LastSuccessfulSyncTime is the last time the chart catalog was fully brought up to date, + including creating and pruning chart resources. + format: date-time + type: string + nextSyncTime: + description: NextSyncTime is the scheduled time of the next synchronization + attempt. + format: date-time + type: string + observedGeneration: + description: Generation represents resource generation that was last + processed by the controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + x-kubernetes-validations: + - message: repository name must be between 3 and 63 characters long + rule: self.metadata.name.size() >= 3 && self.metadata.name.size() <= 63 + served: true + storage: true + subresources: + status: {} diff --git a/crds/helmapplications.yaml b/crds/helmapplications.yaml new file mode 100644 index 00000000..923bd1d5 --- /dev/null +++ b/crds/helmapplications.yaml @@ -0,0 +1,238 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + heritage: deckhouse + module: operator-helm + name: helmapplications.helm.deckhouse.io +spec: + group: helm.deckhouse.io + names: + kind: HelmApplication + listKind: HelmApplicationList + plural: helmapplications + singular: helmapplication + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Helm release chart name. + jsonPath: .spec.chart.name + name: Chart + type: string + - description: Helm release chart version. + jsonPath: .spec.chart.version + name: Chart Version + type: string + - description: The readiness status of the application + jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - description: The namespaced repository the chart is taken from + jsonPath: .spec.chart.repository + name: Repository + priority: 1 + type: string + - description: The cluster-wide repository the chart is taken from + jsonPath: .spec.chart.clusterRepository + name: Cluster Repository + priority: 1 + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmApplication represents an installation of a Helm chart inside + a single namespace. The release is deployed into the namespace of the resource + itself. The chart is applied with a ServiceAccount bound to a Role that + grants every permission inside that namespace, so the right to create a + HelmApplication is equivalent to administrator rights in its namespace; + the Role and the binding belong to the module and are reconciled, so an + edit to either does not outlast the application that needs it. + 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: + clusterRepository: + description: |- + Specifies the name of the cluster-wide HelmClusterApplicationRepository custom + resource that contains the connection details and credentials for the + repository where the chart is located. + maxLength: 63 + minLength: 3 + type: string + name: + description: |- + Specifies the name of the Helm chart to be installed + from the referenced repository (e.g., "nginx" or "redis"). + minLength: 1 + type: string + repository: + description: |- + Specifies the name of the HelmApplicationRepository custom resource in the same + namespace that contains the connection details and credentials for the + repository where the chart is located. + maxLength: 63 + minLength: 3 + type: string + version: + description: Version holds the HelmApplication chart version. + minLength: 1 + type: string + required: + - name + - version + type: object + x-kubernetes-validations: + - message: exactly one of spec.chart.repository or spec.chart.clusterRepository + must be set + rule: has(self.repository) != has(self.clusterRepository) + 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 + values: + description: Values holds the values for this HelmApplication release. + x-kubernetes-preserve-unknown-fields: true + required: + - chart + type: object + status: + properties: + conditions: + description: Conditions represent the latest available observations + of the application 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 + application install or update. + properties: + clusterRepository: + description: |- + Specifies the name of the HelmClusterApplicationRepository custom resource the + chart was last taken from. + type: string + name: + description: Specifies the name of the Helm chart the release + was last deployed from. + type: string + repository: + description: |- + Specifies the name of the HelmApplicationRepository custom resource the chart + was last taken from. + type: string + version: + description: Version holds the chart version the release was last + deployed from. + type: string + type: object + lastAppliedValues: + description: LastAppliedValues represents the latest values that triggered + application install or update. + x-kubernetes-preserve-unknown-fields: true + lastForceReconcileTime: + description: |- + LastForceReconcileTime is the time the most recent force reconcile request was + processed. It records that the request was acted on, not that it succeeded: + the outcome is reported by Ready. + format: date-time + type: string + observedGeneration: + description: Generation represents resource generation that was last + processed by the controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + x-kubernetes-validations: + - message: application name must be at most 63 characters long + rule: self.metadata.name.size() <= 63 + served: true + storage: true + subresources: + status: {} diff --git a/crds/helmclusteraddoncharts.yaml b/crds/helmclusteraddoncharts.yaml index 11302f5f..45c958f2 100644 --- a/crds/helmclusteraddoncharts.yaml +++ b/crds/helmclusteraddoncharts.yaml @@ -45,7 +45,7 @@ spec: properties: conditions: description: Conditions represent the latest available observations - of the addon chart state. + of the chart state. items: description: Condition contains details for one aspect of the current state of this API Resource. @@ -121,9 +121,7 @@ spec: description: |- MediaType is the OCI media type of the layer that holds this chart version. It is set only for a version of an oci:// repository, and only when the layer is - supported: an empty value there means the version cannot be deployed. It stays - empty for a version carrying OCIRef — the layer of such an artifact is examined - at deploy time and is not recorded here. + supported: an empty value there means the version cannot be deployed. type: string ociRef: description: |- @@ -131,9 +129,7 @@ spec: the repository index. It is set only for a version of a helm repository whose index entry points at a registry instead of a chart archive; such a version is deployed through an internal OCIRepository even though its repository is a helm - one. The registry host and path keep the spelling the index used, and the tag is - always explicit: an index entry without one is recorded with its own version as - the tag. + one. type: string unavailableMessage: description: UnavailableMessage carries human readable detail diff --git a/crds/helmclusteraddonrepositories.yaml b/crds/helmclusteraddonrepositories.yaml index f9be8574..b8982c44 100644 --- a/crds/helmclusteraddonrepositories.yaml +++ b/crds/helmclusteraddonrepositories.yaml @@ -103,21 +103,16 @@ spec: type: object status: properties: - conditions: + chartCount: description: |- - Conditions represent the latest available observations of the repository state. - - Ready reports whether the repository is usable: auxiliary resources are in place, - the internal source object is healthy and the repository has responded to a catalog - read on the current spec. A transient read failure does not flip Ready to False. - - Synced reports whether the chart catalog is up to date. - - Reconciling and Stalled follow the kstatus convention: they are present only while - applicable. Reconciling means work is in progress or a retry is scheduled; Stalled - means the repository will not recover without a change. While a synchronization is - running Reconciling carries the reason Synchronization, or ForceReconcile when the - pass was requested through the force reconcile annotation. + ChartCount is the number of charts the repository offered when it was last read + successfully. It is absent until the first successful read, so a repository that + has never been read is distinguishable from one that offers no charts. + format: int32 + type: integer + 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. @@ -206,6 +201,9 @@ spec: required: - spec type: object + x-kubernetes-validations: + - message: repository name must be between 3 and 63 characters long + rule: self.metadata.name.size() >= 3 && self.metadata.name.size() <= 63 served: true storage: true subresources: diff --git a/crds/helmclusteraddons.yaml b/crds/helmclusteraddons.yaml index a95e5945..de35c7ad 100644 --- a/crds/helmclusteraddons.yaml +++ b/crds/helmclusteraddons.yaml @@ -103,12 +103,8 @@ spec: status: properties: conditions: - description: |- - Conditions represent the latest available observations of the addon state. - - Reconciling is present only while applicable, following the kstatus convention. - It carries the reason ForceReconcile while a reconciliation requested through - the force reconcile annotation is running. + description: Conditions represent the latest available observations + of the addon state. items: description: Condition contains details for one aspect of the current state of this API Resource. diff --git a/crds/helmclusterapplicationcharts.yaml b/crds/helmclusterapplicationcharts.yaml new file mode 100644 index 00000000..7a2e9283 --- /dev/null +++ b/crds/helmclusterapplicationcharts.yaml @@ -0,0 +1,162 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + heritage: deckhouse + module: operator-helm + name: helmclusterapplicationcharts.helm.deckhouse.io +spec: + group: helm.deckhouse.io + names: + kind: HelmClusterApplicationChart + listKind: HelmClusterApplicationChartList + plural: helmclusterapplicationcharts + singular: helmclusterapplicationchart + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmClusterApplicationChart represents a specific Helm chart + discovered within a HelmClusterApplicationRepository. These resources are + automatically managed during repository synchronization and are immutable + to user modifications. + 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 chart 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 + iconURL: + description: IconURL is the URL to the Helm chart icon (applicable + to Helm Chart repository charts only). + type: string + observedGeneration: + description: Generation represents resource generation that was last + processed by the controller. + format: int64 + type: integer + versions: + description: |- + Versions lists every chart version the controller has examined. A version is + usable when it has no unavailableReason; for an OCI repository a usable version + also carries the media type of the layer that holds it. + items: + properties: + mediaType: + description: |- + MediaType is the OCI media type of the layer that holds this chart version. It + is set only for a version of an oci:// repository, and only when the layer is + supported: an empty value there means the version cannot be deployed. + type: string + ociRef: + description: |- + OCIRef is the OCI reference this version is published at, as recorded from + the repository index. It is set only for a version of a helm repository whose + index entry points at a registry instead of a chart archive; such a version is + deployed through an internal OCIRepository even though its repository is a helm + one. + type: string + unavailableMessage: + description: UnavailableMessage carries human readable detail + for UnavailableReason. + type: string + unavailableReason: + description: |- + UnavailableReason explains why this version cannot be deployed. Its absence means + the version is usable. + enum: + - RemovedFromRepository + - UnsupportedMediaType + - ResolvePending + - InvalidChartReference + type: string + version: + description: Helm chart version + minLength: 1 + type: string + required: + - version + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/crds/helmclusterapplicationrepositories.yaml b/crds/helmclusterapplicationrepositories.yaml new file mode 100644 index 00000000..cba2d039 --- /dev/null +++ b/crds/helmclusterapplicationrepositories.yaml @@ -0,0 +1,210 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + heritage: deckhouse + module: operator-helm + name: helmclusterapplicationrepositories.helm.deckhouse.io +spec: + group: helm.deckhouse.io + names: + kind: HelmClusterApplicationRepository + listKind: HelmClusterApplicationRepositoryList + plural: helmclusterapplicationrepositories + singular: helmclusterapplicationrepository + scope: Cluster + versions: + - additionalPrinterColumns: + - description: The readiness status of the repository + jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Status + type: string + - description: Repository synchronization status + jsonPath: .status.conditions[?(@.type=='Synced')].status + name: Synced + type: string + - description: Time of the last successful catalog synchronization + jsonPath: .status.lastSuccessfulSyncTime + name: Last Sync + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - description: Scheduled time of the next synchronization attempt + jsonPath: .status.nextSyncTime + name: Next Sync + priority: 1 + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].message + name: Message + priority: 1 + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmClusterApplicationRepository represents a cluster-wide Helm + or OCI-compliant repository containing Helm charts that can be referenced + by HelmApplication resources from any namespace. + 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 + insecureSkipVerify: + description: InsecureSkipVerify disable 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: + chartCount: + description: |- + ChartCount is the number of charts the repository offered when it was last read + successfully. It is absent until the first successful read, so a repository that + has never been read is distinguishable from one that offers no charts. + format: int32 + type: integer + 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 + consecutiveFetchFailures: + description: |- + ConsecutiveFetchFailures counts consecutive failures to read from the repository. + It drives the retry backoff and resets on the first success. + format: int32 + type: integer + lastForceReconcileTime: + description: |- + LastForceReconcileTime is the time the most recent force reconcile request was + processed. It records that the request was acted on, not that it succeeded: + the outcome is reported by Ready and Synced. + format: date-time + type: string + lastSuccessfulSyncTime: + description: |- + LastSuccessfulSyncTime is the last time the chart catalog was fully brought up to date, + including creating and pruning chart resources. + format: date-time + type: string + nextSyncTime: + description: NextSyncTime is the scheduled time of the next synchronization + attempt. + format: date-time + type: string + observedGeneration: + description: Generation represents resource generation that was last + processed by the controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + x-kubernetes-validations: + - message: repository name must be between 3 and 63 characters long + rule: self.metadata.name.size() >= 3 && self.metadata.name.size() <= 63 + served: true + storage: true + subresources: + status: {} diff --git a/docs/EXAMPLE.md b/docs/EXAMPLE.md index 699d6ccd..e1119167 100644 --- a/docs/EXAMPLE.md +++ b/docs/EXAMPLE.md @@ -78,6 +78,43 @@ Only one instance of HelmClusterAddon using a specific Helm chart from a specifi The `.spec.chart.version` parameter is optional. If omitted, the latest available version of the chart will be installed. {{< /alert >}} +## Deploying a namespaced application + +A namespace owner can deploy a chart into their own namespace without cluster-wide rights, using HelmApplicationRepository and HelmApplication instead of the cluster-scoped resources above. + +To add a repository, create a HelmApplicationRepository resource in the target namespace: + +```yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmApplicationRepository +metadata: + name: podinfo + namespace: test +spec: + url: https://stefanprodan.github.io/podinfo +``` + +To deploy a chart from it, create a HelmApplication resource in the same namespace, specifying the chart name, version, and the repository to take it from: + +```yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmApplication +metadata: + name: podinfo + namespace: test +spec: + chart: + name: podinfo + repository: podinfo + version: 6.10.2 +``` + +The release is always deployed into the namespace of the HelmApplication resource itself, so there is no separate namespace field to set. A chart may also be taken from a cluster-wide HelmClusterApplicationRepository by setting `.spec.chart.clusterRepository` instead of `.spec.chart.repository`. + +{{< alert level="warning" >}} +Creating a HelmApplication grants it administrator-level rights inside its namespace — see the module documentation's Limitations section for details. +{{< /alert >}} + ## Triggering a manual reconciliation To trigger an immediate reconciliation of a resource without waiting for the next scheduled sync, annotate it with `reconcile.helm.deckhouse.io/force`. The controller will detect the annotation, run a full reconciliation cycle, and remove the annotation automatically once processing is complete. diff --git a/docs/EXAMPLE.ru.md b/docs/EXAMPLE.ru.md index 5e54c64a..51168795 100644 --- a/docs/EXAMPLE.ru.md +++ b/docs/EXAMPLE.ru.md @@ -78,6 +78,43 @@ spec: Параметр `.spec.chart.version` является необязательным. Если он не указан, будет установлена последняя доступная версия чарта. {{< /alert >}} +## Развёртывание приложения в пространстве имён + +Владелец namespace может развернуть чарт в собственном namespace без прав на весь кластер, используя HelmApplicationRepository и HelmApplication вместо кластерных ресурсов, описанных выше. + +Для добавления репозитория создайте ресурс HelmApplicationRepository в целевом namespace: + +```yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmApplicationRepository +metadata: + name: podinfo + namespace: test +spec: + url: https://stefanprodan.github.io/podinfo +``` + +Для развёртывания чарта из него создайте ресурс HelmApplication в том же namespace, указав имя и версию чарта, а также репозиторий, из которого его нужно взять: + +```yaml +apiVersion: helm.deckhouse.io/v1alpha1 +kind: HelmApplication +metadata: + name: podinfo + namespace: test +spec: + chart: + name: podinfo + repository: podinfo + version: 6.10.2 +``` + +Релиз всегда разворачивается в namespace самого ресурса HelmApplication, поэтому отдельного поля для имени namespace здесь нет. Чарт также можно взять из кластерного HelmClusterApplicationRepository, указав вместо `.spec.chart.repository` поле `.spec.chart.clusterRepository`. + +{{< alert level="warning" >}} +Создание HelmApplication даёт ему права уровня администратора внутри его namespace — подробнее см. раздел «Ограничения» документации модуля. +{{< /alert >}} + ## Ручной запуск реконсиляции Чтобы запустить немедленную реконсиляцию ресурса, не дожидаясь следующей запланированной синхронизации, добавьте к нему аннотацию `reconcile.helm.deckhouse.io/force`. Контроллер обнаружит аннотацию, выполнит полный цикл реконсиляции и автоматически удалит аннотацию после завершения обработки. diff --git a/docs/README.md b/docs/README.md index 929ebc31..1f5bf889 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,15 +4,16 @@ description: "Deckhouse Kubernetes Platform — the operator-helm module for dec weight: 10 --- -The `operator-helm` module allows you to declaratively manage Helm chart deployments in the cluster. It is designed for cluster administrators and DevOps engineers and automates application installation using custom resources. +The `operator-helm` module allows you to declaratively manage Helm chart deployments in the cluster. It automates chart installation using custom resources and covers two scopes: a cluster-scoped addon family for cluster administrators and DevOps engineers, and a namespaced application family that lets a namespace owner install charts into their own namespace without cluster-wide privileges. -The module controller monitors the state of HelmClusterAddon resources and automatically reconciles Helm releases in the cluster with the specified parameters. +The module controller monitors the state of HelmClusterAddon and HelmApplication resources and automatically reconciles Helm releases in the cluster with the specified parameters. ## Main Features - 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. +- Automatic chart version discovery and tracking via HelmClusterAddonChart, HelmApplicationChart and HelmClusterApplicationChart resources. +- Configurable chart values through HelmClusterAddon and HelmApplication resources. +- Namespace-scoped chart installation through HelmApplication, in addition to cluster-wide installation through HelmClusterAddon. - 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. @@ -23,12 +24,21 @@ The module controller monitors the state of HelmClusterAddon resources and autom The following custom resources are used to manage Helm charts in the module: - **HelmClusterAddonRepository** — a Helm or OCI registry containing Helm charts for deployment in the cluster. -- **HelmClusterAddonChart** — a Helm chart discovered in the connected repository. These resources are automatically created and updated by the controller during repository synchronization and are protected from manual changes. - **HelmClusterAddon** — a declarative description of a specific Helm chart release. The resource contains the target chart version, the namespace name for deployment, and custom values. +- **HelmApplicationRepository** — a Helm or OCI registry containing Helm charts that can be referenced by HelmApplication resources from the same namespace. +- **HelmClusterApplicationRepository** — a Helm or OCI registry containing Helm charts that can be referenced by HelmApplication resources from any namespace. +- **HelmApplication** — a declarative description of a Helm chart installation inside a single namespace. The release is always deployed into the namespace of the resource itself; the resource contains the target chart version, a reference to either a same-namespace HelmApplicationRepository or a cluster-wide HelmClusterApplicationRepository, and custom values. + +Each repository also publishes a catalog of the charts it offers — HelmClusterAddonChart, HelmApplicationChart and HelmClusterApplicationChart. The controller creates and updates them during repository synchronization; they are read-only and are not edited by hand. ## Limitations -- Admin privileges (the `cluster-admin` role) are required to manage HelmClusterAddon and HelmClusterAddonRepository resources. +- The addon family (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) is entirely cluster-scoped, so managing it requires the `ClusterAdmin` role. +- The application family is namespaced: a namespace owner can create and manage HelmApplication and HelmApplicationRepository in their own namespace without cluster-wide rights, with the `Admin` role. HelmClusterApplicationRepository is cluster-scoped, so creating one requires the `ClusterAdmin` role, but any HelmApplication may reference an existing one from its own namespace. +- Creating a HelmApplication is effectively equivalent to having administrator rights inside its namespace: the controller creates a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount. Both objects are owned by the module and reconciled: the controller watches them and restores its own rules, subjects and labels, so narrowing or deleting either does not outlast the application that needs it. Ownership is decided by the `helm.deckhouse.io/managed-by: operator-helm` label: an object occupying one of these names without that label — pre-created by someone else, or stripped of the label afterwards — is never adopted, patched or deleted, and the application reports `Stalled` with the reason `ForeignAccessObject` and installs nothing. Restoring the label resumes the application on its own; removing an object that never carried the label does not, because nothing watches it, so ask for a reconciliation with the `reconcile.helm.deckhouse.io/force` annotation afterwards. Because the granted rights come from the module rather than from the creator's own rights, granting someone only the right to create a HelmApplication — without other rights in the namespace — hands them the same namespace-admin-level access through the installed chart. +- A HelmApplication cannot be created in a system namespace (`kube-system`, `kube-public`, `kube-node-lease`, or any namespace whose name starts with `d8-`, including the module's own `d8-operator-helm`); the admission webhook rejects it. +- `HelmApplicationRepository` and `HelmClusterApplicationRepository` store their registry credentials in plaintext (`spec.auth.username` and `spec.auth.password`; there is no `secretRef` alternative), so any right to read a repository resource is a right to read its password. That is one reason repositories are reachable no lower than `Admin`. +- Two Deckhouse roles reach this module, and the levels accumulate upwards. `Admin` may do anything with HelmApplication and HelmApplicationRepository, and may read both catalogs an application can pick a chart from: HelmApplicationChart and HelmClusterApplicationChart. `ClusterAdmin` covers the cluster-scoped kinds: full rights over HelmClusterAddon, HelmClusterAddonRepository and HelmClusterApplicationRepository, and a read of HelmClusterAddonChart. Note what the first of these means: installing an application is equivalent to namespace-admin rights, as explained above, so `Admin` is the lowest level that reaches this module at all. No level may write a chart catalog of any kind — the controller is its only author. - A HelmClusterAddon resource referencing a specific HelmClusterAddonChart can only be created as a single instance in the cluster. This is because Helm charts can contain custom resource definitions (CRDs), and installing them multiple times at the cluster level is not allowed. See [usage examples](example.html) for practical scenarios. diff --git a/docs/README.ru.md b/docs/README.ru.md index c15c6cc1..2e1cc807 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -4,31 +4,40 @@ description: "Deckhouse Kubernetes Platform — модуль operator-helm дл weight: 10 --- -Модуль `operator-helm` позволяет декларативно управлять развёртыванием Helm-чартов в кластере. Он ориентирован на администраторов кластеров и DevOps-инженеров и автоматизирует установку приложений с помощью кастомных ресурсов. +Модуль `operator-helm` позволяет декларативно управлять развёртыванием Helm-чартов в кластере. Он автоматизирует установку чартов с помощью кастомных ресурсов и охватывает два уровня: кластерное семейство аддонов для администраторов кластеров и DevOps-инженеров и пространственное (namespaced) семейство приложений, которое позволяет владельцу namespace устанавливать чарты в собственном namespace без прав на весь кластер. -Контроллер модуля отслеживает состояние ресурсов HelmClusterAddon и автоматически приводит Helm-релизы в кластере в соответствие с заданными параметрами. +Контроллер модуля отслеживает состояние ресурсов HelmClusterAddon и HelmApplication и автоматически приводит Helm-релизы в кластере в соответствие с заданными параметрами. ## Основные возможности - Развёртывание Helm-чартов из классических HTTP/HTTPS-репозиториев и OCI-репозиториев через единый декларативный API. -- Автоматическое обнаружение и отслеживание версий чартов через ресурсы HelmClusterAddonChart. -- Настройка параметров чартов через ресурсы HelmClusterAddon. +- Автоматическое обнаружение и отслеживание версий чартов через ресурсы HelmClusterAddonChart, HelmApplicationChart и HelmClusterApplicationChart. +- Настройка параметров чартов через ресурсы HelmClusterAddon и HelmApplication. +- Установка чартов в отдельном namespace через HelmApplication в дополнение к установке на уровне кластера через HelmClusterAddon. - Режим обслуживания для приостановки согласования и ручного вмешательства в управляемые релизы. - Поддержка проверки TLS-сертификатов и аутентификации для приватных OCI и Helm репозиториев. - Управление через CLI (`d8 k`) или веб-интерфейс Deckhouse. - ## Кастомные ресурсы Для управления Helm-чартами в модуле используются следующие кастомные ресурсы: - **HelmClusterAddonRepository** — репозиторий Helm или OCI, содержащий Helm-чарты для последующей установки в кластере. -- **HelmClusterAddonChart** — Helm-чарт, обнаруженный в подключённом репозитории. Эти ресурсы создаются и обновляются контроллером автоматически при синхронизации репозиториев и защищены от изменений. - **HelmClusterAddon** — декларативное описание конкретного релиза Helm-чарта. Ресурс содержит целевую версию чарта, имя пространства имён для развёртывания и пользовательские значения параметров. +- **HelmApplicationRepository** — репозиторий Helm или OCI, на Helm-чарты которого могут ссылаться ресурсы HelmApplication из того же namespace. +- **HelmClusterApplicationRepository** — репозиторий Helm или OCI, на Helm-чарты которого могут ссылаться ресурсы HelmApplication из любого namespace. +- **HelmApplication** — декларативное описание установки Helm-чарта в пределах одного namespace. Релиз всегда развёртывается в namespace самого ресурса; ресурс содержит целевую версию чарта, ссылку либо на HelmApplicationRepository из того же namespace, либо на кластерный HelmClusterApplicationRepository, а также пользовательские значения параметров. + +Каждый репозиторий дополнительно публикует каталог предлагаемых им чартов — HelmClusterAddonChart, HelmApplicationChart и HelmClusterApplicationChart. Контроллер создаёт и обновляет их при синхронизации репозиториев; эти ресурсы доступны только для чтения и вручную не редактируются. ## Ограничения -- Для управления ресурсами HelmClusterAddon и HelmClusterAddonRepository требуются права администратора кластера (роль `cluster-admin`). +- Семейство аддонов (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) полностью кластерное, поэтому для управления им требуется роль `ClusterAdmin`. +- Семейство приложений является namespaced: владелец namespace с ролью `Admin` может создавать HelmApplication и HelmApplicationRepository в своём namespace и управлять ими без прав на весь кластер. HelmClusterApplicationRepository — кластерный ресурс, поэтому для его создания нужна роль `ClusterAdmin`, но любой HelmApplication может ссылаться на уже существующий из своего namespace. +- Создание HelmApplication фактически равносильно правам администратора внутри его namespace: контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения. Оба объекта принадлежат модулю и реконсилируются: контроллер следит за ними и восстанавливает свои правила, subjects и метки, поэтому сузить или удалить любой из них на срок дольше одного прохода не получится. Принадлежность определяется меткой `helm.deckhouse.io/managed-by: operator-helm`: объект, занявший одно из этих имён без этой метки — созданный кем-то заранее или лишившийся метки позже, — никогда не присваивается, не патчится и не удаляется, а приложение сообщает `Stalled` с причиной `ForeignAccessObject` и ничего не устанавливает. Возврат метки поднимает приложение сам; удаление объекта, который метки никогда не нёс, — нет, потому что за ним никто не следит, поэтому после удаления запросите реконсиляцию аннотацией `reconcile.helm.deckhouse.io/force`. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание HelmApplication без прочих прав в namespace даёт через устанавливаемый чарт тот же уровень доступа, что и права администратора namespace. +- HelmApplication нельзя создать в системном namespace (`kube-system`, `kube-public`, `kube-node-lease`, а также в любом namespace, имя которого начинается с `d8-`, включая собственный namespace модуля `d8-operator-helm`); admission-контроллер отклоняет такую попытку. +- HelmApplicationRepository и HelmClusterApplicationRepository хранят учётные данные реестра в открытом виде (`spec.auth.username` и `spec.auth.password`; альтернативы через `secretRef` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. В том числе поэтому репозитории доступны не ниже уровня `Admin`. +- К модулю обращаются две роли Deckhouse, и уровни накапливаются снизу вверх. `Admin` может делать что угодно с HelmApplication и HelmApplicationRepository и получает чтение обоих каталогов, из которых приложение выбирает чарт: HelmApplicationChart и HelmClusterApplicationChart. `ClusterAdmin` покрывает кластерные виды: полные права на HelmClusterAddon, HelmClusterAddonRepository и HelmClusterApplicationRepository, а также чтение HelmClusterAddonChart. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, поэтому `Admin` — самый низкий уровень, которому модуль вообще доступен. Записывать каталог чартов не может ни один уровень — его единственный автор контроллер. - Ресурс HelmClusterAddon, ссылающийся на заданный HelmClusterAddonChart, может быть создан в кластере только в единственном экземпляре. Это обусловлено тем, что Helm-чарты могут содержать определения кастомных ресурсов (CRD), повторная установка которых на уровне кластера недопустима. Примеры использования приведены в разделе [примеры использования](example.html). diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 460b4413..da74f8e4 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -105,4 +105,3 @@ description: "Release notes for Deckhouse operator-helm." ### New Features * initial release with basic capabilities - diff --git a/docs/RELEASE_NOTES.ru.md b/docs/RELEASE_NOTES.ru.md index ad77d863..d7ce3d27 100644 --- a/docs/RELEASE_NOTES.ru.md +++ b/docs/RELEASE_NOTES.ru.md @@ -105,4 +105,3 @@ description: "Релизы Deckhouse operator-helm." ### Новые возможности * выпущена первоначальная версия с базовыми возможностями - diff --git a/images/chart-values-controller/.prettierignore b/images/chart-values-controller/.prettierignore new file mode 100644 index 00000000..b8675540 --- /dev/null +++ b/images/chart-values-controller/.prettierignore @@ -0,0 +1,4 @@ +/.task/ +werf.inc.yaml + + diff --git a/images/chart-values-controller/Taskfile.dist.yaml b/images/chart-values-controller/Taskfile.dist.yaml index 27664273..7bf197ea 100644 --- a/images/chart-values-controller/Taskfile.dist.yaml +++ b/images/chart-values-controller/Taskfile.dist.yaml @@ -10,6 +10,12 @@ includes: gciPrefix: '{{.gciPrefix | default "github.com/deckhouse/"}}' golangciConfigPath: '{{.golangciConfigPath | default "./.golangci.yaml"}}' golangciLintBinDir: '{{.golangciLintBinDir | default "../../bin"}}' - golangciLintVersion: '{{.golangciLintVersion | default "v2.8.0"}}' + golangciLintVersion: '{{.golangciLintVersion | default "v2.13.2"}}' golangciPaths: '{{.golangciPaths | default "./..."}}' paths: '{{.paths | default "."}}' + +tasks: + test:unit: + desc: "Run the unit tests of this module." + cmds: + - go test ./... diff --git a/images/chart-values-controller/cmd/chart-values-controller/main.go b/images/chart-values-controller/cmd/chart-values-controller/main.go index df13083f..83ac809b 100644 --- a/images/chart-values-controller/cmd/chart-values-controller/main.go +++ b/images/chart-values-controller/cmd/chart-values-controller/main.go @@ -22,7 +22,7 @@ import ( "os" "time" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" clientgoscheme "k8s.io/client-go/kubernetes/scheme" diff --git a/images/chart-values-controller/go.mod b/images/chart-values-controller/go.mod index 3da490be..49f48bd4 100644 --- a/images/chart-values-controller/go.mod +++ b/images/chart-values-controller/go.mod @@ -6,13 +6,13 @@ replace github.com/deckhouse/operator-helm/api => ../../api require ( github.com/deckhouse/operator-helm/api v0.0.0-00010101000000-000000000000 + github.com/fluxcd/pkg/apis/meta v1.30.2 + github.com/fluxcd/source-controller/api v1.9.5 github.com/google/go-containerregistry v0.20.6 - github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 - github.com/werf/nelm-source-controller/api v0.1.5 - k8s.io/api v0.35.1 - k8s.io/apimachinery v0.35.1 - k8s.io/client-go v0.35.1 - sigs.k8s.io/controller-runtime v0.23.1 + k8s.io/api v0.36.1 + k8s.io/apimachinery v0.36.4 + k8s.io/client-go v0.36.0 + sigs.k8s.io/controller-runtime v0.24.1 ) require ( @@ -23,15 +23,27 @@ require ( 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/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fluxcd/pkg/apis/acl v0.10.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // 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/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // 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 @@ -50,35 +62,34 @@ require ( 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/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.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/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 + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // 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/net v0.56.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // 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 - k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + k8s.io/apiextensions-apiserver v0.36.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25 // indirect + k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // 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/structured-merge-diff/v6 v6.4.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/images/chart-values-controller/go.sum b/images/chart-values-controller/go.sum index 1ce8ad9a..d9bcb4b1 100644 --- a/images/chart-values-controller/go.sum +++ b/images/chart-values-controller/go.sum @@ -1,5 +1,6 @@ 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/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= 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= @@ -18,14 +19,24 @@ github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqI 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/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= 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/fluxcd/pkg/apis/acl v0.10.0 h1:KPfAmELNvtvaz8wixnm/MYXqa+MJf7ntVVMUU93Aenk= +github.com/fluxcd/pkg/apis/acl v0.10.0/go.mod h1:a87i2A7AlFO5N2J8CxtzaUCCDmuLLWOHwkKu3eJF5fY= +github.com/fluxcd/pkg/apis/meta v1.30.2 h1:FbSQsUqLZrnyFhGqc0uE5zJOOu42OQ8YpO0vXmj+g5o= +github.com/fluxcd/pkg/apis/meta v1.30.2/go.mod h1:xc7Z4qD5ikDVfjMDYgmFbLJiwJQaaXLkocqmxhywXzA= +github.com/fluxcd/source-controller/api v1.9.5 h1:QwOqmw6/NqOXUR+kGmBJ18CEvthD8DNrSRTtjQG+bHQ= +github.com/fluxcd/source-controller/api v1.9.5/go.mod h1:Y5mcHYzML/mJYjvSJRcIo7eLLjd+cZjnKR6WeIGrouE= 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/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/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= @@ -36,6 +47,30 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF 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-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= 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= @@ -79,8 +114,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq 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/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= 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/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= 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= @@ -96,8 +133,12 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -127,33 +168,55 @@ 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.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/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/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= 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/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= 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/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= 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/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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 v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= 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= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= @@ -168,25 +231,43 @@ gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= +k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= +k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= +k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apimachinery v0.36.4 h1:PT2UzkupGuAx/+xT5XjiMJ1WGpY3fn9/hdAvjweRet4= +k8s.io/apimachinery v0.36.4/go.mod h1:p2I2dipt7JHG+quVwQ1d02d28O4GdDi77RByQ13MTpk= 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/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= +k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= 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/kube-openapi v0.0.0-20260603220949-865597e52e25 h1:mPMaPMpBij2V1Wv/fR+HW124vVGXXvOSS9ver/9yjWs= +k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= +k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= 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/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= 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/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0/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/chart-values-controller/internal/auth/auth.go b/images/chart-values-controller/internal/auth/auth.go index 827f4775..8de6d6d7 100644 --- a/images/chart-values-controller/internal/auth/auth.go +++ b/images/chart-values-controller/internal/auth/auth.go @@ -27,12 +27,15 @@ import ( authzclientv1 "k8s.io/client-go/kubernetes/typed/authorization/v1" ) -// Access is the cluster-scoped resource permission a request must hold. It maps -// directly onto a SubjectAccessReview resource attribute check. +// Access is the resource permission a request must hold. It maps directly onto a +// SubjectAccessReview resource attribute check. Namespace is empty for a +// cluster-scoped resource; for a namespaced one it must be set, or the API server +// answers for the cluster scope — a different question with a different answer. type Access struct { - Group string - Resource string - Verb string + Group string + Resource string + Verb string + Namespace string } // Result reports the outcome of reviewing a bearer token. Authorized is only @@ -77,9 +80,10 @@ func (r *Reviewer) Review(ctx context.Context, token string, access Access) (Res Groups: user.Groups, Extra: convertExtra(user.Extra), ResourceAttributes: &authzv1.ResourceAttributes{ - Verb: access.Verb, - Group: access.Group, - Resource: access.Resource, + Namespace: access.Namespace, + Verb: access.Verb, + Group: access.Group, + Resource: access.Resource, }, }, }, metav1.CreateOptions{}) diff --git a/images/chart-values-controller/internal/auth/auth_test.go b/images/chart-values-controller/internal/auth/auth_test.go new file mode 100644 index 00000000..8a748b20 --- /dev/null +++ b/images/chart-values-controller/internal/auth/auth_test.go @@ -0,0 +1,77 @@ +/* +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 auth + +import ( + "context" + "testing" + + authnv1 "k8s.io/api/authentication/v1" + authzv1 "k8s.io/api/authorization/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +// TestReviewCarriesTheNamespaceIntoTheAccessReview pins the check that makes a +// namespaced resource's authorization meaningful: without the namespace the API +// server answers for the cluster scope, which is a different question entirely. +func TestReviewCarriesTheNamespaceIntoTheAccessReview(t *testing.T) { + clientset := fake.NewClientset() + + clientset.PrependReactor("create", "tokenreviews", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, &authnv1.TokenReview{ + Status: authnv1.TokenReviewStatus{ + Authenticated: true, + User: authnv1.UserInfo{Username: "alice", UID: "uid-1", Groups: []string{"dev"}}, + }, + }, nil + }) + + var recorded *authzv1.SubjectAccessReview + clientset.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) { + recorded = action.(k8stesting.CreateAction).GetObject().(*authzv1.SubjectAccessReview) + + return true, &authzv1.SubjectAccessReview{Status: authzv1.SubjectAccessReviewStatus{Allowed: true}}, nil + }) + + reviewer := New(clientset.AuthenticationV1().TokenReviews(), clientset.AuthorizationV1().SubjectAccessReviews()) + + result, err := reviewer.Review(context.Background(), "token", Access{ + Group: "helm.deckhouse.io", + Resource: "helmapplications", + Verb: "create", + Namespace: "team-a", + }) + if err != nil { + t.Fatalf("Review returned %v", err) + } + if !result.Authenticated || !result.Authorized || result.Username != "alice" { + t.Fatalf("result = %+v", result) + } + + if recorded == nil { + t.Fatal("no SubjectAccessReview was created") + } + attrs := recorded.Spec.ResourceAttributes + if attrs == nil || attrs.Namespace != "team-a" || attrs.Resource != "helmapplications" || attrs.Verb != "create" { + t.Fatalf("resource attributes = %+v, want a namespaced create on helmapplications", attrs) + } + if recorded.Spec.User != "alice" || len(recorded.Spec.Groups) != 1 { + t.Fatalf("subject = %q groups %v, want the reviewed identity", recorded.Spec.User, recorded.Spec.Groups) + } +} diff --git a/images/chart-values-controller/internal/chartartifact/probe.go b/images/chart-values-controller/internal/chartartifact/probe.go index 27356f13..ca4f5fe5 100644 --- a/images/chart-values-controller/internal/chartartifact/probe.go +++ b/images/chart-values-controller/internal/chartartifact/probe.go @@ -52,6 +52,21 @@ var ErrNotAChart = errors.New("artifact is not a packaged helm chart") // Also a verdict rather than a transient failure. var ErrTagNotFound = errors.New("registry has no such tag") +// probeTimeout bounds one probe end to end: the TLS handshake, the couple of +// retries remote.WithRetryBackoff attempts internally, and reading the manifest +// body. It applies even when the caller's context carries no deadline of its own, +// so a slow or unresponsive registry cannot hold the probe open indefinitely. The +// probe reads only a small manifest, so this is well under the timeout used to +// download a full chart artifact. +const probeTimeout = 20 * time.Second + +// probeContext bounds ctx by probeTimeout. context.WithTimeout keeps whichever +// deadline is sooner, so a caller's own shorter deadline still wins; only a +// caller with none at all falls back to probeTimeout. +func probeContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, probeTimeout) +} + // Prober finds the chart layer of one OCI artifact. type Prober interface { ChartLayerMediaType(ctx context.Context, ref string, transport http.RoundTripper) (string, error) @@ -70,6 +85,15 @@ type registryProber struct{} // authenticated would report a chart the pull could not fetch. rt carries transport // settings only and may be nil. func (registryProber) ChartLayerMediaType(ctx context.Context, ref string, rt http.RoundTripper) (string, error) { + ctx, cancel := probeContext(ctx) + defer cancel() + + // A transport built by Transport is single-use: fresh per probe and never + // shared, so its pool must be drained here rather than outliving this call. + if closer, ok := rt.(interface{ CloseIdleConnections() }); ok { + defer closer.CloseIdleConnections() + } + tag, err := name.NewTag(strings.TrimPrefix(ref, "oci://")) if err != nil { return "", fmt.Errorf("chart reference %q cannot be parsed: %w", ref, err) @@ -100,7 +124,7 @@ func (registryProber) ChartLayerMediaType(ctx context.Context, ref string, rt ht manifest, err := v1.ParseManifest(bytes.NewReader(desc.Manifest)) if err != nil { - return "", fmt.Errorf("%w: cannot parse the manifest of %s: %s", ErrNotAChart, ref, err) + return "", fmt.Errorf("%w: cannot parse the manifest of %s: %w", ErrNotAChart, ref, err) } if !helmv1alpha1.IsChartConfigMediaType(string(manifest.Config.MediaType)) { @@ -121,6 +145,15 @@ func (registryProber) ChartLayerMediaType(ctx context.Context, ref string, rt ht // Transport builds the transport settings for reaching a registry the repository // itself names. It returns nil when the repository has nothing to say about the // transport, which leaves the caller on the default one. +// +// The result is a clone of http.DefaultTransport rather than a bare struct, so it +// keeps the default's dial and idle-connection timeouts instead of inheriting +// none of them. It is built fresh for each probe and is not cached across calls: +// a repository's TLS settings are read (and can change, e.g. a rotated CA) on +// every hybrid probe, and the probe is rare enough — only while a version's +// media type is still unresolved — that caching would add a keyed cache and its +// invalidation for no measurable benefit. Because it is single-use, the caller +// closes its idle connections once the probe finishes. func Transport(caCertificate string, insecure bool) http.RoundTripper { if caCertificate == "" && !insecure { return nil @@ -134,5 +167,8 @@ func Transport(caCertificate string, insecure bool) http.RoundTripper { tlsConfig.RootCAs = pool } - return &http.Transport{TLSClientConfig: tlsConfig} + rt := http.DefaultTransport.(*http.Transport).Clone() + rt.TLSClientConfig = tlsConfig + + return rt } diff --git a/images/chart-values-controller/internal/chartartifact/probe_test.go b/images/chart-values-controller/internal/chartartifact/probe_test.go new file mode 100644 index 00000000..aebe83ad --- /dev/null +++ b/images/chart-values-controller/internal/chartartifact/probe_test.go @@ -0,0 +1,214 @@ +/* +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 chartartifact + +import ( + "context" + "encoding/pem" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +// TestChartLayerMediaTypeGivesUpOnAServerThatNeverAnswers pins the second half of +// the probe's bound: whatever deadline the caller's context carries, the whole +// probe — dial through body read — must end when that deadline passes rather +// than hang on a registry that never answers. +func TestChartLayerMediaTypeGivesUpOnAServerThatNeverAnswers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + // Never respond; only give up when the request's own context ends, the + // same way a hung connection to a real registry would behave. + <-r.Context().Done() + })) + t.Cleanup(server.Close) + + host := strings.TrimPrefix(server.URL, "http://") + + // The deadline is set on the caller's context, not read from probeTimeout: + // the point of this test is that the probe respects whatever bound it is + // given, including one shorter than its own floor. + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := Default.ChartLayerMediaType(ctx, "oci://"+host+"/podinfo:1.0.0", nil) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected an error from a server that never answers") + } + if elapsed > 5*time.Second { + t.Fatalf("probe took %s to give up on a 200ms deadline: it is not bounded by the caller's context", elapsed) + } +} + +// TestChartLayerMediaTypeWithCustomTLSSettings pins the other half: a transport +// built by Transport for a registry with its own CA must still let the probe +// succeed, proving the clone in Transport carries a usable TLS configuration +// rather than a broken bare one. +func TestChartLayerMediaTypeWithCustomTLSSettings(t *testing.T) { + server := httptest.NewTLSServer(registry.New(registry.Logger(log.New(io.Discard, "", 0)))) + t.Cleanup(server.Close) + + host := strings.TrimPrefix(server.URL, "https://") + + pushTestChart(t, host, server.Client().Transport) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + + rt := Transport(string(certPEM), false) + if rt == nil { + t.Fatal("Transport must return a round tripper when a CA certificate is given") + } + + mediaType, err := Default.ChartLayerMediaType(context.Background(), "oci://"+host+"/podinfo:1.0.0", rt) + if err != nil { + t.Fatalf("probing over a transport trusting the registry's CA: %v", err) + } + if mediaType != "application/vnd.cncf.helm.chart.content.v1.tar+gzip" { + t.Fatalf("media type = %q, want the chart layer's type", mediaType) + } +} + +// TestTransportClonesDefaultTimeouts pins the first half of the bound: a +// transport built for one probe must carry the configured default's dial and +// idle-connection timeouts, not the zero values of a bare &http.Transport{}. +func TestTransportClonesDefaultTimeouts(t *testing.T) { + rt := Transport("", true) + + transport, ok := rt.(*http.Transport) + if !ok { + t.Fatalf("Transport must return an *http.Transport, got %T", rt) + } + + want := http.DefaultTransport.(*http.Transport) //nolint:forcetypeassert // http.DefaultTransport is always *http.Transport + if transport.IdleConnTimeout != want.IdleConnTimeout { + t.Fatalf("IdleConnTimeout = %v, want the default's %v: a bare transport carries none of its timeouts", transport.IdleConnTimeout, want.IdleConnTimeout) + } + if transport.TLSHandshakeTimeout != want.TLSHandshakeTimeout { + t.Fatalf("TLSHandshakeTimeout = %v, want %v", transport.TLSHandshakeTimeout, want.TLSHandshakeTimeout) + } + if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("the clone must still carry the requested TLS configuration") + } +} + +// TestProbeContextBoundsAnUnboundedCaller is the fast, deterministic half of the +// deadline test: a caller that supplies no deadline at all (context.Background(), +// exactly what a reconcile loop passes) must still get one from probeTimeout, +// without this test having to wait for a real hang. +func TestProbeContextBoundsAnUnboundedCaller(t *testing.T) { + ctx, cancel := probeContext(context.Background()) + defer cancel() + + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("a probe given no deadline must still be bounded by probeTimeout") + } + if remaining := time.Until(deadline); remaining <= 0 || remaining > probeTimeout { + t.Fatalf("deadline is %s from now, want within (0, %s]", remaining, probeTimeout) + } +} + +// TestProbeContextKeepsAnEarlierCallerDeadline pins that probeContext narrows +// but never widens: a caller deadline sooner than probeTimeout must still win. +func TestProbeContextKeepsAnEarlierCallerDeadline(t *testing.T) { + parent, cancelParent := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancelParent() + + ctx, cancel := probeContext(parent) + defer cancel() + + parentDeadline, _ := parent.Deadline() + deadline, _ := ctx.Deadline() + if !deadline.Equal(parentDeadline) { + t.Fatalf("deadline = %s, want the caller's own earlier deadline %s", deadline, parentDeadline) + } +} + +// spyTransport counts how many times its idle connections are closed, so a test +// can pin that a single-use transport is drained exactly once per probe. +type spyTransport struct { + http.RoundTripper + closed int +} + +func (s *spyTransport) CloseIdleConnections() { + s.closed++ +} + +// TestChartLayerMediaTypeClosesTheTransportItWasGiven pins the other half of the +// transport fix: Transport builds a single-use pool for one probe, so the probe +// must drain it once finished rather than leaking it for the life of the process. +func TestChartLayerMediaTypeClosesTheTransportItWasGiven(t *testing.T) { + server := httptest.NewTLSServer(registry.New(registry.Logger(log.New(io.Discard, "", 0)))) + t.Cleanup(server.Close) + + host := strings.TrimPrefix(server.URL, "https://") + + pushTestChart(t, host, server.Client().Transport) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + spy := &spyTransport{RoundTripper: Transport(string(certPEM), false)} + + if _, err := Default.ChartLayerMediaType(context.Background(), "oci://"+host+"/podinfo:1.0.0", spy); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if spy.closed != 1 { + t.Fatalf("CloseIdleConnections called %d times, want exactly 1: the pool must not outlive this single probe", spy.closed) + } +} + +// pushTestChart writes a single-layer chart artifact to host/podinfo:1.0.0 using +// a transport that trusts the test registry's certificate. +func pushTestChart(t *testing.T, host string, rt http.RoundTripper) { + t.Helper() + + const helmConfigMediaType = types.MediaType("application/vnd.cncf.helm.config.v1+json") + const chartLayerMediaType = types.MediaType("application/vnd.cncf.helm.chart.content.v1.tar+gzip") + + img, err := mutate.Append(empty.Image, mutate.Addendum{ + Layer: static.NewLayer([]byte("chart-1.0.0"), chartLayerMediaType), + MediaType: chartLayerMediaType, + }) + if err != nil { + t.Fatalf("appending layer: %v", err) + } + img = mutate.MediaType(img, types.OCIManifestSchema1) + img = mutate.ConfigMediaType(img, helmConfigMediaType) + + ref, err := name.NewTag(host + "/podinfo:1.0.0") + if err != nil { + t.Fatalf("parsing tag: %v", err) + } + if err := remote.Write(ref, img, remote.WithTransport(rt)); err != nil { + t.Fatalf("pushing chart: %v", err) + } +} diff --git a/images/chart-values-controller/internal/controller/controller.go b/images/chart-values-controller/internal/controller/controller.go index d9b23e41..b4ce9f87 100644 --- a/images/chart-values-controller/internal/controller/controller.go +++ b/images/chart-values-controller/internal/controller/controller.go @@ -24,8 +24,8 @@ import ( "sync" "time" - "github.com/werf/3p-fluxcd-pkg/apis/meta" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/images/chart-values-controller/internal/naming/naming.go b/images/chart-values-controller/internal/naming/naming.go index 8c96831d..f0361696 100644 --- a/images/chart-values-controller/internal/naming/naming.go +++ b/images/chart-values-controller/internal/naming/naming.go @@ -25,33 +25,57 @@ import ( const ( resourcePrefix = "tmp" - // maxPartLen bounds each human-readable name part so the whole name stays - // within the 63-character DNS-1123 label limit: + // maxPartLen bounds each human-readable name part of a cluster-scoped name so + // the whole name stays within the 63-character DNS-1123 label limit: // "tmp-" (4) + repo (<=20) + "-" + chart (<=20) + "-" + hash (16) = 62. maxPartLen = 20 + + // maxNamespacedPartLen is the same bound for a namespaced name, which carries + // one part more: "tmp-" (4) + namespace (<=12) + "-" + repo (<=12) + "-" + + // chart (<=12) + "-" + hash (16) = 59, within the 63-character limit. + maxNamespacedPartLen = 12 ) // AuxResourceName returns a deterministic DNS-1123 name (<=63 chars) for the -// auxiliary source resource backing a (kind, repository, chart, version) tuple: -// "tmp---". The same tuple always maps to the same name, -// which makes polling requests idempotent and lets concurrent requests converge -// on one resource. The repo/chart parts are only human-readable hints; the hash -// over the full tuple (including kind) guarantees uniqueness even if those parts -// collide across repository kinds. -func AuxResourceName(kind, repository, chart, version string) string { - sum := sha256.Sum256([]byte(kind + "\x00" + repository + "/" + chart + "@" + version)) +// auxiliary source resource backing a (kind, namespace, repository, chart, version) +// tuple. The same tuple always maps to the same name, which makes polling requests +// idempotent and lets concurrent requests converge on one resource. +// +// namespace is empty for a cluster-scoped repository kind and is then absent from +// both the hash input and the readable part, so the names the addon family already +// uses do not move. For a namespaced kind it is what keeps two same-named +// repositories in different namespaces apart: the auxiliary objects of every kind +// share one namespace, so the tuple without it is not unique. A namespaced name +// carries one readable part more, so each of its parts is bounded more tightly. +// +// The readable parts are only hints; the hash over the full tuple guarantees +// uniqueness even if they collide. +func AuxResourceName(kind, namespace, repository, chart, version string) string { + if namespace == "" { + sum := sha256.Sum256([]byte(kind + "\x00" + repository + "/" + chart + "@" + version)) + + return fmt.Sprintf("%s-%s-%s-%x", resourcePrefix, sanitize(repository, maxPartLen), sanitize(chart, maxPartLen), sum[:8]) + } + + sum := sha256.Sum256([]byte(kind + "\x00" + namespace + "/" + repository + "/" + chart + "@" + version)) - return fmt.Sprintf("%s-%s-%s-%x", resourcePrefix, sanitizePart(repository), sanitizePart(chart), sum[:8]) + return fmt.Sprintf("%s-%s-%s-%s-%x", + resourcePrefix, + sanitize(namespace, maxNamespacedPartLen), + sanitize(repository, maxNamespacedPartLen), + sanitize(chart, maxNamespacedPartLen), + sum[:8], + ) } -// sanitizePart lowercases s, replaces characters invalid in a DNS-1123 label -// with '-', truncates to maxPartLen, and trims leading/trailing '-'. -func sanitizePart(s string) string { +// sanitize lowercases s, replaces characters invalid in a DNS-1123 label +// with '-', truncates to limit, and trims leading/trailing '-'. +func sanitize(s string, limit int) string { s = strings.ToLower(s) var b strings.Builder for _, r := range s { - if b.Len() >= maxPartLen { + if b.Len() >= limit { break } if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { diff --git a/images/chart-values-controller/internal/naming/naming_test.go b/images/chart-values-controller/internal/naming/naming_test.go index 7267bd91..3f26754d 100644 --- a/images/chart-values-controller/internal/naming/naming_test.go +++ b/images/chart-values-controller/internal/naming/naming_test.go @@ -24,10 +24,17 @@ import ( var dns1123 = regexp.MustCompile(`^[a-z]([-a-z0-9]*[a-z0-9])?$`) -const testKind = "HelmClusterAddonRepository" +const ( + // testKind is lower case because Resolve lower-cases the kind before it ever + // reaches AuxResourceName, so that is the only casing production hashes. + testKind = "helmclusteraddonrepository" + // nsKind is a namespaced repository kind; its names must additionally depend + // on the namespace. + nsKind = "helmapplicationrepository" +) func TestAuxResourceNameReadableHints(t *testing.T) { - name := AuxResourceName(testKind, "GitHub", "Pod.Info", "6.7.1") + name := AuxResourceName(testKind, "", "GitHub", "Pod.Info", "6.7.1") if !strings.HasPrefix(name, "tmp-") { t.Fatalf("expected tmp- prefix, got %q", name) } @@ -37,25 +44,27 @@ func TestAuxResourceNameReadableHints(t *testing.T) { } func TestAuxResourceNameDeterministic(t *testing.T) { - a := AuxResourceName(testKind, "github", "podinfo", "6.7.1") - b := AuxResourceName(testKind, "github", "podinfo", "6.7.1") + a := AuxResourceName(testKind, "", "github", "podinfo", "6.7.1") + b := AuxResourceName(testKind, "", "github", "podinfo", "6.7.1") if a != b { t.Fatalf("expected deterministic name, got %q and %q", a, b) } } func TestAuxResourceNameDistinct(t *testing.T) { - cases := [][4]string{ - {testKind, "github", "podinfo", "6.7.1"}, - {testKind, "github", "podinfo", "6.7.2"}, - {testKind, "github", "nginx", "6.7.1"}, - {testKind, "gitlab", "podinfo", "6.7.1"}, - {"FutureRepository", "github", "podinfo", "6.7.1"}, // same name/chart/version, different kind + cases := [][5]string{ + {testKind, "", "github", "podinfo", "6.7.1"}, + {testKind, "", "github", "podinfo", "6.7.2"}, + {testKind, "", "github", "nginx", "6.7.1"}, + {testKind, "", "gitlab", "podinfo", "6.7.1"}, + {"FutureRepository", "", "github", "podinfo", "6.7.1"}, // same name/chart/version, different kind + {nsKind, "team-a", "stable", "podinfo", "6.7.1"}, + {nsKind, "team-b", "stable", "podinfo", "6.7.1"}, // same repository name in another namespace } seen := map[string]bool{} for _, c := range cases { - name := AuxResourceName(c[0], c[1], c[2], c[3]) + name := AuxResourceName(c[0], c[1], c[2], c[3], c[4]) if seen[name] { t.Fatalf("name collision for %v: %q", c, name) } @@ -64,7 +73,7 @@ func TestAuxResourceNameDistinct(t *testing.T) { } func TestAuxResourceNameValidDNS1123(t *testing.T) { - name := AuxResourceName(testKind, "really-long-repository-name", "really-long-chart-name", "1.2.3-alpha.1+build") + name := AuxResourceName(testKind, "", "really-long-repository-name", "really-long-chart-name", "1.2.3-alpha.1+build") if len(name) > 63 { t.Fatalf("name too long (%d): %q", len(name), name) } @@ -72,3 +81,39 @@ func TestAuxResourceNameValidDNS1123(t *testing.T) { t.Fatalf("name is not a valid DNS-1123 label: %q", name) } } + +// TestAuxResourceNameClusterScopedNamesAreFrozen pins the names the addon family +// already uses: a cluster-scoped repository passes an empty namespace, and its name +// must not move — the objects are live and shared by every polling client. +func TestAuxResourceNameClusterScopedNamesAreFrozen(t *testing.T) { + cases := []struct { + repository string + chart string + version string + want string + }{ + {"github", "podinfo", "6.7.1", "tmp-github-podinfo-1379a792462c3a85"}, + {"GitHub", "Pod.Info", "6.7.1", "tmp-github-pod-info-4aaba5a5ae371dec"}, + } + + for _, tc := range cases { + got := AuxResourceName(testKind, "", tc.repository, tc.chart, tc.version) + if got != tc.want { + t.Fatalf("AuxResourceName(%q, \"\", %q, %q, %q) = %q, want %q", + testKind, tc.repository, tc.chart, tc.version, got, tc.want) + } + } +} + +// TestAuxResourceNameCarriesTheNamespaceHint keeps a namespaced name diagnosable: +// the namespace appears in the readable part, not only in the hash. +func TestAuxResourceNameCarriesTheNamespaceHint(t *testing.T) { + name := AuxResourceName(nsKind, "team-a", "stable", "podinfo", "6.7.1") + + if !strings.Contains(name, "team-a") { + t.Fatalf("namespaced name %q must carry the namespace hint", name) + } + if !dns1123.MatchString(name) || len(name) > 63 { + t.Fatalf("name %q is not a valid DNS-1123 label of at most 63 characters", name) + } +} diff --git a/images/chart-values-controller/internal/resolver/family.go b/images/chart-values-controller/internal/resolver/family.go new file mode 100644 index 00000000..6dd4d359 --- /dev/null +++ b/images/chart-values-controller/internal/resolver/family.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 resolver + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + + apinaming "github.com/deckhouse/operator-helm/api/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +// repositorySpec is what resolving a chart needs from a repository, whichever kind +// it is: where the charts come from and how to reach them. +type repositorySpec struct { + URL string + Auth *helmv1alpha1.RepositoryAuth + CACertificate string + InsecureSkipVerify bool +} + +// repositoryFamily is everything that differs between the repository kinds: how the +// repository and its chart catalog are read, and how the internal objects the +// operator derived from that repository are recognised. Namespaced says whether a +// request must carry a namespace. +// +// The functions take the client rather than closing over one because the resolver +// owns a single client and a family is a value, not a service. +type repositoryFamily struct { + Kind RepositoryKind + Namespaced bool + + // GetRepository reads the repository. A NotFound error means the caller should + // report repository_not_found. + GetRepository func(ctx context.Context, c client.Client, namespace, name string) (*repositorySpec, error) + // ChartVersions reads the catalog entry of one chart. A NotFound error means the + // catalog has not caught up yet and the caller should report pending. + ChartVersions func(ctx context.Context, c client.Client, namespace, repository, chart string) ([]helmv1alpha1.ChartVersion, error) + // InternalLabels selects the internal objects operator-helm-controller derived + // from this repository — its HelmRepository and its auth/TLS secrets, all of + // which live in the operator namespace and carry the source labels of their kind. + InternalLabels func(namespace, repository string) map[string]string +} + +// families is the registry every supported repository kind is looked up in. +var families = map[RepositoryKind]repositoryFamily{ + RepositoryKindHelmClusterAddon: { + Kind: RepositoryKindHelmClusterAddon, + Namespaced: false, + GetRepository: func(ctx context.Context, c client.Client, _, name string) (*repositorySpec, error) { + repo := &helmv1alpha1.HelmClusterAddonRepository{} + if err := c.Get(ctx, client.ObjectKey{Name: name}, repo); err != nil { + return nil, err + } + + return specOf(repo.Spec), nil + }, + ChartVersions: func(ctx context.Context, c client.Client, _, repository, chart string) ([]helmv1alpha1.ChartVersion, error) { + obj := &helmv1alpha1.HelmClusterAddonChart{} + key := client.ObjectKey{Name: apinaming.HelmClusterAddonChartName(repository, chart)} + if err := c.Get(ctx, key, obj); err != nil { + return nil, err + } + + return obj.Status.Versions, nil + }, + InternalLabels: func(_, repository string) map[string]string { + return map[string]string{helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repository} + }, + }, + RepositoryKindHelmApplication: { + Kind: RepositoryKindHelmApplication, + Namespaced: true, + GetRepository: func(ctx context.Context, c client.Client, namespace, name string) (*repositorySpec, error) { + repo := &helmv1alpha1.HelmApplicationRepository{} + if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, repo); err != nil { + return nil, err + } + + return specOf(repo.Spec), nil + }, + ChartVersions: func(ctx context.Context, c client.Client, namespace, repository, chart string) ([]helmv1alpha1.ChartVersion, error) { + obj := &helmv1alpha1.HelmApplicationChart{} + key := client.ObjectKey{Namespace: namespace, Name: apinaming.ApplicationChartName(repository, chart)} + if err := c.Get(ctx, key, obj); err != nil { + return nil, err + } + + return obj.Status.Versions, nil + }, + InternalLabels: func(namespace, repository string) map[string]string { + return map[string]string{ + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: repository, + helmv1alpha1.LabelSourceNamespace: namespace, + } + }, + }, + RepositoryKindHelmClusterApplication: { + Kind: RepositoryKindHelmClusterApplication, + Namespaced: false, + GetRepository: func(ctx context.Context, c client.Client, _, name string) (*repositorySpec, error) { + repo := &helmv1alpha1.HelmClusterApplicationRepository{} + if err := c.Get(ctx, client.ObjectKey{Name: name}, repo); err != nil { + return nil, err + } + + return specOf(repo.Spec), nil + }, + ChartVersions: func(ctx context.Context, c client.Client, _, repository, chart string) ([]helmv1alpha1.ChartVersion, error) { + obj := &helmv1alpha1.HelmClusterApplicationChart{} + key := client.ObjectKey{Name: apinaming.ClusterApplicationChartName(repository, chart)} + if err := c.Get(ctx, key, obj); err != nil { + return nil, err + } + + return obj.Status.Versions, nil + }, + InternalLabels: func(_, repository string) map[string]string { + return map[string]string{helmv1alpha1.HelmClusterApplicationRepositoryLabelSourceName: repository} + }, + }, +} + +// familyFor looks a kind up. The kind is already lower-cased by Resolve. +func familyFor(kind RepositoryKind) (repositoryFamily, bool) { + family, ok := families[kind] + + return family, ok +} + +func specOf(spec helmv1alpha1.RepositorySpec) *repositorySpec { + return &repositorySpec{ + URL: spec.URL, + Auth: spec.Auth, + CACertificate: spec.CACertificate, + InsecureSkipVerify: spec.InsecureSkipVerify, + } +} + +// requireNamespace reports the request-shape error of a family: a namespaced kind +// needs a namespace to identify its repository. A cluster-scoped kind accepts any +// namespace, including a non-empty one: for such a kind the namespace is not part of +// the chart's identity but the caller's authorization context (e.g. the namespace it +// intends to create a HelmApplication in), which this resolver does not use. +func (f repositoryFamily) requireNamespace(namespace string) error { + if f.Namespaced && namespace == "" { + return fmt.Errorf("repository kind %q is namespaced: namespace is required", f.Kind) + } + + return nil +} diff --git a/images/chart-values-controller/internal/resolver/family_test.go b/images/chart-values-controller/internal/resolver/family_test.go new file mode 100644 index 00000000..a5b82eff --- /dev/null +++ b/images/chart-values-controller/internal/resolver/family_test.go @@ -0,0 +1,202 @@ +/* +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 resolver + +import ( + "context" + "reflect" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +func TestFamilyForRejectsAnUnknownKind(t *testing.T) { + if _, ok := familyFor("somethingelse"); ok { + t.Fatal("an unknown kind must not resolve to a family") + } +} + +// TestAddonFamilyReadsTheClusterScopedRepositoryAndCatalog pins that the addon +// family is unchanged: a cluster-scoped repository read by name alone, its catalog +// object named by the shared scheme, and internal objects found by the one label +// the operator writes for it. +func TestAddonFamilyReadsTheClusterScopedRepositoryAndCatalog(t *testing.T) { + repo := &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "example"}, + Spec: helmv1alpha1.RepositorySpec{ + URL: "oci://ghcr.io/example/charts", + Auth: &helmv1alpha1.RepositoryAuth{Username: "u", Password: "p"}, + CACertificate: "-----BEGIN CERTIFICATE-----", + InsecureSkipVerify: true, + }, + } + // The name is a literal, not built through the same apinaming helper the family + // under test calls: otherwise a wrong naming scheme in the family could never be + // caught, since the fixture would always agree with whatever the family did. + chart := &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{Name: "example-chart-podinfo-015bdf9886f6"}, + Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "6.7.1"}}}, + } + + family, ok := familyFor(RepositoryKindHelmClusterAddon) + if !ok { + t.Fatal("the addon kind must resolve to a family") + } + if family.Namespaced { + t.Fatal("HelmClusterAddonRepository is cluster-scoped") + } + + c := newTestResolver(t, repo, chart).client + + spec, err := family.GetRepository(context.Background(), c, "", "example") + if err != nil { + t.Fatalf("GetRepository returned %v", err) + } + want := &repositorySpec{ + URL: repo.Spec.URL, + Auth: repo.Spec.Auth, + CACertificate: repo.Spec.CACertificate, + InsecureSkipVerify: true, + } + if !reflect.DeepEqual(spec, want) { + t.Fatalf("repository spec = %+v, want %+v", spec, want) + } + + versions, err := family.ChartVersions(context.Background(), c, "", "example", "podinfo") + if err != nil { + t.Fatalf("ChartVersions returned %v", err) + } + if len(versions) != 1 || versions[0].Version != "6.7.1" { + t.Fatalf("versions = %+v, want [6.7.1]", versions) + } + + wantLabels := map[string]string{helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: "example"} + if got := family.InternalLabels("", "example"); !reflect.DeepEqual(got, wantLabels) { + t.Fatalf("InternalLabels = %v, want %v", got, wantLabels) + } +} + +func TestAddonFamilyReportsAMissingRepositoryAsNotFound(t *testing.T) { + family, _ := familyFor(RepositoryKindHelmClusterAddon) + c := newTestResolver(t).client + + _, err := family.GetRepository(context.Background(), c, "", "missing") + if !apierrors.IsNotFound(err) { + t.Fatalf("err = %v, want a NotFound so the caller can report repository_not_found", err) + } +} + +func TestAddonFamilyReportsAMissingCatalogAsNotFound(t *testing.T) { + family, _ := familyFor(RepositoryKindHelmClusterAddon) + c := newTestResolver(t).client + + _, err := family.ChartVersions(context.Background(), c, "", "example", "podinfo") + if !apierrors.IsNotFound(err) { + t.Fatalf("err = %v, want a NotFound so the caller can report pending", err) + } +} + +// TestApplicationFamiliesReadTheirOwnObjects pins the two application kinds: the +// namespaced one reads its repository and catalog from the request namespace, the +// cluster one from the cluster scope, and each recognises its own internal objects. +func TestApplicationFamiliesReadTheirOwnObjects(t *testing.T) { + namespaced := &helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/stable"}, + } + // Both catalog objects are named with a literal, not apinaming.*ChartName, for the + // same reason as the addon fixture above. + namespacedChart := &helmv1alpha1.HelmApplicationChart{ + ObjectMeta: metav1.ObjectMeta{Name: "stable-chart-podinfo-d433c642288b", Namespace: "team-a"}, + Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "6.7.1"}}}, + } + cluster := &helmv1alpha1.HelmClusterApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "shared"}, + Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/charts"}, + } + clusterChart := &helmv1alpha1.HelmClusterApplicationChart{ + ObjectMeta: metav1.ObjectMeta{Name: "shared-chart-podinfo-6c7443a6e003"}, + Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "1.2.3"}}}, + } + + c := newTestResolver(t, namespaced, namespacedChart, cluster, clusterChart).client + + nsFamily, ok := familyFor(RepositoryKindHelmApplication) + if !ok || !nsFamily.Namespaced { + t.Fatalf("the namespaced application kind must resolve to a namespaced family (ok=%v)", ok) + } + + spec, err := nsFamily.GetRepository(context.Background(), c, "team-a", "stable") + if err != nil || spec.URL != namespaced.Spec.URL { + t.Fatalf("namespaced repository = %+v, err = %v", spec, err) + } + versions, err := nsFamily.ChartVersions(context.Background(), c, "team-a", "stable", "podinfo") + if err != nil || len(versions) != 1 || versions[0].Version != "6.7.1" { + t.Fatalf("namespaced versions = %+v, err = %v", versions, err) + } + wantNSLabels := map[string]string{ + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: "stable", + helmv1alpha1.LabelSourceNamespace: "team-a", + } + if got := nsFamily.InternalLabels("team-a", "stable"); !reflect.DeepEqual(got, wantNSLabels) { + t.Fatalf("namespaced internal labels = %v, want %v", got, wantNSLabels) + } + + // A repository of the same name in another namespace is a different repository. + if _, err := nsFamily.GetRepository(context.Background(), c, "team-b", "stable"); !apierrors.IsNotFound(err) { + t.Fatalf("err = %v, want NotFound for a same-named repository in another namespace", err) + } + + clusterFamily, ok := familyFor(RepositoryKindHelmClusterApplication) + if !ok || clusterFamily.Namespaced { + t.Fatalf("the cluster application kind must resolve to a cluster-scoped family (ok=%v)", ok) + } + + spec, err = clusterFamily.GetRepository(context.Background(), c, "", "shared") + if err != nil || spec.URL != cluster.Spec.URL { + t.Fatalf("cluster repository = %+v, err = %v", spec, err) + } + versions, err = clusterFamily.ChartVersions(context.Background(), c, "", "shared", "podinfo") + if err != nil || len(versions) != 1 || versions[0].Version != "1.2.3" { + t.Fatalf("cluster versions = %+v, err = %v", versions, err) + } + wantClusterLabels := map[string]string{helmv1alpha1.HelmClusterApplicationRepositoryLabelSourceName: "shared"} + if got := clusterFamily.InternalLabels("", "shared"); !reflect.DeepEqual(got, wantClusterLabels) { + t.Fatalf("cluster internal labels = %v, want %v", got, wantClusterLabels) + } +} + +func TestRequireNamespaceRejectsTheWrongRequestShape(t *testing.T) { + nsFamily, _ := familyFor(RepositoryKindHelmApplication) + if err := nsFamily.requireNamespace(""); err == nil { + t.Fatal("a namespaced kind without a namespace must be rejected") + } + if err := nsFamily.requireNamespace("team-a"); err != nil { + t.Fatalf("a namespaced kind with a namespace must be accepted, got %v", err) + } + + addon, _ := familyFor(RepositoryKindHelmClusterAddon) + if err := addon.requireNamespace("team-a"); err != nil { + t.Fatalf("a cluster-scoped kind with a namespace must be accepted: the namespace is not part of its identity, got %v", err) + } + if err := addon.requireNamespace(""); err != nil { + t.Fatalf("a cluster-scoped kind without a namespace must be accepted, got %v", err) + } +} diff --git a/images/chart-values-controller/internal/resolver/resolver.go b/images/chart-values-controller/internal/resolver/resolver.go index b780c368..c99e30b4 100644 --- a/images/chart-values-controller/internal/resolver/resolver.go +++ b/images/chart-values-controller/internal/resolver/resolver.go @@ -25,13 +25,12 @@ import ( "strings" "time" - "github.com/werf/3p-fluxcd-pkg/apis/meta" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" 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" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" @@ -41,12 +40,11 @@ import ( "github.com/deckhouse/chart-values-controller/internal/chartartifact" "github.com/deckhouse/chart-values-controller/internal/labels" "github.com/deckhouse/chart-values-controller/internal/naming" - apinaming "github.com/deckhouse/operator-helm/api/naming" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) // RepositoryKind identifies the kind of repository a chart lives in. New -// repository kinds are added as new constants plus a case in Resolve. +// repository kinds are added as new constants plus a registry entry in families. type RepositoryKind string const ( @@ -54,6 +52,16 @@ const ( // HelmClusterAddonRepository custom resource. Kind values are stored and // compared in lower case, so the request casing does not matter. RepositoryKindHelmClusterAddon RepositoryKind = "helmclusteraddonrepository" + + // RepositoryKindHelmApplication is a chart referenced by a + // HelmApplicationRepository, the namespaced repository of the application + // family: a request for it must name the namespace. + RepositoryKindHelmApplication RepositoryKind = "helmapplicationrepository" + + // RepositoryKindHelmClusterApplication is a chart referenced by a + // HelmClusterApplicationRepository, the cluster-wide repository of the + // application family. + RepositoryKindHelmClusterApplication RepositoryKind = "helmclusterapplicationrepository" ) // Outcome enumerates the possible results of resolving a chart-values request. @@ -66,12 +74,19 @@ const ( OutcomeUnsupportedRepositoryKind Outcome = "unsupported_repository_kind" OutcomeFetchFailed Outcome = "fetch_failed" OutcomeValuesNotFound Outcome = "values_not_found" + + // OutcomeInvalidRequest means the request itself does not make sense for the + // kind it names — a namespaced kind without a namespace. + OutcomeInvalidRequest Outcome = "invalid_request" ) -// Request identifies a chart by repository kind, repository name, chart name and -// chart version. +// Request identifies a chart by repository kind, repository namespace, repository +// name, chart name and chart version. Namespace identifies the repository only for a +// namespaced repository kind; for a cluster-scoped one it may still be set (it is the +// caller's authorization context) but Resolve does not use it to find the chart. type Request struct { Kind RepositoryKind + Namespace string RepositoryName string Chart string Version string @@ -119,12 +134,24 @@ func (r *Resolver) Resolve(ctx context.Context, req Request) (Result, error) { requested := req.Kind req.Kind = RepositoryKind(strings.ToLower(string(req.Kind))) - switch req.Kind { - case RepositoryKindHelmClusterAddon: - return r.resolveHelmClusterAddon(ctx, req) - default: + family, ok := familyFor(req.Kind) + if !ok { return Result{Outcome: OutcomeUnsupportedRepositoryKind, Message: fmt.Sprintf("unsupported repository kind %q", requested)}, nil } + + if err := family.requireNamespace(req.Namespace); err != nil { + return Result{Outcome: OutcomeInvalidRequest, Message: err.Error()}, nil + } + + if !family.Namespaced { + // The namespace is part of a chart's identity only for a namespaced family: for + // a cluster-scoped one it is just the caller's authorization context and must not + // reach the resource name, the cache key or the family's lookups, or the same + // chart would resolve to a different auxiliary object per namespace. + req.Namespace = "" + } + + return r.resolveChart(ctx, family, req) } // chartVersion finds the catalog entry for the requested version. It reports only @@ -137,11 +164,9 @@ func (r *Resolver) Resolve(ctx context.Context, req Request) (Result, error) { // a registry down the HTTP path. // // A non-nil Result means the caller must stop and return it. -func (r *Resolver) chartVersion(ctx context.Context, req Request) (*helmv1alpha1.HelmClusterAddonChartVersion, *Result, error) { - chart := &helmv1alpha1.HelmClusterAddonChart{} - key := types.NamespacedName{Name: apinaming.HelmClusterAddonChartName(req.RepositoryName, req.Chart)} - - if err := r.client.Get(ctx, key, chart); err != nil { +func (r *Resolver) chartVersion(ctx context.Context, family repositoryFamily, req Request) (*helmv1alpha1.ChartVersion, *Result, error) { + versions, err := family.ChartVersions(ctx, r.client, req.Namespace, req.RepositoryName, req.Chart) + if err != nil { if apierrors.IsNotFound(err) { // The chart object is created by operator-helm-controller when it synchronizes // the repository: until then the catalog simply has not caught up. @@ -151,8 +176,8 @@ func (r *Resolver) chartVersion(ctx context.Context, req Request) (*helmv1alpha1 return nil, nil, fmt.Errorf("getting chart: %w", err) } - for i := range chart.Status.Versions { - version := &chart.Status.Versions[i] + for i := range versions { + version := &versions[i] if version.Version != req.Version { continue } @@ -179,7 +204,7 @@ func (r *Resolver) chartVersion(ctx context.Context, req Request) (*helmv1alpha1 // verdict on it, so an empty media type is a state rather than a value. // // A non-nil Result means the caller must stop and return it. -func ociMediaType(req Request, version *helmv1alpha1.HelmClusterAddonChartVersion) (string, *Result) { +func ociMediaType(req Request, version *helmv1alpha1.ChartVersion) (string, *Result) { if version.MediaType != "" { return version.MediaType, nil } @@ -204,7 +229,7 @@ func ociMediaType(req Request, version *helmv1alpha1.HelmClusterAddonChartVersio } // versionDetail renders why a catalog entry is unusable. -func versionDetail(version *helmv1alpha1.HelmClusterAddonChartVersion) string { +func versionDetail(version *helmv1alpha1.ChartVersion) string { detail := version.UnavailableReason if version.UnavailableMessage != "" { detail += ": " + version.UnavailableMessage @@ -213,11 +238,13 @@ func versionDetail(version *helmv1alpha1.HelmClusterAddonChartVersion) string { return detail } -// resolveHelmClusterAddon ensures the auxiliary source resource for a chart from -// a HelmClusterAddonRepository exists, inspects its status and returns the -// chart's values.yaml once the artifact is ready. -func (r *Resolver) resolveHelmClusterAddon(ctx context.Context, req Request) (Result, error) { - name := naming.AuxResourceName(string(req.Kind), req.RepositoryName, req.Chart, req.Version) +// resolveChart ensures the auxiliary source resource for one chart exists, inspects +// its status and returns the chart's values.yaml once the artifact is ready. Every +// repository kind takes this path; what differs — where the repository and its +// catalog are read from, and how its internal objects are recognised — arrives in +// the family. +func (r *Resolver) resolveChart(ctx context.Context, family repositoryFamily, req Request) (Result, error) { + name := naming.AuxResourceName(string(req.Kind), req.Namespace, req.RepositoryName, req.Chart, req.Version) // Fast path: the cache (keyed by the auxiliary resource name) is kept fresh by // the auxiliary-resource controller via a watch with a revision-change predicate, @@ -226,8 +253,8 @@ func (r *Resolver) resolveHelmClusterAddon(ctx context.Context, req Request) (Re return Result{Outcome: OutcomeReady, Values: values}, nil } - repo := &helmv1alpha1.HelmClusterAddonRepository{} - if err := r.client.Get(ctx, types.NamespacedName{Name: req.RepositoryName}, repo); err != nil { + repo, err := family.GetRepository(ctx, r.client, req.Namespace, req.RepositoryName) + if err != nil { if apierrors.IsNotFound(err) { return Result{Outcome: OutcomeRepositoryNotFound, Message: fmt.Sprintf("repository %q not found", req.RepositoryName)}, nil } @@ -236,7 +263,7 @@ func (r *Resolver) resolveHelmClusterAddon(ctx context.Context, req Request) (Re expiresAt := time.Now().UTC().Add(r.ttl).Format(time.RFC3339) - version, done, err := r.chartVersion(ctx, req) + version, done, err := r.chartVersion(ctx, family, req) if err != nil { return Result{}, err } @@ -252,7 +279,7 @@ func (r *Resolver) resolveHelmClusterAddon(ctx context.Context, req Request) (Re // The index publishes this version in a registry, whatever the repository's // own url scheme is. Reading it through a HelmChart would make the source // controller download the index url over HTTP and fail on the oci:// scheme. - ociRepo, done, err := r.ensureHybridOCIRepository(ctx, repo, req, name, expiresAt, version) + ociRepo, done, err := r.ensureHybridOCIRepository(ctx, family, repo, req, name, expiresAt, version) if err != nil { return Result{}, err } @@ -260,19 +287,19 @@ func (r *Resolver) resolveHelmClusterAddon(ctx context.Context, req Request) (Re return *done, nil } conditions, art = ociRepo.Status.Conditions, ociRepo.Status.Artifact - case isOCI(repo.Spec.URL): + case isOCI(repo.URL): mediaType, done := ociMediaType(req, version) if done != nil { return *done, nil } - ociRepo, err := r.ensureOCIRepository(ctx, repo, req, name, expiresAt, repo.Spec.URL, req.Version, mediaType, true, true) + ociRepo, err := r.ensureOCIRepository(ctx, family, repo, req, name, expiresAt, repo.URL, req.Version, mediaType, true, true) if err != nil { return Result{}, err } conditions, art = ociRepo.Status.Conditions, ociRepo.Status.Artifact - case isHelm(repo.Spec.URL): - chart, pending, err := r.ensureHelmChart(ctx, repo, req, name, expiresAt) + case isHelm(repo.URL): + chart, pending, err := r.ensureHelmChart(ctx, family, req, name, expiresAt) if err != nil { return Result{}, err } @@ -281,7 +308,7 @@ func (r *Resolver) resolveHelmClusterAddon(ctx context.Context, req Request) (Re } conditions, art = chart.Status.Conditions, chart.Status.Artifact default: - return Result{Outcome: OutcomeFetchFailed, Message: fmt.Sprintf("unsupported repository URL scheme: %q", repo.Spec.URL)}, nil + return Result{Outcome: OutcomeFetchFailed, Message: fmt.Sprintf("unsupported repository URL scheme: %q", repo.URL)}, nil } switch outcome, message := classify(conditions, art); outcome { @@ -310,14 +337,14 @@ func (r *Resolver) readValues(ctx context.Context, name string, art *meta.Artifa return Result{Outcome: OutcomeReady, Values: values}, nil } -func (r *Resolver) ensureHelmChart(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, req Request, name, expiresAt string) (*sourcev1.HelmChart, bool, error) { - helmRepoName, err := r.findHelmRepositoryName(ctx, repo.Name) +func (r *Resolver) ensureHelmChart(ctx context.Context, family repositoryFamily, req Request, name, expiresAt string) (*sourcev1.HelmChart, bool, error) { + helmRepoName, err := r.findHelmRepositoryName(ctx, family, req) if err != nil { return nil, false, err } if helmRepoName == "" { // The backing HelmRepository is created by operator-helm-controller when - // it reconciles the HelmClusterAddonRepository; until then, wait. + // it reconciles the repository; until then, wait. return nil, true, nil } @@ -352,12 +379,13 @@ func (r *Resolver) ensureHelmChart(ctx context.Context, repo *helmv1alpha1.HelmC // say whether the repository's own secrets describe the host being addressed. func (r *Resolver) ensureOCIRepository( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, + family repositoryFamily, + repo *repositorySpec, req Request, name, expiresAt, url, tag, mediaType string, credentials, tls bool, ) (*sourcev1.OCIRepository, error) { - authSecret, tlsSecret, err := r.findRepositorySecretNames(ctx, repo.Name) + authSecret, tlsSecret, err := r.findRepositorySecretNames(ctx, family, req) if err != nil { return nil, err } @@ -383,12 +411,12 @@ func (r *Resolver) ensureOCIRepository( ociRepo.Spec.SecretRef = nil ociRepo.Spec.CertSecretRef = nil if tls { - ociRepo.Spec.Insecure = repo.Spec.InsecureSkipVerify - if repo.Spec.CACertificate != "" && tlsSecret != "" { + ociRepo.Spec.Insecure = repo.InsecureSkipVerify + if repo.CACertificate != "" && tlsSecret != "" { ociRepo.Spec.CertSecretRef = &meta.LocalObjectReference{Name: tlsSecret} } } - if credentials && repo.Spec.Auth != nil && authSecret != "" { + if credentials && repo.Auth != nil && authSecret != "" { ociRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: authSecret} } @@ -406,10 +434,11 @@ func (r *Resolver) ensureOCIRepository( // and its answer is then carried by the source object's layer selector. func (r *Resolver) ensureHybridOCIRepository( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, + family repositoryFamily, + repo *repositorySpec, req Request, name, expiresAt string, - version *helmv1alpha1.HelmClusterAddonChartVersion, + version *helmv1alpha1.ChartVersion, ) (*sourcev1.OCIRepository, *Result, error) { url, tag, err := helmv1alpha1.SplitOCIRef(version.OCIRef, "") if err != nil { @@ -421,13 +450,13 @@ func (r *Resolver) ensureHybridOCIRepository( // The repository's transport settings describe the host it names. A registry only // its index names is reached as a public one, and its credentials are never sent // there. - sameHost := sameRegistryHost(repo.Spec.URL, url) + sameHost := sameRegistryHost(repo.URL, url) mediaType := version.MediaType if mediaType == "" { var rt http.RoundTripper if sameHost { - rt = chartartifact.Transport(repo.Spec.CACertificate, repo.Spec.InsecureSkipVerify) + rt = chartartifact.Transport(repo.CACertificate, repo.InsecureSkipVerify) } mediaType, err = r.prober.ChartLayerMediaType(ctx, version.OCIRef, rt) @@ -446,7 +475,7 @@ func (r *Resolver) ensureHybridOCIRepository( } } - ociRepo, err := r.ensureOCIRepository(ctx, repo, req, name, expiresAt, url, tag, mediaType, false, sameHost) + ociRepo, err := r.ensureOCIRepository(ctx, family, repo, req, name, expiresAt, url, tag, mediaType, false, sameHost) if err != nil { return nil, nil, err } @@ -471,12 +500,16 @@ func sameRegistryHost(repoURL, artifactURL string) bool { return repoHost.Host == artifactHost.Host } -func (r *Resolver) findHelmRepositoryName(ctx context.Context, repoName string) (string, error) { +// findHelmRepositoryName finds the internal HelmRepository operator-helm-controller +// derived from this repository. It is selected by the source labels of the +// repository's own kind: internal objects of every family live in one namespace, so +// the labels are what tell them apart. +func (r *Resolver) findHelmRepositoryName(ctx context.Context, family repositoryFamily, req Request) (string, error) { var list sourcev1.HelmRepositoryList if err := r.client.List( ctx, &list, client.InNamespace(r.namespace), - client.MatchingLabels{helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repoName}, + client.MatchingLabels(family.InternalLabels(req.Namespace, req.RepositoryName)), ); err != nil { return "", fmt.Errorf("listing helm repositories: %w", err) } @@ -488,12 +521,12 @@ func (r *Resolver) findHelmRepositoryName(ctx context.Context, repoName string) return list.Items[0].Name, nil } -func (r *Resolver) findRepositorySecretNames(ctx context.Context, repoName string) (auth, tls string, err error) { +func (r *Resolver) findRepositorySecretNames(ctx context.Context, family repositoryFamily, req Request) (auth, tls string, err error) { var list corev1.SecretList if err := r.client.List( ctx, &list, client.InNamespace(r.namespace), - client.MatchingLabels{helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repoName}, + client.MatchingLabels(family.InternalLabels(req.Namespace, req.RepositoryName)), ); err != nil { return "", "", fmt.Errorf("listing repository secrets: %w", err) } diff --git a/images/chart-values-controller/internal/resolver/resolver_test.go b/images/chart-values-controller/internal/resolver/resolver_test.go index 38514e31..3c5b5f7e 100644 --- a/images/chart-values-controller/internal/resolver/resolver_test.go +++ b/images/chart-values-controller/internal/resolver/resolver_test.go @@ -23,7 +23,7 @@ import ( "testing" "time" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -50,13 +50,13 @@ func newTestResolver(t *testing.T, objects ...client.Object) *Resolver { c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - return &Resolver{client: c} + return &Resolver{client: c, cache: cache.New(t.TempDir())} } -func chartWithVersions(repoName, chartName string, versions ...helmv1alpha1.HelmClusterAddonChartVersion) *helmv1alpha1.HelmClusterAddonChart { +func chartWithVersions(repoName, chartName string, versions ...helmv1alpha1.ChartVersion) *helmv1alpha1.HelmClusterAddonChart { return &helmv1alpha1.HelmClusterAddonChart{ ObjectMeta: metav1.ObjectMeta{Name: naming.HelmClusterAddonChartName(repoName, chartName)}, - Status: helmv1alpha1.HelmClusterAddonChartStatus{Versions: versions}, + Status: helmv1alpha1.ChartCatalogStatus{Versions: versions}, } } @@ -68,7 +68,7 @@ func TestOCIMediaType(t *testing.T) { req := Request{Kind: RepositoryKindHelmClusterAddon, RepositoryName: "example", Chart: "podinfo", Version: "6.7.1"} t.Run("a usable version returns its media type", func(t *testing.T) { - mediaType, done := ociMediaType(req, &helmv1alpha1.HelmClusterAddonChartVersion{ + mediaType, done := ociMediaType(req, &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", }) if done != nil { @@ -80,7 +80,7 @@ func TestOCIMediaType(t *testing.T) { }) t.Run("an unusable version is values_not_found with the reason", func(t *testing.T) { - _, done := ociMediaType(req, &helmv1alpha1.HelmClusterAddonChartVersion{ + _, done := ociMediaType(req, &helmv1alpha1.ChartVersion{ Version: "6.7.1", UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, UnavailableMessage: "config media type \"application/vnd.unknown.config.v1+json\" is not a helm chart config", @@ -94,7 +94,7 @@ func TestOCIMediaType(t *testing.T) { }) t.Run("a resolve-pending version is pending, not values_not_found", func(t *testing.T) { - _, done := ociMediaType(req, &helmv1alpha1.HelmClusterAddonChartVersion{ + _, done := ociMediaType(req, &helmv1alpha1.ChartVersion{ Version: "6.7.1", UnavailableReason: helmv1alpha1.UnavailableReasonResolvePending, }) @@ -108,14 +108,14 @@ func TestOCIMediaType(t *testing.T) { // oci:// repository had before this controller started recording verdicts. The // migration path (client.KnownVersions) treats this exactly like ResolvePending // and re-resolves it on the next normal synchronization. - _, done := ociMediaType(req, &helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1"}) + _, done := ociMediaType(req, &helmv1alpha1.ChartVersion{Version: "6.7.1"}) if done == nil || done.Outcome != OutcomePending { t.Fatalf("outcome is %+v, want pending: an empty verdict is the pre-upgrade migration state and must be retried, not reported as a permanent failure", done) } }) t.Run("a removed version keeps its media type usable", func(t *testing.T) { - mediaType, done := ociMediaType(req, &helmv1alpha1.HelmClusterAddonChartVersion{ + mediaType, done := ociMediaType(req, &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", UnavailableReason: helmv1alpha1.UnavailableReasonRemovedFromRepository, @@ -135,10 +135,10 @@ func TestChartVersion(t *testing.T) { t.Run("an existing version is returned as recorded", func(t *testing.T) { resolver := newTestResolver(t, chartWithVersions("example", "podinfo", - helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, + helmv1alpha1.ChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, )) - version, done, err := resolver.chartVersion(context.Background(), req) + version, done, err := resolver.chartVersion(context.Background(), mustFamily(t, req.Kind), req) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -155,10 +155,10 @@ func TestChartVersion(t *testing.T) { // verdict is a perfectly normal archive version, and reading it as "unresolved" // made every such version report pending forever. resolver := newTestResolver(t, chartWithVersions("example", "podinfo", - helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1"}, + helmv1alpha1.ChartVersion{Version: "6.7.1"}, )) - version, done, err := resolver.chartVersion(context.Background(), req) + version, done, err := resolver.chartVersion(context.Background(), mustFamily(t, req.Kind), req) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -172,14 +172,14 @@ func TestChartVersion(t *testing.T) { t.Run("an unaddressable index reference is values_not_found", func(t *testing.T) { resolver := newTestResolver(t, chartWithVersions("example", "podinfo", - helmv1alpha1.HelmClusterAddonChartVersion{ + helmv1alpha1.ChartVersion{ Version: "6.7.1", UnavailableReason: helmv1alpha1.UnavailableReasonInvalidChartReference, UnavailableMessage: "oci reference \"oci://BAD_HOST//:::\" is not a valid tagged reference", }, )) - _, done, err := resolver.chartVersion(context.Background(), req) + _, done, err := resolver.chartVersion(context.Background(), mustFamily(t, req.Kind), req) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -191,7 +191,7 @@ func TestChartVersion(t *testing.T) { t.Run("a missing chart is pending", func(t *testing.T) { resolver := newTestResolver(t) - _, done, err := resolver.chartVersion(context.Background(), req) + _, done, err := resolver.chartVersion(context.Background(), mustFamily(t, req.Kind), req) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -202,10 +202,10 @@ func TestChartVersion(t *testing.T) { t.Run("a missing version is pending", func(t *testing.T) { resolver := newTestResolver(t, chartWithVersions("example", "podinfo", - helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.0", MediaType: "application/tar+gzip"}, + helmv1alpha1.ChartVersion{Version: "6.7.0", MediaType: "application/tar+gzip"}, )) - _, done, err := resolver.chartVersion(context.Background(), req) + _, done, err := resolver.chartVersion(context.Background(), mustFamily(t, req.Kind), req) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -268,9 +268,9 @@ func newHybridResolver(t *testing.T, prober chartartifact.Prober, objects ...cli func TestResolveHybridVersionUsesOCIRepository(t *testing.T) { repo := &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example"}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://charts.example.invalid/stable"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/stable"}, } - chart := chartWithVersions("example", "nginx", helmv1alpha1.HelmClusterAddonChartVersion{ + chart := chartWithVersions("example", "nginx", helmv1alpha1.ChartVersion{ Version: "0.1.0", OCIRef: "oci://ghcr.io/drey/nginx/nginx:0.1.0", }) @@ -286,11 +286,11 @@ func TestResolveHybridVersionUsesOCIRepository(t *testing.T) { resolver, c := newHybridResolver(t, prober, repo, chart) - if _, err := resolver.resolveHelmClusterAddon(context.Background(), req); err != nil { + if _, err := resolver.resolveChart(context.Background(), mustFamily(t, req.Kind), req); err != nil { t.Fatalf("unexpected error: %v", err) } - name := cvnaming.AuxResourceName(string(req.Kind), req.RepositoryName, req.Chart, req.Version) + name := cvnaming.AuxResourceName(string(req.Kind), req.Namespace, req.RepositoryName, req.Chart, req.Version) key := client.ObjectKey{Name: name, Namespace: "d8-operator-helm"} ociRepo := &sourcev1.OCIRepository{} @@ -339,7 +339,7 @@ func TestResolveHybridVersionUsesOCIRepository(t *testing.T) { func TestResolveArchiveVersionUsesHelmChart(t *testing.T) { repo := &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "bitnami"}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://charts.example.invalid/bitnami"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/bitnami"}, } helmRepo := &sourcev1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ @@ -348,7 +348,7 @@ func TestResolveArchiveVersionUsesHelmChart(t *testing.T) { Labels: map[string]string{helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: "bitnami"}, }, } - chart := chartWithVersions("bitnami", "nginx", helmv1alpha1.HelmClusterAddonChartVersion{Version: "0.2.0"}) + chart := chartWithVersions("bitnami", "nginx", helmv1alpha1.ChartVersion{Version: "0.2.0"}) req := Request{ Kind: RepositoryKindHelmClusterAddon, @@ -359,7 +359,7 @@ func TestResolveArchiveVersionUsesHelmChart(t *testing.T) { resolver, c := newHybridResolver(t, nil, repo, chart, helmRepo) - result, err := resolver.resolveHelmClusterAddon(context.Background(), req) + result, err := resolver.resolveChart(context.Background(), mustFamily(t, req.Kind), req) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -367,7 +367,7 @@ func TestResolveArchiveVersionUsesHelmChart(t *testing.T) { t.Fatalf("an archive version must not be reported as unreadable: %+v", result) } - name := cvnaming.AuxResourceName(string(req.Kind), req.RepositoryName, req.Chart, req.Version) + name := cvnaming.AuxResourceName(string(req.Kind), req.Namespace, req.RepositoryName, req.Chart, req.Version) key := client.ObjectKey{Name: name, Namespace: "d8-operator-helm"} helmChart := &sourcev1.HelmChart{} @@ -378,3 +378,105 @@ func TestResolveArchiveVersionUsesHelmChart(t *testing.T) { t.Fatalf("chart version = %q, want 0.2.0", helmChart.Spec.Version) } } + +// TestResolveDispatchesOnTheRequestShape covers what the HTTP layer cannot: an +// unknown kind and a namespaced kind without a namespace are request errors, +// distinguishable from "the repository is gone". A namespace on a cluster-scoped +// kind is not a request error — see TestClusterScopedRequestsIgnoreAStrayNamespace +// for what happens to it instead. +func TestResolveDispatchesOnTheRequestShape(t *testing.T) { + r := newTestResolver(t) + + cases := []struct { + name string + req Request + want Outcome + }{ + { + name: "unknown kind", + req: Request{Kind: "somethingelse", RepositoryName: "example", Chart: "podinfo", Version: "6.7.1"}, + want: OutcomeUnsupportedRepositoryKind, + }, + { + name: "namespaced kind without a namespace", + req: Request{Kind: RepositoryKindHelmApplication, RepositoryName: "stable", Chart: "podinfo", Version: "6.7.1"}, + want: OutcomeInvalidRequest, + }, + { + name: "cluster addon kind with a namespace is not rejected", + req: Request{Kind: RepositoryKindHelmClusterAddon, Namespace: "team-a", RepositoryName: "example", Chart: "podinfo", Version: "6.7.1"}, + want: OutcomeRepositoryNotFound, + }, + { + // The spec requires a namespace here for authorization, even though it is a + // cluster-scoped repository kind: it must not be rejected, and it does not + // identify the repository, so "example" is still looked up cluster-wide. + name: "cluster application kind with a namespace is not rejected", + req: Request{Kind: RepositoryKindHelmClusterApplication, Namespace: "team-a", RepositoryName: "shared", Chart: "podinfo", Version: "6.7.1"}, + want: OutcomeRepositoryNotFound, + }, + { + name: "kind casing does not matter", + req: Request{Kind: "HelmApplicationRepository", Namespace: "team-a", RepositoryName: "missing", Chart: "podinfo", Version: "6.7.1"}, + want: OutcomeRepositoryNotFound, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := r.Resolve(context.Background(), tc.req) + if err != nil { + t.Fatalf("Resolve returned %v", err) + } + if got.Outcome != tc.want { + t.Fatalf("outcome = %q, want %q (message %q)", got.Outcome, tc.want, got.Message) + } + }) + } +} + +// TestClusterScopedRequestsIgnoreAStrayNamespace pins the rule the spec draws for +// the request namespace: it is part of a chart's identity only for a namespaced +// family. For a cluster-scoped kind — the addon family, whose contract must not +// move, and the cluster application family, which the caller must authorize through +// a namespace it does not otherwise use — a namespace on the request must resolve to +// the exact same auxiliary resource name and cache entry as a request without one. +// Otherwise the same chart would get a second, per-namespace copy of its internal +// objects for every namespace a caller happens to send. +func TestClusterScopedRequestsIgnoreAStrayNamespace(t *testing.T) { + for _, kind := range []RepositoryKind{RepositoryKindHelmClusterAddon, RepositoryKindHelmClusterApplication} { + t.Run(string(kind), func(t *testing.T) { + r := newTestResolver(t) + + req := Request{Kind: kind, RepositoryName: "example", Chart: "podinfo", Version: "6.7.1"} + name := cvnaming.AuxResourceName(string(kind), "", req.RepositoryName, req.Chart, req.Version) + if err := r.cache.Put(name, []byte("replicaCount: 1\n")); err != nil { + t.Fatalf("seeding cache: %v", err) + } + + withNamespace := req + withNamespace.Namespace = "team-a" + + got, err := r.Resolve(context.Background(), withNamespace) + if err != nil { + t.Fatalf("Resolve returned %v", err) + } + if got.Outcome != OutcomeReady || string(got.Values) != "replicaCount: 1\n" { + t.Fatalf("got %+v, want the entry cached without a namespace: a stray namespace must not miss it or create a second identity", got) + } + }) + } +} + +// mustFamily resolves the family of a request's kind, failing the test if the kind +// is unknown. +func mustFamily(t *testing.T, kind RepositoryKind) repositoryFamily { + t.Helper() + + family, ok := familyFor(kind) + if !ok { + t.Fatalf("no family for kind %q", kind) + } + + return family +} diff --git a/images/chart-values-controller/internal/server/server.go b/images/chart-values-controller/internal/server/server.go index db0147d7..a360c68a 100644 --- a/images/chart-values-controller/internal/server/server.go +++ b/images/chart-values-controller/internal/server/server.go @@ -20,11 +20,13 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "strconv" "strings" "time" + "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/controller-runtime/pkg/log" "github.com/deckhouse/chart-values-controller/internal/auth" @@ -36,6 +38,33 @@ import ( // prepared, so polling clients back off consistently. const retryAfterSeconds = 3 +const ( + // maxRequestBodyBytes bounds the request body before it is decoded. A + // legitimate request is five short string fields (repositoryKind, namespace, + // repositoryName, chart, version); 4 KiB is generous headroom over that and + // still small enough that an unauthenticated caller cannot use the body to + // hold the handler on an oversized read. + maxRequestBodyBytes = 4 << 10 // 4 KiB + + // readTimeout and writeTimeout bound how long a connection may take to send + // its request body or receive its response, closing the gap + // ReadHeaderTimeout alone leaves open: an unauthenticated client could + // otherwise hold either half of the exchange open indefinitely. + readTimeout = 10 * time.Second + writeTimeout = 10 * time.Second + + // maxChartLen bounds the chart field. Chart names are not restricted to a + // naming grammar — an index entry may legally contain a space — so only a + // generous length ceiling is enforced. + maxChartLen = 253 + + // maxVersionLen bounds the version field at the OCI Distribution Spec's own + // tag length limit (128 characters): a version may travel on as an OCI tag, + // and a Helm repository index version is always far shorter. It is not + // validated as semver, because an OCI tag is not one. + maxVersionLen = 128 +) + type chartValuesResolver interface { Resolve(ctx context.Context, req resolver.Request) (resolver.Result, error) } @@ -82,11 +111,7 @@ func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("POST /v1/chart-values", s.handleChartValues) - srv := &http.Server{ - Addr: s.addr, - Handler: mux, - ReadHeaderTimeout: 10 * time.Second, - } + srv := newHTTPServer(s.addr, mux) go func() { <-ctx.Done() @@ -111,8 +136,23 @@ func (s *Server) Start(ctx context.Context) error { return nil } +// newHTTPServer builds the http.Server the API is served through, with every +// timeout that keeps an unauthenticated client from holding a connection open +// indefinitely: ReadHeaderTimeout for the request line and headers, +// ReadTimeout for the body, and WriteTimeout for the response. +func newHTTPServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + } +} + type chartValuesRequest struct { RepositoryKind string `json:"repositoryKind"` + Namespace string `json:"namespace"` RepositoryName string `json:"repositoryName"` Chart string `json:"chart"` Version string `json:"version"` @@ -121,8 +161,24 @@ type chartValuesRequest struct { func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { logger := log.FromContext(r.Context()) + // The bearer token is read from the header alone, so this check can run + // before the body is even looked at: an unauthenticated caller is rejected + // without the cost of reading or decoding whatever it sent. + token, ok := bearerToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing or malformed Authorization header") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) + var req chartValuesRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeError(w, http.StatusRequestEntityTooLarge, "REQUEST_TOO_LARGE", "request body exceeds the size limit") + return + } writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "request body is not valid JSON") return } @@ -133,16 +189,40 @@ func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { return } - // Resolving a chart from a HelmClusterAddonRepository exposes the values that - // feed into a HelmClusterAddon, so the caller must be allowed to create one. - if strings.EqualFold(req.RepositoryKind, string(resolver.RepositoryKindHelmClusterAddon)) { - if !s.authorizeCreateHelmClusterAddon(w, r) { + // Answering exposes the values that feed into the resource of the repository's + // family, so the caller must be allowed to create one — in the namespace it + // would be created in, when that resource is namespaced. + access, displayKind, namespaced, ok := accessFor(req.RepositoryKind, req.Namespace) + if !ok { + writeError(w, http.StatusBadRequest, "UNSUPPORTED_REPOSITORY_KIND", + fmt.Sprintf("unsupported repository kind %q", req.RepositoryKind)) + return + } + if namespaced { + // A name no namespace could carry would otherwise travel as far as the + // access review and come back as a 403, which tells the caller nothing + // about the field they got wrong. + if errs := validation.IsDNS1123Label(req.Namespace); len(errs) > 0 { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", + "namespace is required for this repository kind and must be a valid namespace name") + return } } + if err := validateChartValuesFields(req); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) + return + } + + // The access review itself needs the resource derived above, so it cannot run + // any earlier than this. + if !s.authorize(w, r, token, access, displayKind) { + return + } result, err := s.resolver.Resolve(r.Context(), resolver.Request{ - Kind: resolver.RepositoryKind(req.RepositoryKind), + Kind: resolver.RepositoryKind(strings.ToLower(req.RepositoryKind)), + Namespace: req.Namespace, RepositoryName: req.RepositoryName, Chart: req.Chart, Version: req.Version, @@ -173,29 +253,100 @@ func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnprocessableEntity, "VALUES_NOT_FOUND", result.Message) case resolver.OutcomeFetchFailed: writeError(w, http.StatusBadGateway, "CHART_FETCH_FAILED", result.Message) + case resolver.OutcomeInvalidRequest: + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", result.Message) default: logger.Info("unexpected resolve outcome", "outcome", result.Outcome) writeError(w, http.StatusInternalServerError, "INTERNAL", "internal server error") } } -// authorizeCreateHelmClusterAddon reviews the request's bearer token and reports -// whether it may proceed. On any negative outcome it writes the response itself -// and returns false. -func (s *Server) authorizeCreateHelmClusterAddon(w http.ResponseWriter, r *http.Request) bool { - logger := log.FromContext(r.Context()) +// accessFor maps a repository kind to the permission that answering for it +// requires, plus the Kubernetes kind that permission is expressed in (for use in a +// message to the caller) and whether that permission is a namespaced question. Both +// application kinds are namespaced: even the cluster-wide repository's values reach +// a HelmApplication that lives in a namespace. Values of a chart from an application +// repository — namespaced or cluster-wide — feed into a HelmApplication in the +// request's namespace, so that is what the caller must be allowed to create; values +// from an addon repository feed into the cluster-scoped HelmClusterAddon. +func accessFor(kind, namespace string) (access auth.Access, displayKind string, namespaced, ok bool) { + switch strings.ToLower(kind) { + case string(resolver.RepositoryKindHelmClusterAddon): + return auth.Access{ + Group: helmv1alpha1.GroupName, + Resource: helmv1alpha1.HelmClusterAddonResource, + Verb: "create", + }, helmv1alpha1.HelmClusterAddonKind, false, true + case string(resolver.RepositoryKindHelmApplication), string(resolver.RepositoryKindHelmClusterApplication): + return auth.Access{ + Group: helmv1alpha1.GroupName, + Resource: helmv1alpha1.HelmApplicationResource, + Verb: "create", + Namespace: namespace, + }, helmv1alpha1.HelmApplicationKind, true, true + default: + return auth.Access{}, "", false, false + } +} - token, ok := bearerToken(r) - if !ok { - writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing or malformed Authorization header") - return false +// repositoryNameBounds returns the length bounds kind's own repository CRD +// enforces on metadata.name, so a repositoryName that could never have been +// created is rejected here instead of reaching the resolver and reading back as +// repository_not_found. A zero bound means the CRD imposes none beyond a valid +// object name: HelmClusterAddonRepository carries no name-length rule, while +// HelmApplicationRepository and HelmClusterApplicationRepository both require +// between 3 and 63 characters. +func repositoryNameBounds(kind string) (minLen, maxLen int) { + switch strings.ToLower(kind) { + case string(resolver.RepositoryKindHelmApplication), string(resolver.RepositoryKindHelmClusterApplication): + return 3, 63 + default: + return 0, 0 } +} - result, err := s.reviewer.Review(r.Context(), token, auth.Access{ - Group: helmv1alpha1.GroupName, - Resource: helmv1alpha1.HelmClusterAddonResource, - Verb: "create", - }) +// validateChartValuesFields checks repositoryName, chart and version beyond the +// mere non-emptiness already checked by the caller. +func validateChartValuesFields(req chartValuesRequest) error { + if errs := validation.IsDNS1123Subdomain(req.RepositoryName); len(errs) > 0 { + return fmt.Errorf("repositoryName must be a valid object name: %s", strings.Join(errs, "; ")) + } + minLen, maxLen := repositoryNameBounds(req.RepositoryKind) + if minLen > 0 && len(req.RepositoryName) < minLen { + return fmt.Errorf("repositoryName must be at least %d characters long", minLen) + } + if maxLen > 0 && len(req.RepositoryName) > maxLen { + return fmt.Errorf("repositoryName must be at most %d characters long", maxLen) + } + + // chart and version carry no naming grammar of their own — a repository index + // entry may legally contain a space, and an OCI tag is not semver — so only a + // whitespace-only value and a generous length bound are rejected. + if strings.TrimSpace(req.Chart) == "" { + return errors.New("chart must not be blank") + } + if len(req.Chart) > maxChartLen { + return fmt.Errorf("chart must be at most %d characters long", maxChartLen) + } + + if strings.TrimSpace(req.Version) == "" { + return errors.New("version must not be blank") + } + if len(req.Version) > maxVersionLen { + return fmt.Errorf("version must be at most %d characters long", maxVersionLen) + } + + return nil +} + +// authorize reviews token against access and reports whether the request may +// proceed. On any negative outcome it writes the response itself and returns +// false. displayKind names the Kubernetes kind access.Resource stands for, for the +// FORBIDDEN message. +func (s *Server) authorize(w http.ResponseWriter, r *http.Request, token string, access auth.Access, displayKind string) bool { + logger := log.FromContext(r.Context()) + + result, err := s.reviewer.Review(r.Context(), token, access) if err != nil { logger.Error(err, "failed to review request token") writeError(w, http.StatusInternalServerError, "INTERNAL", "internal server error") @@ -206,7 +357,7 @@ func (s *Server) authorizeCreateHelmClusterAddon(w http.ResponseWriter, r *http. return false } if !result.Authorized { - writeError(w, http.StatusForbidden, "FORBIDDEN", "not allowed to create HelmClusterAddon") + writeError(w, http.StatusForbidden, "FORBIDDEN", fmt.Sprintf("not allowed to create %s", displayKind)) return false } diff --git a/images/chart-values-controller/internal/server/server_test.go b/images/chart-values-controller/internal/server/server_test.go index 035d6809..70a9593d 100644 --- a/images/chart-values-controller/internal/server/server_test.go +++ b/images/chart-values-controller/internal/server/server_test.go @@ -46,6 +46,30 @@ func (f fakeReviewer) Review(_ context.Context, _ string, _ auth.Access) (auth.R return f.result, f.err } +type recordingReviewer struct { + result auth.Result + err error + access auth.Access +} + +func (f *recordingReviewer) Review(_ context.Context, _ string, access auth.Access) (auth.Result, error) { + f.access = access + + return f.result, f.err +} + +type recordingResolver struct { + result resolver.Result + err error + req resolver.Request +} + +func (f *recordingResolver) Resolve(_ context.Context, req resolver.Request) (resolver.Result, error) { + f.req = req + + return f.result, f.err +} + // authorized is the default reviewer for tests unconcerned with authorization. var authorized = fakeReviewer{result: auth.Result{Authenticated: true, Authorized: true}} @@ -194,3 +218,311 @@ func assertCode(t *testing.T, body []byte, field, want string) { t.Fatalf("%s = %v, want %q", field, resp[field], want) } } + +// TestHandleRejectsANamespacedKindWithoutANamespace covers the request-shape guard +// at the HTTP boundary: the resolver would refuse it too, but the client deserves a +// 400 naming the missing field rather than a generic outcome. +func TestHandleRejectsANamespacedKindWithoutANamespace(t *testing.T) { + cases := []struct { + name string + body string + }{ + { + name: "the field is absent", + body: `{"repositoryKind":"HelmApplicationRepository","repositoryName":"stable","chart":"podinfo","version":"6.7.1"}`, + }, + { + // Without this, a namespace no cluster can have reaches the access + // review and comes back as a 403 the caller cannot act on. + name: "the field holds a name no namespace can have", + body: `{"repositoryKind":"HelmApplicationRepository","namespace":" ","repositoryName":"stable","chart":"podinfo","version":"6.7.1"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, fakeResolver{}, tc.body) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertCode(t, rec.Body.Bytes(), "code", "INVALID_REQUEST") + }) + } +} + +// TestHandleAuthorizesPerFamily pins which permission each repository kind demands: +// the addon family a cluster-scoped create of HelmClusterAddon, both application +// kinds a create of HelmApplication in the request's namespace — that is the +// resource whose values are exposed by the answer. +func TestHandleAuthorizesPerFamily(t *testing.T) { + cases := []struct { + name string + body string + wantResource string + wantNamespace string + }{ + { + name: "addon", + body: validBody, + wantResource: "helmclusteraddons", + }, + { + name: "namespaced application repository", + body: `{"repositoryKind":"HelmApplicationRepository","namespace":"team-a","repositoryName":"stable","chart":"podinfo","version":"6.7.1"}`, + wantResource: "helmapplications", + wantNamespace: "team-a", + }, + { + name: "cluster application repository", + body: `{"repositoryKind":"HelmClusterApplicationRepository","namespace":"team-a","repositoryName":"shared","chart":"podinfo","version":"6.7.1"}`, + wantResource: "helmapplications", + wantNamespace: "team-a", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rev := &recordingReviewer{result: auth.Result{Authenticated: true, Authorized: true}} + rec := doAuth(t, fakeResolver{result: resolver.Result{Outcome: resolver.OutcomeReady}}, rev, tc.body) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) + } + if rev.access.Resource != tc.wantResource || rev.access.Namespace != tc.wantNamespace || rev.access.Verb != "create" { + t.Fatalf("access = %+v, want create on %s in %q", rev.access, tc.wantResource, tc.wantNamespace) + } + }) + } +} + +// TestHandlePassesTheNamespaceToTheResolver makes sure the namespace is not merely +// validated and dropped. +func TestHandlePassesTheNamespaceToTheResolver(t *testing.T) { + res := &recordingResolver{result: resolver.Result{Outcome: resolver.OutcomeReady}} + rec := doAuth(t, res, authorized, `{"repositoryKind":"HelmApplicationRepository","namespace":"team-a","repositoryName":"stable","chart":"podinfo","version":"6.7.1"}`) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if res.req.Namespace != "team-a" || res.req.Kind != resolver.RepositoryKindHelmApplication { + t.Fatalf("request = %+v, want the namespace and the lower-cased kind", res.req) + } +} + +// TestHandleInvalidRequestOutcome maps the resolver's request-shape refusal. +func TestHandleInvalidRequestOutcome(t *testing.T) { + rec := do(t, fakeResolver{result: resolver.Result{Outcome: resolver.OutcomeInvalidRequest, Message: "detail"}}, validBody) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertCode(t, rec.Body.Bytes(), "code", "INVALID_REQUEST") +} + +// TestHandleUnknownRepositoryKind pins the response contract for a kind the server +// does not recognise: it must be reported as UNSUPPORTED_REPOSITORY_KIND, the same +// code the resolver's own outcome of that name maps to, not a generic INVALID_REQUEST. +func TestHandleUnknownRepositoryKind(t *testing.T) { + rec := do(t, fakeResolver{}, `{"repositoryKind":"SomethingElse","repositoryName":"github","chart":"podinfo","version":"6.7.1"}`) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertCode(t, rec.Body.Bytes(), "code", "UNSUPPORTED_REPOSITORY_KIND") + assertCode(t, rec.Body.Bytes(), "error", `unsupported repository kind "SomethingElse"`) +} + +// TestHandleForbiddenMessageNamesTheResourceKind pins the FORBIDDEN message's +// wording: it names the Kubernetes kind the caller may not create (e.g. +// "HelmClusterAddon"), not the lower-cased plural resource string used in the +// SubjectAccessReview. +func TestHandleForbiddenMessageNamesTheResourceKind(t *testing.T) { + cases := []struct { + name string + body string + wantMsg string + }{ + {"addon", validBody, "not allowed to create HelmClusterAddon"}, + { + "namespaced application repository", + `{"repositoryKind":"HelmApplicationRepository","namespace":"team-a","repositoryName":"stable","chart":"podinfo","version":"6.7.1"}`, + "not allowed to create HelmApplication", + }, + { + "cluster application repository", + `{"repositoryKind":"HelmClusterApplicationRepository","namespace":"team-a","repositoryName":"shared","chart":"podinfo","version":"6.7.1"}`, + "not allowed to create HelmApplication", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := doAuth(t, fakeResolver{}, fakeReviewer{result: auth.Result{Authenticated: true, Authorized: false}}, tc.body) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + assertCode(t, rec.Body.Bytes(), "code", "FORBIDDEN") + assertCode(t, rec.Body.Bytes(), "error", tc.wantMsg) + }) + } +} + +// TestHandleRequestTooLarge covers the bound placed on the whole body: a +// legitimate request is five short string fields, so a client sending far more +// gets rejected with 413 instead of being decoded in full. +func TestHandleRequestTooLarge(t *testing.T) { + oversized := `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"github","chart":"` + + strings.Repeat("a", maxRequestBodyBytes) + `","version":"6.7.1"}` + + rec := do(t, fakeResolver{}, oversized) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413", rec.Code) + } + assertCode(t, rec.Body.Bytes(), "code", "REQUEST_TOO_LARGE") +} + +// TestHandleMissingTokenRejectsBeforeReadingAnOversizedBody pins the ordering fix: +// the cheap bearer-token check runs before the body is even read, so an +// unauthenticated caller sending an oversized body gets 401, not 413 — the body +// limit is never consulted for a request that never gets past authentication. +func TestHandleMissingTokenRejectsBeforeReadingAnOversizedBody(t *testing.T) { + oversized := `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"github","chart":"` + + strings.Repeat("a", maxRequestBodyBytes) + `","version":"6.7.1"}` + + srv := New("", fakeResolver{}, authorized, NewOptions{}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chart-values", strings.NewReader(oversized)) + srv.handleChartValues(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertCode(t, rec.Body.Bytes(), "code", "UNAUTHENTICATED") +} + +// TestNewHTTPServerSetsEveryTimeout pins the fix for the entry point that +// previously set only ReadHeaderTimeout: an unauthenticated client could hold the +// handler open on the rest of the request, or on an unfinished response. +func TestNewHTTPServerSetsEveryTimeout(t *testing.T) { + srv := newHTTPServer("", http.NewServeMux()) + + if srv.ReadHeaderTimeout <= 0 { + t.Fatal("ReadHeaderTimeout is not set") + } + if srv.ReadTimeout <= 0 { + t.Fatal("ReadTimeout is not set") + } + if srv.WriteTimeout <= 0 { + t.Fatal("WriteTimeout is not set") + } +} + +// TestHandleValidatesRepositoryName pins the fields the resolver received only an +// emptiness check for: an invalid name would otherwise reach the resolver and read +// back as repository_not_found, telling the caller nothing about the field they +// got wrong. +func TestHandleValidatesRepositoryName(t *testing.T) { + cases := []struct { + name string + body string + }{ + { + name: "not a valid object name", + body: `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"Not Valid!","chart":"podinfo","version":"6.7.1"}`, + }, + { + // HelmApplicationRepository's CRD enforces a 3-63 character name. + name: "shorter than the application repository CRD allows", + body: `{"repositoryKind":"HelmApplicationRepository","namespace":"team-a","repositoryName":"ab","chart":"podinfo","version":"6.7.1"}`, + }, + { + name: "longer than the application repository CRD allows", + body: `{"repositoryKind":"HelmApplicationRepository","namespace":"team-a","repositoryName":"` + + strings.Repeat("a", 64) + `","chart":"podinfo","version":"6.7.1"}`, + }, + { + name: "longer than the cluster application repository CRD allows", + body: `{"repositoryKind":"HelmClusterApplicationRepository","namespace":"team-a","repositoryName":"` + + strings.Repeat("a", 64) + `","chart":"podinfo","version":"6.7.1"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, fakeResolver{}, tc.body) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String()) + } + assertCode(t, rec.Body.Bytes(), "code", "INVALID_REQUEST") + }) + } +} + +// TestHandleAcceptsARepositoryNameAtTheAddonCRDsUnboundedLength pins the other +// side of the per-kind bound: HelmClusterAddonRepository's CRD imposes no length +// rule of its own, so a name under 3 characters (which the application repository +// CRDs would reject) must still be accepted for this kind. +func TestHandleAcceptsARepositoryNameAtTheAddonCRDsUnboundedLength(t *testing.T) { + rec := do(t, fakeResolver{result: resolver.Result{Outcome: resolver.OutcomeReady}}, + `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"ab","chart":"podinfo","version":"6.7.1"}`) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) + } +} + +// TestHandleValidatesChartAndVersion pins the whitespace-only and length bounds +// placed on chart and version. Neither field is checked against a naming grammar: +// chart names may contain a space (an index entry can legally be "my chart"), and +// an OCI tag is not semver, so imposing either grammar would reject legal input. +func TestHandleValidatesChartAndVersion(t *testing.T) { + cases := []struct { + name string + body string + }{ + { + name: "a whitespace-only version never matches a catalog entry and would poll pending forever", + body: `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"github","chart":"podinfo","version":" "}`, + }, + { + name: "a whitespace-only chart", + body: `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"github","chart":" ","version":"6.7.1"}`, + }, + { + name: "a chart longer than the bound", + body: `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"github","chart":"` + + strings.Repeat("a", maxChartLen+1) + `","version":"6.7.1"}`, + }, + { + name: "a version longer than the OCI tag bound", + body: `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"github","chart":"podinfo","version":"` + + strings.Repeat("1", maxVersionLen+1) + `"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, fakeResolver{}, tc.body) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String()) + } + assertCode(t, rec.Body.Bytes(), "code", "INVALID_REQUEST") + }) + } +} + +// TestHandleAcceptsAChartNameWithASpace pins that chart carries no character +// grammar: only a whitespace-only value and the length bound are rejected. +func TestHandleAcceptsAChartNameWithASpace(t *testing.T) { + rec := do(t, fakeResolver{result: resolver.Result{Outcome: resolver.OutcomeReady}}, + `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"github","chart":"my chart","version":"6.7.1"}`) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) + } +} diff --git a/images/helm-controller/werf.inc.yaml b/images/helm-controller/werf.inc.yaml index 5619cf27..b251fa42 100644 --- a/images/helm-controller/werf.inc.yaml +++ b/images/helm-controller/werf.inc.yaml @@ -1,4 +1,4 @@ -{{- $helmControllerTag := "v0.1.5" }} +{{- $helmControllerTag := "v1.6.4" }} --- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact final: false @@ -8,8 +8,8 @@ secrets: value: {{ .SOURCE_REPO }} shell: install: - - git clone --branch {{ $helmControllerTag }} --single-branch $(cat /run/secrets/SOURCE_REPO)/werf/3p-helm-controller.git /src/3p-helm-controller - - rm -rf /src/3p-helm-controller/.git + - git clone --branch {{ $helmControllerTag }} --single-branch $(cat /run/secrets/SOURCE_REPO)/fluxcd/helm-controller.git /src/helm-controller + - rm -rf /src/helm-controller/.git --- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact @@ -27,7 +27,7 @@ import: before: install shell: install: - - cd /src/3p-helm-controller + - cd /src/helm-controller - | export GOOS=linux export GOARCH=amd64 @@ -39,7 +39,7 @@ image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: base/distroless import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact - add: /src/3p-helm-controller/helm-controller + add: /src/helm-controller/helm-controller to: /usr/bin/helm-controller before: install imageSpec: diff --git a/images/hooks/Taskfile.dist.yaml b/images/hooks/Taskfile.dist.yaml index f95e456d..448a6d38 100644 --- a/images/hooks/Taskfile.dist.yaml +++ b/images/hooks/Taskfile.dist.yaml @@ -10,6 +10,12 @@ includes: gciPrefix: '{{.gciPrefix | default "github.com/deckhouse/"}}' golangciConfigPath: '{{.golangciConfigPath | default "./.golangci.yaml"}}' golangciLintBinDir: '{{.golangciLintBinDir | default "../../bin"}}' - golangciLintVersion: '{{.golangciLintVersion | default "v2.8.0"}}' + golangciLintVersion: '{{.golangciLintVersion | default "v2.13.2"}}' golangciPaths: '{{.golangciPaths | default "./..."}}' paths: '{{.paths | default "."}}' + +tasks: + test:unit: + desc: "Run the unit tests of this module." + cmds: + - go test ./... diff --git a/images/hooks/go.mod b/images/hooks/go.mod index a97c0f6d..515accc2 100644 --- a/images/hooks/go.mod +++ b/images/hooks/go.mod @@ -1,6 +1,6 @@ module hooks -go 1.25.0 +go 1.26.3 require ( github.com/deckhouse/module-sdk v0.10.2 diff --git a/images/hooks/pkg/hooks/cleanup-finalizers/hook.go b/images/hooks/pkg/hooks/cleanup-finalizers/hook.go index 41cb6a86..8ab78709 100644 --- a/images/hooks/pkg/hooks/cleanup-finalizers/hook.go +++ b/images/hooks/pkg/hooks/cleanup-finalizers/hook.go @@ -19,14 +19,16 @@ package cleanup_finalizers import ( "context" "fmt" - "hooks/pkg/kube" - "hooks/pkg/settings" + + "github.com/pkg/errors" "github.com/deckhouse/module-sdk/pkg" objectpatch "github.com/deckhouse/module-sdk/pkg/object-patch" "github.com/deckhouse/module-sdk/pkg/registry" "github.com/deckhouse/module-sdk/pkg/utils/ptr" - "github.com/pkg/errors" + + "hooks/pkg/kube" + "hooks/pkg/settings" ) const ( diff --git a/images/hooks/pkg/hooks/delete-namespace/hook.go b/images/hooks/pkg/hooks/delete-namespace/hook.go index 93f339db..fd28935d 100644 --- a/images/hooks/pkg/hooks/delete-namespace/hook.go +++ b/images/hooks/pkg/hooks/delete-namespace/hook.go @@ -19,11 +19,12 @@ package delete_namespace import ( "context" "fmt" - "hooks/pkg/kube" - "hooks/pkg/settings" "github.com/deckhouse/module-sdk/pkg" "github.com/deckhouse/module-sdk/pkg/registry" + + "hooks/pkg/kube" + "hooks/pkg/settings" ) var _ = registry.RegisterFunc(&pkg.HookConfig{ diff --git a/images/hooks/pkg/hooks/tls-certificates-controller/hook.go b/images/hooks/pkg/hooks/tls-certificates-controller/hook.go index c58223cb..4114392e 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/kube-api-rewriter/pkg/operatornelm/operatornelm_crds_test.go b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_crds_test.go new file mode 100644 index 00000000..48c7bd2b --- /dev/null +++ b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_crds_test.go @@ -0,0 +1,154 @@ +/* +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 ( + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + + "sigs.k8s.io/yaml" +) + +// definitionsGlob finds the definitions the generator writes from upstream +// flux. They are the other half of the rules in this package: the proxy +// renames a request to a group, kind and resource type, and one of these +// declares them. A resource renamed to something no definition declares is +// not found, and nothing but a live cluster says so. +const definitionsGlob = "../../../../crds/embedded/*.yaml" + +type definition struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + Group string `json:"group"` + Names 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"` + } `json:"names"` + Versions []struct { + Name string `json:"name"` + Served bool `json:"served"` + Storage bool `json:"storage"` + } `json:"versions"` + } `json:"spec"` +} + +func TestRulesMatchGeneratedDefinitions(t *testing.T) { + paths, err := filepath.Glob(definitionsGlob) + if err != nil || len(paths) == 0 { + t.Fatalf("no definitions found at %s: %v", definitionsGlob, err) + } + + separator := regexp.MustCompile(`(?m)^---$`) + + // Keyed by the name the API server knows, which is the one thing both + // sides derive independently. + definitions := make(map[string]definition) + + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + + for _, doc := range separator.Split(string(raw), -1) { + if strings.TrimSpace(doc) == "" { + continue + } + + var parsed definition + if err := yaml.Unmarshal([]byte(doc), &parsed); err != nil { + t.Fatalf("%s: %v", path, err) + } + + definitions[parsed.Metadata.Name] = parsed + } + } + + matched := make(map[string]bool, len(definitions)) + + for group, groupRule := range OperatorNelmAPIGroupsRules { + for resourceType, rule := range groupRule.ResourceRules { + name := OperatorNelmRewriteRules.ResourceTypePrefix + resourceType + "." + groupRule.GroupRule.Renamed + + parsed, ok := definitions[name] + if !ok { + t.Errorf("%s/%s is renamed to %s, which no definition declares", group, resourceType, name) + + continue + } + + matched[name] = true + + checks := map[string][2]any{ + "group": {parsed.Spec.Group, groupRule.GroupRule.Renamed}, + "kind": {parsed.Spec.Names.Kind, OperatorNelmRewriteRules.KindPrefix + rule.Kind}, + "list kind": {parsed.Spec.Names.ListKind, OperatorNelmRewriteRules.KindPrefix + rule.ListKind}, + "plural": {parsed.Spec.Names.Plural, OperatorNelmRewriteRules.ResourceTypePrefix + rule.Plural}, + "singular": {parsed.Spec.Names.Singular, OperatorNelmRewriteRules.ResourceTypePrefix + rule.Singular}, + "short names": {len(parsed.Spec.Names.ShortNames), 0}, + "categories": {len(parsed.Spec.Names.Categories), 0}, + } + + for what, pair := range checks { + if pair[0] != pair[1] { + t.Errorf("%s: %s is %v, the rule says %v", name, what, pair[0], pair[1]) + } + } + + var served []string + + storage := "" + + for _, version := range parsed.Spec.Versions { + if version.Served { + served = append(served, version.Name) + } + + if version.Storage { + storage = version.Name + } + } + + if !reflect.DeepEqual(served, rule.Versions) { + t.Errorf("%s: serves %v, the rule offers %v", name, served, rule.Versions) + } + + // The proxy answers discovery with the preferred version, so a + // definition storing a different one would have every write land + // in a conversion the module never asked for. + if storage != rule.PreferredVersion { + t.Errorf("%s: stores %q, the rule prefers %q", name, storage, rule.PreferredVersion) + } + } + } + + for name := range definitions { + if !matched[name] { + t.Errorf("%s is declared but no rule renames anything to it", name) + } + } +} diff --git a/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules.go b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules.go index e8e45e0c..9beecb99 100644 --- a/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules.go +++ b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules.go @@ -27,37 +27,36 @@ const ( 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}, + {Original: "source.toolkit.fluxcd.io", Renamed: "source." + internalPrefix}, + {Original: "helm.toolkit.fluxcd.io", Renamed: "helm." + internalPrefix}, }, Prefixes: []MetadataReplaceRule{ - {Original: "source.werf.io", Renamed: "source." + internalPrefix}, - {Original: "helm.werf.io", Renamed: "helm." + internalPrefix}, + {Original: "source.toolkit.fluxcd.io", Renamed: "source." + internalPrefix}, + {Original: "helm.toolkit.fluxcd.io", Renamed: "helm." + internalPrefix}, }, }, - //reconcile.internal.operator-helm.deckhouse.io/forceAt Annotations: MetadataReplace{ Names: []MetadataReplaceRule{ - {Original: "reconcile.werf.io/forceAt", Renamed: "reconcile." + internalPrefix + "/forceAt"}, - {Original: "reconcile.werf.io/requestedAt", Renamed: "reconcile." + internalPrefix + "/requestedAt"}, + {Original: "reconcile.fluxcd.io/requestedAt", Renamed: "reconcile." + internalPrefix + "/requestedAt"}, + {Original: "reconcile.fluxcd.io/forceAt", Renamed: "reconcile." + internalPrefix + "/forceAt"}, + // Unlike its siblings, resetAt is never written by this module: no + // live object carries it, so there is nothing stored to preserve. + // It joins them in the internal namespace instead of freezing on + // the fork's domain. + {Original: "reconcile.fluxcd.io/resetAt", Renamed: "reconcile." + internalPrefix + "/resetAt"}, }, Prefixes: []MetadataReplaceRule{ - {Original: "source.werf.io", Renamed: "source." + internalPrefix}, - {Original: "helm.werf.io", Renamed: "helm." + internalPrefix}, + {Original: "source.toolkit.fluxcd.io", Renamed: "source." + internalPrefix}, + {Original: "helm.toolkit.fluxcd.io", Renamed: "helm." + internalPrefix}, }, }, Finalizers: MetadataReplace{ Names: []MetadataReplaceRule{ - {Original: "finalizers.werf.io", Renamed: "finalizers." + internalPrefix}, - }, - Prefixes: []MetadataReplaceRule{ - {Original: "werf.io", Renamed: "werf." + internalPrefix}, + {Original: "finalizers.fluxcd.io", Renamed: "finalizers." + internalPrefix}, }, }, Excludes: []ExcludeRule{}, @@ -68,10 +67,10 @@ var OperatorNelmRewriteRules = &RewriteRules{ } var OperatorNelmAPIGroupsRules = map[string]APIGroupRule{ - "source.werf.io": { + "source.toolkit.fluxcd.io": { GroupRule: GroupRule{ - Group: "source.werf.io", - Versions: []string{"v1beta1", "v1beta2", "v1"}, + Group: "source.toolkit.fluxcd.io", + Versions: []string{"v1"}, PreferredVersion: "v1", Renamed: "source." + internalPrefix, }, @@ -81,7 +80,7 @@ var OperatorNelmAPIGroupsRules = map[string]APIGroupRule{ ListKind: "BucketList", Plural: "buckets", Singular: "bucket", - Versions: []string{"v1beta2", "v1"}, + Versions: []string{"v1"}, PreferredVersion: "v1", Categories: []string{}, ShortNames: []string{}, @@ -101,47 +100,47 @@ var OperatorNelmAPIGroupsRules = map[string]APIGroupRule{ ListKind: "GitRepositoryList", Plural: "gitrepositories", Singular: "gitrepository", - Versions: []string{"v1beta2", "v1"}, + Versions: []string{"v1"}, PreferredVersion: "v1", Categories: []string{}, - ShortNames: []string{"gitrepo"}, + ShortNames: []string{}, }, "helmcharts": { Kind: "HelmChart", ListKind: "HelmChartList", Plural: "helmcharts", Singular: "helmchart", - Versions: []string{"v1beta2", "v1"}, + Versions: []string{"v1"}, PreferredVersion: "v1", Categories: []string{}, - ShortNames: []string{"hc"}, + ShortNames: []string{}, }, "helmrepositories": { Kind: "HelmRepository", ListKind: "HelmRepositoryList", Plural: "helmrepositories", Singular: "helmrepository", - Versions: []string{"v1beta2", "v1"}, + Versions: []string{"v1"}, PreferredVersion: "v1", Categories: []string{}, - ShortNames: []string{"helmrepo"}, + ShortNames: []string{}, }, "ocirepositories": { Kind: "OCIRepository", ListKind: "OCIRepositoryList", Plural: "ocirepositories", Singular: "ocirepository", - Versions: []string{"v1beta2", "v1"}, + Versions: []string{"v1"}, PreferredVersion: "v1", Categories: []string{}, - ShortNames: []string{"ocirepo"}, + ShortNames: []string{}, }, }, }, - "helm.werf.io": { + "helm.toolkit.fluxcd.io": { GroupRule: GroupRule{ - Group: "helm.werf.io", - Versions: []string{"v2beta1", "v2beta2", "v2"}, + Group: "helm.toolkit.fluxcd.io", + Versions: []string{"v2"}, PreferredVersion: "v2", Renamed: "helm." + internalPrefix, }, @@ -151,10 +150,10 @@ var OperatorNelmAPIGroupsRules = map[string]APIGroupRule{ ListKind: "HelmReleaseList", Plural: "helmreleases", Singular: "helmrelease", - Versions: []string{"v2beta1", "v2beta2", "v2"}, + Versions: []string{"v2"}, PreferredVersion: "v2", Categories: []string{}, - ShortNames: []string{"hr"}, + ShortNames: []string{}, }, }, }, diff --git a/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules_test.go b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules_test.go index 876ed3f6..d0be66be 100644 --- a/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules_test.go +++ b/images/kube-api-rewriter/pkg/operatornelm/operatornelm_rules_test.go @@ -18,6 +18,8 @@ package operatornelm import ( "fmt" + "reflect" + "strings" "testing" "sigs.k8s.io/yaml" @@ -31,3 +33,113 @@ func TestOperatorNelmRulesToYAML(t *testing.T) { fmt.Printf("%s\n", string(b)) } + +func TestRulesMapUpstreamGroups(t *testing.T) { + source, ok := OperatorNelmAPIGroupsRules["source.toolkit.fluxcd.io"] + if !ok { + t.Fatal("the upstream source group has no rule") + } + if source.GroupRule.Renamed != "source.internal.operator-helm.deckhouse.io" { + t.Fatalf("source group renamed to %q", source.GroupRule.Renamed) + } + + helm, ok := OperatorNelmAPIGroupsRules["helm.toolkit.fluxcd.io"] + if !ok { + t.Fatal("the upstream helm group has no rule") + } + if helm.GroupRule.Renamed != "helm.internal.operator-helm.deckhouse.io" { + t.Fatalf("helm group renamed to %q", helm.GroupRule.Renamed) + } + + if _, ok := OperatorNelmAPIGroupsRules["source.werf.io"]; ok { + t.Fatal("the fork group is still mapped") + } + if _, ok := OperatorNelmAPIGroupsRules["helm.werf.io"]; ok { + t.Fatal("the fork group is still mapped") + } +} + +// TestRulesServeOneVersionPerKind pins what upstream actually serves at the +// pinned tags: the beta versions are gone, and declaring one the api server does +// not know makes discovery answer for a version nothing can serve. +func TestRulesServeOneVersionPerKind(t *testing.T) { + source := OperatorNelmAPIGroupsRules["source.toolkit.fluxcd.io"] + if !reflect.DeepEqual(source.GroupRule.Versions, []string{"v1"}) { + t.Fatalf("source versions = %v, want [v1]", source.GroupRule.Versions) + } + + for name, rule := range source.ResourceRules { + if !reflect.DeepEqual(rule.Versions, []string{"v1"}) { + t.Fatalf("%s versions = %v, want [v1]", name, rule.Versions) + } + } + + helm := OperatorNelmAPIGroupsRules["helm.toolkit.fluxcd.io"] + if !reflect.DeepEqual(helm.GroupRule.Versions, []string{"v2"}) { + t.Fatalf("helm versions = %v, want [v2]", helm.GroupRule.Versions) + } +} + +// TestMetadataRenamesKeepTheStoredSide pins the constraint the whole migration +// rests on: objects already live in clusters, so what is written into them must +// not move. Only the left side of the table follows upstream. +func TestMetadataRenamesKeepTheStoredSide(t *testing.T) { + const internal = "internal.operator-helm.deckhouse.io" + + wantAnnotations := map[string]string{ + "reconcile.fluxcd.io/requestedAt": "reconcile." + internal + "/requestedAt", + "reconcile.fluxcd.io/forceAt": "reconcile." + internal + "/forceAt", + // resetAt moves into the internal namespace too; see the rule's comment for why. + "reconcile.fluxcd.io/resetAt": "reconcile." + internal + "/resetAt", + } + got := map[string]string{} + for _, rule := range OperatorNelmRewriteRules.Annotations.Names { + got[rule.Original] = rule.Renamed + } + if !reflect.DeepEqual(got, wantAnnotations) { + t.Fatalf("annotation names = %v, want %v", got, wantAnnotations) + } + + wantFinalizers := map[string]string{ + "finalizers.fluxcd.io": "finalizers." + internal, + } + got = map[string]string{} + for _, rule := range OperatorNelmRewriteRules.Finalizers.Names { + got[rule.Original] = rule.Renamed + } + if !reflect.DeepEqual(got, wantFinalizers) { + t.Fatalf("finalizer names = %v, want %v", got, wantFinalizers) + } + + for _, rule := range OperatorNelmRewriteRules.Labels.Names { + if !strings.HasSuffix(rule.Renamed, internal) { + t.Fatalf("label %q renamed to %q, outside the internal prefix", rule.Original, rule.Renamed) + } + if !strings.HasSuffix(rule.Original, "toolkit.fluxcd.io") { + t.Fatalf("label rule still matches the fork: %q", rule.Original) + } + } +} + +// TestNoShortNamesAndNoCategory pins that the internal kinds claim neither. The +// upstream short names would take "hr" and "hc" from a real flux in the cluster, +// and the upstream categories include "all". +func TestNoShortNamesAndNoCategory(t *testing.T) { + if OperatorNelmRewriteRules.ShortNamePrefix != "" { + t.Fatalf("short name prefix is %q, want none", OperatorNelmRewriteRules.ShortNamePrefix) + } + if len(OperatorNelmRewriteRules.Categories) != 0 { + t.Fatalf("categories = %v, want none", OperatorNelmRewriteRules.Categories) + } + + for group, rules := range OperatorNelmAPIGroupsRules { + for name, rule := range rules.ResourceRules { + if len(rule.ShortNames) != 0 { + t.Fatalf("%s/%s declares short names %v", group, name, rule.ShortNames) + } + if len(rule.Categories) != 0 { + t.Fatalf("%s/%s declares categories %v", group, name, rule.Categories) + } + } + } +} diff --git a/images/operator-helm-controller/Taskfile.dist.yaml b/images/operator-helm-controller/Taskfile.dist.yaml index 27664273..7bf197ea 100644 --- a/images/operator-helm-controller/Taskfile.dist.yaml +++ b/images/operator-helm-controller/Taskfile.dist.yaml @@ -10,6 +10,12 @@ includes: gciPrefix: '{{.gciPrefix | default "github.com/deckhouse/"}}' golangciConfigPath: '{{.golangciConfigPath | default "./.golangci.yaml"}}' golangciLintBinDir: '{{.golangciLintBinDir | default "../../bin"}}' - golangciLintVersion: '{{.golangciLintVersion | default "v2.8.0"}}' + golangciLintVersion: '{{.golangciLintVersion | default "v2.13.2"}}' golangciPaths: '{{.golangciPaths | default "./..."}}' paths: '{{.paths | default "."}}' + +tasks: + test:unit: + desc: "Run the unit tests of this module." + cmds: + - go test ./... diff --git a/images/operator-helm-controller/cmd/operator-helm-controller/main.go b/images/operator-helm-controller/cmd/operator-helm-controller/main.go index fdf9f8d0..05b3c856 100644 --- a/images/operator-helm-controller/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-controller/cmd/operator-helm-controller/main.go @@ -20,25 +20,40 @@ import ( "flag" "os" - helmv2 "github.com/werf/3p-helm-controller/api/v2" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/labels" "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/cache" + "sigs.k8s.io/controller-runtime/pkg/client" "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/internal/controller/helmapplication" + "github.com/deckhouse/operator-helm/internal/controller/helmapplicationrepository" "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddon" "github.com/deckhouse/operator-helm/internal/controller/helmclusteraddonrepository" + "github.com/deckhouse/operator-helm/internal/controller/helmclusterapplicationrepository" "github.com/deckhouse/operator-helm/internal/index" + helmapplicationwebhook "github.com/deckhouse/operator-helm/internal/webhook/helmapplication" helmclusteraddonwebhook "github.com/deckhouse/operator-helm/internal/webhook/helmclusteraddon" ) var scheme = runtime.NewScheme() +// managedByOperatorHelm selects the RBAC objects this module manages in the users' +// namespaces. +var managedByOperatorHelm = labels.SelectorFromSet(labels.Set{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, +}) + func init() { _ = clientgoscheme.AddToScheme(scheme) _ = helmv1alpha1.AddToScheme(scheme) @@ -71,6 +86,37 @@ func main() { HealthProbeBindAddress: healthProbeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "operator-helm-controller.helm.deckhouse.io", + Client: client.Options{ + // AccessService reads these three kinds only to reconcile the objects its + // own release names. The Roles and RoleBindings it manages are watched, but + // through an informer that selects on the managed-by label — an object + // stripped of the label is missing from it, and reading through it would + // then report an object that exists as absent and try to create it again. + // Reads go to the API server for that reason, and for ServiceAccounts + // because nothing watches them at all and a cached typed Get would start a + // cluster-wide informer for the kind. + Cache: &client.CacheOptions{ + DisableFor: []client.Object{&corev1.ServiceAccount{}, &rbacv1.Role{}, &rbacv1.RoleBinding{}}, + }, + }, + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{ + // The repository controllers watch Secrets, and every Secret they read + // or write lives in the module namespace, so the informer is scoped + // there instead of holding every Secret in the cluster in memory. + &corev1.Secret{}: { + Namespaces: map[string]cache.Config{ + helmv1alpha1.TargetNamespace: {}, + }, + }, + // The application controller watches the Roles and RoleBindings making + // up a release identity, in whichever namespace the application lives, + // so neither informer can be scoped by namespace. The label is what + // keeps them off every other Role and RoleBinding in the cluster. + &rbacv1.Role{}: {Label: managedByOperatorHelm}, + &rbacv1.RoleBinding{}: {Label: managedByOperatorHelm}, + }, + }, }) if err != nil { logger.Error(err, "unable to create manager") @@ -82,11 +128,36 @@ func main() { os.Exit(1) } + if err := index.SetupApplicationRepository(mgr); err != nil { + logger.Error(err, "unable to setup indexes", "index", index.ApplicationRepository) + os.Exit(1) + } + + if err := index.SetupApplicationChart(mgr); err != nil { + logger.Error(err, "unable to setup indexes", "index", index.ApplicationChart) + os.Exit(1) + } + if err := helmclusteraddonrepository.SetupWithManager(mgr); err != nil { logger.Error(err, "unable to setup HelmClusterAddonRepository controller") os.Exit(1) } + if err := helmapplicationrepository.SetupWithManager(mgr); err != nil { + logger.Error(err, "unable to setup HelmApplicationRepository controller") + os.Exit(1) + } + + if err := helmclusterapplicationrepository.SetupWithManager(mgr); err != nil { + logger.Error(err, "unable to setup HelmClusterApplicationRepository controller") + os.Exit(1) + } + + if err := helmapplication.SetupWithManager(mgr); err != nil { + logger.Error(err, "unable to setup HelmApplication controller") + os.Exit(1) + } + if err := helmclusteraddon.SetupWithManager(mgr); err != nil { logger.Error(err, "unable to setup HelmClusterAddon controller") os.Exit(1) @@ -102,6 +173,11 @@ func main() { os.Exit(1) } + if err = helmapplicationwebhook.SetupWebhookWithManager(mgr); err != nil { + logger.Error(err, "unable to create webhook", "webhook", "HelmApplication") + 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-controller/go.mod b/images/operator-helm-controller/go.mod index 4db359c9..9cb6e476 100644 --- a/images/operator-helm-controller/go.mod +++ b/images/operator-helm-controller/go.mod @@ -5,24 +5,40 @@ go 1.26.3 replace github.com/deckhouse/operator-helm/api => ../../api require ( - github.com/Masterminds/semver/v3 v3.4.0 + github.com/Masterminds/semver/v3 v3.5.0 github.com/deckhouse/operator-helm/api v0.0.0-00010101000000-000000000000 + github.com/fluxcd/helm-controller/api v1.6.4 + github.com/fluxcd/pkg/apis/meta v1.30.2 + github.com/fluxcd/pkg/chartutil v1.27.2 + github.com/fluxcd/source-controller/api v1.9.5 github.com/google/go-containerregistry v0.20.6 github.com/opencontainers/go-digest v1.0.0 github.com/samber/lo v1.53.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.5 - github.com/werf/nelm-source-controller/api v0.1.5 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/sync v0.19.0 - helm.sh/helm/v3 v3.20.2 - k8s.io/api v0.35.1 - k8s.io/apimachinery v0.35.1 - k8s.io/client-go v0.35.1 - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + golang.org/x/sync v0.21.0 + helm.sh/helm/v4 v4.2.2 + k8s.io/api v0.36.4 + k8s.io/apimachinery v0.36.4 + k8s.io/client-go v0.36.4 + k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 sigs.k8s.io/cli-utils v0.37.2 - sigs.k8s.io/controller-runtime v0.23.1 + sigs.k8s.io/controller-runtime v0.24.1 +) + +require ( + github.com/fluxcd/pkg/apis/acl v0.10.0 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.19.2 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect ) require ( @@ -33,23 +49,23 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/docker/cli v29.2.0+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/docker/docker-credential-helpers v0.9.5 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // 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/fxamacker/cbor/v2 v2.9.2 // 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/go-openapi/swag v0.25.4 // 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/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.4 // 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 @@ -62,35 +78,33 @@ require ( 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/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.4 // 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 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/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 + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/time v0.15.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // 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 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 + k8s.io/apiextensions-apiserver v0.36.4 + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25 // 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/structured-merge-diff/v6 v6.4.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/images/operator-helm-controller/go.sum b/images/operator-helm-controller/go.sum index bbec7d6f..63dc9570 100644 --- a/images/operator-helm-controller/go.sum +++ b/images/operator-helm-controller/go.sum @@ -2,6 +2,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9 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/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.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= @@ -22,16 +24,34 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi 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/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= 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/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/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/fluxcd/helm-controller/api v1.6.4 h1:jDgB87F6RRNF0QEZDAw+8+HPU13toF5IZpPsytA0Nuo= +github.com/fluxcd/helm-controller/api v1.6.4/go.mod h1:mYa4WKwgCCE59dMdYzOJI6B17/wofN6wtmtjWWEQesQ= +github.com/fluxcd/pkg/apis/acl v0.10.0 h1:KPfAmELNvtvaz8wixnm/MYXqa+MJf7ntVVMUU93Aenk= +github.com/fluxcd/pkg/apis/acl v0.10.0/go.mod h1:a87i2A7AlFO5N2J8CxtzaUCCDmuLLWOHwkKu3eJF5fY= +github.com/fluxcd/pkg/apis/kustomize v1.19.2 h1:/E8Nmn1XdAGUIqxI5vK60WemyBX3pIpfmP3mr/E3p9c= +github.com/fluxcd/pkg/apis/kustomize v1.19.2/go.mod h1:HryXaJ1GpvagUOmh5uMkVtXVlfXWdKjc3/5IlLsjyzI= +github.com/fluxcd/pkg/apis/meta v1.30.2 h1:FbSQsUqLZrnyFhGqc0uE5zJOOu42OQ8YpO0vXmj+g5o= +github.com/fluxcd/pkg/apis/meta v1.30.2/go.mod h1:xc7Z4qD5ikDVfjMDYgmFbLJiwJQaaXLkocqmxhywXzA= +github.com/fluxcd/pkg/chartutil v1.27.2 h1:Ghf0PRrJ5vKEgXRrbuti9/efpHnrw9Wh6QBRf0JxdfA= +github.com/fluxcd/pkg/chartutil v1.27.2/go.mod h1:NeLXZUTWI5KhIQZOzFtVCM3VTg6QiaoQSlTXKYUPz3U= +github.com/fluxcd/source-controller/api v1.9.5 h1:QwOqmw6/NqOXUR+kGmBJ18CEvthD8DNrSRTtjQG+bHQ= +github.com/fluxcd/source-controller/api v1.9.5/go.mod h1:Y5mcHYzML/mJYjvSJRcIo7eLLjd+cZjnKR6WeIGrouE= 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/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/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= @@ -42,6 +62,30 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF 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-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= 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= @@ -66,6 +110,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr 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/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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= @@ -90,8 +136,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq 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/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= 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/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= 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= @@ -107,8 +155,12 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= 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/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= @@ -117,6 +169,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV 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/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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= @@ -149,8 +203,12 @@ 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.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/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/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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= @@ -159,17 +217,24 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh 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/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= 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/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= 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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= @@ -177,19 +242,28 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w 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/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= 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/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= 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/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= 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= @@ -198,6 +272,8 @@ 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= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= @@ -212,29 +288,49 @@ 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.2 h1:binM4rvPx5DcNsa1sIt7UZi55lRbu3pZUFmQkSoRh48= helm.sh/helm/v3 v3.20.2/go.mod h1:Fl1kBaWCpkUrM6IYXPjQ3bdZQfFrogKArqptvueZ6Ww= +helm.sh/helm/v4 v4.2.2 h1:E2zSCA2uUm9PNiZsSC/BioDVGsYk7nF2jNJFg/i+Dng= +helm.sh/helm/v4 v4.2.2/go.mod h1:dp3ihfy1AhCLKANDaPETmVWhqPkOmwvJtpK/biHfopE= k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/api v0.36.4 h1:RxrvqCL6vgH5/+UnTeu1IIFqYmGfy0hnyrod1rn35Oo= +k8s.io/api v0.36.4/go.mod h1:S2B3orCFBDhrgyWbLeuKcT2QdHIpQesBkCYSlWtwUOw= k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= +k8s.io/apiextensions-apiserver v0.36.4 h1:SfvCVt+4CqKWvzuVytYDT5g9hyb9MztoiYELIkPVrFc= +k8s.io/apiextensions-apiserver v0.36.4/go.mod h1:JT9V2Ju7ys1FY4zbSpmX9XOvKB3/BwsODc4hFQEa+Xo= k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apimachinery v0.36.4 h1:PT2UzkupGuAx/+xT5XjiMJ1WGpY3fn9/hdAvjweRet4= +k8s.io/apimachinery v0.36.4/go.mod h1:p2I2dipt7JHG+quVwQ1d02d28O4GdDi77RByQ13MTpk= 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/client-go v0.36.4 h1:MDvfDNvMSt0Br94SK8neviVlwL9qifw9B26hJCpD1K0= +k8s.io/client-go v0.36.4/go.mod h1:pNK4WKELbwlEDvtbE8l22lEZL5THYF61H5EealokZmA= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= 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/kube-openapi v0.0.0-20260603220949-865597e52e25 h1:mPMaPMpBij2V1Wv/fR+HW124vVGXXvOSS9ver/9yjWs= +k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= +k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/cli-utils v0.37.2 h1:GOfKw5RV2HDQZDJlru5KkfLO1tbxqMoyn1IYUxqBpNg= sigs.k8s.io/cli-utils v0.37.2/go.mod h1:V+IZZr4UoGj7gMJXklWBg6t5xbdThFBcpj4MrZuCYco= 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/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= 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/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0/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-controller/internal/adapter/addon_release.go b/images/operator-helm-controller/internal/adapter/addon_release.go new file mode 100644 index 00000000..174ac9bf --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/addon_release.go @@ -0,0 +1,178 @@ +/* +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 adapter + +import ( + "context" + "fmt" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/operator-helm/api/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/index" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +var _ source.Release = (*AddonRelease)(nil) + +// AddonRelease adapts a HelmClusterAddon. Its labels and internal names are exactly +// what the addon controller has always written; the release name is the addon name +// bounded to Helm's limit, which changes nothing for a name that already fits. +type AddonRelease struct { + obj *helmv1alpha1.HelmClusterAddon +} + +func NewAddonRelease(obj *helmv1alpha1.HelmClusterAddon) *AddonRelease { + return &AddonRelease{obj: obj} +} + +// EmptyAddonRelease returns an adapter around a zero object, for the reconciler to +// read the API object into. +func EmptyAddonRelease() source.Release { + return NewAddonRelease(&helmv1alpha1.HelmClusterAddon{}) +} + +func (r *AddonRelease) Object() status.ObjectWithConditions { return r.obj } +func (r *AddonRelease) Kind() string { return helmv1alpha1.HelmClusterAddonKind } +func (r *AddonRelease) Name() string { return r.obj.Name } +func (r *AddonRelease) Namespace() string { return r.obj.Namespace } +func (r *AddonRelease) Generation() int64 { return r.obj.Generation } + +func (r *AddonRelease) ChartRef() source.ChartRef { + return source.ChartRef{ + Repository: source.RepositoryRef{ + Kind: helmv1alpha1.HelmClusterAddonRepositoryKind, + Name: r.obj.Spec.Chart.HelmClusterAddonRepository, + }, + Chart: r.obj.Spec.Chart.HelmClusterAddonChartName, + Version: r.obj.Spec.Chart.Version, + } +} + +func (r *AddonRelease) TargetNamespace() string { return r.obj.Spec.Namespace } +func (r *AddonRelease) Values() *apiextensionsv1.JSON { return r.obj.Spec.Values } +func (r *AddonRelease) MaintenanceActivated() bool { return r.obj.MaintenanceModeActivated() } +func (r *AddonRelease) MaintenanceEnabled() bool { return r.obj.MaintenanceModeEnabled() } +func (r *AddonRelease) ForceReconcileRequired() bool { return r.obj.ForceReconcileRequired() } +func (r *AddonRelease) ReleaseName() string { return utils.HelmReleaseName(r.obj.Name) } +func (r *AddonRelease) IsChartStatusInfoOutdated() bool { return r.obj.IsChartStatusInfoOutdated() } +func (r *AddonRelease) LastAppliedValues() *apiextensionsv1.JSON { + return r.obj.Status.LastAppliedValues +} + +func (r *AddonRelease) SourceLabels() map[string]string { + return map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonLabelSourceName: r.obj.Name, + } +} + +func (r *AddonRelease) HelmChartLabels() map[string]string { + labels := r.SourceLabels() + labels[helmv1alpha1.HelmClusterAddonChartLabelSourceName] = naming.HelmClusterAddonChartName( + r.obj.Spec.Chart.HelmClusterAddonRepository, r.obj.Spec.Chart.HelmClusterAddonChartName, + ) + + return labels +} + +func (r *AddonRelease) InternalNames() source.ReleaseNames { + return source.ReleaseNames{ + HelmChart: utils.GetInternalHelmChartName(r.obj.Name), + HelmRelease: utils.GetInternalHelmReleaseName(r.obj.Name), + OCIRepository: utils.GetInternalOCIRepositoryName(r.obj.Name), + } +} + +func (r *AddonRelease) LastAppliedChart() *source.ChartRef { + last := r.obj.Status.LastAppliedChart + if last == nil { + return nil + } + + return &source.ChartRef{ + Repository: source.RepositoryRef{ + Kind: helmv1alpha1.HelmClusterAddonRepositoryKind, + Name: last.HelmClusterAddonRepository, + }, + Chart: last.HelmClusterAddonChartName, + Version: last.Version, + } +} + +func (r *AddonRelease) SetLastAppliedChart(ref source.ChartRef) { + r.obj.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonChartName: ref.Chart, + HelmClusterAddonRepository: ref.Repository.Name, + Version: ref.Version, + } +} + +func (r *AddonRelease) SetLastAppliedValues(values *apiextensionsv1.JSON) { + r.obj.Status.LastAppliedValues = values +} + +func (r *AddonRelease) SetLastForceReconcileTime(t metav1.Time) { + r.obj.Status.LastForceReconcileTime = &t +} + +// addonRepositoryResolver loads the one repository kind an addon can reference. +type addonRepositoryResolver struct { + client client.Client + catalog source.Catalog +} + +func NewAddonRepositoryResolver(c client.Client) *addonRepositoryResolver { + return &addonRepositoryResolver{client: c, catalog: NewAddonCatalog(c)} +} + +func (r *addonRepositoryResolver) Resolve(ctx context.Context, ref source.RepositoryRef) (source.Repository, source.Catalog, error) { + repo := &helmv1alpha1.HelmClusterAddonRepository{} + if err := r.client.Get(ctx, client.ObjectKey{Name: ref.Name}, repo); err != nil { + return nil, nil, fmt.Errorf("getting %s %q: %w", helmv1alpha1.HelmClusterAddonRepositoryKind, ref.Name, err) + } + + return NewAddonRepository(repo), r.catalog, nil +} + +// ListAddonReleases lists the HelmClusterAddon objects consuming a repository, or +// only those consuming one of its charts, through the two addon indexes. +func ListAddonReleases(c client.Client) source.ReleaseLister { + return func(ctx context.Context, repo source.Repository, chartName string) ([]source.Release, error) { + selector := client.MatchingFields{index.AddonRepository: repo.Name()} + if chartName != "" { + selector = client.MatchingFields{index.AddonChart: index.AddonChartValue(repo.Name(), chartName)} + } + + var addons helmv1alpha1.HelmClusterAddonList + if err := c.List(ctx, &addons, selector); err != nil { + return nil, fmt.Errorf("listing addons of repository %q: %w", repo.Name(), err) + } + + releases := make([]source.Release, 0, len(addons.Items)) + for i := range addons.Items { + releases = append(releases, NewAddonRelease(&addons.Items[i])) + } + + return releases, nil + } +} diff --git a/images/operator-helm-controller/internal/adapter/addon_release_test.go b/images/operator-helm-controller/internal/adapter/addon_release_test.go new file mode 100644 index 00000000..a40e50a8 --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/addon_release_test.go @@ -0,0 +1,224 @@ +/* +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 adapter + +import ( + "context" + "reflect" + "testing" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/operator-helm/api/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/utils" +) + +func addonRelease(name string) *helmv1alpha1.HelmClusterAddon { + return &helmv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{Name: name, Generation: 2, Annotations: map[string]string{helmv1alpha1.AnnotationForceReconcile: ""}}, + Spec: helmv1alpha1.HelmClusterAddonSpec{ + Namespace: "app", + Maintenance: string(helmv1alpha1.NoResourceReconciliation), + Values: &apiextensionsv1.JSON{Raw: []byte(`{"replicas":2}`)}, + Chart: helmv1alpha1.HelmClusterAddonChartRef{ + HelmClusterAddonRepository: "example", + HelmClusterAddonChartName: "podinfo", + Version: "6.7.1", + }, + }, + } +} + +// TestAddonReleaseKeepsTheReleasedNamesAndLabels pins the adapter to what the addon +// controller writes today. The three internal names come from the frozen naming +// functions; the release name is the addon name itself for every name Helm accepts. +func TestAddonReleaseKeepsTheReleasedNamesAndLabels(t *testing.T) { + obj := addonRelease("consumer") + rel := NewAddonRelease(obj) + + if rel.Object() != obj { + t.Fatal("Object must return the wrapped object itself") + } + if rel.Kind() != helmv1alpha1.HelmClusterAddonKind || rel.Name() != "consumer" || rel.Namespace() != "" || rel.Generation() != 2 { + t.Fatalf("identity = %s %q/%q gen %d", rel.Kind(), rel.Namespace(), rel.Name(), rel.Generation()) + } + + wantRef := source.ChartRef{ + Repository: source.RepositoryRef{Kind: helmv1alpha1.HelmClusterAddonRepositoryKind, Name: "example"}, + Chart: "podinfo", + Version: "6.7.1", + } + if got := rel.ChartRef(); got != wantRef { + t.Fatalf("ChartRef = %+v, want %+v", got, wantRef) + } + if rel.TargetNamespace() != "app" { + t.Fatalf("TargetNamespace = %q, want the spec namespace", rel.TargetNamespace()) + } + if rel.Values() != obj.Spec.Values { + t.Fatal("Values must point at the spec values") + } + if !rel.MaintenanceActivated() || rel.MaintenanceEnabled() { + t.Fatal("maintenance is requested by the spec but not yet reflected by the Managed condition") + } + if !rel.ForceReconcileRequired() { + t.Fatal("ForceReconcileRequired must follow the annotation") + } + if rel.ReleaseName() != "consumer" { + t.Fatalf("ReleaseName = %q, want the addon name itself", rel.ReleaseName()) + } + + wantLabels := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonLabelSourceName: "consumer", + } + if got := rel.SourceLabels(); !reflect.DeepEqual(got, wantLabels) { + t.Fatalf("SourceLabels = %v, want %v", got, wantLabels) + } + wantChartLabels := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonLabelSourceName: "consumer", + helmv1alpha1.HelmClusterAddonChartLabelSourceName: naming.HelmClusterAddonChartName("example", "podinfo"), + } + if got := rel.HelmChartLabels(); !reflect.DeepEqual(got, wantChartLabels) { + t.Fatalf("HelmChartLabels = %v, want %v", got, wantChartLabels) + } + + wantNames := source.ReleaseNames{ + HelmChart: utils.GetInternalHelmChartName("consumer"), + HelmRelease: utils.GetInternalHelmReleaseName("consumer"), + OCIRepository: utils.GetInternalOCIRepositoryName("consumer"), + } + if got := rel.InternalNames(); got != wantNames { + t.Fatalf("InternalNames = %+v, want %+v (no service account for the addon family)", got, wantNames) + } +} + +func TestAddonReleaseStatusAccessorsWriteThroughToTheObject(t *testing.T) { + obj := addonRelease("consumer") + rel := NewAddonRelease(obj) + + if rel.LastAppliedChart() != nil { + t.Fatal("LastAppliedChart must be nil before a first deployment") + } + if !rel.IsChartStatusInfoOutdated() { + t.Fatal("a release that was never applied is outdated") + } + + rel.SetLastAppliedChart(rel.ChartRef()) + + want := &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonRepository: "example", HelmClusterAddonChartName: "podinfo", Version: "6.7.1", + } + if !reflect.DeepEqual(obj.Status.LastAppliedChart, want) { + t.Fatalf("status.lastAppliedChart = %+v, want %+v", obj.Status.LastAppliedChart, want) + } + if got := rel.LastAppliedChart(); got == nil || *got != rel.ChartRef() { + t.Fatalf("LastAppliedChart = %+v, want the applied ref", got) + } + if rel.IsChartStatusInfoOutdated() { + t.Fatal("after applying the desired chart the status is current") + } + + rel.SetLastAppliedValues(obj.Spec.Values) + if obj.Status.LastAppliedValues != obj.Spec.Values || rel.LastAppliedValues() != obj.Spec.Values { + t.Fatal("LastAppliedValues must write through to the object") + } + + now := metav1.Now() + rel.SetLastForceReconcileTime(now) + if obj.Status.LastForceReconcileTime == nil || !obj.Status.LastForceReconcileTime.Equal(&now) { + t.Fatalf("status.lastForceReconcileTime = %v, want %v", obj.Status.LastForceReconcileTime, now) + } +} + +func TestAddonReleaseBoundsALongReleaseName(t *testing.T) { + long := "abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefg" + rel := NewAddonRelease(addonRelease(long)) + + if got := rel.ReleaseName(); got != utils.HelmReleaseName(long) || len(got) > 53 { + t.Fatalf("ReleaseName = %q (%d chars), want the bounded name", got, len(got)) + } +} + +func TestAddonRepositoryResolverLoadsTheRepositoryAndTheAddonCatalog(t *testing.T) { + repo := &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "example"}, + Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/charts"}, + } + c := addonClient(t, repo) + + got, cat, err := NewAddonRepositoryResolver(c).Resolve(context.Background(), source.RepositoryRef{ + Kind: helmv1alpha1.HelmClusterAddonRepositoryKind, Name: "example", + }) + if err != nil { + t.Fatalf("Resolve returned %v", err) + } + if got.Name() != "example" || got.URL() != repo.Spec.URL || got.OwnerGVK() != helmv1alpha1.HelmClusterAddonRepositoryGVK { + t.Fatalf("resolved repository = %q %q %v", got.Name(), got.URL(), got.OwnerGVK()) + } + if cat == nil { + t.Fatal("Resolve must return the addon catalog") + } + + if _, _, err := NewAddonRepositoryResolver(c).Resolve(context.Background(), source.RepositoryRef{ + Kind: helmv1alpha1.HelmClusterAddonRepositoryKind, Name: "missing", + }); err == nil { + t.Fatal("Resolve of a missing repository must fail") + } +} + +// TestListAddonReleasesUsesTheIndexes pins the two lookups a repository needs: every +// consumer of the repository, and only the consumers of one chart. +func TestListAddonReleasesUsesTheIndexes(t *testing.T) { + podinfo := addonRelease("podinfo-consumer") + nginx := addonRelease("nginx-consumer") + nginx.Spec.Chart.HelmClusterAddonChartName = "nginx" + foreign := addonRelease("foreign") + foreign.Spec.Chart.HelmClusterAddonRepository = "another" + + c := addonClient(t, podinfo, nginx, foreign) + repo := NewAddonRepository(&helmv1alpha1.HelmClusterAddonRepository{ObjectMeta: metav1.ObjectMeta{Name: "example"}}) + list := ListAddonReleases(c) + + all, err := list(context.Background(), repo, "") + if err != nil { + t.Fatalf("listing consumers: %v", err) + } + if names := releaseNames(all); !reflect.DeepEqual(names, map[string]bool{"podinfo-consumer": true, "nginx-consumer": true}) { + t.Fatalf("consumers of the repository = %v", names) + } + + ofChart, err := list(context.Background(), repo, "nginx") + if err != nil { + t.Fatalf("listing consumers of a chart: %v", err) + } + if names := releaseNames(ofChart); !reflect.DeepEqual(names, map[string]bool{"nginx-consumer": true}) { + t.Fatalf("consumers of nginx = %v", names) + } +} + +func releaseNames(releases []source.Release) map[string]bool { + out := make(map[string]bool, len(releases)) + for _, rel := range releases { + out[rel.Name()] = true + } + + return out +} diff --git a/images/operator-helm-controller/internal/adapter/addon_repository.go b/images/operator-helm-controller/internal/adapter/addon_repository.go new file mode 100644 index 00000000..a2cd47ff --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/addon_repository.go @@ -0,0 +1,77 @@ +/* +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 adapter + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +var _ source.Repository = (*AddonRepository)(nil) + +// AddonRepository adapts a HelmClusterAddonRepository. Its labels and internal +// names are exactly what the addon controller has always written: those are live +// objects, and the frozen naming functions in utils keep them in place. +type AddonRepository struct { + obj *helmv1alpha1.HelmClusterAddonRepository +} + +func NewAddonRepository(obj *helmv1alpha1.HelmClusterAddonRepository) *AddonRepository { + return &AddonRepository{obj: obj} +} + +// EmptyAddonRepository returns an adapter around a zero object, for the reconciler +// to read the API object into. +func EmptyAddonRepository() source.Repository { + return NewAddonRepository(&helmv1alpha1.HelmClusterAddonRepository{}) +} + +func (r *AddonRepository) Object() status.ObjectWithConditions { return r.obj } +func (r *AddonRepository) Name() string { return r.obj.Name } +func (r *AddonRepository) Namespace() string { return r.obj.Namespace } +func (r *AddonRepository) Generation() int64 { return r.obj.Generation } + +func (r *AddonRepository) OwnerGVK() schema.GroupVersionKind { + return helmv1alpha1.HelmClusterAddonRepositoryGVK +} + +func (r *AddonRepository) URL() string { return r.obj.Spec.URL } +func (r *AddonRepository) Auth() *helmv1alpha1.RepositoryAuth { return r.obj.Spec.Auth } +func (r *AddonRepository) CACertificate() string { return r.obj.Spec.CACertificate } + +func (r *AddonRepository) InsecureSkipVerify() bool { return r.obj.Spec.InsecureSkipVerify } +func (r *AddonRepository) Status() *helmv1alpha1.RepositoryStatus { return &r.obj.Status } +func (r *AddonRepository) ForceReconcileRequired() bool { return r.obj.ForceReconcileRequired() } + +func (r *AddonRepository) SourceLabels() map[string]string { + return map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: r.obj.Name, + } +} + +func (r *AddonRepository) InternalNames() source.InternalNames { + return source.InternalNames{ + HelmRepository: utils.GetInternalHelmRepositoryName(r.obj.Name), + AuthSecret: utils.GetInternalRepositoryAuthSecretName(r.obj.Name), + TLSSecret: utils.GetInternalRepositoryTLSSecretName(r.obj.Name), + } +} diff --git a/images/operator-helm-controller/internal/adapter/application_release.go b/images/operator-helm-controller/internal/adapter/application_release.go new file mode 100644 index 00000000..15ba220d --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/application_release.go @@ -0,0 +1,245 @@ +/* +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 adapter + +import ( + "context" + "fmt" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/operator-helm/api/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/index" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +// applicationPrefix prefixes every internal object derived from a HelmApplication: +// HelmChart, HelmRelease, OCIRepository and ServiceAccount share one name, as the +// addon's do. The same prefix starts the Helm release name. +const applicationPrefix = "hap" + +var _ source.Release = (*ApplicationRelease)(nil) + +// ApplicationRelease adapts a HelmApplication. This is the one place the two +// mutually exclusive repository fields are read: everything downstream sees the +// resolved kind, namespace and name. The release deploys into the application's +// own namespace and is applied as a ServiceAccount derived here. +type ApplicationRelease struct { + obj *helmv1alpha1.HelmApplication +} + +func NewApplicationRelease(obj *helmv1alpha1.HelmApplication) *ApplicationRelease { + return &ApplicationRelease{obj: obj} +} + +func EmptyApplicationRelease() source.Release { + return NewApplicationRelease(&helmv1alpha1.HelmApplication{}) +} + +func (r *ApplicationRelease) Object() status.ObjectWithConditions { return r.obj } +func (r *ApplicationRelease) Kind() string { return helmv1alpha1.HelmApplicationKind } +func (r *ApplicationRelease) Name() string { return r.obj.Name } +func (r *ApplicationRelease) Namespace() string { return r.obj.Namespace } +func (r *ApplicationRelease) Generation() int64 { return r.obj.Generation } + +// repositoryRef resolves the XOR of the spec into one reference. A namespaced +// repository lives in the application's own namespace. +func (r *ApplicationRelease) repositoryRef(kind, name string) source.RepositoryRef { + ref := source.RepositoryRef{Kind: kind, Name: name} + if kind == helmv1alpha1.HelmApplicationRepositoryKind { + ref.Namespace = r.obj.Namespace + } + + return ref +} + +func (r *ApplicationRelease) ChartRef() source.ChartRef { + return source.ChartRef{ + Repository: r.repositoryRef(r.obj.RepositoryKind(), r.obj.RepositoryName()), + Chart: r.obj.Spec.Chart.Name, + Version: r.obj.Spec.Chart.Version, + } +} + +func (r *ApplicationRelease) TargetNamespace() string { return r.obj.Namespace } +func (r *ApplicationRelease) Values() *apiextensionsv1.JSON { return r.obj.Spec.Values } +func (r *ApplicationRelease) MaintenanceActivated() bool { return r.obj.MaintenanceModeActivated() } + +func (r *ApplicationRelease) MaintenanceEnabled() bool { return r.obj.MaintenanceModeEnabled() } + +func (r *ApplicationRelease) ForceReconcileRequired() bool { return r.obj.ForceReconcileRequired() } + +func (r *ApplicationRelease) IsChartStatusInfoOutdated() bool { + return r.obj.IsChartStatusInfoOutdated() +} + +func (r *ApplicationRelease) LastAppliedValues() *apiextensionsv1.JSON { + return r.obj.Status.LastAppliedValues +} + +// ReleaseName is prefixed so an application cannot take over a release someone +// installed by hand under the same name in the same namespace; without the prefix +// helm-controller would upgrade that release instead of failing. The hash is +// unconditional for the same reason: two applications whose names met in one release +// name would share its storage and overwrite each other's history. +func (r *ApplicationRelease) ReleaseName() string { + return utils.HashedReleaseName(applicationPrefix + "-" + r.obj.Name) +} + +func (r *ApplicationRelease) SourceLabels() map[string]string { + return map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationLabelSourceName: r.obj.Name, + helmv1alpha1.LabelSourceNamespace: r.obj.Namespace, + } +} + +func (r *ApplicationRelease) HelmChartLabels() map[string]string { + labels := r.SourceLabels() + ref := r.ChartRef() + + switch ref.Repository.Kind { + case helmv1alpha1.HelmApplicationRepositoryKind: + labels[helmv1alpha1.HelmApplicationChartLabelSourceName] = naming.ApplicationChartName(ref.Repository.Name, ref.Chart) + case helmv1alpha1.HelmClusterApplicationRepositoryKind: + labels[helmv1alpha1.HelmClusterApplicationChartLabelSourceName] = naming.ClusterApplicationChartName(ref.Repository.Name, ref.Chart) + } + + return labels +} + +func (r *ApplicationRelease) InternalNames() source.ReleaseNames { + name := utils.DerivedName(applicationPrefix, helmv1alpha1.HelmApplicationKind, r.obj.Namespace, r.obj.Name) + + return source.ReleaseNames{ + HelmChart: name, + HelmRelease: name, + OCIRepository: name, + ServiceAccount: name, + } +} + +func (r *ApplicationRelease) LastAppliedChart() *source.ChartRef { + last := r.obj.Status.LastAppliedChart + if last == nil || last.RepositoryKind() == "" { + return nil + } + + return &source.ChartRef{ + Repository: r.repositoryRef(last.RepositoryKind(), last.RepositoryName()), + Chart: last.Name, + Version: last.Version, + } +} + +// SetLastAppliedChart replaces the record wholesale. A merge would leave the old +// repository field set next to the new clusterRepository, both fields would be +// filled, and IsChartStatusInfoOutdated would stay true forever. +func (r *ApplicationRelease) SetLastAppliedChart(ref source.ChartRef) { + last := &helmv1alpha1.HelmApplicationLastAppliedChartRef{ + Name: ref.Chart, + Version: ref.Version, + } + + switch ref.Repository.Kind { + case helmv1alpha1.HelmApplicationRepositoryKind: + last.Repository = ref.Repository.Name + case helmv1alpha1.HelmClusterApplicationRepositoryKind: + last.ClusterRepository = ref.Repository.Name + } + + r.obj.Status.LastAppliedChart = last +} + +func (r *ApplicationRelease) SetLastAppliedValues(values *apiextensionsv1.JSON) { + r.obj.Status.LastAppliedValues = values +} + +func (r *ApplicationRelease) SetLastForceReconcileTime(t metav1.Time) { + r.obj.Status.LastForceReconcileTime = &t +} + +// applicationRepositoryResolver loads whichever of the two repository kinds an +// application references, with the catalog of that kind. +type applicationRepositoryResolver struct { + client client.Client + namespaced source.Catalog + clusterCatalog source.Catalog +} + +func NewApplicationRepositoryResolver(c client.Client) *applicationRepositoryResolver { + return &applicationRepositoryResolver{ + client: c, + namespaced: NewApplicationCatalog(c), + clusterCatalog: NewClusterApplicationCatalog(c), + } +} + +func (r *applicationRepositoryResolver) Resolve(ctx context.Context, ref source.RepositoryRef) (source.Repository, source.Catalog, error) { + switch ref.Kind { + case helmv1alpha1.HelmApplicationRepositoryKind: + repo := &helmv1alpha1.HelmApplicationRepository{} + if err := r.client.Get(ctx, client.ObjectKey{Namespace: ref.Namespace, Name: ref.Name}, repo); err != nil { + return nil, nil, fmt.Errorf("getting %s %s/%s: %w", ref.Kind, ref.Namespace, ref.Name, err) + } + + return NewApplicationRepository(repo), r.namespaced, nil + case helmv1alpha1.HelmClusterApplicationRepositoryKind: + repo := &helmv1alpha1.HelmClusterApplicationRepository{} + if err := r.client.Get(ctx, client.ObjectKey{Name: ref.Name}, repo); err != nil { + return nil, nil, fmt.Errorf("getting %s %q: %w", ref.Kind, ref.Name, err) + } + + return NewClusterApplicationRepository(repo), r.clusterCatalog, nil + default: + // The CEL rule on spec.chart keeps exactly one reference set on any persisted + // object, so this is reachable only for an object built in memory. + return nil, nil, fmt.Errorf("unsupported repository kind %q", ref.Kind) + } +} + +// ListApplicationReleases lists the HelmApplication objects consuming a repository, +// or only those consuming one of its charts. The index value carries the repository +// kind and namespace, so a namespaced repository only ever sees the applications of +// its own namespace. +func ListApplicationReleases(c client.Client) source.ReleaseLister { + return func(ctx context.Context, repo source.Repository, chartName string) ([]source.Release, error) { + kind := repo.OwnerGVK().Kind + + selector := client.MatchingFields{index.ApplicationRepository: index.ApplicationRepositoryValue(kind, repo.Namespace(), repo.Name())} + if chartName != "" { + selector = client.MatchingFields{index.ApplicationChart: index.ApplicationChartValue(kind, repo.Namespace(), repo.Name(), chartName)} + } + + var apps helmv1alpha1.HelmApplicationList + if err := c.List(ctx, &apps, selector); err != nil { + return nil, fmt.Errorf("listing applications of repository %s %s/%s: %w", kind, repo.Namespace(), repo.Name(), err) + } + + releases := make([]source.Release, 0, len(apps.Items)) + for i := range apps.Items { + releases = append(releases, NewApplicationRelease(&apps.Items[i])) + } + + return releases, nil + } +} diff --git a/images/operator-helm-controller/internal/adapter/application_release_test.go b/images/operator-helm-controller/internal/adapter/application_release_test.go new file mode 100644 index 00000000..b44b9573 --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/application_release_test.go @@ -0,0 +1,245 @@ +/* +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 adapter + +import ( + "context" + "reflect" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/operator-helm/api/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/index" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/utils" +) + +func applicationClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationRepository, index.ApplicationRepositoryIndexer). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). + Build() +} + +func namespacedApp(namespace, name, repo string) *helmv1alpha1.HelmApplication { + return &helmv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Generation: 1}, + Spec: helmv1alpha1.HelmApplicationSpec{ + Chart: helmv1alpha1.HelmApplicationChartRef{Name: "podinfo", Repository: repo, Version: "6.7.1"}, + }, + } +} + +func clusterApp(namespace, name, repo string) *helmv1alpha1.HelmApplication { + app := namespacedApp(namespace, name, "") + app.Spec.Chart.ClusterRepository = repo + + return app +} + +// TestApplicationReleaseResolvesTheRepositoryReference pins the one place the XOR of +// spec.chart.repository / spec.chart.clusterRepository is read: everything +// downstream sees a kind, a namespace and a name. +func TestApplicationReleaseResolvesTheRepositoryReference(t *testing.T) { + cases := []struct { + name string + app *helmv1alpha1.HelmApplication + wantRef source.RepositoryRef + wantChartLbl string + wantChartName string + }{ + { + name: "namespaced repository lives in the application namespace", + app: namespacedApp("team-a", "my-app", "stable"), + wantRef: source.RepositoryRef{Kind: helmv1alpha1.HelmApplicationRepositoryKind, Namespace: "team-a", Name: "stable"}, + wantChartLbl: helmv1alpha1.HelmApplicationChartLabelSourceName, + wantChartName: naming.ApplicationChartName("stable", "podinfo"), + }, + { + name: "cluster repository has no namespace", + app: clusterApp("team-a", "my-app", "shared"), + wantRef: source.RepositoryRef{Kind: helmv1alpha1.HelmClusterApplicationRepositoryKind, Name: "shared"}, + wantChartLbl: helmv1alpha1.HelmClusterApplicationChartLabelSourceName, + wantChartName: naming.ClusterApplicationChartName("shared", "podinfo"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rel := NewApplicationRelease(tc.app) + + want := source.ChartRef{Repository: tc.wantRef, Chart: "podinfo", Version: "6.7.1"} + if got := rel.ChartRef(); got != want { + t.Fatalf("ChartRef = %+v, want %+v", got, want) + } + if got := rel.HelmChartLabels()[tc.wantChartLbl]; got != tc.wantChartName { + t.Fatalf("catalog label %s = %q, want %q", tc.wantChartLbl, got, tc.wantChartName) + } + if rel.TargetNamespace() != "team-a" { + t.Fatalf("TargetNamespace = %q, want the application's own namespace", rel.TargetNamespace()) + } + }) + } +} + +func TestApplicationReleaseNamesAndLabels(t *testing.T) { + rel := NewApplicationRelease(namespacedApp("team-a", "my-app", "stable")) + + if rel.Kind() != helmv1alpha1.HelmApplicationKind { + t.Fatalf("Kind = %q", rel.Kind()) + } + // The hash is what keeps two application names from meeting in one release, so + // it is present even on a name far below the limit. The literal is pinned rather + // than recomputed: calling the same helper the adapter calls would assert nothing. + if rel.ReleaseName() != "hap-my-app-7d0dcc45388e" { + t.Fatalf("ReleaseName = %q, want the prefixed name with its hash", rel.ReleaseName()) + } + + wantLabels := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationLabelSourceName: "my-app", + helmv1alpha1.LabelSourceNamespace: "team-a", + } + if got := rel.SourceLabels(); !reflect.DeepEqual(got, wantLabels) { + t.Fatalf("SourceLabels = %v, want %v", got, wantLabels) + } + + const derived = "hap-team-a-my-app-de20cad3987c" + want := source.ReleaseNames{HelmChart: derived, HelmRelease: derived, OCIRepository: derived, ServiceAccount: derived} + if got := rel.InternalNames(); got != want { + t.Fatalf("InternalNames = %+v, want %+v", got, want) + } + if want.HelmChart != utils.DerivedName("hap", helmv1alpha1.HelmApplicationKind, "team-a", "my-app") { + t.Fatal("the pinned literal drifted from DerivedName") + } + + other := NewApplicationRelease(namespacedApp("team-b", "my-app", "stable")) + if other.InternalNames().HelmRelease == rel.InternalNames().HelmRelease { + t.Fatal("same-named applications in different namespaces must derive different internal names") + } +} + +// TestApplicationReleaseReplacesLastAppliedChartWholesale pins the rule from the API +// design: a merge into lastAppliedChart would leave a stale repository next to a new +// clusterRepository and pin IsChartStatusInfoOutdated to true forever. +func TestApplicationReleaseReplacesLastAppliedChartWholesale(t *testing.T) { + app := namespacedApp("team-a", "my-app", "stable") + rel := NewApplicationRelease(app) + + if rel.LastAppliedChart() != nil { + t.Fatal("LastAppliedChart must be nil before a first deployment") + } + + rel.SetLastAppliedChart(rel.ChartRef()) + if app.Status.LastAppliedChart.Repository != "stable" || app.Status.LastAppliedChart.ClusterRepository != "" { + t.Fatalf("after a namespaced apply: %+v", app.Status.LastAppliedChart) + } + if rel.IsChartStatusInfoOutdated() { + t.Fatal("the applied chart is the desired one") + } + + app.Spec.Chart.Repository = "" + app.Spec.Chart.ClusterRepository = "shared" + if !rel.IsChartStatusInfoOutdated() { + t.Fatal("moving to a cluster repository is a chart change") + } + + rel.SetLastAppliedChart(rel.ChartRef()) + if app.Status.LastAppliedChart.ClusterRepository != "shared" || app.Status.LastAppliedChart.Repository != "" { + t.Fatalf("the record must be replaced, not merged: %+v", app.Status.LastAppliedChart) + } + if got := rel.LastAppliedChart(); got == nil || *got != rel.ChartRef() { + t.Fatalf("LastAppliedChart = %+v, want the applied ref", got) + } +} + +func TestApplicationRepositoryResolverPicksTheKind(t *testing.T) { + namespaced := &helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/stable"}, + } + cluster := &helmv1alpha1.HelmClusterApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "shared"}, + Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/charts"}, + } + resolver := NewApplicationRepositoryResolver(applicationClient(t, namespaced, cluster)) + + repo, _, err := resolver.Resolve(context.Background(), source.RepositoryRef{Kind: helmv1alpha1.HelmApplicationRepositoryKind, Namespace: "team-a", Name: "stable"}) + if err != nil { + t.Fatalf("Resolve(namespaced) returned %v", err) + } + if repo.OwnerGVK() != helmv1alpha1.HelmApplicationRepositoryGVK || repo.Namespace() != "team-a" { + t.Fatalf("resolved %v %q", repo.OwnerGVK(), repo.Namespace()) + } + + repo, _, err = resolver.Resolve(context.Background(), source.RepositoryRef{Kind: helmv1alpha1.HelmClusterApplicationRepositoryKind, Name: "shared"}) + if err != nil { + t.Fatalf("Resolve(cluster) returned %v", err) + } + if repo.OwnerGVK() != helmv1alpha1.HelmClusterApplicationRepositoryGVK { + t.Fatalf("resolved %v", repo.OwnerGVK()) + } + + if _, _, err := resolver.Resolve(context.Background(), source.RepositoryRef{Kind: "Something", Name: "x"}); err == nil { + t.Fatal("an unknown repository kind must be an error") + } +} + +// TestListApplicationReleasesIsScopedByRepositoryNamespace is the property the +// namespaced catalog depends on: a repository named "stable" in team-a must not see +// the applications using "stable" in team-b. +func TestListApplicationReleasesIsScopedByRepositoryNamespace(t *testing.T) { + c := applicationClient(t, + namespacedApp("team-a", "a1", "stable"), + namespacedApp("team-a", "a2", "stable"), + namespacedApp("team-b", "b1", "stable"), + clusterApp("team-b", "b2", "stable"), + ) + list := ListApplicationReleases(c) + + teamA := NewApplicationRepository(&helmv1alpha1.HelmApplicationRepository{ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a"}}) + got, err := list(context.Background(), teamA, "") + if err != nil { + t.Fatalf("listing: %v", err) + } + if names := releaseNames(got); !reflect.DeepEqual(names, map[string]bool{"a1": true, "a2": true}) { + t.Fatalf("consumers of team-a/stable = %v", names) + } + + shared := NewClusterApplicationRepository(&helmv1alpha1.HelmClusterApplicationRepository{ObjectMeta: metav1.ObjectMeta{Name: "stable"}}) + got, err = list(context.Background(), shared, "podinfo") + if err != nil { + t.Fatalf("listing: %v", err) + } + if names := releaseNames(got); !reflect.DeepEqual(names, map[string]bool{"b2": true}) { + t.Fatalf("consumers of the cluster repository stable/podinfo = %v", names) + } +} diff --git a/images/operator-helm-controller/internal/adapter/application_repository.go b/images/operator-helm-controller/internal/adapter/application_repository.go new file mode 100644 index 00000000..6496c4bd --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/application_repository.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 adapter + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +// Prefixes of the internal objects derived from a HelmApplicationRepository. The +// "ap" stands for application and keeps the family apart from the addon prefixes +// hca/hcar, which are frozen. +const ( + applicationRepositoryPrefix = "hapr" + applicationRepositoryAuthPrefix = "hapr-auth" + applicationRepositoryTLSPrefix = "hapr-tls" +) + +var _ source.Repository = (*ApplicationRepository)(nil) + +// ApplicationRepository adapts a HelmApplicationRepository. The kind is +// namespaced while its internal objects live in the operator namespace, so both +// the labels and the derived names carry the repository's namespace. +type ApplicationRepository struct { + obj *helmv1alpha1.HelmApplicationRepository +} + +func NewApplicationRepository(obj *helmv1alpha1.HelmApplicationRepository) *ApplicationRepository { + return &ApplicationRepository{obj: obj} +} + +func EmptyApplicationRepository() source.Repository { + return NewApplicationRepository(&helmv1alpha1.HelmApplicationRepository{}) +} + +func (r *ApplicationRepository) Object() status.ObjectWithConditions { return r.obj } +func (r *ApplicationRepository) Name() string { return r.obj.Name } +func (r *ApplicationRepository) Namespace() string { return r.obj.Namespace } +func (r *ApplicationRepository) Generation() int64 { return r.obj.Generation } + +func (r *ApplicationRepository) OwnerGVK() schema.GroupVersionKind { + return helmv1alpha1.HelmApplicationRepositoryGVK +} + +func (r *ApplicationRepository) URL() string { return r.obj.Spec.URL } +func (r *ApplicationRepository) Auth() *helmv1alpha1.RepositoryAuth { return r.obj.Spec.Auth } +func (r *ApplicationRepository) CACertificate() string { return r.obj.Spec.CACertificate } + +func (r *ApplicationRepository) InsecureSkipVerify() bool { return r.obj.Spec.InsecureSkipVerify } +func (r *ApplicationRepository) Status() *helmv1alpha1.RepositoryStatus { return &r.obj.Status } +func (r *ApplicationRepository) ForceReconcileRequired() bool { return r.obj.ForceReconcileRequired() } + +func (r *ApplicationRepository) SourceLabels() map[string]string { + return map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: r.obj.Name, + helmv1alpha1.LabelSourceNamespace: r.obj.Namespace, + } +} + +func (r *ApplicationRepository) InternalNames() source.InternalNames { + kind := helmv1alpha1.HelmApplicationRepositoryKind + + return source.InternalNames{ + HelmRepository: utils.DerivedName(applicationRepositoryPrefix, kind, r.obj.Namespace, r.obj.Name), + AuthSecret: utils.DerivedName(applicationRepositoryAuthPrefix, kind, r.obj.Namespace, r.obj.Name), + TLSSecret: utils.DerivedName(applicationRepositoryTLSPrefix, kind, r.obj.Namespace, r.obj.Name), + } +} diff --git a/images/operator-helm-controller/internal/adapter/catalogs.go b/images/operator-helm-controller/internal/adapter/catalogs.go new file mode 100644 index 00000000..bf6e074d --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/catalogs.go @@ -0,0 +1,115 @@ +/* +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 adapter + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/operator-helm/api/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/catalog" + "github.com/deckhouse/operator-helm/internal/source" +) + +// NewAddonCatalog builds the HelmClusterAddonChart catalog. Its consumers are the +// HelmClusterAddon objects referencing a repository/chart pair. +func NewAddonCatalog(c client.Client) source.Catalog { + return catalog.New(c, catalog.Config[*helmv1alpha1.HelmClusterAddonChart, *helmv1alpha1.HelmClusterAddonChartList]{ + Kind: helmv1alpha1.HelmClusterAddonChartKind, + NewObject: func() *helmv1alpha1.HelmClusterAddonChart { return &helmv1alpha1.HelmClusterAddonChart{} }, + NewList: func() *helmv1alpha1.HelmClusterAddonChartList { return &helmv1alpha1.HelmClusterAddonChartList{} }, + Items: func(l *helmv1alpha1.HelmClusterAddonChartList) []*helmv1alpha1.HelmClusterAddonChart { + return pointers(l.Items) + }, + Status: func(o *helmv1alpha1.HelmClusterAddonChart) *helmv1alpha1.ChartCatalogStatus { return &o.Status }, + ObjectName: naming.HelmClusterAddonChartName, + Consumers: chartConsumers(ListAddonReleases(c)), + }) +} + +// NewApplicationCatalog builds the HelmApplicationChart catalog; its consumers are the HelmApplication objects of the repository's namespace. +func NewApplicationCatalog(c client.Client) source.Catalog { + return catalog.New(c, catalog.Config[*helmv1alpha1.HelmApplicationChart, *helmv1alpha1.HelmApplicationChartList]{ + Kind: helmv1alpha1.HelmApplicationChartKind, + NewObject: func() *helmv1alpha1.HelmApplicationChart { return &helmv1alpha1.HelmApplicationChart{} }, + NewList: func() *helmv1alpha1.HelmApplicationChartList { return &helmv1alpha1.HelmApplicationChartList{} }, + Items: func(l *helmv1alpha1.HelmApplicationChartList) []*helmv1alpha1.HelmApplicationChart { + return pointers(l.Items) + }, + Status: func(o *helmv1alpha1.HelmApplicationChart) *helmv1alpha1.ChartCatalogStatus { return &o.Status }, + ObjectName: naming.ApplicationChartName, + Consumers: chartConsumers(ListApplicationReleases(c)), + }) +} + +// NewClusterApplicationCatalog builds the HelmClusterApplicationChart catalog; its consumers are the HelmApplication objects of every namespace. +func NewClusterApplicationCatalog(c client.Client) source.Catalog { + return catalog.New(c, catalog.Config[*helmv1alpha1.HelmClusterApplicationChart, *helmv1alpha1.HelmClusterApplicationChartList]{ + Kind: helmv1alpha1.HelmClusterApplicationChartKind, + NewObject: func() *helmv1alpha1.HelmClusterApplicationChart { return &helmv1alpha1.HelmClusterApplicationChart{} }, + NewList: func() *helmv1alpha1.HelmClusterApplicationChartList { + return &helmv1alpha1.HelmClusterApplicationChartList{} + }, + Items: func(l *helmv1alpha1.HelmClusterApplicationChartList) []*helmv1alpha1.HelmClusterApplicationChart { + return pointers(l.Items) + }, + Status: func(o *helmv1alpha1.HelmClusterApplicationChart) *helmv1alpha1.ChartCatalogStatus { return &o.Status }, + ObjectName: naming.ClusterApplicationChartName, + Consumers: chartConsumers(ListApplicationReleases(c)), + }) +} + +// pointers returns a pointer to every element of items, so a caller can mutate the +// listed objects in place. +func pointers[T any](items []T) []*T { + out := make([]*T, len(items)) + for i := range items { + out[i] = &items[i] + } + + return out +} + +// chartConsumers derives the in-use versions of a chart from the releases that +// reference it. Both the desired version and the last applied one count, since +// they differ during an upgrade — the latter only while it still names this +// repository/chart pair: LastAppliedChart can lag behind the spec after a release +// was repointed at another chart, and a stale entry would protect a phantom version +// on the new chart while no longer protecting the version applied on the old one. +func chartConsumers(list source.ReleaseLister) func(context.Context, source.Repository, string) (map[string]struct{}, error) { + return func(ctx context.Context, repo source.Repository, chartName string) (map[string]struct{}, error) { + releases, err := list(ctx, repo, chartName) + if err != nil { + return nil, err + } + + pair := source.RepositoryRef{Kind: repo.OwnerGVK().Kind, Namespace: repo.Namespace(), Name: repo.Name()} + inUse := make(map[string]struct{}, 2*len(releases)) + + for _, rel := range releases { + inUse[rel.ChartRef().Version] = struct{}{} + + if last := rel.LastAppliedChart(); last != nil && last.Chart == chartName && last.Repository == pair { + inUse[last.Version] = struct{}{} + } + } + + return inUse, nil + } +} diff --git a/images/operator-helm-controller/internal/adapter/catalogs_test.go b/images/operator-helm-controller/internal/adapter/catalogs_test.go new file mode 100644 index 00000000..c8d7ec60 --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/catalogs_test.go @@ -0,0 +1,124 @@ +/* +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 adapter + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/index" +) + +func addonClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&helmv1alpha1.HelmClusterAddon{}, index.AddonChart, func(obj client.Object) []string { + addon := obj.(*helmv1alpha1.HelmClusterAddon) + + return []string{index.AddonChartValue(addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName)} + }). + WithIndex(&helmv1alpha1.HelmClusterAddon{}, index.AddonRepository, func(obj client.Object) []string { + addon := obj.(*helmv1alpha1.HelmClusterAddon) + + return []string{addon.Spec.Chart.HelmClusterAddonRepository} + }). + Build() +} + +// TestAddonCatalogInUseVersionsCountsDesiredAndLastApplied reproduces the addon +// in-use rule that moved here from services: the desired version and the last +// applied one both count, the latter only while it still names this chart. +func TestAddonCatalogInUseVersionsCountsDesiredAndLastApplied(t *testing.T) { + addon := &helmv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{Name: "consumer"}, + Spec: helmv1alpha1.HelmClusterAddonSpec{ + Namespace: "app", + Chart: helmv1alpha1.HelmClusterAddonChartRef{HelmClusterAddonRepository: "example", HelmClusterAddonChartName: "podinfo", Version: "2.0.0"}, + }, + Status: helmv1alpha1.HelmClusterAddonStatus{ + LastAppliedChart: &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{HelmClusterAddonRepository: "example", HelmClusterAddonChartName: "podinfo", Version: "1.0.0"}, + }, + } + switched := &helmv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{Name: "switched"}, + Spec: helmv1alpha1.HelmClusterAddonSpec{ + Namespace: "app", + Chart: helmv1alpha1.HelmClusterAddonChartRef{HelmClusterAddonRepository: "example", HelmClusterAddonChartName: "nginx", Version: "3.0.0"}, + }, + Status: helmv1alpha1.HelmClusterAddonStatus{ + // Last applied still names podinfo, but Spec moved to nginx: podinfo's + // index entry no longer lists this addon, so 9.9.9 must not be protected. + LastAppliedChart: &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{HelmClusterAddonRepository: "example", HelmClusterAddonChartName: "podinfo", Version: "9.9.9"}, + }, + } + + c := addonClient(t, addon, switched) + cat := NewAddonCatalog(c) + repo := NewAddonRepository(&helmv1alpha1.HelmClusterAddonRepository{ObjectMeta: metav1.ObjectMeta{Name: "example"}}) + + inUse, err := cat.InUseVersions(context.Background(), repo, "podinfo") + if err != nil { + t.Fatalf("InUseVersions returned %v", err) + } + + for _, want := range []string{"1.0.0", "2.0.0"} { + if _, ok := inUse[want]; !ok { + t.Fatalf("in use = %v, want %s included", inUse, want) + } + } + if _, ok := inUse["9.9.9"]; ok { + t.Fatalf("in use = %v, a stale lastAppliedChart of an addon that moved to another chart must not count", inUse) + } + if len(inUse) != 2 { + t.Fatalf("in use = %v, want exactly two versions", inUse) + } +} + +// TestApplicationCatalogInUseVersionsSeesOnlyItsOwnNamespace pins spec 6.2: the +// in-use versions of a namespaced repository's chart come from the applications of +// that namespace, or a same-named repository elsewhere would keep versions alive. +func TestApplicationCatalogInUseVersionsSeesOnlyItsOwnNamespace(t *testing.T) { + inA := namespacedApp("team-a", "a1", "stable") + inB := namespacedApp("team-b", "b1", "stable") + inB.Spec.Chart.Version = "9.9.9" + + c := applicationClient(t, inA, inB) + cat := NewApplicationCatalog(c) + repo := NewApplicationRepository(&helmv1alpha1.HelmApplicationRepository{ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a"}}) + + inUse, err := cat.InUseVersions(context.Background(), repo, "podinfo") + if err != nil { + t.Fatalf("InUseVersions returned %v", err) + } + if _, ok := inUse["6.7.1"]; !ok || len(inUse) != 1 { + t.Fatalf("in use = %v, want only team-a's 6.7.1", inUse) + } +} diff --git a/images/operator-helm-controller/internal/adapter/cluster_application_repository.go b/images/operator-helm-controller/internal/adapter/cluster_application_repository.go new file mode 100644 index 00000000..1e8c2c79 --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/cluster_application_repository.go @@ -0,0 +1,94 @@ +/* +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 adapter + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +// Prefixes of the internal objects derived from a HelmClusterApplicationRepository. +const ( + clusterApplicationRepositoryPrefix = "hcapr" + clusterApplicationRepositoryAuthPrefix = "hcapr-auth" + clusterApplicationRepositoryTLSPrefix = "hcapr-tls" +) + +var _ source.Repository = (*ClusterApplicationRepository)(nil) + +// ClusterApplicationRepository adapts a HelmClusterApplicationRepository. It is +// cluster-scoped, so its derived names carry no namespace part, and its labels no +// namespace label. +type ClusterApplicationRepository struct { + obj *helmv1alpha1.HelmClusterApplicationRepository +} + +func NewClusterApplicationRepository(obj *helmv1alpha1.HelmClusterApplicationRepository) *ClusterApplicationRepository { + return &ClusterApplicationRepository{obj: obj} +} + +func EmptyClusterApplicationRepository() source.Repository { + return NewClusterApplicationRepository(&helmv1alpha1.HelmClusterApplicationRepository{}) +} + +func (r *ClusterApplicationRepository) Object() status.ObjectWithConditions { return r.obj } +func (r *ClusterApplicationRepository) Name() string { return r.obj.Name } +func (r *ClusterApplicationRepository) Namespace() string { return r.obj.Namespace } + +func (r *ClusterApplicationRepository) Generation() int64 { return r.obj.Generation } + +func (r *ClusterApplicationRepository) OwnerGVK() schema.GroupVersionKind { + return helmv1alpha1.HelmClusterApplicationRepositoryGVK +} + +func (r *ClusterApplicationRepository) URL() string { return r.obj.Spec.URL } +func (r *ClusterApplicationRepository) Auth() *helmv1alpha1.RepositoryAuth { return r.obj.Spec.Auth } + +func (r *ClusterApplicationRepository) CACertificate() string { return r.obj.Spec.CACertificate } + +func (r *ClusterApplicationRepository) InsecureSkipVerify() bool { + return r.obj.Spec.InsecureSkipVerify +} + +func (r *ClusterApplicationRepository) Status() *helmv1alpha1.RepositoryStatus { + return &r.obj.Status +} + +func (r *ClusterApplicationRepository) ForceReconcileRequired() bool { + return r.obj.ForceReconcileRequired() +} + +func (r *ClusterApplicationRepository) SourceLabels() map[string]string { + return map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterApplicationRepositoryLabelSourceName: r.obj.Name, + } +} + +func (r *ClusterApplicationRepository) InternalNames() source.InternalNames { + kind := helmv1alpha1.HelmClusterApplicationRepositoryKind + + return source.InternalNames{ + HelmRepository: utils.DerivedName(clusterApplicationRepositoryPrefix, kind, "", r.obj.Name), + AuthSecret: utils.DerivedName(clusterApplicationRepositoryAuthPrefix, kind, "", r.obj.Name), + TLSSecret: utils.DerivedName(clusterApplicationRepositoryTLSPrefix, kind, "", r.obj.Name), + } +} diff --git a/images/operator-helm-controller/internal/adapter/doc.go b/images/operator-helm-controller/internal/adapter/doc.go new file mode 100644 index 00000000..3cb0430e --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/doc.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 adapter binds each user-facing kind to the contracts of +// internal/source. Everything that differs between two kinds of the same role — +// labels, internal object names, the catalog kind, who consumes the charts — +// lives here and nowhere else; the services and the reconcilers stay kind-agnostic. +// +// An adapter wraps the API object rather than embedding it: the wrapper is not +// registered in the scheme, so it must never reach the client, and an explicit +// Object() accessor makes that boundary visible at every call site. +package adapter diff --git a/images/operator-helm-controller/internal/adapter/repository_test.go b/images/operator-helm-controller/internal/adapter/repository_test.go new file mode 100644 index 00000000..54f9d0cf --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/repository_test.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 adapter + +import ( + "reflect" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/utils" +) + +func addonRepo(name string) *helmv1alpha1.HelmClusterAddonRepository { + return &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: name, Generation: 3, Annotations: map[string]string{helmv1alpha1.AnnotationForceReconcile: ""}}, + Spec: helmv1alpha1.RepositorySpec{ + URL: "oci://ghcr.io/example/charts", + Auth: &helmv1alpha1.RepositoryAuth{Username: "u", Password: "p"}, + CACertificate: "-----BEGIN CERTIFICATE-----", + InsecureSkipVerify: true, + }, + } +} + +// TestAddonRepositoryKeepsTheReleasedNamesAndLabels pins the adapter to what the +// addon controller writes today: the same three internal names and the same two +// labels. Anything else here would re-create live objects. +func TestAddonRepositoryKeepsTheReleasedNamesAndLabels(t *testing.T) { + obj := addonRepo("example") + repo := NewAddonRepository(obj) + + if repo.Object() != obj { + t.Fatal("Object must return the wrapped object itself, it is what the client reads and patches") + } + if repo.Name() != "example" || repo.Namespace() != "" || repo.Generation() != 3 { + t.Fatalf("identity = %q/%q gen %d, want example/\"\" gen 3", repo.Namespace(), repo.Name(), repo.Generation()) + } + if repo.OwnerGVK() != helmv1alpha1.HelmClusterAddonRepositoryGVK { + t.Fatalf("OwnerGVK = %v", repo.OwnerGVK()) + } + if repo.URL() != obj.Spec.URL || repo.Auth() != obj.Spec.Auth || + repo.CACertificate() != obj.Spec.CACertificate || repo.InsecureSkipVerify() != obj.Spec.InsecureSkipVerify { + t.Fatal("spec accessors must proxy the spec fields") + } + if repo.Status() != &obj.Status { + t.Fatal("Status must point at the wrapped object's status so a write through it lands on the object") + } + if !repo.ForceReconcileRequired() { + t.Fatal("ForceReconcileRequired must follow the annotation") + } + + wantLabels := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: "example", + } + if got := repo.SourceLabels(); !reflect.DeepEqual(got, wantLabels) { + t.Fatalf("SourceLabels = %v, want %v", got, wantLabels) + } + + wantNames := source.InternalNames{ + HelmRepository: utils.GetInternalHelmRepositoryName("example"), + AuthSecret: utils.GetInternalRepositoryAuthSecretName("example"), + TLSSecret: utils.GetInternalRepositoryTLSSecretName("example"), + } + if got := repo.InternalNames(); got != wantNames { + t.Fatalf("InternalNames = %+v, want %+v", got, wantNames) + } +} + +func TestApplicationRepositoryCarriesNamespaceInLabelsAndNames(t *testing.T) { + obj := &helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a", Generation: 1}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/stable"}, + } + repo := NewApplicationRepository(obj) + + if repo.Namespace() != "team-a" || repo.Name() != "stable" { + t.Fatalf("identity = %q/%q", repo.Namespace(), repo.Name()) + } + if repo.OwnerGVK() != helmv1alpha1.HelmApplicationRepositoryGVK { + t.Fatalf("OwnerGVK = %v", repo.OwnerGVK()) + } + + wantLabels := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: "stable", + helmv1alpha1.LabelSourceNamespace: "team-a", + } + if got := repo.SourceLabels(); !reflect.DeepEqual(got, wantLabels) { + t.Fatalf("SourceLabels = %v, want %v", got, wantLabels) + } + + want := source.InternalNames{ + HelmRepository: "hapr-team-a-stable-42df68033b1e", + AuthSecret: utils.DerivedName("hapr-auth", helmv1alpha1.HelmApplicationRepositoryKind, "team-a", "stable"), + TLSSecret: utils.DerivedName("hapr-tls", helmv1alpha1.HelmApplicationRepositoryKind, "team-a", "stable"), + } + if got := repo.InternalNames(); got != want { + t.Fatalf("InternalNames = %+v, want %+v", got, want) + } +} + +// TestSameNamedRepositoriesNeverShareInternalNames is the property the whole +// naming scheme exists for: every internal object of every repository lives in one +// namespace, so two sources that share a name must still derive distinct names — +// across namespaces and across kinds alike. +func TestSameNamedRepositoriesNeverShareInternalNames(t *testing.T) { + spec := helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/stable"} + + teamA := NewApplicationRepository(&helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a"}, Spec: spec, + }) + teamB := NewApplicationRepository(&helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-b"}, Spec: spec, + }) + cluster := NewClusterApplicationRepository(&helmv1alpha1.HelmClusterApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable"}, Spec: spec, + }) + addon := NewAddonRepository(&helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable"}, Spec: spec, + }) + + seen := map[string]string{} + for label, repo := range map[string]source.Repository{"team-a": teamA, "team-b": teamB, "cluster": cluster, "addon": addon} { + names := repo.InternalNames() + for _, n := range []string{names.HelmRepository, names.AuthSecret, names.TLSSecret} { + if owner, dup := seen[n]; dup { + t.Fatalf("%s and %s derive the same internal name %q", owner, label, n) + } + seen[n] = label + if len(n) > 63 { + t.Fatalf("%q is %d characters, the limit is 63", n, len(n)) + } + } + } +} + +func TestClusterApplicationRepositoryHasNoNamespaceLabel(t *testing.T) { + repo := NewClusterApplicationRepository(&helmv1alpha1.HelmClusterApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "shared"}, + Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/charts"}, + }) + + if repo.Namespace() != "" { + t.Fatalf("Namespace = %q, want empty", repo.Namespace()) + } + if repo.OwnerGVK() != helmv1alpha1.HelmClusterApplicationRepositoryGVK { + t.Fatalf("OwnerGVK = %v", repo.OwnerGVK()) + } + + wantLabels := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterApplicationRepositoryLabelSourceName: "shared", + } + if got := repo.SourceLabels(); !reflect.DeepEqual(got, wantLabels) { + t.Fatalf("SourceLabels = %v, want %v", got, wantLabels) + } + if got := repo.InternalNames().HelmRepository; got != "hcapr-shared-"+utils.GetHash(helmv1alpha1.HelmClusterApplicationRepositoryKind+"//shared") { + t.Fatalf("HelmRepository name = %q", got) + } +} + +func TestEmptyConstructorsReturnAddressableObjectsOfTheirKind(t *testing.T) { + cases := map[string]struct { + repo source.Repository + gvk string + }{ + "addon": {EmptyAddonRepository(), helmv1alpha1.HelmClusterAddonRepositoryKind}, + "application": {EmptyApplicationRepository(), helmv1alpha1.HelmApplicationRepositoryKind}, + "cluster application": {EmptyClusterApplicationRepository(), helmv1alpha1.HelmClusterApplicationRepositoryKind}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if tc.repo.Object() == nil { + t.Fatal("Object must not be nil: the reconciler reads the API object into it") + } + if tc.repo.OwnerGVK().Kind != tc.gvk { + t.Fatalf("kind = %q, want %q", tc.repo.OwnerGVK().Kind, tc.gvk) + } + }) + } +} diff --git a/images/operator-helm-controller/internal/catalog/catalog.go b/images/operator-helm-controller/internal/catalog/catalog.go new file mode 100644 index 00000000..503746b1 --- /dev/null +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -0,0 +1,313 @@ +/* +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 catalog + +import ( + "context" + "fmt" + + 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" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" + "github.com/deckhouse/operator-helm/internal/source" +) + +// Config describes one chart catalog kind. C is the pointer type of the object, +// CL the pointer type of its list. +type Config[C client.Object, CL client.ObjectList] struct { + // Kind names the catalog kind in log lines. + Kind string + NewObject func() C + NewList func() CL + // Items returns pointers into the list, so a status write through them lands + // on the listed object. + Items func(CL) []C + Status func(C) *helmv1alpha1.ChartCatalogStatus + // ObjectName derives the catalog object name of one repository/chart pair; it + // is one of the entries of api/naming. + ObjectName func(repoName, chartName string) string + // Consumers reports the versions of one chart that resources of the family + // still reference. nil means nothing in the family can reference a chart yet, + // and every unlisted version is prunable. + Consumers func(ctx context.Context, repo source.Repository, chartName string) (map[string]struct{}, error) +} + +// New builds the source.Catalog of one kind. +func New[C client.Object, CL client.ObjectList](c client.Client, cfg Config[C, CL]) source.Catalog { + return &typed[C, CL]{client: c, cfg: cfg} +} + +type typed[C client.Object, CL client.ObjectList] struct { + client client.Client + cfg Config[C, CL] +} + +// list returns the catalog objects of one repository. A namespaced repository's +// objects live in its namespace; for a cluster repository the namespace is empty and +// InNamespace is a no-op. Listing by namespace is what keeps two same-named +// repositories in different namespaces apart. +func (t *typed[C, CL]) list(ctx context.Context, repo source.Repository) ([]C, error) { + list := t.cfg.NewList() + if err := t.client.List(ctx, list, + client.InNamespace(repo.Namespace()), + client.MatchingLabels{helmv1alpha1.LabelRepositoryName: repo.Name()}, + ); err != nil { + return nil, fmt.Errorf("listing %s objects of repository %s: %w", + t.cfg.Kind, describeKey(client.ObjectKey{Namespace: repo.Namespace(), Name: repo.Name()}), err) + } + + return t.cfg.Items(list), nil +} + +// Known collects the verdicts recorded by previous passes, so the client can skip +// the tags it has already examined. The chart objects are the only store of that +// state: keeping a separate fingerprint would be one more thing to drift. +func (t *typed[C, CL]) Known(ctx context.Context, repo source.Repository) (repoclient.KnownCharts, error) { + charts, err := t.list(ctx, repo) + if err != nil { + return nil, err + } + + logger := log.FromContext(ctx) + known := make(repoclient.KnownCharts, len(charts)) + + for _, chart := range charts { + chartName := chart.GetLabels()[helmv1alpha1.LabelChartName] + if chartName == "" { + // The chart label is the only way back from the object name (a + // truncated hash) to the chart name it belongs to. Without it the + // recorded verdicts for this chart cannot be looked up here, so every + // tag is re-examined on the next fetch; that is safe but not free, so + // it is worth surfacing. + logger.Info("Chart object has no chart label, dropping its recorded verdicts", + "kind", t.cfg.Kind, "chartObject", client.ObjectKeyFromObject(chart)) + + continue + } + + recorded := t.cfg.Status(chart).Versions + versions := make(repoclient.KnownVersions, len(recorded)) + for _, version := range recorded { + versions[version.Version] = repoclient.KnownVersion{ + MediaType: version.MediaType, + UnavailableReason: version.UnavailableReason, + UnavailableMessage: version.UnavailableMessage, + } + } + + known[chartName] = versions + } + + return known, nil +} + +func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, charts []repoclient.Chart) error { + logger := log.FromContext(ctx) + + desired := make(map[string]struct{}, len(charts)) + + for _, chart := range charts { + name := t.cfg.ObjectName(repo.Name(), chart.Name) + // A chart with no usable version is still created: it carries the reason each of + // its versions is unusable, and skipping it here would let the pruning loop below + // delete a chart whose tags merely failed to resolve. + existing := t.cfg.NewObject() + existing.SetName(name) + existing.SetNamespace(repo.Namespace()) + + desired[name] = struct{}{} + + op, err := controllerutil.CreateOrPatch(ctx, t.client, existing, func() error { + existing.SetOwnerReferences([]metav1.OwnerReference{ + *metav1.NewControllerRef(repo.Object(), repo.OwnerGVK()), + }) + existing.SetLabels(map[string]string{ + helmv1alpha1.LabelDeckhouseHeritage: helmv1alpha1.LabelDeckhouseHeritageValue, + helmv1alpha1.LabelRepositoryName: repo.Name(), + helmv1alpha1.LabelChartName: chart.Name, + }) + + return nil + }) + if err != nil { + return fmt.Errorf("creating or updating chart %s: %w", describeKey(client.ObjectKeyFromObject(existing)), err) + } + + if op != controllerutil.OperationResultNone { + logger.Info("Reconciled chart catalog object", + "kind", t.cfg.Kind, "operation", op, "chartObject", client.ObjectKeyFromObject(existing)) + } + + inUse, err := t.InUseVersions(ctx, repo, chart.Name) + if err != nil { + return err + } + + base := existing.DeepCopyObject().(C) + + status := t.cfg.Status(existing) + if len(chart.Versions) > 0 { + status.IconURL = chart.Versions[0].IconURL + } + status.Versions = mergeChartVersions(chart.Versions, status.Versions, inUse) + + if err := t.client.Status().Patch(ctx, existing, client.MergeFrom(base)); err != nil { + return fmt.Errorf("updating versions of chart %s: %w", describeKey(client.ObjectKeyFromObject(existing)), err) + } + } + + existingCharts, err := t.list(ctx, repo) + if err != nil { + return fmt.Errorf("listing charts for pruning: %w", err) + } + + for _, chart := range existingCharts { + if _, wanted := desired[chart.GetName()]; wanted { + continue + } + + chartName := chart.GetLabels()[helmv1alpha1.LabelChartName] + if chartName == "" { + // The chart label is the only way back from the object name (a + // truncated hash) to the chart name a consumer references, so + // InUseVersions cannot find anything to protect and this chart is + // pruned even if a consumer still uses it. That fail-open is unavoidable + // as written, so at least make it diagnosable. + logger.Info("Pruning a chart with no chart label; in-use protection could not be checked", + "kind", t.cfg.Kind, "chartObject", client.ObjectKeyFromObject(chart)) + } + + inUse, err := t.InUseVersions(ctx, repo, chartName) + if err != nil { + return err + } + if len(inUse) > 0 { + // A consumer still references this chart: deleting the object would make + // its own reconciliation fail on a missing chart and block every change + // to it, including its removal. + logger.Info("Keeping a chart referenced by a consumer", + "kind", t.cfg.Kind, "chartObject", client.ObjectKeyFromObject(chart)) + + continue + } + + if err := client.IgnoreNotFound(t.client.Delete(ctx, chart)); err != nil { + return fmt.Errorf("deleting stale charts: %w", err) + } + } + + return nil +} + +// MigrateNames moves a repository's catalog objects to the names the current scheme +// derives, whatever scheme they were written under. An object is recognised by its +// chart label rather than by recomputing an older name, so this covers every scheme +// the repository has ever been reconciled with, including a chart the repository no +// longer offers and that only a consumer keeps alive: the pruning loop would keep +// such an object under its old name forever, while the consumer already resolves the +// new one. +// +// The status is copied only into an object that has none, and the old object is +// deleted only once the copy has landed, so a failure anywhere leaves the old object +// in place to be migrated again on the next pass. +// +// TRANSITIONAL: remove this method, its interface entry and its call once every +// cluster has reconciled each repository at least once under the current scheme. +func (t *typed[C, CL]) MigrateNames(ctx context.Context, repo source.Repository) error { + logger := log.FromContext(ctx) + + existingCharts, err := t.list(ctx, repo) + if err != nil { + return err + } + + for _, legacy := range existingCharts { + chartName := legacy.GetLabels()[helmv1alpha1.LabelChartName] + if chartName == "" { + continue + } + + name := t.cfg.ObjectName(repo.Name(), chartName) + if legacy.GetName() == name { + continue + } + + current := t.cfg.NewObject() + current.SetName(name) + current.SetNamespace(repo.Namespace()) + + if _, err := controllerutil.CreateOrPatch(ctx, t.client, current, func() error { + current.SetOwnerReferences(legacy.GetOwnerReferences()) + current.SetLabels(legacy.GetLabels()) + + return nil + }); err != nil { + return fmt.Errorf("renaming chart %s: %w", describeKey(client.ObjectKeyFromObject(legacy)), err) + } + + if status := t.cfg.Status(current); len(status.Versions) == 0 { + base := current.DeepCopyObject().(C) + *status = *t.cfg.Status(legacy) + + if err := t.client.Status().Patch(ctx, current, client.MergeFrom(base)); err != nil { + return fmt.Errorf("carrying the status of chart %s over: %w", describeKey(client.ObjectKeyFromObject(legacy)), err) + } + } + + logger.Info("Renamed a chart catalog object", + "kind", t.cfg.Kind, "from", client.ObjectKeyFromObject(legacy), "to", client.ObjectKeyFromObject(current), "chart", chartName) + + if err := client.IgnoreNotFound(t.client.Delete(ctx, legacy)); err != nil { + return fmt.Errorf("deleting chart %s after renaming it: %w", describeKey(client.ObjectKeyFromObject(legacy)), err) + } + } + + return nil +} + +func (t *typed[C, CL]) InUseVersions(ctx context.Context, repo source.Repository, chartName string) (map[string]struct{}, error) { + if chartName == "" || t.cfg.Consumers == nil { + return nil, nil + } + + return t.cfg.Consumers(ctx, repo, chartName) +} + +func (t *typed[C, CL]) Lookup(ctx context.Context, repo source.Repository, chartName string) (client.Object, *helmv1alpha1.ChartCatalogStatus, error) { + obj := t.cfg.NewObject() + key := client.ObjectKey{Namespace: repo.Namespace(), Name: t.cfg.ObjectName(repo.Name(), chartName)} + + if err := t.client.Get(ctx, key, obj); err != nil { + return nil, nil, fmt.Errorf("getting %s %s: %w", t.cfg.Kind, key, err) + } + + return obj, t.cfg.Status(obj), nil +} + +// describeKey names an object in a message. A cluster-scoped object has no +// namespace, and the key's own rendering would give it a leading slash. +func describeKey(key client.ObjectKey) string { + if key.Namespace == "" { + return key.Name + } + + return key.String() +} diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go new file mode 100644 index 00000000..697596c6 --- /dev/null +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -0,0 +1,624 @@ +/* +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 catalog_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "github.com/deckhouse/operator-helm/api/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" + "github.com/deckhouse/operator-helm/internal/index" + "github.com/deckhouse/operator-helm/internal/source" +) + +func newClient(t *testing.T) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&helmv1alpha1.HelmApplicationChart{}, &helmv1alpha1.HelmClusterApplicationChart{}). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationRepository, index.ApplicationRepositoryIndexer). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). + Build() +} + +func applicationRepo(namespace, name string) source.Repository { //nolint:unparam // the parameter names the value the assertions read; inlining it would hide what the fixture stands for + return adapter.NewApplicationRepository(&helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID(namespace + "/" + name)}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/" + name}, + }) +} + +// newAddonClient builds a client for the addon family's own tests, indexed the way +// the addon consumer lookup (chartConsumers over ListAddonReleases) requires. +func newAddonClient(t *testing.T, objects ...client.Object) client.WithWatch { + t.Helper() + + return newAddonClientWithInterceptor(t, interceptor.Funcs{}, objects...) +} + +func newAddonClientWithInterceptor(t *testing.T, funcs interceptor.Funcs, objects ...client.Object) client.WithWatch { + t.Helper() + + scheme := runtime.NewScheme() + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithInterceptorFuncs(funcs). + WithStatusSubresource(&helmv1alpha1.HelmClusterAddonChart{}). + WithObjects(objects...). + WithIndex(&helmv1alpha1.HelmClusterAddon{}, index.AddonChart, func(obj client.Object) []string { + addon := obj.(*helmv1alpha1.HelmClusterAddon) + + return []string{index.AddonChartValue(addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName)} + }). + WithIndex(&helmv1alpha1.HelmClusterAddon{}, index.AddonRepository, func(obj client.Object) []string { + addon := obj.(*helmv1alpha1.HelmClusterAddon) + + return []string{addon.Spec.Chart.HelmClusterAddonRepository} + }). + Build() +} + +func addonRepo() source.Repository { + return adapter.NewAddonRepository(&helmv1alpha1.HelmClusterAddonRepository{ObjectMeta: metav1.ObjectMeta{Name: "example"}}) +} + +// addonConsumer builds a HelmClusterAddon referencing one repository/chart/version, +// so InUseVersions reports that version as in use. +func addonConsumer(name, repoName, chartName, version string) *helmv1alpha1.HelmClusterAddon { //nolint:unparam // the parameter names the value the assertions read; inlining it would hide what the fixture stands for + return &helmv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: helmv1alpha1.HelmClusterAddonSpec{ + Namespace: "app", + Chart: helmv1alpha1.HelmClusterAddonChartRef{ + HelmClusterAddonRepository: repoName, + HelmClusterAddonChartName: chartName, + Version: version, + }, + }, + } +} + +// legacyAddonChart builds a HelmClusterAddonChart under a name that predates the +// current scheme, labelled the way every catalog object is labelled. The name is a +// plain literal, not a recomputation of any past scheme: the migration finds this +// object by its chart label alone. +func legacyAddonChart(name, repoName, chartName string, versions ...helmv1alpha1.ChartVersion) *helmv1alpha1.HelmClusterAddonChart { //nolint:unparam // the parameter names the value the assertions read; inlining it would hide what the fixture stands for + return &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + helmv1alpha1.LabelDeckhouseHeritage: helmv1alpha1.LabelDeckhouseHeritageValue, + helmv1alpha1.LabelRepositoryName: repoName, + helmv1alpha1.LabelChartName: chartName, + }, + }, + Status: helmv1alpha1.ChartCatalogStatus{Versions: versions}, + } +} + +// syncCatalog runs the two steps the repository reconciler runs, in its order and +// with its gate: the rename first, independent of any fetch, and the catalog write +// only if the rename finished. The gate is what keeps an unfinished rename from +// being written over; it lives in the reconciler, so the test that owns it is +// TestUnfinishedRenameDoesNotOverwriteTheCatalog in internal/reconcile/repository, +// not here. +func syncCatalog(cat source.Catalog, repo source.Repository, charts ...repoclient.Chart) error { + if err := cat.MigrateNames(context.Background(), repo); err != nil { + return err + } + + return cat.Reconcile(context.Background(), repo, charts) +} + +func chart(name, version string) repoclient.Chart { + return repoclient.Chart{ + Name: name, + Versions: []repoclient.ChartVersion{{Version: semver.MustParse(version), IconURL: "https://example.invalid/" + name + ".png"}}, + } +} + +func TestReconcileWritesNamespacedCatalogNextToTheRepository(t *testing.T) { + c := newClient(t) + cat := adapter.NewApplicationCatalog(c) + repo := applicationRepo("team-a", "stable") + + if err := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "6.7.1")}); err != nil { + t.Fatalf("Reconcile returned %v", err) + } + + got := &helmv1alpha1.HelmApplicationChart{} + key := client.ObjectKey{Namespace: "team-a", Name: naming.ApplicationChartName("stable", "podinfo")} + if err := c.Get(context.Background(), key, got); err != nil { + t.Fatalf("chart object was not created in the repository namespace: %v", err) + } + + if got.Labels[helmv1alpha1.LabelRepositoryName] != "stable" || got.Labels[helmv1alpha1.LabelChartName] != "podinfo" { + t.Fatalf("labels = %v, want repository=stable chart=podinfo", got.Labels) + } + if got.Labels[helmv1alpha1.LabelDeckhouseHeritage] != helmv1alpha1.LabelDeckhouseHeritageValue { + t.Fatalf("heritage label = %q", got.Labels[helmv1alpha1.LabelDeckhouseHeritage]) + } + + if len(got.OwnerReferences) != 1 { + t.Fatalf("owner references = %v, want exactly one", got.OwnerReferences) + } + owner := got.OwnerReferences[0] + if owner.Kind != helmv1alpha1.HelmApplicationRepositoryKind || owner.Name != "stable" || owner.Controller == nil || !*owner.Controller { + t.Fatalf("owner = %+v, want a controller reference to HelmApplicationRepository/stable", owner) + } + + if len(got.Status.Versions) != 1 || got.Status.Versions[0].Version != "6.7.1" { + t.Fatalf("versions = %+v, want [6.7.1]", got.Status.Versions) + } + if got.Status.IconURL != "https://example.invalid/podinfo.png" { + t.Fatalf("icon = %q", got.Status.IconURL) + } +} + +// TestSameNamedRepositoriesKeepSeparateCatalogs is why the namespaced catalog +// lists by namespace: two repositories named "stable" in different namespaces +// produce same-named chart objects, and one repository's pruning must not see the +// other's. +func TestSameNamedRepositoriesKeepSeparateCatalogs(t *testing.T) { + c := newClient(t) + cat := adapter.NewApplicationCatalog(c) + teamA := applicationRepo("team-a", "stable") + teamB := applicationRepo("team-b", "stable") + + if err := cat.Reconcile(context.Background(), teamA, []repoclient.Chart{chart("podinfo", "1.0.0")}); err != nil { + t.Fatalf("Reconcile(team-a) returned %v", err) + } + if err := cat.Reconcile(context.Background(), teamB, []repoclient.Chart{chart("nginx", "2.0.0")}); err != nil { + t.Fatalf("Reconcile(team-b) returned %v", err) + } + + known, err := cat.Known(context.Background(), teamA) + if err != nil { + t.Fatalf("Known returned %v", err) + } + if _, ok := known["podinfo"]; !ok || len(known) != 1 { + t.Fatalf("Known(team-a) = %v, want only podinfo", known) + } + + // Re-reconciling team-a with an empty list prunes its own catalog only. + if err := cat.Reconcile(context.Background(), teamA, nil); err != nil { + t.Fatalf("Reconcile(team-a, empty) returned %v", err) + } + + var charts helmv1alpha1.HelmApplicationChartList + if err := c.List(context.Background(), &charts); err != nil { + t.Fatalf("listing charts: %v", err) + } + if len(charts.Items) != 1 || charts.Items[0].Namespace != "team-b" { + t.Fatalf("remaining charts = %v, want only team-b's", charts.Items) + } +} + +func TestClusterCatalogObjectsHaveNoNamespace(t *testing.T) { + c := newClient(t) + cat := adapter.NewClusterApplicationCatalog(c) + repo := adapter.NewClusterApplicationRepository(&helmv1alpha1.HelmClusterApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "shared", UID: "shared"}, + Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/charts"}, + }) + + if err := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "6.7.1")}); err != nil { + t.Fatalf("Reconcile returned %v", err) + } + + got := &helmv1alpha1.HelmClusterApplicationChart{} + key := client.ObjectKey{Name: naming.ClusterApplicationChartName("shared", "podinfo")} + if err := c.Get(context.Background(), key, got); err != nil { + t.Fatalf("cluster chart object was not created: %v", err) + } + if got.OwnerReferences[0].Kind != helmv1alpha1.HelmClusterApplicationRepositoryKind { + t.Fatalf("owner kind = %q", got.OwnerReferences[0].Kind) + } +} + +// TestUnreferencedVersionsArePruned pins what happens to a version nothing uses. +// The application catalogs do have consumers — the HelmApplication objects of the +// repository — but this fixture creates none, so no version is protected and every +// unlisted one goes away. +func TestUnreferencedVersionsArePruned(t *testing.T) { + c := newClient(t) + cat := adapter.NewApplicationCatalog(c) + repo := applicationRepo("team-a", "stable") + + if err := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "1.0.0"), chart("nginx", "1.0.0")}); err != nil { + t.Fatalf("first Reconcile returned %v", err) + } + if err := syncCatalog(cat, repo, chart("podinfo", "1.0.0")); err != nil { + t.Fatalf("second Reconcile returned %v", err) + } + + var charts helmv1alpha1.HelmApplicationChartList + if err := c.List(context.Background(), &charts, client.InNamespace("team-a")); err != nil { + t.Fatalf("listing charts: %v", err) + } + if len(charts.Items) != 1 || charts.Items[0].Labels[helmv1alpha1.LabelChartName] != "podinfo" { + t.Fatalf("remaining charts = %v, want only podinfo", charts.Items) + } + + inUse, err := cat.InUseVersions(context.Background(), repo, "podinfo") + if err != nil { + t.Fatalf("InUseVersions returned %v", err) + } + if len(inUse) != 0 { + t.Fatalf("InUseVersions = %v, want none without consumers", inUse) + } +} + +func TestLookupReturnsTheCatalogObjectAndItsStatus(t *testing.T) { + c := newClient(t) + cat := adapter.NewApplicationCatalog(c) + repo := applicationRepo("team-a", "stable") + + if err := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "6.7.1")}); err != nil { + t.Fatalf("Reconcile returned %v", err) + } + + obj, status, err := cat.Lookup(context.Background(), repo, "podinfo") + if err != nil { + t.Fatalf("Lookup returned %v", err) + } + if obj.GetNamespace() != "team-a" || obj.GetName() != naming.ApplicationChartName("stable", "podinfo") { + t.Fatalf("Lookup returned %s/%s, want the catalog object next to the repository", obj.GetNamespace(), obj.GetName()) + } + if len(status.Versions) != 1 || status.Versions[0].Version != "6.7.1" { + t.Fatalf("status versions = %+v, want [6.7.1]", status.Versions) + } + + _, _, err = cat.Lookup(context.Background(), repo, "missing") + if !apierrors.IsNotFound(err) { + t.Fatalf("Lookup of an unknown chart must be a NotFound error, got %v", err) + } +} + +// TestListErrorNamesAClusterScopedRepositoryWithoutALeadingSlash pins the message a +// failed catalog read puts into the repository's Synced condition: an object key +// renders a cluster-scoped name as "/name", which reaches the user verbatim. +func TestListErrorNamesAClusterScopedRepositoryWithoutALeadingSlash(t *testing.T) { + scheme := runtime.NewScheme() + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error { + return errors.New("boom") + }, + }). + Build() + + err := adapter.NewClusterApplicationCatalog(c). + Reconcile(context.Background(), adapter.NewClusterApplicationRepository( + &helmv1alpha1.HelmClusterApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "shared", UID: types.UID("shared")}, + }, + ), nil) + if err == nil { + t.Fatal("Reconcile must report the list failure") + } + if !strings.Contains(err.Error(), "repository shared:") { + t.Fatalf("error = %q, want it to name the repository as \"shared\"", err) + } +} + +// TestMigratesALegacyNamedObjectStillInUse pins the migration for a chart a +// consumer still references: the legacy object is deleted regardless of that +// reference, and the version it protected survives on the new object marked +// RemovedFromRepository, media type included, exactly as an ordinary in-use prune +// would have kept it had the name never changed. +func TestMigratesALegacyNamedObjectStillInUse(t *testing.T) { + const legacyName = "example-chart-podinfo" + + c := newAddonClient(t, + legacyAddonChart(legacyName, "example", "podinfo", + helmv1alpha1.ChartVersion{Version: "1.0.0", MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"}, + ), + addonConsumer("consumer", "example", "podinfo", "1.0.0"), + ) + cat := adapter.NewAddonCatalog(c) + repo := addonRepo() + + if err := syncCatalog(cat, repo, chart("podinfo", "2.0.0")); err != nil { + t.Fatalf("Reconcile returned %v", err) + } + + newName := naming.HelmClusterAddonChartName("example", "podinfo") + + got := &helmv1alpha1.HelmClusterAddonChart{} + if err := c.Get(context.Background(), client.ObjectKey{Name: newName}, got); err != nil { + t.Fatalf("new-scheme object was not created: %v", err) + } + + var retained *helmv1alpha1.ChartVersion + for i := range got.Status.Versions { + if got.Status.Versions[i].Version == "1.0.0" { + retained = &got.Status.Versions[i] + } + } + if retained == nil { + t.Fatalf("versions = %+v, want 1.0.0 retained from the legacy object", got.Status.Versions) + } + if retained.UnavailableReason != helmv1alpha1.UnavailableReasonRemovedFromRepository { + t.Fatalf("1.0.0 unavailable reason = %q, want %q", retained.UnavailableReason, helmv1alpha1.UnavailableReasonRemovedFromRepository) + } + if retained.MediaType != "application/vnd.cncf.helm.chart.content.v1.tar+gzip" { + t.Fatalf("1.0.0 media type = %q, want it carried from the legacy object", retained.MediaType) + } + + err := c.Get(context.Background(), client.ObjectKey{Name: legacyName}, &helmv1alpha1.HelmClusterAddonChart{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("legacy object err = %v, want NotFound: it must be deleted even though a consumer still uses one of its versions", err) + } +} + +// TestMigratesALegacyNamedObjectNotInUse pins the same migration for a chart +// nothing references: the legacy object is still replaced by a new-scheme one, not +// merely left behind the way an ordinary rename would have left it. +func TestMigratesALegacyNamedObjectNotInUse(t *testing.T) { + const legacyName = "example-chart-podinfo" + + c := newAddonClient(t, + legacyAddonChart(legacyName, "example", "podinfo", + helmv1alpha1.ChartVersion{Version: "1.0.0"}, + ), + ) + cat := adapter.NewAddonCatalog(c) + repo := addonRepo() + + if err := syncCatalog(cat, repo, chart("podinfo", "2.0.0")); err != nil { + t.Fatalf("Reconcile returned %v", err) + } + + newName := naming.HelmClusterAddonChartName("example", "podinfo") + + got := &helmv1alpha1.HelmClusterAddonChart{} + if err := c.Get(context.Background(), client.ObjectKey{Name: newName}, got); err != nil { + t.Fatalf("new-scheme object was not created: %v", err) + } + if len(got.Status.Versions) != 1 || got.Status.Versions[0].Version != "2.0.0" { + t.Fatalf("versions = %+v, want only the fetched 2.0.0: nothing protects the unreferenced legacy version", got.Status.Versions) + } + + err := c.Get(context.Background(), client.ObjectKey{Name: legacyName}, &helmv1alpha1.HelmClusterAddonChart{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("legacy object err = %v, want NotFound: it must not be left behind", err) + } +} + +// TestMigrationIsANoOpOnASecondReconcile pins that the seed-and-delete migration +// runs exactly once: the second reconcile finds no legacy object left to seed from +// or delete, so the new object's own status stands and nothing is deleted. +func TestMigrationIsANoOpOnASecondReconcile(t *testing.T) { + const legacyName = "example-chart-podinfo" + + c := newAddonClient(t, + legacyAddonChart(legacyName, "example", "podinfo", + helmv1alpha1.ChartVersion{Version: "1.0.0", MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"}, + ), + addonConsumer("consumer", "example", "podinfo", "1.0.0"), + ) + cat := adapter.NewAddonCatalog(c) + repo := addonRepo() + + if err := syncCatalog(cat, repo, chart("podinfo", "2.0.0")); err != nil { + t.Fatalf("first Reconcile returned %v", err) + } + + newName := naming.HelmClusterAddonChartName("example", "podinfo") + + before := &helmv1alpha1.HelmClusterAddonChart{} + if err := c.Get(context.Background(), client.ObjectKey{Name: newName}, before); err != nil { + t.Fatalf("getting the migrated object: %v", err) + } + if err := c.Get(context.Background(), client.ObjectKey{Name: legacyName}, &helmv1alpha1.HelmClusterAddonChart{}); !apierrors.IsNotFound(err) { + t.Fatalf("legacy object err = %v, want NotFound after the first reconcile already migrated it", err) + } + + deletes := 0 + c = interceptedClient(t, c, func() { deletes++ }) + + cat = adapter.NewAddonCatalog(c) + if err := syncCatalog(cat, repo, chart("podinfo", "2.0.0")); err != nil { + t.Fatalf("second Reconcile returned %v", err) + } + + if deletes != 0 { + t.Fatalf("second Reconcile issued %d delete(s), want none: nothing is left over to migrate", deletes) + } + + after := &helmv1alpha1.HelmClusterAddonChart{} + if err := c.Get(context.Background(), client.ObjectKey{Name: newName}, after); err != nil { + t.Fatalf("getting the object after the second reconcile: %v", err) + } + if len(after.Status.Versions) != len(before.Status.Versions) || after.Status.Versions[0] != before.Status.Versions[0] { + t.Fatalf("status changed on a no-op reconcile: before %+v, after %+v", before.Status.Versions, after.Status.Versions) + } +} + +// interceptedClient wraps c so onDelete is called for every delete it forwards, +// while every other call still reaches c unchanged. +func interceptedClient(t *testing.T, c client.WithWatch, onDelete func()) client.WithWatch { + t.Helper() + + return interceptor.NewClient(c, interceptor.Funcs{ + Delete: func(ctx context.Context, wc client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { + onDelete() + + return wc.Delete(ctx, obj, opts...) + }, + }) +} + +// TestMigrationLeavesUnrelatedChartsToExistingPruning pins that the migration only +// touches charts being reconciled: an unrelated chart's object is still governed by +// the ordinary in-use rule, kept while referenced and pruned once it is not. +func TestMigrationLeavesUnrelatedChartsToExistingPruning(t *testing.T) { + c := newAddonClient(t, + &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.HelmClusterAddonChartName("example", "kept"), + Labels: map[string]string{helmv1alpha1.LabelRepositoryName: "example", helmv1alpha1.LabelChartName: "kept"}, + }, + }, + &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.HelmClusterAddonChartName("example", "pruned"), + Labels: map[string]string{helmv1alpha1.LabelRepositoryName: "example", helmv1alpha1.LabelChartName: "pruned"}, + }, + }, + addonConsumer("consumer", "example", "kept", "1.0.0"), + ) + cat := adapter.NewAddonCatalog(c) + repo := addonRepo() + + // podinfo is the only chart being reconciled; "kept" and "pruned" are not part + // of this call, exactly like an ordinary reconcile of a repository whose index + // dropped them. + if err := syncCatalog(cat, repo, chart("podinfo", "1.0.0")); err != nil { + t.Fatalf("Reconcile returned %v", err) + } + + err := c.Get(context.Background(), client.ObjectKey{Name: naming.HelmClusterAddonChartName("example", "kept")}, &helmv1alpha1.HelmClusterAddonChart{}) + if err != nil { + t.Fatalf("the referenced unrelated chart must be kept, got %v", err) + } + + err = c.Get(context.Background(), client.ObjectKey{Name: naming.HelmClusterAddonChartName("example", "pruned")}, &helmv1alpha1.HelmClusterAddonChart{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("the unreferenced unrelated chart must be pruned, got %v", err) + } +} + +// TestMigratesAChartTheRepositoryNoLongerOffers pins the case the pruning loop +// cannot handle: the repository dropped the chart entirely and only a consumer keeps +// it alive. Its object is not part of any fetch, so a migration driven by the fetched +// charts would leave it under the legacy name, while the consumer already resolves +// the new one and its release would never reconcile again. +func TestMigratesAChartTheRepositoryNoLongerOffers(t *testing.T) { + const legacyName = "example-chart-gone" + + c := newAddonClient(t, + legacyAddonChart(legacyName, "example", "gone", + helmv1alpha1.ChartVersion{Version: "1.0.0", MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"}, + ), + addonConsumer("consumer", "example", "gone", "1.0.0"), + ) + cat := adapter.NewAddonCatalog(c) + + if err := syncCatalog(cat, addonRepo(), chart("podinfo", "2.0.0")); err != nil { + t.Fatalf("Reconcile returned %v", err) + } + + got := &helmv1alpha1.HelmClusterAddonChart{} + if err := c.Get(context.Background(), client.ObjectKey{Name: naming.HelmClusterAddonChartName("example", "gone")}, got); err != nil { + t.Fatalf("the chart a consumer still holds was not moved to its new name: %v", err) + } + if len(got.Status.Versions) != 1 || got.Status.Versions[0].Version != "1.0.0" { + t.Fatalf("versions = %+v, want the legacy status carried over", got.Status.Versions) + } + if got.Status.Versions[0].MediaType == "" { + t.Fatal("the media type was lost, so the consumer's internal source cannot be built") + } + + err := c.Get(context.Background(), client.ObjectKey{Name: legacyName}, &helmv1alpha1.HelmClusterAddonChart{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("legacy object err = %v, want NotFound", err) + } +} + +// TestMigrationSurvivesAFailedStatusWrite pins that the legacy object outlives a +// failure: it is the only copy of a retained version, so deleting it before its +// status has landed would lose that version for good. +func TestMigrationSurvivesAFailedStatusWrite(t *testing.T) { + const legacyName = "example-chart-podinfo" + + failed := false + c := newAddonClientWithInterceptor(t, interceptor.Funcs{ + SubResourcePatch: func(ctx context.Context, cl client.Client, sub string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if !failed { + failed = true + + return errors.New("transient status patch failure") + } + + return cl.SubResource(sub).Patch(ctx, obj, patch, opts...) + }, + }, + legacyAddonChart(legacyName, "example", "podinfo", + helmv1alpha1.ChartVersion{Version: "1.0.0", MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"}, + ), + addonConsumer("consumer", "example", "podinfo", "1.0.0"), + ) + cat := adapter.NewAddonCatalog(c) + + if err := syncCatalog(cat, addonRepo(), chart("podinfo", "2.0.0")); err == nil { + t.Fatal("Reconcile must report the failed status write") + } + + if err := c.Get(context.Background(), client.ObjectKey{Name: legacyName}, &helmv1alpha1.HelmClusterAddonChart{}); err != nil { + t.Fatalf("legacy object err = %v, want it kept until its status has been carried over", err) + } + + if err := syncCatalog(cat, addonRepo(), chart("podinfo", "2.0.0")); err != nil { + t.Fatalf("second Reconcile returned %v", err) + } + + got := &helmv1alpha1.HelmClusterAddonChart{} + if err := c.Get(context.Background(), client.ObjectKey{Name: naming.HelmClusterAddonChartName("example", "podinfo")}, got); err != nil { + t.Fatalf("new-scheme object was not created: %v", err) + } + + var retained bool + for _, version := range got.Status.Versions { + if version.Version == "1.0.0" && version.MediaType != "" { + retained = true + } + } + if !retained { + t.Fatalf("versions = %+v, want 1.0.0 and its media type carried over on the retry", got.Status.Versions) + } +} diff --git a/images/operator-helm-controller/internal/catalog/doc.go b/images/operator-helm-controller/internal/catalog/doc.go new file mode 100644 index 00000000..cbdb44c8 --- /dev/null +++ b/images/operator-helm-controller/internal/catalog/doc.go @@ -0,0 +1,24 @@ +/* +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 catalog writes the chart catalog of a repository into the chart +// catalog kind of its family. It is the one place in the controller that is +// generic over API types: the catalog objects must be created, listed and +// status-patched with their concrete kind, and everything a kind contributes — +// constructors, accessors, the naming function, who consumes the charts — arrives +// through a Config. The result is exposed as source.Catalog, so no generic type +// leaks into the services. +package catalog diff --git a/images/operator-helm-controller/internal/catalog/log_test.go b/images/operator-helm-controller/internal/catalog/log_test.go new file mode 100644 index 00000000..fd872ab6 --- /dev/null +++ b/images/operator-helm-controller/internal/catalog/log_test.go @@ -0,0 +1,78 @@ +/* +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 catalog_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/deckhouse/operator-helm/internal/adapter" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" +) + +// TestCatalogLogsDoNotShadowTheReconciledObject holds the catalog reconcile to the +// rule every log line here follows: the top-level name/namespace keys belong to +// controller-runtime and identify the object being reconciled, so anything else a +// line names goes under a key of its own. A second "name" is not an error for zap — +// it emits both, and every JSON reader downstream keeps the last one, which silently +// reattributes the line to the wrong resource. +func TestCatalogLogsDoNotShadowTheReconciledObject(t *testing.T) { + var out bytes.Buffer + + // What builder.Build installs for a reconcile of HelmApplicationRepository + // team-a/stable; see LogConstructor in controller-runtime's pkg/builder. + logger := zap.New(zap.UseDevMode(false), zap.WriteTo(&out)).WithValues( + "controller", "helmapplicationrepository-controller", + "controllerGroup", "helm.deckhouse.io", + "controllerKind", "HelmApplicationRepository", + "HelmApplicationRepository", klog.KRef("team-a", "stable"), + "namespace", "team-a", "name", "stable", "reconcileID", "r-1", + ) + ctx := log.IntoContext(context.Background(), logger) + + cat := adapter.NewApplicationCatalog(newClient(t)) + if err := cat.Reconcile(ctx, applicationRepo("team-a", "stable"), []repoclient.Chart{chart("podinfo", "6.7.1")}); err != nil { + t.Fatalf("Reconcile returned %v", err) + } + + if out.Len() == 0 { + t.Fatal("the reconcile logged nothing, so the assertion below would prove nothing") + } + + for _, line := range bytes.Split(bytes.TrimSpace(out.Bytes()), []byte("\n")) { + var fields map[string]any + if err := json.Unmarshal(line, &fields); err != nil { + t.Fatalf("log line is not JSON: %v", err) + } + + // encoding/json keeps the last of two identical keys, exactly as the log + // pipeline does, so this reads back the shadowing value if there is one. + if got := fields["name"]; got != "stable" { + t.Errorf("log line %q reports name %q, want the reconciled repository %q", + fields["msg"], got, "stable") + } + if got := fields["namespace"]; got != "team-a" { + t.Errorf("log line %q reports namespace %q, want %q", fields["msg"], got, "team-a") + } + } +} diff --git a/images/operator-helm-controller/internal/catalog/merge.go b/images/operator-helm-controller/internal/catalog/merge.go new file mode 100644 index 00000000..e18317f9 --- /dev/null +++ b/images/operator-helm-controller/internal/catalog/merge.go @@ -0,0 +1,128 @@ +/* +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 catalog + +import ( + "sort" + + "github.com/Masterminds/semver/v3" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" +) + +// mergeChartVersions builds the desired version list from the fetched entries and the +// ones already recorded. A recorded version the registry no longer lists is dropped, +// unless a consumer still references it: then it is retained with RemovedFromRepository +// and keeps both its media type and its recorded OCI reference, without either of +// which the consumer's internal OCIRepository could not be built at all. +// +// The same protection applies to a version that is still listed but whose tag was +// re-pushed as a non-chart artifact: the fresh verdict carries no media type, but if a +// consumer still references the version, its previously recorded media type is carried +// forward alongside the fresh UnsupportedMediaType reason and message. Without the old +// media type the internal OCIRepository could not be built at all, which would block +// every change to the running consumer (values, maintenance mode, ...) rather than just +// the pull that the new artifact actually breaks; the real pull failure is reported by +// the source controller instead. +func mergeChartVersions( + fetched []repoclient.ChartVersion, + current []helmv1alpha1.ChartVersion, + inUse map[string]struct{}, +) []helmv1alpha1.ChartVersion { + merged := make([]helmv1alpha1.ChartVersion, 0, len(fetched)+len(current)) + listed := make(map[string]struct{}, len(fetched)) + + currentByVersion := make(map[string]helmv1alpha1.ChartVersion, len(current)) + for _, version := range current { + currentByVersion[version.Version] = version + } + + for _, version := range fetched { + name := version.Version.Original() + listed[name] = struct{}{} + + mediaType := version.MediaType + // The carry-forward only makes sense for a version that still resolves to an + // archive: a fresh entry that now carries an OCIRef must probe its own layer + // media type from scratch, or a stale value stamped here would be read by + // resolveMediaType before the force-reconcile cache bypass and the pull would + // fail forever with no way to correct it. + if mediaType == "" && version.OCIRef == "" { + if _, referenced := inUse[name]; referenced { + if old, recorded := currentByVersion[name]; recorded && old.MediaType != "" { + mediaType = old.MediaType + } + } + } + + merged = append(merged, helmv1alpha1.ChartVersion{ + Version: name, + OCIRef: version.OCIRef, + MediaType: mediaType, + UnavailableReason: version.UnavailableReason, + UnavailableMessage: version.UnavailableMessage, + }) + } + + for _, version := range current { + if _, stillListed := listed[version.Version]; stillListed { + continue + } + if _, referenced := inUse[version.Version]; !referenced { + continue + } + + version.UnavailableReason = helmv1alpha1.UnavailableReasonRemovedFromRepository + version.UnavailableMessage = "the repository no longer offers this version" + merged = append(merged, version) + } + + sortChartVersions(merged) + + return merged +} + +// sortChartVersions orders versions by descending semver, breaking ties by a reverse +// string comparison. A version that does not parse as semver sorts after every +// version that does, ordered among themselves by the same reverse string comparison. +// Parsability has to be the primary key: comparing a parsable and an unparsable +// version by semver on one pair and by string on another can produce a cycle (e.g. +// "6.10.0" > "6.9.0" by semver, "6.9.0" > "6.5.x" and "6.5.x" > "6.10.0" by string), +// which is not a valid ordering for sort.SliceStable. Today's clients never write an +// unparsable version, but legacy status data can still carry one, and the order has to +// be deterministic regardless: the merge goes through maps, and an unstable order +// would produce a status patch on every synchronization for a catalog that did not +// change. +func sortChartVersions(versions []helmv1alpha1.ChartVersion) { + sort.SliceStable(versions, func(i, j int) bool { + left, leftErr := semver.NewVersion(versions[i].Version) + right, rightErr := semver.NewVersion(versions[j].Version) + + leftParses, rightParses := leftErr == nil, rightErr == nil + + if leftParses != rightParses { + return leftParses + } + + if leftParses && !left.Equal(right) { + return left.GreaterThan(right) + } + + return versions[i].Version > versions[j].Version + }) +} diff --git a/images/operator-helm-controller/internal/catalog/merge_test.go b/images/operator-helm-controller/internal/catalog/merge_test.go new file mode 100644 index 00000000..1c7a87b6 --- /dev/null +++ b/images/operator-helm-controller/internal/catalog/merge_test.go @@ -0,0 +1,103 @@ +/* +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 catalog + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" +) + +// TestMergeChartVersionsCarriesOCIRef pins the three things that can happen to a +// recorded reference. Fresh index data always wins, which is how a version +// re-published as an archive loses its reference; a version the index no longer +// offers keeps it, without which the addon still using it could not build its +// internal OCIRepository and would be blocked from every change, including its own +// removal. +func TestMergeChartVersionsCarriesOCIRef(t *testing.T) { + fetched := []repoclient.ChartVersion{ + {Version: semver.MustParse("3.0.0"), OCIRef: "oci://registry.example.com/charts/podinfo:3.0.0"}, + {Version: semver.MustParse("2.0.0")}, + } + + current := []helmv1alpha1.ChartVersion{ + {Version: "2.0.0", OCIRef: "oci://registry.example.com/charts/podinfo:2.0.0"}, + {Version: "1.0.0", OCIRef: "oci://registry.example.com/charts/podinfo:1.0.0"}, + } + + inUse := map[string]struct{}{"1.0.0": {}} + + merged := mergeChartVersions(fetched, current, inUse) + + byVersion := map[string]helmv1alpha1.ChartVersion{} + for _, version := range merged { + byVersion[version.Version] = version + } + + if got := byVersion["3.0.0"].OCIRef; got != "oci://registry.example.com/charts/podinfo:3.0.0" { + t.Fatalf("3.0.0 oci ref = %q, want the fetched one", got) + } + if got := byVersion["2.0.0"].OCIRef; got != "" { + t.Fatalf("2.0.0 oci ref = %q, want empty: the index now offers an archive", got) + } + + retained, ok := byVersion["1.0.0"] + if !ok { + t.Fatal("a version still referenced by an addon must be retained") + } + if retained.OCIRef != "oci://registry.example.com/charts/podinfo:1.0.0" { + t.Fatalf("retained oci ref = %q, want the recorded one", retained.OCIRef) + } + if retained.UnavailableReason != helmv1alpha1.UnavailableReasonRemovedFromRepository { + t.Fatalf("retained reason = %q, want %q", retained.UnavailableReason, helmv1alpha1.UnavailableReasonRemovedFromRepository) + } +} + +// TestMergeChartVersionsDoesNotCarryMediaTypeOntoOCIRef pins the invariant the API +// documentation asserts: MediaType stays empty for a version carrying OCIRef. A +// version that now resolves to an OCI artifact must probe its own layer media type +// from scratch even though an addon still references it and a previous pass (back +// when the version was an archive) recorded one: resolveMediaType checks +// version.MediaType != "" before the force-reconcile cache bypass, so a stale +// carried-forward value would use the wrong layer selector and no force reconcile +// could ever correct it. +func TestMergeChartVersionsDoesNotCarryMediaTypeOntoOCIRef(t *testing.T) { + fetched := []repoclient.ChartVersion{ + {Version: semver.MustParse("6.7.1"), OCIRef: "oci://other-registry.example.com/x/podinfo:6.7.1"}, + } + + current := []helmv1alpha1.ChartVersion{ + {Version: "6.7.1", MediaType: "application/tar+gzip"}, + } + + inUse := map[string]struct{}{"6.7.1": {}} + + merged := mergeChartVersions(fetched, current, inUse) + + if len(merged) != 1 { + t.Fatalf("merged = %+v, want exactly one version", merged) + } + if merged[0].OCIRef != "oci://other-registry.example.com/x/podinfo:6.7.1" { + t.Fatalf("OCIRef = %q, want the fetched one", merged[0].OCIRef) + } + if merged[0].MediaType != "" { + t.Fatalf("MediaType = %q, want empty: a version carrying OCIRef must not carry a stale media type forward", merged[0].MediaType) + } +} diff --git a/images/operator-helm-controller/internal/chartsource/chartsource.go b/images/operator-helm-controller/internal/chartsource/chartsource.go new file mode 100644 index 00000000..ec789374 --- /dev/null +++ b/images/operator-helm-controller/internal/chartsource/chartsource.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 chartsource names where one chart version comes from. A release is served +// either by an internal HelmChart, when the repository hands out packaged archives, +// or by an internal OCIRepository, when it hands out registry artifacts — and which +// of the two applies is a property of the version, not of the repository alone. The +// vocabulary lives in its own package because every layer speaks it: the repository +// client picks an implementation by Kind, the services write the internal object it +// names, and both reconcilers branch on it. +package chartsource + +import ( + "fmt" + "net/url" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/utils" +) + +// Kind is which internal source object serves a chart: an internal HelmRepository +// hands out packaged archives, an internal OCIRepository registry artifacts. +type Kind string + +const ( + Helm Kind = "helm" + OCI Kind = "oci" +) + +// Source is where one chart version is actually fetched from. It is not the +// same thing as the repository type: the repository type follows the scheme of +// spec.url and decides the catalog client, the shape of the auth secret and whether +// an internal HelmRepository exists at all, while Source decides which internal +// source object one addon needs for the version it asks for. The two differ exactly +// when a helm repository's index points a version at a registry. +type Source struct { + Kind Kind + // URL is the artifact address with the oci:// scheme and without the tag. It is + // empty for Kind == Helm. + URL string + // Tag is the artifact tag. It is empty for Kind == Helm. + Tag string +} + +// Resolve decides where one chart version comes from. A recorded OCI reference wins +// over the repository scheme: that is the hybrid case this exists for. It takes the +// repository url rather than the repository object so that every repository kind can +// use it. +func Resolve( + repoURL string, + version *helmv1alpha1.ChartVersion, +) (Source, error) { + if version.OCIRef != "" { + // The recorded reference always carries a tag, so there is no fallback to + // offer here; a reference that cannot be split was never recorded by the + // catalog and can only come from data written by hand or by an older version. + url, tag, err := utils.SplitOCIRef(version.OCIRef, "") + if err != nil { + return Source{}, fmt.Errorf("resolving the source of version %q: %w", version.Version, err) + } + + return Source{Kind: OCI, URL: url, Tag: tag}, nil + } + + repoType, err := KindOf(repoURL) + if err != nil { + return Source{}, fmt.Errorf("resolving the source of version %q: %w", version.Version, err) + } + + if repoType == OCI { + return Source{Kind: OCI, URL: repoURL, Tag: version.Version}, nil + } + + return Source{Kind: Helm}, nil +} + +// KindOf reads the kind off a repository url alone. It answers for the repository +// as a whole — which catalog client to use, which auth secret shape, whether an +// internal HelmRepository exists at all — where Resolve answers for one version. +func KindOf(s string) (Kind, error) { + parsedURL, err := url.Parse(s) + if err != nil { + return "", fmt.Errorf("cannot parse url: %w", err) + } + + switch parsedURL.Scheme { + case "http", "https": + return Helm, nil + case "oci": + return OCI, nil + default: + return "", fmt.Errorf("unsupported repository schema in use: %s", parsedURL.Scheme) + } +} + +// GetRegistryHost extracts the registry host (with port, if any) from a repository +// URL. Registry credentials in the docker config format are keyed by host, the +// chart path inside the registry is not part of the key. diff --git a/images/operator-helm-controller/internal/chartsource/chartsource_test.go b/images/operator-helm-controller/internal/chartsource/chartsource_test.go new file mode 100644 index 00000000..0d512b98 --- /dev/null +++ b/images/operator-helm-controller/internal/chartsource/chartsource_test.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 chartsource + +import ( + "testing" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +func TestResolve(t *testing.T) { + helmRepo := &helmv1alpha1.HelmClusterAddonRepository{ + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.com/stable"}, + } + ociRepo := &helmv1alpha1.HelmClusterAddonRepository{ + Spec: helmv1alpha1.RepositorySpec{URL: "oci://registry.example.com/charts/podinfo"}, + } + + tests := []struct { + name string + repo *helmv1alpha1.HelmClusterAddonRepository + version helmv1alpha1.ChartVersion + want Source + wantErr bool + }{ + { + // The whole point of the feature: the index entry decides, not the + // repository scheme. + name: "index entry pointing at a registry wins over the repository scheme", + repo: helmRepo, + version: helmv1alpha1.ChartVersion{ + Version: "25.0.2", + OCIRef: "oci://registry-1.docker.io/bitnamicharts/airflow:25.0.2", + }, + want: Source{ + Kind: OCI, + URL: "oci://registry-1.docker.io/bitnamicharts/airflow", + Tag: "25.0.2", + }, + }, + { + name: "helm repository without an oci reference stays on the helm path", + repo: helmRepo, + version: helmv1alpha1.ChartVersion{Version: "6.7.1"}, + want: Source{Kind: Helm}, + }, + { + name: "oci repository addresses its own url at the version tag", + repo: ociRepo, + version: helmv1alpha1.ChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, + want: Source{ + Kind: OCI, + URL: "oci://registry.example.com/charts/podinfo", + Tag: "6.7.1", + }, + }, + { + name: "unparsable recorded reference is an error", + repo: helmRepo, + version: helmv1alpha1.ChartVersion{Version: "1.0.0", OCIRef: "oci://BAD_HOST//:::"}, + wantErr: true, + }, + { + name: "unsupported repository scheme is an error", + repo: &helmv1alpha1.HelmClusterAddonRepository{ + Spec: helmv1alpha1.RepositorySpec{URL: "ftp://charts.example.com"}, + }, + version: helmv1alpha1.ChartVersion{Version: "1.0.0"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Resolve(tt.repo.Spec.URL, &tt.version) + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got %+v", got) + } + + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("source = %+v, want %+v", got, tt.want) + } + }) + } +} diff --git a/images/operator-helm-controller/internal/client/repository/client.go b/images/operator-helm-controller/internal/client/repository/client.go index c997bd82..5ec89b63 100644 --- a/images/operator-helm-controller/internal/client/repository/client.go +++ b/images/operator-helm-controller/internal/client/repository/client.go @@ -26,7 +26,7 @@ import ( "github.com/Masterminds/semver/v3" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/utils" + "github.com/deckhouse/operator-helm/internal/chartsource" ) type Chart struct { @@ -108,11 +108,11 @@ type ChartResolverInterface interface { ResolveChartArtifact(ctx context.Context, ref string, config *RepoConfig) (string, error) } -func NewClient(repoType utils.InternalRepositoryType) (ClientInterface, error) { +func NewClient(repoType chartsource.Kind) (ClientInterface, error) { switch repoType { - case utils.InternalHelmRepository: + case chartsource.Helm: return HelmRepositoryDefaultClient, nil - case utils.InternalOCIRepository: + case chartsource.OCI: return OCIRepositoryDefaultClient, nil default: return nil, fmt.Errorf("unknown repository type: %s", repoType) diff --git a/images/operator-helm-controller/internal/client/repository/errors.go b/images/operator-helm-controller/internal/client/repository/errors.go index 5cd70fd7..7d42f9ff 100644 --- a/images/operator-helm-controller/internal/client/repository/errors.go +++ b/images/operator-helm-controller/internal/client/repository/errors.go @@ -68,6 +68,12 @@ func TerminalFromStatusCode(code int, url string) *TerminalError { Reason: helmv1alpha1.ReasonSourceNotFound, Message: fmt.Sprintf("repository %s not found (HTTP %d)", url, code), } + case code == http.StatusTooManyRequests: + // A throttle is the one rejection in this range that says "later", not "no". + // Calling it terminal would park a release on a registry that is merely busy, + // and would saturate a repository's failure counter on its first throttled + // read instead of letting the backoff spread the next one out. + return nil case code >= 400 && code < 500: return &TerminalError{ Reason: helmv1alpha1.ReasonSourceRejectedRequest, diff --git a/images/operator-helm-controller/internal/client/repository/errors_test.go b/images/operator-helm-controller/internal/client/repository/errors_test.go new file mode 100644 index 00000000..eb6c427e --- /dev/null +++ b/images/operator-helm-controller/internal/client/repository/errors_test.go @@ -0,0 +1,62 @@ +/* +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 ( + "net/http" + "testing" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +// TestTerminalFromStatusCode pins which rejections are verdicts and which are +// "later". The distinction decides whether a repository is reported as Stalled and +// whether a release that could not examine its artifact ever tries again, so the one +// retriable code in the 4xx range is pinned alongside the terminal ones. +func TestTerminalFromStatusCode(t *testing.T) { + tests := []struct { + code int + reason string + }{ + {http.StatusUnauthorized, helmv1alpha1.ReasonAuthenticationFailed}, + {http.StatusForbidden, helmv1alpha1.ReasonAuthenticationFailed}, + {http.StatusNotFound, helmv1alpha1.ReasonSourceNotFound}, + {http.StatusBadRequest, helmv1alpha1.ReasonSourceRejectedRequest}, + {http.StatusTooManyRequests, ""}, + {http.StatusInternalServerError, ""}, + {http.StatusOK, ""}, + } + + for _, test := range tests { + terminal := TerminalFromStatusCode(test.code, "https://charts.example.invalid") + + if test.reason == "" { + if terminal != nil { + t.Fatalf("HTTP %d = %+v, want it left retriable", test.code, terminal) + } + + continue + } + + if terminal == nil { + t.Fatalf("HTTP %d was left retriable, want reason %q", test.code, test.reason) + } + if terminal.Reason != test.reason { + t.Fatalf("HTTP %d reason = %q, want %q", test.code, terminal.Reason, test.reason) + } + } +} diff --git a/images/operator-helm-controller/internal/client/repository/helm.go b/images/operator-helm-controller/internal/client/repository/helm.go index 228b34a0..2c0a1046 100644 --- a/images/operator-helm-controller/internal/client/repository/helm.go +++ b/images/operator-helm-controller/internal/client/repository/helm.go @@ -24,12 +24,11 @@ import ( "strings" "time" + "github.com/Masterminds/semver/v3" "go.yaml.in/yaml/v3" "k8s.io/apimachinery/pkg/util/wait" "sigs.k8s.io/controller-runtime/pkg/log" - "github.com/Masterminds/semver/v3" - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/deckhouse/operator-helm/internal/utils" ) diff --git a/images/operator-helm-controller/internal/controller/helmapplication/controller.go b/images/operator-helm-controller/internal/controller/helmapplication/controller.go new file mode 100644 index 00000000..90f28c92 --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmapplication/controller.go @@ -0,0 +1,135 @@ +/* +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 helmapplication wires the shared release reconciler to the HelmApplication +// kind: no chart claim, no target-namespace creation (the release deploys into its +// own namespace), and an identity of its own to apply the chart with. The kind can +// reference either repository kind of its family, so it watches both, and both +// catalogs. The Role and the RoleBinding making up that identity are watched too, so +// an edit to either is repaired rather than waited out. +package helmapplication + +import ( + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + rbacv1 "k8s.io/api/rbac/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" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + reconcile "github.com/deckhouse/operator-helm/internal/reconcile/release" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + ControllerName = "helmapplication-controller" +) + +func SetupWithManager(mgr ctrl.Manager) error { + client := mgr.GetClient() + + r := reconcile.New(client, reconcile.Deps{ + NewRelease: adapter.EmptyApplicationRelease, + Repositories: adapter.NewApplicationRepositoryResolver(client), + Chart: services.NewChartService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + OCI: services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil), + Release: services.NewReleaseService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Maintenance: services.NewMaintenanceService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Claim: reconcile.NoChartClaim{}, + Namespaces: reconcile.ExistingTargetNamespace{}, + Access: services.NewAccessService(client, helmv1alpha1.TargetNamespace), + Status: status.NewManager(client), + }) + + mapInternal := utils.MapNamespacedInternalResources( + ControllerName, + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationLabelSourceName, + helmv1alpha1.LabelSourceNamespace, + ) + + return ctrl.NewControllerManagedBy(mgr). + Named(ControllerName). + WithOptions(controller.Options{MaxConcurrentReconciles: 2}). + For( + &helmv1alpha1.HelmApplication{}, + builder.WithPredicates(predicate.Or( + predicate.GenerationChangedPredicate{}, + predicate.AnnotationChangedPredicate{}, + )), + ). + Watches( + &sourcev1.HelmChart{}, + handler.EnqueueRequestsFromMapFunc(mapInternal), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv2.HelmRelease{}, + handler.EnqueueRequestsFromMapFunc(mapInternal), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &sourcev1.OCIRepository{}, + handler.EnqueueRequestsFromMapFunc(mapInternal), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + // The Role and the binding are the identity the chart is applied with, and + // nothing but these two watches reports an edit to them: they are not derived + // from the application's spec, so no event on the application itself follows + // when one is narrowed, relabelled or deleted out of band. + Watches( + &rbacv1.Role{}, + handler.EnqueueRequestsFromMapFunc(mapRoleToApplications(client)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &rbacv1.RoleBinding{}, + handler.EnqueueRequestsFromMapFunc(mapRoleBindingToApplications(client)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv1alpha1.HelmApplicationRepository{}, + handler.EnqueueRequestsFromMapFunc(mapRepositoryToApplications(client, helmv1alpha1.HelmApplicationRepositoryKind)), + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). + Watches( + &helmv1alpha1.HelmClusterApplicationRepository{}, + handler.EnqueueRequestsFromMapFunc(mapRepositoryToApplications(client, helmv1alpha1.HelmClusterApplicationRepositoryKind)), + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). + // A catalog write is a status-only change, so a generation predicate would + // never let it through; a terminal probe verdict being reversible depends on + // these two watches firing. + Watches( + &helmv1alpha1.HelmApplicationChart{}, + handler.EnqueueRequestsFromMapFunc(mapChartToApplications(client, helmv1alpha1.HelmApplicationRepositoryKind)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv1alpha1.HelmClusterApplicationChart{}, + handler.EnqueueRequestsFromMapFunc(mapChartToApplications(client, helmv1alpha1.HelmClusterApplicationRepositoryKind)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Complete(r) +} diff --git a/images/operator-helm-controller/internal/controller/helmapplication/mapper.go b/images/operator-helm-controller/internal/controller/helmapplication/mapper.go new file mode 100644 index 00000000..452361a9 --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmapplication/mapper.go @@ -0,0 +1,163 @@ +/* +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 helmapplication + +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" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/index" + "github.com/deckhouse/operator-helm/internal/services" +) + +// mapRoleToApplications enqueues every application of the namespace a change to the +// namespace Role reaches. They all deploy as subjects bound to that one Role, so an +// edit to it concerns all of them: any of them writes back a narrowing, and any of +// them reports a Role that stopped being ours. +// +// The object is matched by name rather than by label. The informer behind this watch +// selects on the managed-by label, so a Role stripped of it leaves the informer and +// arrives here as a deletion — a state the object may equally reach through a +// tombstone, and the name is the one thing every form of the event carries. That +// event is the only notice such a Role ever produces: one that never carried the +// label is invisible to the informer, so its arrival and its removal both pass +// unseen and the application finds it on a pass it runs for another reason. +func mapRoleToApplications(c client.Client) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + if obj.GetName() != services.ApplicationRoleName { + return nil + } + + apps, err := applicationsInNamespace(ctx, c, obj) + if err != nil { + return nil + } + + requests := make([]reconcile.Request, 0, len(apps)) + for i := range apps { + requests = append(requests, requestFor(&apps[i])) + } + + return requests + } +} + +// mapRoleBindingToApplications enqueues the one application whose identity a role +// binding carries. The binding is named after the application's service account, +// whose name is derived from the application, so the applications of the binding's +// namespace are matched on their own derived name — again rather than on the labels +// the binding carries, for the reason above. +func mapRoleBindingToApplications(c client.Client) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + apps, err := applicationsInNamespace(ctx, c, obj) + if err != nil { + return nil + } + + for i := range apps { + if adapter.NewApplicationRelease(&apps[i]).InternalNames().ServiceAccount == obj.GetName() { + return []reconcile.Request{requestFor(&apps[i])} + } + } + + return nil + } +} + +func applicationsInNamespace(ctx context.Context, c client.Client, obj client.Object) ([]helmv1alpha1.HelmApplication, error) { + var apps helmv1alpha1.HelmApplicationList + if err := c.List(ctx, &apps, client.InNamespace(obj.GetNamespace())); err != nil { + log.FromContext(ctx).Error(err, "Failed to list HelmApplications for access mapping", + "controller", ControllerName, "watchedObject", client.ObjectKeyFromObject(obj)) + + return nil, err + } + + return apps.Items, nil +} + +func requestFor(app *helmv1alpha1.HelmApplication) reconcile.Request { + return reconcile.Request{NamespacedName: types.NamespacedName{Namespace: app.Namespace, Name: app.Name}} +} + +// mapRepositoryToApplications enqueues the HelmApplication objects referencing a +// repository object of the given kind. The index value carries the kind and the +// repository namespace, so a namespaced repository reaches only the applications of +// its own namespace and never a same-named repository's consumers elsewhere. +func mapRepositoryToApplications(c client.Client, repositoryKind string) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + var apps helmv1alpha1.HelmApplicationList + if err := c.List(ctx, &apps, client.MatchingFields{ + index.ApplicationRepository: index.ApplicationRepositoryValue(repositoryKind, obj.GetNamespace(), obj.GetName()), + }); err != nil { + log.FromContext(ctx).Error(err, "Failed to list HelmApplications for repository mapping", + "repositoryKind", repositoryKind, "watchedObject", client.ObjectKeyFromObject(obj)) + + return nil + } + + return applicationRequests(apps) + } +} + +// mapChartToApplications enqueues the HelmApplication objects using a chart catalog +// object of the family whose repository kind is given. Like mapChartToAddons, this is +// the only watch that fires on a catalog write, which a terminal probe verdict being +// reversible depends on. The repository is identified by the catalog object's +// namespace and repository label. +func mapChartToApplications(c client.Client, repositoryKind string) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + labels := obj.GetLabels() + repoName := labels[helmv1alpha1.LabelRepositoryName] + chartName := labels[helmv1alpha1.LabelChartName] + if repoName == "" || chartName == "" { + log.FromContext(ctx).Info("Chart object missing repository or chart label, cannot map to applications", + "repositoryKind", repositoryKind, "watchedObject", client.ObjectKeyFromObject(obj)) + + return nil + } + + var apps helmv1alpha1.HelmApplicationList + if err := c.List(ctx, &apps, client.MatchingFields{ + index.ApplicationChart: index.ApplicationChartValue(repositoryKind, obj.GetNamespace(), repoName, chartName), + }); err != nil { + log.FromContext(ctx).Error(err, "Failed to list HelmApplications for chart mapping", + "repositoryKind", repositoryKind, "watchedObject", client.ObjectKeyFromObject(obj), + "repository", repoName, "chart", chartName) + + return nil + } + + return applicationRequests(apps) + } +} + +func applicationRequests(apps helmv1alpha1.HelmApplicationList) []reconcile.Request { + requests := make([]reconcile.Request, 0, len(apps.Items)) + for _, app := range apps.Items { + requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: app.Namespace, Name: app.Name}}) + } + + return requests +} diff --git a/images/operator-helm-controller/internal/controller/helmapplication/mapper_test.go b/images/operator-helm-controller/internal/controller/helmapplication/mapper_test.go new file mode 100644 index 00000000..a85daf76 --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmapplication/mapper_test.go @@ -0,0 +1,202 @@ +/* +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 helmapplication + +import ( + "context" + "reflect" + "testing" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/index" + "github.com/deckhouse/operator-helm/internal/services" +) + +func newMapperClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + clientgoscheme.AddToScheme, + helmv1alpha1.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatalf("registering scheme: %v", err) + } + } + + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() +} + +func application(namespace, name string) *helmv1alpha1.HelmApplication { + return &helmv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } +} + +// TestMapRoleToApplications pins that the namespace Role reaches every application +// of its namespace and nothing outside it, and that an unrelated Role carrying our +// label — one left behind by another module, say — enqueues nothing. +func TestMapRoleToApplications(t *testing.T) { + c := newMapperClient(t, + application("team-a", "first"), + application("team-a", "second"), + application("team-b", "elsewhere"), + ) + mapper := mapRoleToApplications(c) + + role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: services.ApplicationRoleName, Namespace: "team-a"}} + got := mapper(context.Background(), role) + + want := []reconcile.Request{ + {NamespacedName: types.NamespacedName{Namespace: "team-a", Name: "first"}}, + {NamespacedName: types.NamespacedName{Namespace: "team-a", Name: "second"}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %v, want %v", got, want) + } + + other := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: "someone-elses-role", Namespace: "team-a"}} + if got := mapper(context.Background(), other); got != nil { + t.Fatalf("a role under another name must map to nothing, got %v", got) + } +} + +// TestMapRoleBindingToApplications pins that a binding reaches the one application +// whose derived service account name it carries — matched on the name rather than on +// the labels, which is what lets a binding stripped of them still be repaired. +func TestMapRoleBindingToApplications(t *testing.T) { + app := application("team-a", "first") + c := newMapperClient(t, app, application("team-a", "second")) + mapper := mapRoleBindingToApplications(c) + + name := adapter.NewApplicationRelease(app).InternalNames().ServiceAccount + binding := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "team-a"}} + + got := mapper(context.Background(), binding) + want := []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "team-a", Name: "first"}}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %v, want %v", got, want) + } + + foreign := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "someone-elses-binding", Namespace: "team-a"}} + if got := mapper(context.Background(), foreign); got != nil { + t.Fatalf("a binding no application is named after must map to nothing, got %v", got) + } +} + +func applicationMapperClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationRepository, index.ApplicationRepositoryIndexer). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). + Build() +} + +func applicationUsing(namespace, name, repository, clusterRepository string) *helmv1alpha1.HelmApplication { + return &helmv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: helmv1alpha1.HelmApplicationSpec{ + Chart: helmv1alpha1.HelmApplicationChartRef{ + Name: "podinfo", Repository: repository, ClusterRepository: clusterRepository, Version: "6.7.1", + }, + }, + } +} + +func requestSet(reqs []reconcile.Request) map[types.NamespacedName]bool { + out := make(map[types.NamespacedName]bool, len(reqs)) + for _, r := range reqs { + out[r.NamespacedName] = true + } + + return out +} + +// TestMapRepositoryToApplications pins that a namespaced repository enqueues only the +// applications of its own namespace referencing it, and a cluster repository the +// applications of every namespace referencing it by its field. + +func TestMapRepositoryToApplications(t *testing.T) { + c := applicationMapperClient(t, + applicationUsing("team-a", "a1", "stable", ""), + applicationUsing("team-b", "b1", "stable", ""), + applicationUsing("team-b", "b2", "", "stable"), + ) + + namespaced := mapRepositoryToApplications(c, helmv1alpha1.HelmApplicationRepositoryKind) + got := namespaced(context.Background(), &helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a"}, + }) + want := map[types.NamespacedName]bool{{Namespace: "team-a", Name: "a1"}: true} + if !reflect.DeepEqual(requestSet(got), want) { + t.Fatalf("namespaced mapping = %v, want %v", got, want) + } + + cluster := mapRepositoryToApplications(c, helmv1alpha1.HelmClusterApplicationRepositoryKind) + got = cluster(context.Background(), &helmv1alpha1.HelmClusterApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable"}, + }) + want = map[types.NamespacedName]bool{{Namespace: "team-b", Name: "b2"}: true} + if !reflect.DeepEqual(requestSet(got), want) { + t.Fatalf("cluster mapping = %v, want %v", got, want) + } +} + +func TestMapChartToApplications(t *testing.T) { + c := applicationMapperClient(t, + applicationUsing("team-a", "a1", "stable", ""), + applicationUsing("team-a", "other", "stable", ""), + applicationUsing("team-b", "b1", "stable", ""), + ) + mapper := mapChartToApplications(c, helmv1alpha1.HelmApplicationRepositoryKind) + + got := mapper(context.Background(), &helmv1alpha1.HelmApplicationChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "stable-chart-podinfo", Namespace: "team-a", + Labels: map[string]string{helmv1alpha1.LabelRepositoryName: "stable", helmv1alpha1.LabelChartName: "podinfo"}, + }, + }) + want := map[types.NamespacedName]bool{{Namespace: "team-a", Name: "a1"}: true, {Namespace: "team-a", Name: "other"}: true} + if !reflect.DeepEqual(requestSet(got), want) { + t.Fatalf("chart mapping = %v, want %v", got, want) + } + + if got := mapper(context.Background(), &helmv1alpha1.HelmApplicationChart{ + ObjectMeta: metav1.ObjectMeta{Name: "unlabelled", Namespace: "team-a"}, + }); got != nil { + t.Fatalf("a chart object without labels cannot be mapped, got %v", got) + } +} diff --git a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go new file mode 100644 index 00000000..348c59a6 --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go @@ -0,0 +1,95 @@ +/* +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 helmapplicationrepository wires the shared repository reconciler to the +// HelmApplicationRepository kind. The kind is namespaced while its internal objects +// live in the operator namespace, so the watches on those objects map back through +// both source labels. +package helmapplicationrepository + +import ( + sourcev1 "github.com/fluxcd/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" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" + reconcile "github.com/deckhouse/operator-helm/internal/reconcile/repository" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + ControllerName = "helmapplicationrepository-controller" +) + +func SetupWithManager(mgr ctrl.Manager) error { + client := mgr.GetClient() + + r := reconcile.New(client, reconcile.Deps{ + NewRepository: adapter.EmptyApplicationRepository, + Secrets: services.NewRepoSecretsService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Internal: services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Consumers: services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(client)), + Catalog: services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewApplicationCatalog(client)), + Status: status.NewManager(client), + }) + + mapInternal := utils.MapNamespacedInternalResources( + ControllerName, + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName, + helmv1alpha1.LabelSourceNamespace, + ) + + return ctrl.NewControllerManagedBy(mgr). + Named(ControllerName). + WithOptions(controller.Options{MaxConcurrentReconciles: 2}). + For( + &helmv1alpha1.HelmApplicationRepository{}, + builder.WithPredicates(predicate.Or( + predicate.GenerationChangedPredicate{}, + predicate.AnnotationChangedPredicate{}, + )), + ). + Watches( + &sourcev1.HelmRepository{}, + handler.EnqueueRequestsFromMapFunc(mapInternal), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(mapInternal), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv1alpha1.HelmApplicationChart{}, + handler.EnqueueRequestForOwner( + mgr.GetScheme(), + mgr.GetRESTMapper(), + &helmv1alpha1.HelmApplicationRepository{}, + handler.OnlyControllerOwner(), + ), + ).Complete(r) +} diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go index 1e12d897..267c91ea 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go @@ -17,8 +17,8 @@ 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" + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/controller" @@ -26,9 +26,10 @@ 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/adapter" + reconcile "github.com/deckhouse/operator-helm/internal/reconcile/release" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -39,15 +40,18 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - r := reconcile.New( - mgr.GetClient(), - services.NewChartService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil), - services.NewReleaseService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewMaintenanceService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewClaimService(client, mgr.GetAPIReader(), helmv1alpha1.TargetNamespace), - status.NewManager(client), - ) + r := reconcile.New(client, reconcile.Deps{ + NewRelease: adapter.EmptyAddonRelease, + Repositories: adapter.NewAddonRepositoryResolver(client), + Chart: services.NewChartService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + OCI: services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil), + Release: services.NewReleaseService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Maintenance: services.NewMaintenanceService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Claim: services.NewClaimService(client, mgr.GetAPIReader(), helmv1alpha1.TargetNamespace), + Namespaces: services.NewNamespaceService(client, mgr.GetAPIReader()), + Access: reconcile.NoAccess{}, + Status: status.NewManager(client), + }) return ctrl.NewControllerManagedBy(mgr). Named(ControllerName). @@ -96,15 +100,16 @@ func SetupWithManager(mgr ctrl.Manager) error { helmv1alpha1.HelmClusterAddonLabelSourceName, ), ), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). Watches( &helmv1alpha1.HelmClusterAddonRepository{}, - handler.EnqueueRequestsFromMapFunc(utils.MapRepositoryToAddons(client)), + handler.EnqueueRequestsFromMapFunc(mapRepositoryToAddons(client)), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). Watches( &helmv1alpha1.HelmClusterAddonChart{}, - handler.EnqueueRequestsFromMapFunc(utils.MapChartToAddons(client)), + handler.EnqueueRequestsFromMapFunc(mapChartToAddons(client)), // A catalog write is a status-only change on the chart, so a // generation-only predicate (as used for HelmClusterAddonRepository // above) would never let it through; only a terminal probe verdict diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddon/mapper.go b/images/operator-helm-controller/internal/controller/helmclusteraddon/mapper.go new file mode 100644 index 00000000..c21884af --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmclusteraddon/mapper.go @@ -0,0 +1,92 @@ +/* +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" + + "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" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/index" +) + +// MapRepositoryToAddons enqueues the addons referencing a repository object. +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{index.AddonRepository: obj.GetName()}); err != nil { + log.FromContext(ctx).Error(err, "Failed to list HelmClusterAddons for repository mapping", + "watchedObject", client.ObjectKeyFromObject(obj)) + 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 + } +} + +// mapChartToAddons enqueues the addon that claims a HelmClusterAddonChart's +// repository/chart pair whenever the chart object changes. This is the addon +// controller's only watch that can fire on a catalog write: after a terminal probe +// verdict (not a chart, or the tag no longer exists) the addon's internal HelmChart +// has already been removed and no OCIRepository was created for it, so none of the +// addon controller's other watches cover it, and without this one the addon would +// stay Ready=False until a human forces a reconcile even after the repository +// republishes something usable. +func mapChartToAddons(c client.Client) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + labels := obj.GetLabels() + repoName := labels[helmv1alpha1.LabelRepositoryName] + chartName := labels[helmv1alpha1.LabelChartName] + if repoName == "" || chartName == "" { + // Same fail-open tradeoff as knownCharts: without both labels there is no + // repository/chart pair to look an addon up by, so this chart object + // cannot be mapped back to anything. + log.FromContext(ctx).Info("Chart object missing repository or chart label, cannot map to addons", + "watchedObject", client.ObjectKeyFromObject(obj)) + + return nil + } + + var addons helmv1alpha1.HelmClusterAddonList + if err := c.List(ctx, &addons, client.MatchingFields{ + index.AddonChart: index.AddonChartValue(repoName, chartName), + }); err != nil { + log.FromContext(ctx).Error(err, "Failed to list HelmClusterAddons for chart mapping", + "watchedObject", client.ObjectKeyFromObject(obj), "repository", repoName, "chart", chartName) + + return nil + } + + requests := make([]reconcile.Request, 0, len(addons.Items)) + for _, addon := range addons.Items { + requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Name: addon.Name}}) + } + + return requests + } +} diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddon/mapper_test.go b/images/operator-helm-controller/internal/controller/helmclusteraddon/mapper_test.go new file mode 100644 index 00000000..31537624 --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmclusteraddon/mapper_test.go @@ -0,0 +1,133 @@ +/* +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" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/operator-helm/api/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/index" +) + +func newChartMapperClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("registering client-go scheme: %v", err) + } + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&helmv1alpha1.HelmClusterAddon{}, index.AddonChart, func(obj client.Object) []string { + addon := obj.(*helmv1alpha1.HelmClusterAddon) + + return []string{index.AddonChartValue( + addon.Spec.Chart.HelmClusterAddonRepository, + addon.Spec.Chart.HelmClusterAddonChartName, + )} + }). + Build() +} + +func chartObject(repoName, chartName string) *helmv1alpha1.HelmClusterAddonChart { + return &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.HelmClusterAddonChartName(repoName, chartName), + Labels: map[string]string{ + helmv1alpha1.LabelRepositoryName: repoName, + helmv1alpha1.LabelChartName: chartName, + }, + }, + } +} + +func addonUsingChart(repoName, chartName, version string) *helmv1alpha1.HelmClusterAddon { + return &helmv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{Name: "consumer"}, + Spec: helmv1alpha1.HelmClusterAddonSpec{ + Namespace: "app", + Chart: helmv1alpha1.HelmClusterAddonChartRef{ + HelmClusterAddonRepository: repoName, + HelmClusterAddonChartName: chartName, + Version: version, + }, + }, + } +} + +// TestmapChartToAddonsEnqueuesTheClaimingAddon covers the reason this watch exists: +// after a terminal probe verdict the addon has no internal HelmChart or OCIRepository +// left for any other watch to catch, so a status change on the chart itself must be +// the thing that wakes it. + +func TestMapChartToAddonsEnqueuesTheClaimingAddon(t *testing.T) { + chart := chartObject("repo-a", "podinfo") + addon := addonUsingChart("repo-a", "podinfo", "6.7.1") + + c := newChartMapperClient(t, chart, addon) + + requests := mapChartToAddons(c)(context.Background(), chart) + + if len(requests) != 1 { + t.Fatalf("requests = %+v, want exactly one", requests) + } + if requests[0].Name != addon.Name { + t.Fatalf("request name = %q, want %q", requests[0].Name, addon.Name) + } +} + +// TestmapChartToAddonsNoAddonClaimsTheChart covers the case where nothing references +// the chart yet: no request should be produced. +func TestMapChartToAddonsNoAddonClaimsTheChart(t *testing.T) { + chart := chartObject("repo-a", "podinfo") + + c := newChartMapperClient(t, chart) + + if requests := mapChartToAddons(c)(context.Background(), chart); len(requests) != 0 { + t.Fatalf("requests = %+v, want none", requests) + } +} + +// TestmapChartToAddonsMissingLabels covers a chart object with no repository or chart +// label: the catalog synchronization treats that the same way (fail open, log and move +// on), and this map function must not panic or list every addon by an empty index +// value. +func TestMapChartToAddonsMissingLabels(t *testing.T) { + chart := &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{Name: "orphan-chart"}, + } + addon := addonUsingChart("repo-a", "podinfo", "6.7.1") + + c := newChartMapperClient(t, chart, addon) + + if requests := mapChartToAddons(c)(context.Background(), chart); len(requests) != 0 { + t.Fatalf("requests = %+v, want none for a chart with no labels", requests) + } +} diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go index 3a8ffd9c..79daf2bb 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go @@ -17,7 +17,7 @@ limitations under the License. package helmclusteraddonrepository import ( - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" @@ -26,10 +26,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" repoclient "github.com/deckhouse/operator-helm/internal/client/repository" - "github.com/deckhouse/operator-helm/internal/manager/status" - reconcile "github.com/deckhouse/operator-helm/internal/reconcile/helmclusteraddonrepository" + reconcile "github.com/deckhouse/operator-helm/internal/reconcile/repository" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -40,13 +41,14 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - r := reconcile.New( - client, - services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil), - services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient), - status.NewManager(client), - ) + r := reconcile.New(client, reconcile.Deps{ + NewRepository: adapter.EmptyAddonRepository, + Secrets: services.NewRepoSecretsService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Internal: services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Consumers: services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListAddonReleases(client)), + Catalog: services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewAddonCatalog(client)), + Status: status.NewManager(client), + }) return ctrl.NewControllerManagedBy(mgr). Named(ControllerName). @@ -66,7 +68,8 @@ func SetupWithManager(mgr ctrl.Manager) error { helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName), + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName, + ), ), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). @@ -78,7 +81,8 @@ func SetupWithManager(mgr ctrl.Manager) error { helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName), + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName, + ), ), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). diff --git a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go new file mode 100644 index 00000000..96653b17 --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go @@ -0,0 +1,92 @@ +/* +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 helmclusterapplicationrepository wires the shared repository reconciler +// to the HelmClusterApplicationRepository kind. +package helmclusterapplicationrepository + +import ( + sourcev1 "github.com/fluxcd/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" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" + reconcile "github.com/deckhouse/operator-helm/internal/reconcile/repository" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + ControllerName = "helmclusterapplicationrepository-controller" +) + +func SetupWithManager(mgr ctrl.Manager) error { + client := mgr.GetClient() + + r := reconcile.New(client, reconcile.Deps{ + NewRepository: adapter.EmptyClusterApplicationRepository, + Secrets: services.NewRepoSecretsService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Internal: services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + Consumers: services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(client)), + Catalog: services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewClusterApplicationCatalog(client)), + Status: status.NewManager(client), + }) + + mapInternal := utils.MapInternalResources( + ControllerName, + helmv1alpha1.TargetNamespace, + helmv1alpha1.LabelManagedBy, + helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmClusterApplicationRepositoryLabelSourceName, + ) + + return ctrl.NewControllerManagedBy(mgr). + Named(ControllerName). + WithOptions(controller.Options{MaxConcurrentReconciles: 2}). + For( + &helmv1alpha1.HelmClusterApplicationRepository{}, + builder.WithPredicates(predicate.Or( + predicate.GenerationChangedPredicate{}, + predicate.AnnotationChangedPredicate{}, + )), + ). + Watches( + &sourcev1.HelmRepository{}, + handler.EnqueueRequestsFromMapFunc(mapInternal), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(mapInternal), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv1alpha1.HelmClusterApplicationChart{}, + handler.EnqueueRequestForOwner( + mgr.GetScheme(), + mgr.GetRESTMapper(), + &helmv1alpha1.HelmClusterApplicationRepository{}, + handler.OnlyControllerOwner(), + ), + ).Complete(r) +} diff --git a/images/operator-helm-controller/internal/index/index.go b/images/operator-helm-controller/internal/index/index.go index aa9114e5..68ea78a6 100644 --- a/images/operator-helm-controller/internal/index/index.go +++ b/images/operator-helm-controller/internal/index/index.go @@ -68,3 +68,76 @@ func SetupAddonRepository(mgr ctrl.Manager) error { }, ) } + +// ApplicationRepository indexes HelmApplication objects by the repository they +// reference. The value carries the repository kind and namespace, not just the name: +// a HelmApplicationRepository named "stable" in one namespace must not attract the +// reconciliations of applications referencing a same-named one elsewhere, and a +// namespaced and a cluster repository may share a name too. +const ApplicationRepository = ".spec.chart.repositoryRef" + +// ApplicationRepositoryValue builds the index value of a repository reference. The +// namespace is empty for a cluster-scoped kind, which yields "//". +func ApplicationRepositoryValue(kind, namespace, name string) string { + return kind + "/" + namespace + "/" + name +} + +// ApplicationChart indexes HelmApplication objects by the repository/chart pair they +// reference, with the repository identified the same way as in ApplicationRepository. +const ApplicationChart = ".spec.chart.repositoryAndChart" + +// ApplicationChartValue builds the index value of a repository/chart pair. +func ApplicationChartValue(kind, namespace, name, chart string) string { + return ApplicationRepositoryValue(kind, namespace, name) + "/" + chart +} + +// applicationRepositoryRef resolves the two mutually exclusive reference fields of an +// application into the repository kind, namespace and name the index values use. A +// namespaced repository lives in the application's own namespace. +func applicationRepositoryRef(app *helmv1alpha1.HelmApplication) (kind, namespace, name string) { + kind = app.RepositoryKind() + if kind == helmv1alpha1.HelmApplicationRepositoryKind { + namespace = app.Namespace + } + + return kind, namespace, app.RepositoryName() +} + +// ApplicationRepositoryIndexer is the index function behind ApplicationRepository. It +// is exported so tests can register the same function on a fake client. +func ApplicationRepositoryIndexer(obj client.Object) []string { + app := obj.(*helmv1alpha1.HelmApplication) + + kind, namespace, name := applicationRepositoryRef(app) + if kind == "" { + return nil + } + + return []string{ApplicationRepositoryValue(kind, namespace, name)} +} + +// ApplicationChartIndexer is the index function behind ApplicationChart. +func ApplicationChartIndexer(obj client.Object) []string { + app := obj.(*helmv1alpha1.HelmApplication) + + kind, namespace, name := applicationRepositoryRef(app) + if kind == "" { + return nil + } + + return []string{ApplicationChartValue(kind, namespace, name, app.Spec.Chart.Name)} +} + +// SetupApplicationRepository registers the ApplicationRepository index. +func SetupApplicationRepository(mgr ctrl.Manager) error { + return mgr.GetFieldIndexer().IndexField( + context.Background(), &helmv1alpha1.HelmApplication{}, ApplicationRepository, ApplicationRepositoryIndexer, + ) +} + +// SetupApplicationChart registers the ApplicationChart index. +func SetupApplicationChart(mgr ctrl.Manager) error { + return mgr.GetFieldIndexer().IndexField( + context.Background(), &helmv1alpha1.HelmApplication{}, ApplicationChart, ApplicationChartIndexer, + ) +} diff --git a/images/operator-helm-controller/internal/manager/status/condition_rules.go b/images/operator-helm-controller/internal/manager/status/condition_rules.go deleted file mode 100644 index b006b171..00000000 --- a/images/operator-helm-controller/internal/manager/status/condition_rules.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 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 { - reconcilingCond := meta.FindStatusCondition(conditions, "Reconciling") - if reconcilingCond != nil && reconcilingCond.Status == metav1.ConditionTrue && reconcilingCond.Reason != "ProgressingWithRetry" { - return Unknown(parentObj, helmv1alpha1.ReasonReconciling) - } - - 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) - } - } - - 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-controller/internal/manager/status/helpers.go b/images/operator-helm-controller/internal/manager/status/helpers.go deleted file mode 100644 index e7739aae..00000000 --- a/images/operator-helm-controller/internal/manager/status/helpers.go +++ /dev/null @@ -1,77 +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 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" -) - -type statusProxy struct { - Provider - newType string -} - -func (p statusProxy) GetConditionType() string { return p.newType } - -func AsCondition(res Provider, conditionType string) Provider { - return statusProxy{Provider: res, newType: conditionType} -} - -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) Status { - return Status{ - Observed: true, - Status: metav1.ConditionFalse, - Reason: reason, - ObservedGeneration: obj.GetGeneration(), - Message: message, - Err: err, - } -} - -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-controller/internal/manager/status/manager.go b/images/operator-helm-controller/internal/manager/status/manager.go deleted file mode 100644 index db36d122..00000000 --- a/images/operator-helm-controller/internal/manager/status/manager.go +++ /dev/null @@ -1,205 +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 status - -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 Provider interface { - GetStatus() Status - GetConditionType() string -} - -type GenerationProvider interface { - GetObservedGeneration() int64 -} - -type Manager struct { - client.Client -} - -func NewManager(c client.Client) *Manager { - return &Manager{ - Client: c, - } -} - -type MutatorFunc func(ObjectWithConditions, []Provider) (ObjectWithConditions, []Provider) - -var NoopStatusMutator = MutatorFunc(func(o ObjectWithConditions, s []Provider) (ObjectWithConditions, []Provider) { return o, s }) - -type MapperFunc func(string, Status) Status - -var NoopStatusMapper = MapperFunc(func(_ string, status Status) Status { - return status -}) - -func (s *Manager) Update(ctx context.Context, obj ObjectWithConditions, mutatorFunc MutatorFunc, statusMapperFunc MapperFunc, results ...Provider) 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)) -} - -// PatchStatus applies mutate to the object and patches the status subresource -// when it actually changed. It is the thin apply path used by reconcilers that -// compute the whole desired status themselves. -func (s *Manager) PatchStatus(ctx context.Context, obj ObjectWithConditions, mutate func()) error { - oldObj := obj.DeepCopyObject().(ObjectWithConditions) - - mutate() - - if reflect.DeepEqual(obj.GetStatus(), oldObj.GetStatus()) { - return nil - } - - if err := s.Status().Patch(ctx, obj, client.MergeFrom(oldObj)); err != nil { - return fmt.Errorf("patching status: %w", err) - } - - return nil -} - -func DetermineConditions(obj ObjectWithConditions, results ...Provider) []Provider { - var result []Provider - - conditionTypes := obj.GetConditionTypesForUpdate() - if len(results) == 0 { - return result - } - - var decisionRes Provider - 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 -} - -type Status struct { - ConditionType string - Observed bool - Status metav1.ConditionStatus - ObservedGeneration int64 - Reason string - Message string - // 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 { - return s.Status == metav1.ConditionTrue && s.Observed -} diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go deleted file mode 100644 index 16091604..00000000 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go +++ /dev/null @@ -1,678 +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" - "fmt" - "time" - - "github.com/opencontainers/go-digest" - "github.com/werf/3p-fluxcd-pkg/chartutil" - helmchartutil "helm.sh/helm/v3/pkg/chartutil" - 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/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" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/deckhouse/operator-helm/api/naming" - 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" -) - -// internalResourceDeletionRequeueInterval bounds how often reconcileDelete -// re-checks whether the internal resources have finished being deleted. Watches -// on those resources drive most requeues; this is the safety net for a resource -// whose deletion is stuck and stops emitting events. -const internalResourceDeletionRequeueInterval = 30 * time.Second - -// chartClaimConflictRequeueInterval bounds how often an addon that lost the claim -// on its repository/chart pair re-checks whether the owner has released it. There -// is no watch on the claim Lease, so this periodic requeue is what lets a duplicate -// recover once the conflicting addon is deleted or repointed at another chart. -const chartClaimConflictRequeueInterval = 30 * time.Second - -func New( - client client.Client, - chartService *services.ChartService, - ociRepositoryService *services.OCIRepoService, - releaseService *services.ReleaseService, - maintenanceService *services.MaintenanceService, - claimService *services.ClaimService, - statusManager *status.Manager, -) *Reconciler { - return &Reconciler{ - Client: client, - chartService: chartService, - ociRepositoryService: ociRepositoryService, - releaseService: releaseService, - maintenanceService: maintenanceService, - claimService: claimService, - statusManager: statusManager, - } -} - -type Reconciler struct { - client.Client - - chartService *services.ChartService - ociRepositoryService *services.OCIRepoService - releaseService *services.ReleaseService - maintenanceService *services.MaintenanceService - claimService *services.ClaimService - statusManager *status.Manager -} - -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) - } - - // Claim the repository/chart pair before anything else, including adding the - // finalizer. The claim is the authoritative, race-free guard on uniqueness (the - // webhook only fast-rejects the obvious duplicate on CREATE and cannot stop - // concurrent creates from racing past it). It must run before the finalizer - // because a duplicate that loses the race must not accrue a finalizer it would - // otherwise have to clean up: it simply surfaces the conflict on its status and - // requeues, recovering on its own once the owner releases the pair. - acquired, holder, err := r.claimService.Acquire(ctx, addon) - if err != nil { - return reconcile.Result{}, fmt.Errorf("acquiring chart claim: %w", err) - } - if !acquired { - return reconcile.Result{RequeueAfter: chartClaimConflictRequeueInterval}, r.statusManager.Update( - ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, - services.ReleaseResult{Status: status.Failed( - addon, - helmv1alpha1.ReasonChartClaimConflict, - fmt.Sprintf("chart %q is already used by helmclusteraddon/%s", addon.Spec.Chart.HelmClusterAddonChartName, holder), - nil, - )}, - ) - } - - if utils.IsSystemNamespace(addon.Spec.Namespace) { - return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - addon, - helmv1alpha1.ReasonFailed, - "Target namespace cannot be a system namespace", - fmt.Errorf("target namespace %q is a system namespace", addon.Spec.Namespace), - )}) - } - - 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) - } - // Continue reconciling in the same pass: adding a finalizer is a - // metadata-only change that does not bump generation, so the resulting - // update event is dropped by the generation/annotation predicates and - // would not trigger a follow-up reconcile. - } - - if err := r.claimService.ReleaseStale(ctx, addon); err != nil { - return reconcile.Result{}, fmt.Errorf("releasing stale chart claims: %w", err) - } - - if r.maintenanceService.IsMaintenanceModeChangeRequired(addon) { - maintenanceRes := r.maintenanceService.EnsureMaintenanceMode(ctx, addon) - if err := r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, maintenanceRes, status.AsCondition(maintenanceRes, "Ready")); err != nil { - return reconcile.Result{}, err - } - - if !addon.MaintenanceModeActivated() { - // Maintenance is being lifted: a pending force request is about to become - // actionable, so it is left in place for the pass that can honour it. - return reconcile.Result{}, nil - } - - return reconcile.Result{}, r.discardForceReconcile(ctx, addon) - } - - if addon.MaintenanceModeActivated() { - return reconcile.Result{}, r.discardForceReconcile(ctx, addon) - } - - 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, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.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, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - addon, - helmv1alpha1.ReasonFailed, - fmt.Sprintf("Failed to parse repository type: %s", err.Error()), - err, - )}) - } - - if err := r.reconcileAddonNamespace(ctx, addon); err != nil { - return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - addon, - helmv1alpha1.ReasonFailed, - fmt.Sprintf("Failed to reconcile target namespace: %s", err.Error()), - err, - )}) - } - - // From here on every path reaches the status update at the end of the pass, - // which is what consumes the force request. Marking earlier would leave the - // progress condition behind on a validation failure that never consumes it. - forced := addon.ForceReconcileRequired() - if forced { - if err := r.markForceReconcileInProgress(ctx, addon); err != nil { - return reconcile.Result{}, err - } - } - - var chartRes services.ChartResult - var repoRes services.OCIRepoResult - var releaseRes services.ReleaseResult - - _, chartVersion, addonChartErr := r.getHelmClusterAddonChart(ctx, addon, repoType) - - // The source is resolved once, before the branches: which internal object an - // addon needs is a property of the version it asks for, and a version whose - // source cannot be resolved is as unusable as a version that is missing. - var source utils.ChartSource - if addonChartErr == nil { - source, addonChartErr = utils.ResolveChartSource(repo, chartVersion) - } - - switch { - case addonChartErr != nil: - // One report for both branches: until the source is known, neither internal - // object may be touched, and which one would have been touched is precisely - // what could not be determined. - chartRes = services.ChartResult{Status: status.Failed( - addon, - helmv1alpha1.ReasonChartFetchFailed, - "Failed to resolve the desired chart version", - addonChartErr, - )} - case source.Kind == utils.InternalHelmRepository: - // The version may have moved out of a registry — either because the user - // repointed the repository, or because the index re-published it as an - // archive. Either way the internal OCIRepository is no longer the source. - superseded, err := r.ociRepositoryService.RemoveOCIRepository(ctx, addon) - if err != nil { - chartRes = services.ChartResult{ - Status: status.Failed(addon, helmv1alpha1.ReasonFailed, "Repository change failed", err), - } - - break - } - - r.logSourceKindFlip(ctx, addon, source.Kind, superseded != nil) - - chartRes = r.chartService.EnsureHelmChart(ctx, addon) - case source.Kind == utils.InternalOCIRepository: - superseded, err := r.chartService.CleanupHelmChart(ctx, addon) - if err != nil { - chartRes = services.ChartResult{ - Status: status.Failed(addon, helmv1alpha1.ReasonFailed, "Repository change failed", err), - } - - break - } - - r.logSourceKindFlip(ctx, addon, source.Kind, superseded != nil) - - repoRes = r.ociRepositoryService.EnsureInternalOCIRepository(ctx, addon, repo, source, chartVersion) - default: - return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - addon, - helmv1alpha1.ReasonFailed, - fmt.Sprintf("Unsupported chart source: %s", source.Kind), - fmt.Errorf("unsupported chart source: %s", source.Kind), - )}) - } - - if chartRes.HasArtifact() || repoRes.HasArtifact() { - var artifactRevision string - switch source.Kind { - case utils.InternalHelmRepository: - if chartRes.Artifact != nil { - artifactRevision = chartRes.Artifact.Revision - } - case utils.InternalOCIRepository: - if repoRes.Artifact != nil { - artifactRevision = repoRes.Artifact.Revision - } - } - - releaseRes = r.releaseService.EnsureHelmRelease(ctx, addon, source.Kind, artifactRevision) - } - - if err := r.statusManager.Update( - ctx, - addon, - setStatusAttrs(source.Kind, chartRes, repoRes, releaseRes, forceReconcileOutcome{ - forced: forced, - now: time.Now().UTC(), - }), - status.NoopStatusMapper, - chartRes, - repoRes, - releaseRes, - ); client.IgnoreNotFound(err) != nil { - return reconcile.Result{}, fmt.Errorf("failed to update status: %w", err) - } - - // The annotation is consumed after the status patch, so a conflict on the patch - // leaves the request in place to be retried rather than losing it. - if err := r.reconcileForceAnnotation(ctx, req.NamespacedName); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to reconcile force annotation: %w", err) - } - - // A probe that could not reach the registry asks for another pass: there is no - // watch that fires when a foreign registry starts answering again. - return reconcile.Result{RequeueAfter: repoRes.RequeueAfter}, nil -} - -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 - } - - // The finalizer must stay until the internal resources are actually gone. - // A Delete only sets a deletion timestamp; the downstream controllers keep - // their finalizers until they finish tearing the underlying release/source - // down. Removing our finalizer earlier would delete the HelmClusterAddon and - // orphan a HelmRelease that helm-controller never managed to uninstall. - // - // The release is uninstalled first; only once it is gone do we remove the - // chart/repository sources it referenced. Each step waits for the resource to - // actually disappear and surfaces the blocking resource's readiness on the - // addon so the reason a deletion stalls is observable. - release, err := r.releaseService.CleanupHelmRelease(ctx, addon) - if err != nil { - return reconcile.Result{}, err - } - if release != nil { - // The addon is a facade over the HelmRelease: a bad spec parameter that - // blocks helm uninstall is propagated into the release. Keep re-applying - // the (possibly corrected) addon spec to the still-present release so the - // uninstall can be fixed via the addon even while it is being deleted. - if err := r.releaseService.SyncReleaseSpec(ctx, addon, release); err != nil { - return reconcile.Result{}, err - } - return r.awaitInternalResourceDeletion(ctx, addon, "internal release", release) - } - - chart, err := r.chartService.CleanupHelmChart(ctx, addon) - if err != nil { - return reconcile.Result{}, err - } - if chart != nil { - return r.awaitInternalResourceDeletion(ctx, addon, "internal chart", chart) - } - - ociRepo, err := r.ociRepositoryService.RemoveOCIRepository(ctx, addon) - if err != nil { - return reconcile.Result{}, err - } - if ociRepo != nil { - return r.awaitInternalResourceDeletion(ctx, addon, "internal repository", ociRepo) - } - - // Release the claim only once every downstream resource is gone: releasing it - // earlier would let another addon start reconciling the same chart while this - // one's release is still being uninstalled — exactly the collision the claim - // prevents. - if err := r.claimService.Release(ctx, addon); err != nil { - return reconcile.Result{}, fmt.Errorf("releasing chart claim: %w", 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 err - } - } - return nil - }); err != nil { - return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) - } - - logger.Info("Cleanup complete") - - return reconcile.Result{}, nil -} - -// awaitInternalResourceDeletion surfaces that an internal resource is still being -// deleted on the addon's status (via the shared status manager) and requeues -// without removing the finalizer. The resource name is kept abstract so its -// internal type is not leaked to the user. -func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon, name string, resource status.DeletingResource) (reconcile.Result, error) { - log.FromContext(ctx).Info("Waiting for internal resource to be deleted before removing finalizer", "resource", name) - - if err := r.statusManager.MarkUninstallPending(ctx, addon, name, resource); client.IgnoreNotFound(err) != nil { - return reconcile.Result{}, fmt.Errorf("updating deletion status: %w", err) - } - - return reconcile.Result{RequeueAfter: internalResourceDeletionRequeueInterval}, nil -} - -func (r *Reconciler) reconcileAddonNamespace(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { - ns := &corev1.Namespace{} - - err := r.Get(ctx, client.ObjectKey{Name: addon.Spec.Namespace}, ns) - if err != nil { - if !apierrors.IsNotFound(err) { - return fmt.Errorf("getting namespace: %w", err) - } - - ns = &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: addon.Spec.Namespace, - }, - } - - err = r.Create(ctx, ns) - if err != nil { - if apierrors.IsAlreadyExists(err) { - return nil - } - return fmt.Errorf("creating namespace: %w", err) - } - } - - return nil -} - -// markForceReconcileInProgress publishes Reconciling before the work a force -// request asks for begins. A forced pass is the one case where someone is -// watching: they annotated the addon a moment ago and want to see it was picked -// up. The condition is removed again by the status update that ends the pass. -func (r *Reconciler) markForceReconcileInProgress(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { - err := r.statusManager.PatchStatus(ctx, addon, func() { - apimeta.SetStatusCondition(&addon.Status.Conditions, metav1.Condition{ - Type: helmv1alpha1.ConditionTypeReconciling, - Status: metav1.ConditionTrue, - Reason: helmv1alpha1.ReasonForceReconcile, - Message: "Forced reconciliation in progress", - ObservedGeneration: addon.Generation, - }) - }) - if client.IgnoreNotFound(err) != nil { - return fmt.Errorf("publishing forced reconciliation progress: %w", err) - } - - return nil -} - -// discardForceReconcile drops the in-flight force state from an addon that is -// entering, or already sitting in, maintenance mode. Every pass on such an addon -// returns before the work a force request asks for, so the request can never be -// acted on: leaving Reconciling behind would report work in flight to kstatus -// forever, and leaving the annotation would replay a request made days earlier the -// moment maintenance is lifted. Reconciling is removed unconditionally because the -// force path is its only producer on an addon. -// -// lastForceReconcileTime is deliberately untouched — the request was discarded, -// not processed, and the stamp means the latter. -func (r *Reconciler) discardForceReconcile(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { - err := r.statusManager.PatchStatus(ctx, addon, func() { - apimeta.RemoveStatusCondition(&addon.Status.Conditions, helmv1alpha1.ConditionTypeReconciling) - }) - if client.IgnoreNotFound(err) != nil { - return fmt.Errorf("dropping forced reconciliation progress: %w", err) - } - - if err := r.reconcileForceAnnotation(ctx, client.ObjectKeyFromObject(addon)); err != nil { - return fmt.Errorf("failed to reconcile force annotation: %w", err) - } - - return nil -} - -func (r *Reconciler) reconcileForceAnnotation(ctx context.Context, key client.ObjectKey) error { - var addon helmv1alpha1.HelmClusterAddon - - if err := r.Get(ctx, key, &addon); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return fmt.Errorf("getting helm cluster addon: %w", err) - } - - if _, found := addon.Annotations[helmv1alpha1.AnnotationForceReconcile]; !found { - // Guard on the annotation itself, not on the map: an addon carrying any - // unrelated annotation would otherwise take an empty PATCH on every pass. - return nil - } - - patchBase := client.MergeFrom(addon.DeepCopy()) - - delete(addon.Annotations, helmv1alpha1.AnnotationForceReconcile) - - if err := r.Patch(ctx, &addon, patchBase); err != nil { - return fmt.Errorf("removing force reconcile annotation: %w", err) - } - - return nil -} - -// getHelmClusterAddonChart resolves the catalog entry for the version the addon asks -// for and rejects an entry that cannot be deployed. Two things make an entry -// unusable: an index reference that cannot be addressed, and — for a version of an -// oci:// repository — a missing media type, which is exactly "the catalog does not -// yet know enough to build the internal OCIRepository". A version published in a -// registry by a helm index carries no media type by design: its artifact is examined -// at deploy time, so the second rule does not apply to it. -// -// A version retained after its tag disappeared keeps both its media type and its -// reference, so this gate stays open for it and the addon keeps reconciling -// everything else — its values, its maintenance mode, its removal. -func (r *Reconciler) getHelmClusterAddonChart( - ctx context.Context, - addon *helmv1alpha1.HelmClusterAddon, - repoType utils.InternalRepositoryType, -) (*helmv1alpha1.HelmClusterAddonChart, *helmv1alpha1.HelmClusterAddonChartVersion, error) { - addonChartName := naming.HelmClusterAddonChartName( - addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName, - ) - addonChart := &helmv1alpha1.HelmClusterAddonChart{} - - if err := r.Get(ctx, types.NamespacedName{Name: addonChartName}, addonChart); err != nil { - return nil, nil, fmt.Errorf("getting helm cluster addon chart: %w", err) - } - - for i := range addonChart.Status.Versions { - version := &addonChart.Status.Versions[i] - if version.Version != addon.Spec.Chart.Version { - continue - } - - if version.UnavailableReason == helmv1alpha1.UnavailableReasonInvalidChartReference { - // The index publishes this version in a registry at a reference that - // cannot be addressed. Without this the version would fall back to the - // helm path and fail on the very same url, reported by the source - // controller as an opaque fetch error. - return nil, nil, fmt.Errorf( - "chart version %q cannot be deployed: %s", - version.Version, versionUnavailableDetail(*version), - ) - } - - if repoType == utils.InternalOCIRepository && version.OCIRef == "" && version.MediaType == "" { - return nil, nil, fmt.Errorf( - "chart version %q cannot be deployed: %s", - version.Version, versionUnavailableDetail(*version), - ) - } - - return addonChart, version, nil - } - - return nil, nil, fmt.Errorf("helm cluster addon chart does not have version %q", addon.Spec.Chart.Version) -} - -// versionUnavailableDetail explains why a catalog entry is not deployable. -func versionUnavailableDetail(version helmv1alpha1.HelmClusterAddonChartVersion) string { - switch { - case version.UnavailableReason == "": - return "the repository catalog has not resolved it yet" - case version.UnavailableMessage == "": - return version.UnavailableReason - default: - return version.UnavailableReason + ": " + version.UnavailableMessage - } -} - -// logSourceKindFlip reports that the same chart version changed where it is -// published: the repository index moved it between a chart archive and a registry. -// Nothing else surfaces that — status records the applied version but not the source -// it came from — and it upgrades a running release nobody asked to upgrade, so it has -// to be findable in the log. superseded says whether an internal source of the other -// kind was actually removed in this pass. -func (r *Reconciler) logSourceKindFlip( - ctx context.Context, - addon *helmv1alpha1.HelmClusterAddon, - kind utils.InternalRepositoryType, - superseded bool, -) { - if !superseded { - return - } - - last := addon.Status.LastAppliedChart - if last == nil || - last.HelmClusterAddonRepository != addon.Spec.Chart.HelmClusterAddonRepository || - last.HelmClusterAddonChartName != addon.Spec.Chart.HelmClusterAddonChartName || - last.Version != addon.Spec.Chart.Version { - // Not a flip: the addon is moving to another version (or another chart), and - // the superseded source belonged to the one it is leaving. All three fields - // have to match: LastAppliedChart carries its own repository/chart identity - // and can lag behind Spec.Chart when the addon is repointed at a different - // chart, so the version alone could match by coincidence while naming an - // entirely different chart's history. - return - } - - log.FromContext(ctx).Info( - "Chart version changed where it is published; the running release will be upgraded from the new source", - "version", addon.Spec.Chart.Version, - "source", kind, - ) -} - -// forceReconcileOutcome carries what the status mutator needs to close out a -// forced pass. It is a struct so the clock stays with the caller: the mutator -// runs inside the status manager, after it has snapshotted the object it diffs -// against, which is the only place a change to the status is actually patched. -type forceReconcileOutcome struct { - forced bool - now time.Time -} - -func setStatusAttrs( - sourceKind utils.InternalRepositoryType, - chartRes services.ChartResult, - repoRes services.OCIRepoResult, - releaseRes services.ReleaseResult, - force forceReconcileOutcome, -) status.MutatorFunc { - return func(obj status.ObjectWithConditions, results []status.Provider) (status.ObjectWithConditions, []status.Provider) { - results = status.DetermineConditions(obj, results...) - addon := obj.(*helmv1alpha1.HelmClusterAddon) - - if force.forced { - // The stamp records that the request was acted on, not that it succeeded: - // the outcome is reported by Ready. Reconciling is removed explicitly — - // the status manager only ever sets conditions. - addon.Status.LastForceReconcileTime = &metav1.Time{Time: force.now} - apimeta.RemoveStatusCondition(&addon.Status.Conditions, helmv1alpha1.ConditionTypeReconciling) - } - - latestRelease := releaseRes.History.Latest() - - var updateChart bool - - switch sourceKind { - case utils.InternalHelmRepository: - if chartRes.HasArtifact() && releaseRes.IsReady() && addon.IsChartStatusInfoOutdated() { - updateChart = true - } - case utils.InternalOCIRepository: - if repoRes.HasArtifact() && releaseRes.IsReady() && addon.IsChartStatusInfoOutdated() { - updateChart = true - } - } - - if updateChart { - addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ - HelmClusterAddonChartName: addon.Spec.Chart.HelmClusterAddonChartName, - HelmClusterAddonRepository: addon.Spec.Chart.HelmClusterAddonRepository, - Version: addon.Spec.Chart.Version, - } - } - - 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() - } - } - } - - return obj, results - } -} diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go deleted file mode 100644 index bebc8f79..00000000 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go +++ /dev/null @@ -1,892 +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" - "strings" - "testing" - "time" - - "github.com/go-logr/logr/funcr" - helmv2 "github.com/werf/3p-helm-controller/api/v2" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" - 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" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/deckhouse/operator-helm/api/naming" - 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/services" - "github.com/deckhouse/operator-helm/internal/utils" -) - -func testScheme(t *testing.T) *runtime.Scheme { - t.Helper() - - scheme := runtime.NewScheme() - if err := clientgoscheme.AddToScheme(scheme); err != nil { - t.Fatalf("registering client-go scheme: %v", err) - } - if err := helmv1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("registering helm scheme: %v", err) - } - - return scheme -} - -func newTestReconciler(t *testing.T, objects ...client.Object) *Reconciler { - t.Helper() - - scheme := testScheme(t) - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - - return &Reconciler{Client: c} -} - -func testAddon() *helmv1alpha1.HelmClusterAddon { - return &helmv1alpha1.HelmClusterAddon{ - ObjectMeta: metav1.ObjectMeta{Name: "consumer", Generation: 1}, - Spec: helmv1alpha1.HelmClusterAddonSpec{ - Namespace: "app", - Chart: helmv1alpha1.HelmClusterAddonChartRef{ - HelmClusterAddonRepository: "example", - HelmClusterAddonChartName: "podinfo", - Version: "6.7.1", - }, - }, - } -} - -func addonChartFixture(repoName, chartName string, versions ...helmv1alpha1.HelmClusterAddonChartVersion) *helmv1alpha1.HelmClusterAddonChart { - return &helmv1alpha1.HelmClusterAddonChart{ - ObjectMeta: metav1.ObjectMeta{ - Name: naming.HelmClusterAddonChartName(repoName, chartName), - }, - Status: helmv1alpha1.HelmClusterAddonChartStatus{Versions: versions}, - } -} - -// TestGetHelmClusterAddonChart pins the gate that decides whether an addon has -// enough information to be deployed. For an OCI repository, a catalog entry is -// usable exactly when it carries a media type; for a Helm repository the media -// type is never checked, so an entry is usable as soon as the version is present. -func TestGetHelmClusterAddonChart(t *testing.T) { - addon := testAddon() - - tests := []struct { - name string - // version is the sole entry seeded into the HelmClusterAddonChart's - // Status.Versions. Its own Version field decides whether the lookup by - // addon.Spec.Chart.Version ("6.7.1") hits or misses. - version helmv1alpha1.HelmClusterAddonChartVersion - repoType utils.InternalRepositoryType - wantErr bool - wantErrContain string - }{ - { - name: "oci version with a media type passes", - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", - }, - repoType: utils.InternalOCIRepository, - }, - { - // Deliberate: the tag disappeared from the repository, but the entry is - // retained with its media type so the addon keeps reconciling everything - // else. The real pull failure is reported by the source controller. - name: "oci version removed from repository but with a media type still passes", - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - MediaType: "application/tar+gzip", - UnavailableReason: helmv1alpha1.UnavailableReasonRemovedFromRepository, - }, - repoType: utils.InternalOCIRepository, - }, - { - name: "oci version stuck resolving is rejected with reason and message", - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - UnavailableReason: helmv1alpha1.UnavailableReasonResolvePending, - UnavailableMessage: "manifest request failed", - }, - repoType: utils.InternalOCIRepository, - wantErr: true, - wantErrContain: "ResolvePending: manifest request failed", - }, - { - name: "oci version with unsupported media type and no message is rejected with reason alone", - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, - }, - repoType: utils.InternalOCIRepository, - wantErr: true, - wantErrContain: "UnsupportedMediaType", - }, - { - // Same empty-media-type entry as above, but a Helm repository's versions - // never carry a media type: a stricter gate here would break every Helm - // addon, so the presence check alone must let it through. - name: "the same empty media type entry passes for a helm repository", - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, - }, - repoType: utils.InternalHelmRepository, - }, - { - // The repository's URL just switched from oci:// to https://: the - // catalog entry is still OCI-era (it carries a media type from the last - // OCI sync), but the Helm gate never reads the media type, so it passes. - name: "oci-era entry with a media type still passes right after switching to a helm repository", - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", - }, - repoType: utils.InternalHelmRepository, - }, - { - // The repository's URL just switched from https:// to oci://, but the - // first OCI sync has not resolved the tag's media type yet: the entry is - // still Helm-era (no media type, no reason), so the OCI gate must reject - // it rather than let an unresolved layer through. - name: "helm-era entry with no media type is rejected right after switching to an oci repository", - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - }, - repoType: utils.InternalOCIRepository, - wantErr: true, - wantErrContain: "has not resolved it yet", - }, - { - name: "a version the addon does not reference is rejected", - version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "9.9.9"}, - repoType: utils.InternalOCIRepository, - wantErr: true, - wantErrContain: `does not have version "6.7.1"`, - }, - { - // The hybrid case: the version lives in a registry, so its media type is - // resolved at deploy time and is deliberately absent here. The gate must - // not read that absence as "unresolved". - name: "helm repository version published in a registry passes without a media type", - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", - }, - repoType: utils.InternalHelmRepository, - }, - { - // Left through, this version would be sent down the helm path and would - // fail on the same unusable url with an opaque source controller error. - name: "version with an unusable index reference is rejected", - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - UnavailableReason: helmv1alpha1.UnavailableReasonInvalidChartReference, - UnavailableMessage: "oci reference \"oci://BAD_HOST//:::\" is not a valid tagged reference", - }, - repoType: utils.InternalHelmRepository, - wantErr: true, - wantErrContain: "InvalidChartReference", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - chart := addonChartFixture( - addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName, tt.version, - ) - r := newTestReconciler(t, chart) - - gotChart, gotVersion, err := r.getHelmClusterAddonChart(context.Background(), addon, tt.repoType) - - if tt.wantErr { - if err == nil { - t.Fatalf("expected an error, got version %+v", gotVersion) - } - if !strings.Contains(err.Error(), tt.wantErrContain) { - t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErrContain) - } - if gotChart != nil || gotVersion != nil { - t.Fatalf("expected nil chart and version on error, got chart=%v version=%v", gotChart, gotVersion) - } - - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gotChart == nil { - t.Fatal("expected the chart to be returned") - } - if gotVersion == nil { - t.Fatal("expected the matched version to be returned") - } - if gotVersion.Version != tt.version.Version { - t.Fatalf("returned version = %q, want %q", gotVersion.Version, tt.version.Version) - } - if gotVersion.MediaType != tt.version.MediaType { - t.Fatalf("returned version media type = %q, want %q", gotVersion.MediaType, tt.version.MediaType) - } - }) - } -} - -func TestGetHelmClusterAddonChartMissingChart(t *testing.T) { - addon := testAddon() - r := newTestReconciler(t) - - gotChart, gotVersion, err := r.getHelmClusterAddonChart(context.Background(), addon, utils.InternalOCIRepository) - if err == nil { - t.Fatalf("expected an error when the addon chart does not exist, got version %+v", gotVersion) - } - if gotChart != nil || gotVersion != nil { - t.Fatalf("expected nil chart and version on error, got chart=%v version=%v", gotChart, gotVersion) - } -} - -// stubChartResolver stands in for the registry so a reconcile never leaves the -// process. -type stubChartResolver struct { - mediaType string - err error -} - -func (r *stubChartResolver) ResolveChartArtifact(_ context.Context, _ string, _ *repoclient.RepoConfig) (string, error) { - return r.mediaType, r.err -} - -func helmRepositoryFixture() *helmv1alpha1.HelmClusterAddonRepository { - return &helmv1alpha1.HelmClusterAddonRepository{ - ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://charts.example.invalid/stable"}, - } -} - -func newForceTestReconciler( - t *testing.T, - interceptors interceptor.Funcs, - objects ...client.Object, -) (*Reconciler, client.Client) { - t.Helper() - - return newFullReconciler(t, nil, interceptors, objects...) -} - -// newFullReconciler builds a reconciler with the full service set, so a test can -// drive a complete pass rather than a single helper. resolver is handed to the OCI -// service; nil selects the real one, which tests that never reach the hybrid path can -// use safely. -func newFullReconciler( - t *testing.T, - resolver repoclient.ChartResolverInterface, - interceptors interceptor.Funcs, - objects ...client.Object, -) (*Reconciler, client.Client) { - t.Helper() - - scheme := runtime.NewScheme() - for _, add := range []func(*runtime.Scheme) error{ - clientgoscheme.AddToScheme, - helmv1alpha1.AddToScheme, - sourcev1.AddToScheme, - helmv2.AddToScheme, - } { - if err := add(scheme); err != nil { - t.Fatalf("registering scheme: %v", err) - } - } - - c := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&helmv1alpha1.HelmClusterAddon{}). - WithInterceptorFuncs(interceptors). - Build() - - return New( - c, - services.NewChartService(c, scheme, helmv1alpha1.TargetNamespace), - services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, resolver), - services.NewReleaseService(c, scheme, helmv1alpha1.TargetNamespace), - services.NewMaintenanceService(c, scheme, helmv1alpha1.TargetNamespace), - services.NewClaimService(c, c, helmv1alpha1.TargetNamespace), - status.NewManager(c), - ), c -} - -func ociRepositoryFixture() *helmv1alpha1.HelmClusterAddonRepository { - return &helmv1alpha1.HelmClusterAddonRepository{ - ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "oci://ghcr.io/example/podinfo"}, - } -} - -func forceTestFixtures() []client.Object { - return []client.Object{ - ociRepositoryFixture(), - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", - }), - } -} - -func reconcileAddon(t *testing.T, r *Reconciler, name string) { - t.Helper() - - if _, err := r.Reconcile(context.Background(), reconcile.Request{ - NamespacedName: types.NamespacedName{Name: name}, - }); err != nil { - t.Fatalf("Reconcile returned %v", err) - } -} - -// TestReconcileHybridVersionUsesInternalOCIRepository is the end-to-end shape of the -// feature: the repository is a classic helm one, and only the index entry of the -// version points at a registry. The addon must be served by an internal -// OCIRepository addressed by that entry, and no internal HelmChart must be created. -func TestReconcileHybridVersionUsesInternalOCIRepository(t *testing.T) { - addon := testAddon() - resolver := &stubChartResolver{mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"} - - r, c := newFullReconciler(t, resolver, interceptor.Funcs{}, - addon, - helmRepositoryFixture(), - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", - }), - ) - - reconcileAddon(t, r, addon.Name) - - ociRepo := &sourcev1.OCIRepository{} - ociKey := client.ObjectKey{ - Name: utils.GetInternalOCIRepositoryName(addon.Name), - Namespace: helmv1alpha1.TargetNamespace, - } - if err := c.Get(context.Background(), ociKey, ociRepo); err != nil { - t.Fatalf("a version published in a registry must be served by an internal oci repository: %v", err) - } - if ociRepo.Spec.URL != "oci://registry.example.com/charts/podinfo" { - t.Fatalf("url = %q, want the address from the index entry", ociRepo.Spec.URL) - } - if ociRepo.Spec.Reference == nil || ociRepo.Spec.Reference.Tag != "6.7.1" { - t.Fatalf("reference = %+v, want tag 6.7.1", ociRepo.Spec.Reference) - } - if ociRepo.Spec.LayerSelector == nil || ociRepo.Spec.LayerSelector.MediaType != resolver.mediaType { - t.Fatalf("layer selector = %+v, want the examined media type", ociRepo.Spec.LayerSelector) - } - - chart := &sourcev1.HelmChart{} - chartKey := client.ObjectKey{ - Name: utils.GetInternalHelmChartName(addon.Name), - Namespace: helmv1alpha1.TargetNamespace, - } - if err := c.Get(context.Background(), chartKey, chart); err == nil { - t.Fatal("no internal helm chart must be created for a version published in a registry") - } -} - -// TestReconcileArchiveVersionOfHelmRepositoryStaysOnTheHelmPath is the complement: -// the same repository, a version without an index reference, and nothing about the -// hybrid path must engage. -func TestReconcileArchiveVersionOfHelmRepositoryStaysOnTheHelmPath(t *testing.T) { - addon := testAddon() - - r, c := newFullReconciler(t, &stubChartResolver{}, interceptor.Funcs{}, - addon, - helmRepositoryFixture(), - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - }), - ) - - reconcileAddon(t, r, addon.Name) - - chart := &sourcev1.HelmChart{} - chartKey := client.ObjectKey{ - Name: utils.GetInternalHelmChartName(addon.Name), - Namespace: helmv1alpha1.TargetNamespace, - } - if err := c.Get(context.Background(), chartKey, chart); err != nil { - t.Fatalf("an archive version must be served by an internal helm chart: %v", err) - } - - ociRepo := &sourcev1.OCIRepository{} - ociKey := client.ObjectKey{ - Name: utils.GetInternalOCIRepositoryName(addon.Name), - Namespace: helmv1alpha1.TargetNamespace, - } - if err := c.Get(context.Background(), ociKey, ociRepo); err == nil { - t.Fatal("no internal oci repository must be created for an archive version") - } -} - -// TestReconcileVersionMovedOutOfRegistrySupersedesTheOCIRepository is the mirror flip: -// the index re-published a version the addon is already running as an archive from an -// internal OCIRepository, either because the user repointed the repository or because -// the index re-published it out of the registry. The superseded internal OCIRepository -// is removed even though the new source has not produced an artifact yet, for the same -// reason as its HelmChart counterpart: a repository retracting a location is a fact -// the addon state has to reflect, and keeping the old source would let the addon keep -// deploying from a place the repository no longer offers. -func TestReconcileVersionMovedOutOfRegistrySupersedesTheOCIRepository(t *testing.T) { - addon := testAddon() - addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ - HelmClusterAddonRepository: "example", - HelmClusterAddonChartName: "podinfo", - Version: "6.7.1", - } - - supersededOCIRepo := &sourcev1.OCIRepository{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetInternalOCIRepositoryName(addon.Name), - Namespace: helmv1alpha1.TargetNamespace, - }, - } - - r, c := newFullReconciler(t, &stubChartResolver{}, interceptor.Funcs{}, - addon, - helmRepositoryFixture(), - supersededOCIRepo, - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - }), - ) - - reconcileAddon(t, r, addon.Name) - - ociRepo := &sourcev1.OCIRepository{} - if err := c.Get(context.Background(), client.ObjectKeyFromObject(supersededOCIRepo), ociRepo); err == nil { - t.Error("the superseded internal oci repository must be removed") - } - - chart := &sourcev1.HelmChart{} - chartKey := client.ObjectKey{ - Name: utils.GetInternalHelmChartName(addon.Name), - Namespace: helmv1alpha1.TargetNamespace, - } - if err := c.Get(context.Background(), chartKey, chart); err != nil { - t.Fatalf("the new source must be created in the same pass: %v", err) - } -} - -// TestReconcileVersionMovedIntoRegistrySupersedesTheHelmChart is the flip: the index -// re-published a version the addon is already running as an OCI artifact. The -// superseded internal HelmChart is removed even though the new source has not -// produced an artifact yet — a repository retracting a location is a fact the addon -// state has to reflect, and keeping the old source would let the addon keep deploying -// from a place the repository no longer offers. The running release is not torn down -// by that: helm-controller does not uninstall a release because its source is gone. -func TestReconcileVersionMovedIntoRegistrySupersedesTheHelmChart(t *testing.T) { - addon := testAddon() - addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ - HelmClusterAddonRepository: "example", - HelmClusterAddonChartName: "podinfo", - Version: "6.7.1", - } - - supersededChart := &sourcev1.HelmChart{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetInternalHelmChartName(addon.Name), - Namespace: helmv1alpha1.TargetNamespace, - }, - } - - r, c := newFullReconciler(t, &stubChartResolver{mediaType: "application/tar+gzip"}, interceptor.Funcs{}, - addon, - helmRepositoryFixture(), - supersededChart, - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "6.7.1", - OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", - }), - ) - - reconcileAddon(t, r, addon.Name) - - chart := &sourcev1.HelmChart{} - if err := c.Get(context.Background(), client.ObjectKeyFromObject(supersededChart), chart); err == nil { - t.Error("the superseded internal helm chart must be removed") - } - - ociRepo := &sourcev1.OCIRepository{} - ociKey := client.ObjectKey{ - Name: utils.GetInternalOCIRepositoryName(addon.Name), - Namespace: helmv1alpha1.TargetNamespace, - } - if err := c.Get(context.Background(), ociKey, ociRepo); err != nil { - t.Fatalf("the new source must be created in the same pass: %v", err) - } - if ociRepo.Spec.URL != "oci://registry.example.com/charts/podinfo" { - t.Fatalf("url = %q, want the address from the index entry", ociRepo.Spec.URL) - } -} - -// TestLogSourceKindFlipIgnoresStaleEntryFromADifferentChartOrRepository pins the -// guard added to logSourceKindFlip: LastAppliedChart carries its own repository/chart -// identity and can lag behind Spec.Chart, so a version string that happens to match -// is not enough on its own — the repository and chart name have to match too, or an -// addon that switched to an unrelated chart reusing the same version string would be -// misreported as its current chart having changed where it is published. -func TestLogSourceKindFlipIgnoresStaleEntryFromADifferentChartOrRepository(t *testing.T) { - tests := []struct { - name string - last *helmv1alpha1.HelmClusterAddonLastAppliedChartRef - wantLogged bool - }{ - { - name: "same repository, chart and version is a flip", - last: &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ - HelmClusterAddonRepository: "example", - HelmClusterAddonChartName: "podinfo", - Version: "6.7.1", - }, - wantLogged: true, - }, - { - // The version string coincides, but it belongs to a different chart's - // history: the addon was repointed, not flipped. - name: "same version but a different chart name is not a flip", - last: &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ - HelmClusterAddonRepository: "example", - HelmClusterAddonChartName: "other-chart", - Version: "6.7.1", - }, - wantLogged: false, - }, - { - // Same reasoning, the other field: the version string coincides, but it - // belongs to a different repository's history. - name: "same version but a different repository is not a flip", - last: &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ - HelmClusterAddonRepository: "other-repo", - HelmClusterAddonChartName: "podinfo", - Version: "6.7.1", - }, - wantLogged: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addon := testAddon() - addon.Status.LastAppliedChart = tt.last - - var logged bool - logger := funcr.New(func(prefix, args string) { - logged = true - }, funcr.Options{}) - ctx := log.IntoContext(context.Background(), logger) - - r := &Reconciler{} - r.logSourceKindFlip(ctx, addon, utils.InternalOCIRepository, true) - - if logged != tt.wantLogged { - t.Fatalf("logged = %v, want %v", logged, tt.wantLogged) - } - }) - } -} - -// TestReconcileForcedAddonReportsProgressBeforeWorking pins that Reconciling is -// published before the internal source is touched. The user annotated the addon a -// moment ago and is watching it; a condition written only after the release has -// been reconciled would report progress that is already over. -func TestReconcileForcedAddonReportsProgressBeforeWorking(t *testing.T) { - addon := testAddon() - addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} - - var inFlight *metav1.Condition - var c client.Client - - // The internal source is reconciled with CreateOrPatch, so the first write to - // it is a Create on a fresh addon and a Patch on an existing one; hook both so - // the test does not depend on which one this fixture takes. - captureAddonStatus := func(ctx context.Context) { - if inFlight != nil { - return - } - - observed := &helmv1alpha1.HelmClusterAddon{} - if err := c.Get(ctx, types.NamespacedName{Name: addon.Name}, observed); err == nil { - inFlight = apimeta.FindStatusCondition( - observed.Status.Conditions, helmv1alpha1.ConditionTypeReconciling) - } - } - - observe := interceptor.Funcs{ - Create: func( - ctx context.Context, - inner client.WithWatch, - obj client.Object, - opts ...client.CreateOption, - ) error { - if _, isSource := obj.(*sourcev1.OCIRepository); isSource { - captureAddonStatus(ctx) - } - - return inner.Create(ctx, obj, opts...) - }, - Patch: func( - ctx context.Context, - inner client.WithWatch, - obj client.Object, - patch client.Patch, - opts ...client.PatchOption, - ) error { - if _, isSource := obj.(*sourcev1.OCIRepository); isSource { - captureAddonStatus(ctx) - } - - return inner.Patch(ctx, obj, patch, opts...) - }, - } - - r, built := newForceTestReconciler(t, observe, append(forceTestFixtures(), addon)...) - c = built - - reconcileAddon(t, r, addon.Name) - - if inFlight == nil { - t.Fatal("Reconciling must be published before the internal source is reconciled") - } - if inFlight.Status != metav1.ConditionTrue || inFlight.Reason != helmv1alpha1.ReasonForceReconcile { - t.Fatalf("Reconciling is %s/%s, want True/%s", - inFlight.Status, inFlight.Reason, helmv1alpha1.ReasonForceReconcile) - } -} - -// TestReconcileForcedAddonRecordsCompletion covers the other end of the same pass. -func TestReconcileForcedAddonRecordsCompletion(t *testing.T) { - addon := testAddon() - addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} - - r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) - - // metav1.Time serialises at second precision, so the stored stamp can land - // just before an untruncated wall-clock reading of the same second. - before := time.Now().UTC().Truncate(time.Second) - - reconcileAddon(t, r, addon.Name) - - settled := &helmv1alpha1.HelmClusterAddon{} - if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { - t.Fatalf("getting addon: %v", err) - } - - if cond := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling); cond != nil { - t.Fatalf("Reconciling must be gone once the forced pass finished, got %+v", cond) - } - if settled.Status.LastForceReconcileTime == nil { - t.Fatal("lastForceReconcileTime must be recorded by the forced pass") - } - if settled.Status.LastForceReconcileTime.Time.Before(before) { - t.Fatalf("lastForceReconcileTime is %v, want at or after %v", - settled.Status.LastForceReconcileTime.Time, before) - } - if _, found := settled.Annotations[helmv1alpha1.AnnotationForceReconcile]; found { - t.Fatal("the force annotation must be consumed by the pass it triggered") - } -} - -// TestReconcileUnforcedAddonRecordsNoForceReconcile is the complement: an ordinary -// pass must not report a force request that was never made. -func TestReconcileUnforcedAddonRecordsNoForceReconcile(t *testing.T) { - addon := testAddon() - - r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) - - reconcileAddon(t, r, addon.Name) - - settled := &helmv1alpha1.HelmClusterAddon{} - if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { - t.Fatalf("getting addon: %v", err) - } - - if settled.Status.LastForceReconcileTime != nil { - t.Fatalf("lastForceReconcileTime is %v, want it unset without a force request", - settled.Status.LastForceReconcileTime) - } - if cond := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling); cond != nil && - cond.Reason == helmv1alpha1.ReasonForceReconcile { - t.Fatalf("an unforced pass must not report %s", helmv1alpha1.ReasonForceReconcile) - } -} - -// TestReconcileForceAnnotationSkipsUnannotatedAddon pins that an addon carrying -// unrelated annotations is not written on every pass. Guarding on the map instead -// of on the annotation itself sends an empty PATCH each time, which costs a write -// and an update event for every addon in the cluster. -func TestReconcileForceAnnotationSkipsUnannotatedAddon(t *testing.T) { - addon := testAddon() - addon.Annotations = map[string]string{"example.io/unrelated": "value"} - - r, c := newForceTestReconciler(t, interceptor.Funcs{}, addon) - - stored := &helmv1alpha1.HelmClusterAddon{} - key := types.NamespacedName{Name: addon.Name} - if err := c.Get(context.Background(), key, stored); err != nil { - t.Fatalf("getting addon: %v", err) - } - before := stored.ResourceVersion - - if err := r.reconcileForceAnnotation(context.Background(), key); err != nil { - t.Fatalf("reconcileForceAnnotation returned %v", err) - } - - if err := c.Get(context.Background(), key, stored); err != nil { - t.Fatalf("getting addon: %v", err) - } - if stored.ResourceVersion != before { - t.Fatalf("resourceVersion moved from %s to %s: an addon without the force annotation was written", - before, stored.ResourceVersion) - } - if stored.Annotations["example.io/unrelated"] != "value" { - t.Fatal("unrelated annotations must be left in place") - } -} - -// maintainedAddon builds an addon asking for maintenance mode, carrying a force -// request and the progress condition a forced pass publishes before it works. That -// is the state a pass interrupted between the two writes leaves behind. -func maintainedAddon() *helmv1alpha1.HelmClusterAddon { - addon := testAddon() - addon.Spec.Maintenance = string(helmv1alpha1.NoResourceReconciliation) - addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} - addon.Status.Conditions = []metav1.Condition{{ - Type: helmv1alpha1.ConditionTypeReconciling, - Status: metav1.ConditionTrue, - Reason: helmv1alpha1.ReasonForceReconcile, - Message: "Forced reconciliation in progress", - LastTransitionTime: metav1.Now(), - }} - - return addon -} - -// TestReconcileEnteringMaintenanceDiscardsForceReconcile covers the pass that puts -// the addon into maintenance. The controller has just decided to stop reconciling -// it, so a force request it will never act on must not be left claiming progress — -// kstatus reads a standing Reconciling as work in flight. -func TestReconcileEnteringMaintenanceDiscardsForceReconcile(t *testing.T) { - addon := maintainedAddon() - - r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) - - reconcileAddon(t, r, addon.Name) - - settled := &helmv1alpha1.HelmClusterAddon{} - if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { - t.Fatalf("getting addon: %v", err) - } - - if !settled.MaintenanceModeEnabled() { - t.Fatalf("the fixture must reach maintenance mode first, conditions: %v", settled.Status.Conditions) - } - if cond := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling); cond != nil { - t.Fatalf("Reconciling must be dropped when the addon enters maintenance, got %+v", cond) - } - if _, found := settled.Annotations[helmv1alpha1.AnnotationForceReconcile]; found { - t.Fatal("the force annotation must be discarded: maintenance will never act on it") - } - if settled.Status.LastForceReconcileTime != nil { - t.Fatalf("lastForceReconcileTime is %v, want it unset: the request was discarded, not processed", - settled.Status.LastForceReconcileTime) - } -} - -// TestReconcileSittingInMaintenanceDiscardsForceReconcile is the same guarantee for -// an addon already in maintenance, which takes the early return instead of the -// maintenance-change branch. Without it a request annotated onto a maintained addon -// would sit on the object forever. -func TestReconcileSittingInMaintenanceDiscardsForceReconcile(t *testing.T) { - addon := maintainedAddon() - addon.Status.Conditions = append(addon.Status.Conditions, metav1.Condition{ - Type: helmv1alpha1.ConditionTypeManaged, - Status: metav1.ConditionFalse, - Reason: helmv1alpha1.ReasonMaintenanceModeActive, - Message: "Maintenance mode enabled", - LastTransitionTime: metav1.Now(), - }) - - r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) - - if !addon.MaintenanceModeEnabled() || r.maintenanceService.IsMaintenanceModeChangeRequired(addon) { - t.Fatal("the fixture must already be in maintenance, otherwise the test takes the wrong branch") - } - - reconcileAddon(t, r, addon.Name) - - settled := &helmv1alpha1.HelmClusterAddon{} - if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { - t.Fatalf("getting addon: %v", err) - } - - if cond := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling); cond != nil { - t.Fatalf("Reconciling must be dropped on a maintained addon, got %+v", cond) - } - if _, found := settled.Annotations[helmv1alpha1.AnnotationForceReconcile]; found { - t.Fatal("the force annotation must be discarded: maintenance will never act on it") - } -} - -// TestReconcileLeavingMaintenanceKeepsForceReconcile is the complement. Lifting -// maintenance also returns early, but reconciliation is resuming, so the request is -// about to become actionable and must survive to the pass that can honour it. -func TestReconcileLeavingMaintenanceKeepsForceReconcile(t *testing.T) { - addon := testAddon() - addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} - addon.Status.Conditions = []metav1.Condition{{ - Type: helmv1alpha1.ConditionTypeManaged, - Status: metav1.ConditionFalse, - Reason: helmv1alpha1.ReasonMaintenanceModeActive, - Message: "Maintenance mode enabled", - LastTransitionTime: metav1.Now(), - }} - - r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) - - if addon.MaintenanceModeActivated() || !r.maintenanceService.IsMaintenanceModeChangeRequired(addon) { - t.Fatal("the fixture must be leaving maintenance, otherwise the test proves nothing") - } - - reconcileAddon(t, r, addon.Name) - - settled := &helmv1alpha1.HelmClusterAddon{} - if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { - t.Fatalf("getting addon: %v", err) - } - - if _, found := settled.Annotations[helmv1alpha1.AnnotationForceReconcile]; !found { - t.Fatal("the force annotation must survive the pass that lifts maintenance") - } -} diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go deleted file mode 100644 index 94b3ac2f..00000000 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go +++ /dev/null @@ -1,337 +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" - "fmt" - "time" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "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" - "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" -) - -// internalResourceDeletionRequeueInterval bounds how often reconcileDelete -// re-checks whether the internal resources have finished being deleted. Watches -// on those resources drive most requeues; this is the safety net for a resource -// whose deletion is stuck and stops emitting events. -const internalResourceDeletionRequeueInterval = 30 * time.Second - -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 *status.Manager -} - -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, repoTypeErr := utils.GetRepositoryType(repo.Spec.URL) - - 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) - } - // Continue reconciling in the same pass: adding a finalizer is a - // metadata-only change that does not bump generation, so the resulting - // update event is dropped by the generation/annotation predicates and - // would not trigger a follow-up reconcile. - } - - in := Inputs{ - Generation: repo.Generation, - Now: time.Now().UTC(), - Jitter: NewJitter(), - Current: *repo.Status.DeepCopy(), - } - - if repoTypeErr != nil { - in.ConfigErr = &services.ConfigOutcome{ - Reason: helmv1alpha1.ReasonUnsupportedRepositoryType, - Message: repoTypeErr.Error(), - Err: repoTypeErr, - } - - return r.finish(ctx, &repo, in, false) - } - - // Both services embed the same BaseRepoService with the same target namespace, - // so one of them reconciles the auxiliary secrets for either repository type. - in.SecretsErr = r.helmRepositoryService.EnsureSecrets(ctx, &repo, repoType) - - if in.SecretsErr == nil { - switch repoType { - case utils.InternalHelmRepository: - in.InternalRepository, in.InternalRepositoryErr = r.helmRepositoryService.EnsureInternalHelmRepository(ctx, &repo) - case utils.InternalOCIRepository: - // The url may have changed from helm to oci: drop the internal object - // that is no longer used. OCI repositories have none of their own. - in.InternalRepositoryErr = r.helmRepositoryService.RemoveHelmRepository(ctx, repo.Name) - } - } - - in.Forced = repo.ForceReconcileRequired() - - if in.SecretsErr == nil && in.InternalRepositoryErr == nil && - ShouldAttempt(in.Current, in.Generation, in.Now, in.Forced) { - if err := r.markSyncInProgress(ctx, &repo, in.Forced); err != nil { - return reconcile.Result{}, err - } - - outcome := r.chartSyncService.Sync(ctx, &repo, repoType) - - in.Attempted = true - if outcome.FetchAttempted { - // A cluster-side failure before the fetch (see RepoSyncService.Sync) - // leaves outcome.FetchAttempted false; in.Fetch must stay nil then, or - // its zero-value Err == nil would be read as a successful fetch and - // reset ConsecutiveFetchFailures / mark Ready=True off nothing. - in.Fetch = &outcome.Fetch - } - in.Catalog = &outcome.Catalog - } - - return r.finish(ctx, &repo, in, in.Attempted) -} - -// finish applies the decision and consumes the force annotation when an attempt -// actually ran. The annotation is removed after the status patch so a conflict -// does not lose the request. -func (r *Reconciler) finish( - ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, - in Inputs, - attempted bool, -) (reconcile.Result, error) { - decision := Evaluate(in) - - if in.Fetch != nil && in.Fetch.Err != nil { - // A repository read failure is not returned to the work queue — its retry - // is carried by nextSyncTime — so this is the only place it is logged. - log.FromContext(ctx).Error(in.Fetch.Err, in.Fetch.Message, "repository", repo.Name) - } - - if err := r.statusManager.PatchStatus(ctx, repo, func() { - repo.Status = decision.Status - }); client.IgnoreNotFound(err) != nil { - return reconcile.Result{}, err - } - - if attempted { - // A force request reaches an addon's artifact only through the addon's own - // internal OCIRepository, and any repository can have those: an oci:// one for - // every addon, a helm one for every version its index publishes in a registry. - // This runs before the annotation is consumed: a failure leaves the request in - // place to be retried. The versions a helm repository serves as archives need no - // equivalent — there the internal HelmRepository carries the request and its - // HelmCharts follow the re-indexed source on their own. - if repo.ForceReconcileRequired() { - if err := r.ociRepositoryService.ForceReconcileInternalRepositories(ctx, repo.Name); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to force reconcile internal oci repositories: %w", err) - } - } - - if err := r.reconcileForceAnnotation(ctx, client.ObjectKeyFromObject(repo)); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to reconcile force annotation: %w", err) - } - } - - if decision.Err != nil { - // Cluster write failures are handed to the work queue rate limiter; the - // schedule is re-established on the next pass. - return reconcile.Result{}, decision.Err - } - - return reconcile.Result{RequeueAfter: decision.RequeueAfter}, nil -} - -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.InternalOCIRepository: - if err := r.ociRepositoryService.CleanupOCIRepository(ctx, repo.Name); err != nil && !apierrors.IsNotFound(err) { - _ = r.statusManager.MarkDeletionFailed(ctx, repo, "internal repository", err) - return reconcile.Result{}, err - } - default: - // The helm path is the default rather than a case of its own because an - // unknown repository type is a state a real repository can reach: the url - // validation regex on the CRD is looser than url.Parse, so a repository - // whose internal objects already exist can be edited to a url that no - // longer parses and then deleted. Cleaning up the helm way is safe for - // either type — it removes both auxiliary secrets and tolerates a missing - // internal repository — and leaving it out would orphan them. - helmRepo, err := r.helmRepositoryService.CleanupHelmRepository(ctx, repo.Name) - if err != nil && !apierrors.IsNotFound(err) { - _ = r.statusManager.MarkDeletionFailed(ctx, repo, "internal repository", err) - return reconcile.Result{}, err - } - if helmRepo != nil { - return r.awaitInternalResourceDeletion(ctx, repo, "internal repository", helmRepo) - } - } - - 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 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") - - return reconcile.Result{}, nil -} - -// awaitInternalResourceDeletion surfaces that an internal resource is still being -// deleted on the repository's status (via the shared status manager) and requeues -// without removing the finalizer. The resource name is kept abstract so its -// internal type is not leaked to the user. -func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, name string, resource status.DeletingResource) (reconcile.Result, error) { - log.FromContext(ctx).Info("Waiting for internal resource to be deleted before removing finalizer", "resource", name) - - if err := r.statusManager.MarkDeletionPending(ctx, repo, name, resource); client.IgnoreNotFound(err) != nil { - return reconcile.Result{}, fmt.Errorf("updating deletion status: %w", err) - } - - return reconcile.Result{RequeueAfter: internalResourceDeletionRequeueInterval}, nil -} - -// markSyncInProgress publishes Reconciling before the synchronization starts, so -// a pass that is about to read the repository says so while the read is running -// instead of only once it is over — a read can take a while, and until it -// returns nothing else on the status moves. The reason distinguishes the two ways -// a pass is triggered: ForceReconcile is the case someone is actively watching, -// having annotated the repository a moment ago to see it picked up, while -// Synchronization is the ordinary scheduled cadence. The condition is -// deliberately written outside the Inputs snapshot Evaluate works from, so the -// status computed at the end of the pass removes it again without a rule of its -// own. -func (r *Reconciler) markSyncInProgress( - ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, - forced bool, -) error { - reason, message := helmv1alpha1.ReasonSynchronization, "Repository synchronization in progress" - if forced { - reason, message = helmv1alpha1.ReasonForceReconcile, "Forced reconciliation in progress" - } - - err := r.statusManager.PatchStatus(ctx, repo, func() { - apimeta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{ - Type: helmv1alpha1.ConditionTypeReconciling, - Status: metav1.ConditionTrue, - Reason: reason, - Message: message, - ObservedGeneration: repo.Generation, - }) - }) - if client.IgnoreNotFound(err) != nil { - return fmt.Errorf("publishing synchronization progress: %w", err) - } - - return nil -} - -func (r *Reconciler) reconcileForceAnnotation(ctx context.Context, key client.ObjectKey) error { - var repo helmv1alpha1.HelmClusterAddonRepository - - if err := r.Get(ctx, key, &repo); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - - return fmt.Errorf("getting helm cluster addon repository: %w", err) - } - - if _, found := repo.Annotations[helmv1alpha1.AnnotationForceReconcile]; !found { - // Guard on the annotation itself, not on the map: a repository carrying - // any unrelated annotation would otherwise take an empty PATCH on every - // attempted pass. - return nil - } - - patchBase := client.MergeFrom(repo.DeepCopy()) - - delete(repo.Annotations, helmv1alpha1.AnnotationForceReconcile) - - if err := r.Patch(ctx, &repo, patchBase); err != nil { - return fmt.Errorf("removing force reconcile annotation: %w", err) - } - - return nil -} diff --git a/images/operator-helm-controller/internal/reconcile/pass/pass.go b/images/operator-helm-controller/internal/reconcile/pass/pass.go new file mode 100644 index 00000000..61ffa3b8 --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/pass/pass.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. +*/ + +// Package pass holds what one reconcile pass does the same way whichever kind it +// runs on. Both reconcilers consume the force reconcile annotation and both wait +// for an internal resource to finish being deleted; neither needs to know which +// family the object belongs to, so neither is written twice. +package pass + +import ( + "context" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "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" + "github.com/deckhouse/operator-helm/internal/status" +) + +// InternalResourceDeletionRequeueInterval bounds how often a pass re-checks whether +// the internal resources have finished being deleted. Watches on those resources +// drive most requeues; this is the safety net for a resource whose deletion is stuck +// and stops emitting events. +const InternalResourceDeletionRequeueInterval = 30 * time.Second + +// ConsumeForceAnnotation removes the force reconcile annotation from the object at +// key. obj is an empty object of the right kind to read into: the object is fetched +// again rather than patched from the copy the pass has held since it started, so a +// change made meanwhile is not written back over. +func ConsumeForceAnnotation(ctx context.Context, c client.Client, key client.ObjectKey, obj client.Object) error { + if err := c.Get(ctx, key, obj); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + + return fmt.Errorf("getting object: %w", err) + } + + annotations := obj.GetAnnotations() + if _, found := annotations[helmv1alpha1.AnnotationForceReconcile]; !found { + // Guard on the annotation itself, not on the map: an object carrying any + // unrelated annotation would otherwise take an empty PATCH on every pass. + return nil + } + + patchBase := client.MergeFrom(obj.DeepCopyObject().(client.Object)) + + delete(annotations, helmv1alpha1.AnnotationForceReconcile) + obj.SetAnnotations(annotations) + + if err := c.Patch(ctx, obj, patchBase); err != nil { + return fmt.Errorf("removing force reconcile annotation: %w", err) + } + + return nil +} + +// MarkPending is the status write that says an internal resource is still going +// away: MarkDeletionPending for an owner with nothing to uninstall, and +// MarkUninstallPending for one whose Helm release is being removed. +type MarkPending func(ctx context.Context, obj status.ObjectWithConditions, resourceName string, resource status.DeletingResource) error + +// AwaitInternalResourceDeletion surfaces that an internal resource is still being +// deleted on the owner's status and requeues without removing the finalizer. name is +// kept abstract so the internal type is not leaked to the user; the log line names +// the object itself, which is what someone looking into a stuck deletion reaches for. +func AwaitInternalResourceDeletion( + ctx context.Context, + mark MarkPending, + owner status.ObjectWithConditions, + name string, + resource status.DeletingResource, +) (reconcile.Result, error) { + log.FromContext(ctx).Info("Waiting for internal resource to be deleted before removing finalizer", + "resource", name, + "internalType", fmt.Sprintf("%T", resource), + "internalObject", client.ObjectKeyFromObject(resource)) + + if err := mark(ctx, owner, name, resource); client.IgnoreNotFound(err) != nil { + return reconcile.Result{}, fmt.Errorf("updating deletion status: %w", err) + } + + return reconcile.Result{RequeueAfter: InternalResourceDeletionRequeueInterval}, nil +} diff --git a/images/operator-helm-controller/internal/reconcile/pass/pass_test.go b/images/operator-helm-controller/internal/reconcile/pass/pass_test.go new file mode 100644 index 00000000..04206267 --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/pass/pass_test.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 pass + +import ( + "context" + "testing" + + 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/client/fake" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +func newForceClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering scheme: %v", err) + } + + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() +} + +func addonWithAnnotations(annotations map[string]string) *helmv1alpha1.HelmClusterAddon { + return &helmv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{Name: "addon", Annotations: annotations}, + } +} + +func TestConsumeForceAnnotationRemovesTheRequest(t *testing.T) { + addon := addonWithAnnotations(map[string]string{ + helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z", + "example.io/unrelated": "value", + }) + c := newForceClient(t, addon) + key := types.NamespacedName{Name: addon.Name} + + if err := ConsumeForceAnnotation(context.Background(), c, key, &helmv1alpha1.HelmClusterAddon{}); err != nil { + t.Fatalf("ConsumeForceAnnotation returned %v", err) + } + + stored := &helmv1alpha1.HelmClusterAddon{} + if err := c.Get(context.Background(), key, stored); err != nil { + t.Fatalf("getting addon: %v", err) + } + if _, found := stored.Annotations[helmv1alpha1.AnnotationForceReconcile]; found { + t.Fatal("the request must be taken away once it has been acted on") + } + if stored.Annotations["example.io/unrelated"] != "value" { + t.Fatal("unrelated annotations must be left in place") + } +} + +// TestConsumeForceAnnotationSkipsAnObjectWithoutTheRequest pins that an object +// carrying unrelated annotations is not written on every pass. Guarding on the map +// instead of on the annotation itself sends an empty PATCH each time, which costs a +// write and an update event for every object in the cluster. +func TestConsumeForceAnnotationSkipsAnObjectWithoutTheRequest(t *testing.T) { + addon := addonWithAnnotations(map[string]string{"example.io/unrelated": "value"}) + c := newForceClient(t, addon) + key := types.NamespacedName{Name: addon.Name} + + stored := &helmv1alpha1.HelmClusterAddon{} + if err := c.Get(context.Background(), key, stored); err != nil { + t.Fatalf("getting addon: %v", err) + } + before := stored.ResourceVersion + + if err := ConsumeForceAnnotation(context.Background(), c, key, &helmv1alpha1.HelmClusterAddon{}); err != nil { + t.Fatalf("ConsumeForceAnnotation returned %v", err) + } + + if err := c.Get(context.Background(), key, stored); err != nil { + t.Fatalf("getting addon: %v", err) + } + if stored.ResourceVersion != before { + t.Fatalf("resourceVersion moved from %s to %s: an object without the request was written", + before, stored.ResourceVersion) + } +} + +func TestConsumeForceAnnotationToleratesAMissingObject(t *testing.T) { + c := newForceClient(t) + + err := ConsumeForceAnnotation(context.Background(), c, + types.NamespacedName{Name: "gone"}, &helmv1alpha1.HelmClusterAddon{}) + if err != nil { + t.Fatalf("an object deleted mid-pass must not fail the pass, got %v", err) + } +} diff --git a/images/operator-helm-controller/internal/reconcile/release/collaborators.go b/images/operator-helm-controller/internal/reconcile/release/collaborators.go new file mode 100644 index 00000000..db79108e --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/release/collaborators.go @@ -0,0 +1,140 @@ +/* +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 release + +import ( + "context" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/chartsource" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" +) + +// The collaborators of one reconcile pass, declared where they are used rather than +// where they are implemented: a consumer names the behaviour it needs, and anything +// that offers it fits. That is what lets a family opt out of a step with a null +// implementation, and a test replace one step without standing up the rest. + +// RepositoryResolver loads the repository a release references and the catalog of +// that repository's kind. The application family picks between two repository +// kinds; the addon family has one. +type RepositoryResolver interface { + Resolve(ctx context.Context, ref source.RepositoryRef) (source.Repository, source.Catalog, error) +} + +// ChartClaim guards the uniqueness of a repository/chart pair across the releases +// of one family. The addon family enforces it with a Lease; the application family +// does not enforce it at all. +type ChartClaim interface { + // Acquire reports whether the release holds the claim on its pair; when it does + // not, holder names the release that does. + Acquire(ctx context.Context, rel source.Release) (acquired bool, holder string, err error) + // ReleaseStale frees claims this release still holds on pairs it no longer + // references. + ReleaseStale(ctx context.Context, rel source.Release) error + // Release frees the claim on the release's current pair. + Release(ctx context.Context, rel source.Release) error +} + +// TargetNamespaceEnsurer makes sure the namespace a release deploys into exists. +type TargetNamespaceEnsurer interface { + EnsureTargetNamespace(ctx context.Context, rel source.Release) error +} + +// AccessManager provides the identity a release is applied with. The application +// family creates a ServiceAccount, a Role and a RoleBinding and names the account +// on the HelmRelease; the addon family applies charts as helm-controller itself. +type AccessManager interface { + EnsureAccess(ctx context.Context, rel source.Release) services.AccessOutcome + CleanupAccess(ctx context.Context, rel source.Release) error +} + +// ChartManager owns the internal HelmChart of a release, the source object of a +// repository that hands out packaged archives. +type ChartManager interface { + EnsureHelmChart(ctx context.Context, rel source.Release, repo source.Repository) services.ChartOutcome + // CleanupHelmChart returns the object while it is still present, so the caller + // can wait for it to actually go away. + CleanupHelmChart(ctx context.Context, names source.ReleaseNames) (*sourcev1.HelmChart, error) +} + +// OCIRepoManager owns the internal OCIRepository of a release, the source object of +// a repository that hands out registry artifacts. +type OCIRepoManager interface { + EnsureInternalOCIRepository( + ctx context.Context, + rel source.Release, + repo source.Repository, + src chartsource.Source, + version *helmv1alpha1.ChartVersion, + ) services.OCIRepoOutcome + RemoveOCIRepository(ctx context.Context, names source.ReleaseNames) (*sourcev1.OCIRepository, error) +} + +// ReleaseManager owns the internal HelmRelease: the object helm-controller acts on. +type ReleaseManager interface { + EnsureHelmRelease( + ctx context.Context, + rel source.Release, + sourceKind chartsource.Kind, + artifactRevision string, + ) services.ReleaseOutcome + CleanupHelmRelease(ctx context.Context, names source.ReleaseNames) (*helmv2.HelmRelease, error) + // SyncReleaseSpec keeps a release that is being deleted in step with its spec, so + // an uninstall blocked by a bad parameter can be unblocked by correcting it. + SyncReleaseSpec(ctx context.Context, rel source.Release, existing *helmv2.HelmRelease) error +} + +// MaintenanceManager suspends and resumes the internal HelmRelease. +type MaintenanceManager interface { + IsMaintenanceModeChangeRequired(rel source.Release) bool + EnsureMaintenanceMode(ctx context.Context, rel source.Release) services.MaintenanceOutcome +} + +// NoChartClaim is the ChartClaim of a family without a uniqueness rule: every +// release holds its own pair, and the conflict branch of the reconciler is never +// taken. +type NoChartClaim struct{} + +func (NoChartClaim) Acquire(_ context.Context, rel source.Release) (bool, string, error) { + return true, rel.Name(), nil +} + +func (NoChartClaim) ReleaseStale(context.Context, source.Release) error { return nil } + +func (NoChartClaim) Release(context.Context, source.Release) error { return nil } + +// ExistingTargetNamespace is the ensurer of a family whose target namespace is the +// release's own: it exists by definition, or the release could not. +type ExistingTargetNamespace struct{} + +func (ExistingTargetNamespace) EnsureTargetNamespace(context.Context, source.Release) error { + return nil +} + +// NoAccess is the AccessManager of a family that does not impersonate. +type NoAccess struct{} + +func (NoAccess) EnsureAccess(context.Context, source.Release) services.AccessOutcome { + return services.AccessOutcome{} +} + +func (NoAccess) CleanupAccess(context.Context, source.Release) error { return nil } diff --git a/images/operator-helm-controller/internal/reconcile/release/doc.go b/images/operator-helm-controller/internal/reconcile/release/doc.go new file mode 100644 index 00000000..77aa4037 --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/release/doc.go @@ -0,0 +1,38 @@ +/* +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 release holds the one reconciler every release kind is served by: +// HelmClusterAddon and HelmApplication run the exact same pass, and a new family +// is added by wiring collaborators rather than by copying a control flow. +// +// What tells the families apart arrives as Deps: how the API object is read, how +// its repository and catalog are found, whether a repository/chart pair is +// claimed, whether the target namespace is created, and which identity the chart +// is applied with. The reconciler itself never names a kind. +// +// The API object is only ever reached through a source.Release adapter. The +// adapter is not a registered type, so it is never handed to the client; the +// object underneath it is reached through Object() at the few places that talk to +// the API server. +// +// Status is managed the way the repository package manages it, and for the same +// reason: a pass does cluster work and records what came of it in Inputs, and one +// deterministic function — Evaluate — turns that into the whole desired status, +// what to requeue and what to hand back to the work queue. No step writes a +// condition of its own, so what the release reports can be read in one place and +// tested without a cluster. The services this reconciler drives return outcomes +// rather than conditions for the same reason. +package release diff --git a/images/operator-helm-controller/internal/reconcile/release/evaluate.go b/images/operator-helm-controller/internal/reconcile/release/evaluate.go new file mode 100644 index 00000000..04a1bddb --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/release/evaluate.go @@ -0,0 +1,542 @@ +/* +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 release + +import ( + "time" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/services" +) + +// Failure is one way a pass can fail, named the way the release reports it. Reason +// and Message are what the condition carries; Err is logged and, when Retry is set, +// handed to the work queue's rate limiter. +type Failure struct { + Reason string + Message string + Err error + + // Terminal marks a failure nothing this controller can do will resolve: the + // release's own spec is at fault, or something outside it has to be changed by + // hand. It is reported as Stalled, the kstatus condition for exactly that, and + // it excludes Retry — handing such a failure to the work queue would burn the + // rate limiter on an attempt whose outcome is already known. + Terminal bool + // Retry hands Err to the work queue when the pass ends. Without it the failure + // is reported and the release waits for a watch to wake it. + Retry bool + // RequeueAfter schedules another pass on a timer, for a cause no watch covers. + RequeueAfter time.Duration +} + +// Inputs carries everything Evaluate needs. It holds no clients and no clock: Now, +// the current conditions and the derived facts about the release are supplied by the +// caller, so the function is deterministic and unit testable. It is the release-side +// counterpart of the repository package's Inputs. +// +// The failure fields differ in how far the verdict reaches, not in severity. Step is +// a failure of one of the steps that run before the chart is resolved; it owns Ready +// alone, because the pass never got far enough to say anything about installing or +// configuring. ChartSource stands in for the chart outcome when the pass could not +// decide which internal source the release needs, so it is projected across +// ConditionTypes exactly as an outcome would be. Whether a failure is terminal is +// orthogonal to either: it is carried by the Failure itself and adds Stalled. +type Inputs struct { + Generation int64 + ObservedGeneration int64 + Now time.Time + + // ConditionTypes is the set the pass's verdict is projected onto. It is derived + // from the release's own status, which is why the caller computes it. + ConditionTypes []string + + // Forced reports whether this pass was requested through the force reconcile + // annotation. + Forced bool + + Step *Failure + ChartSource *Failure + + // Maintenance is set on the pass that moved the release into or out of + // maintenance mode. Such a pass reports nothing else: the chart is not touched. + Maintenance *services.MaintenanceOutcome + // DiscardForce asks for an in-flight force request to be dropped. A release + // sitting in maintenance can never act on one, and leaving Reconciling behind + // would report work in flight to kstatus forever. + DiscardForce bool + + // Chart and OCIRepo are the two internal source kinds, and they are mutually + // exclusive: a release is served by an internal HelmChart or by an internal + // OCIRepository, never both. + Chart *services.ChartOutcome + OCIRepo *services.OCIRepoOutcome + Release *services.ReleaseOutcome + + // ChartInfoOutdated and ValuesDigest are the two facts the record of what was + // last applied turns on. ValuesDigest is the digest of the values the spec asks + // for, which the deployed revision is compared against. + ChartInfoOutdated bool + ValuesDigest string +} + +// Decision is the full desired status plus the scheduling verdict. Removing a +// condition is expressed by its presence in RemoveConditions; the field writes are +// asked for rather than performed, because they are reached through the release +// adapter and Evaluate holds no adapter. +type Decision struct { + Conditions []metav1.Condition + RemoveConditions []string + + // ObservedGeneration is nil on a pass that reported nothing about the spec it + // was given. A release sitting in maintenance is the case: its spec may have + // changed since, and saying the change was observed while nothing was done about + // it is what would make kstatus call such a release settled. + ObservedGeneration *int64 + + // ForceReconcileTime records that a force request was acted on, not that it + // succeeded: the outcome is carried by Ready. + ForceReconcileTime *metav1.Time + // Reported is the failure behind the verdict this pass wrote, when there is one. + // It is carried out rather than logged where it happened because most release + // failures never reach the work queue: several of them report a fixed message, + // so unless the pass names the cause on its way out it is named nowhere. + Reported *Failure + // ConsumeForce asks for the force annotation to be removed. Only a pass that + // acted on the request, or one that deliberately discarded it, may take it away: + // a pass that failed before the request could be honoured has to leave it for the + // pass that can. Lifting maintenance is the clearest case — the request becomes + // actionable on the very next pass. + ConsumeForce bool + // ApplyChart and ApplyValues ask for the record of what was last applied to be + // advanced to what the spec asks for. + ApplyChart bool + ApplyValues bool + + RequeueAfter time.Duration + Err error +} + +// conditionState is one verdict: the value a condition would carry if this were the +// thing the release reports. It is what Evaluate compares and projects. An internal +// object's own state is already that verdict, so it is carried whole; Err is what +// sits behind a verdict the pass produced itself. +type conditionState struct { + services.InternalObjectState + + Err error +} + +// Evaluate derives the desired release status from the results of a single +// reconcile pass. +func Evaluate(in Inputs) Decision { + observed := max(in.Generation, in.ObservedGeneration) + + decision := Decision{ + ObservedGeneration: &observed, + // Stalled and Reconciling are the two kstatus abnormal-true conditions: by + // that convention each is present only while it applies, so every pass takes + // both away unless it has a reason to write one. A release that recovers must + // not keep reporting that it cannot, and one that settled must not keep + // reporting work in flight. + RemoveConditions: []string{ + helmv1alpha1.ConditionTypeStalled, + helmv1alpha1.ConditionTypeReconciling, + }, + } + + switch { + case in.Step != nil: + decision.set(in, helmv1alpha1.ConditionTypeReady, failureState(*in.Step)) + if in.Step.Terminal { + decision.setAbnormal(in, helmv1alpha1.ConditionTypeStalled, *in.Step) + // Dropped rather than processed, as a release settling into maintenance + // drops one: the stamp means the request was acted on, so it stays + // untouched, while the request itself is taken away. A terminal failure + // leaves no later pass to hand it to, and the annotation is matched on + // presence alone — leaving it behind would make the next force request + // change nothing about the object and so fire no event at all, which is + // exactly how someone who corrected the cause would ask to be retried. + decision.ConsumeForce = true + } else { + decision.setAbnormal(in, helmv1alpha1.ConditionTypeReconciling, Failure{ + Reason: helmv1alpha1.ReasonProgressingWithRetry, + Message: "Retrying the step that failed", + }) + } + decision.Reported = in.Step + decision.Err = retryErr(*in.Step) + decision.RequeueAfter = in.Step.RequeueAfter + + return decision + case in.Maintenance != nil: + state := maintenanceState(*in.Maintenance) + decision.set(in, helmv1alpha1.ConditionTypeManaged, state) + decision.set(in, helmv1alpha1.ConditionTypeReady, state) + decision.Reported = reported(state) + } + + if in.DiscardForce { + // Dropped, not processed: the stamp means the latter, so it stays untouched, + // while the request itself is taken away — replaying one made days earlier the + // moment maintenance is lifted would surprise whoever lifted it. Reconciling + // goes with it, which the default above already arranges: a release in + // maintenance has nothing in flight to report. + decision.ConsumeForce = true + + if in.Maintenance == nil { + // Nothing ran in this pass: the release was already in maintenance and + // stays there, whatever its spec now says. + decision.ObservedGeneration = nil + } + + return decision + } + + if in.Maintenance != nil { + return decision + } + + chart, release := evaluateChart(in), evaluateRelease(in) + + var stalled bool + + verdict, found := decide(chart, release) + if found { + for _, conditionType := range in.ConditionTypes { + decision.set(in, conditionType, verdict) + } + + decision.Reported = reported(verdict) + } + + switch { + case in.OCIRepo != nil && in.OCIRepo.ProbeTerminal: + // The registry rejected the request, or what it serves is not a chart. The + // verdict is about the artifact, so no watch and no timer brings it back: the + // catalog has to publish something else, or the repository has to be fixed. + stalled = true + + decision.setAbnormal(in, helmv1alpha1.ConditionTypeStalled, Failure{ + Reason: in.OCIRepo.ProbeReason, + Message: in.OCIRepo.ProbeMessage, + }) + case found && verdict.Stalled: + // The internal object gave up on the spec it was given, the way the internal + // repository does for its own kind. It has stopped acting on that spec, so it + // goes quiet and the verdict cannot change: only a new spec — a values or + // version edit here — gives it something else to act on. Reported with the + // verdict's own reason, which the error rules made specific, rather than with + // the internal object's count of spent attempts. + stalled = true + + decision.setAbnormal(in, helmv1alpha1.ConditionTypeStalled, Failure{ + Reason: verdict.Reason, + Message: verdict.Message, + }) + } + + if progress, ok := evaluateReconciling(verdict, found, stalled); ok { + decision.setAbnormal(in, helmv1alpha1.ConditionTypeReconciling, progress) + } + + if in.Forced { + decision.ForceReconcileTime = &metav1.Time{Time: in.Now} + decision.ConsumeForce = true + // Reconciling is not taken away here. The pass the force request asked for + // published it before the work began, and whether it stays is the same + // question as on any other pass: it stays while the rollout it kicked off is + // still running, and the pass that sees the release settle removes it. + } + + _, installable := artifactRevision(in) + decision.ApplyChart = installable && release.Ready() && in.ChartInfoOutdated + decision.ApplyValues = release.Ready() && valuesDeployed(in) + decision.RequeueAfter = probeRequeueAfter(in) + + return decision +} + +// evaluateReconciling decides the kstatus progress condition, the way the repository +// package's counterpart of this name does: it is raised while the pass has left +// something to wait for, and it is absent the moment there is nothing. The verdict +// the release reports is the whole input, because that verdict already is "how far +// this pass got". +// +// A terminal failure outranks it, as Stalled outranks Reconciling there: a release +// that cannot proceed is not making progress, and an internal object that gave up is +// exactly such a release — it has stopped acting on the spec it was given. +// Unknown is work genuinely in flight — an internal object still reconciling the spec +// it was given, or one that has not reported on it yet — and the object's own words +// are the most specific thing to show for it. False is a failure that is not +// terminal, so something will come back to it: the watch on the internal object, the +// probe's timer, or the work queue. +func evaluateReconciling(verdict conditionState, found, stalled bool) (Failure, bool) { + if stalled || !found { + return Failure{}, false + } + + switch verdict.Status { + case metav1.ConditionUnknown: + return Failure{Reason: verdict.Reason, Message: verdict.Message}, true + case metav1.ConditionFalse: + return Failure{ + Reason: helmv1alpha1.ReasonProgressingWithRetry, + Message: "Retrying after a failed reconcile", + }, true + default: + return Failure{}, false + } +} + +// decide picks the one verdict the release reports, the way a reader of the status +// would: the first step that did not succeed is the answer, and when every step +// succeeded the last one is. An empty verdict is not an answer — a pass may touch +// only one of the two internal objects. +func decide(states ...conditionState) (conditionState, bool) { + var verdict conditionState + var found bool + + for _, state := range states { + if state.Status == "" || state.Reason == "" { + continue + } + + verdict, found = state, true + if !state.Ready() { + break + } + } + + return verdict, found +} + +// evaluateChart reduces whichever internal source this release needs to one verdict. +// The two kinds are mutually exclusive: a release is served by an internal HelmChart +// or by an internal OCIRepository, never both. +func evaluateChart(in Inputs) conditionState { + switch { + case in.ChartSource != nil: + return failureState(*in.ChartSource) + case in.Chart != nil: + if in.Chart.Err != nil { + return failureState(Failure{ + Reason: helmv1alpha1.ReasonHelmChartFailed, + Message: "Failed to create helm chart", + Err: in.Chart.Err, + }) + } + + return conditionState{InternalObjectState: in.Chart.Internal} + case in.OCIRepo != nil: + return evaluateOCIRepo(*in.OCIRepo) + default: + return conditionState{} + } +} + +func evaluateOCIRepo(out services.OCIRepoOutcome) conditionState { + if out.ProbeErr != nil { + return failureState(Failure{Reason: out.ProbeReason, Message: out.ProbeMessage, Err: out.ProbeErr}) + } + + if out.Err != nil { + return failureState(Failure{ + Reason: helmv1alpha1.ReasonFailed, + Message: "Failed to reconcile oci repository", + Err: out.Err, + }) + } + + state := conditionState{InternalObjectState: out.Internal} + + if out.VersionRemoved && state.Status != metav1.ConditionTrue { + // The version is still recorded — that is what keeps this release + // reconcilable — but the repository no longer offers the tag, so the pull + // cannot succeed. Name that cause instead of leaving only the source + // controller's "not found". + state.Reason = helmv1alpha1.ReasonChartVersionRemoved + state.Message = "Version " + out.Version + " is no longer offered by repository " + + out.RepositoryName + ": " + state.Message + } + + return state +} + +func evaluateRelease(in Inputs) conditionState { + if in.Release == nil { + return conditionState{} + } + + if in.Release.Err != nil { + return failureState(Failure{ + Reason: helmv1alpha1.ReasonReleaseFailed, + Message: "Failed to create helm release", + Err: in.Release.Err, + }) + } + + state := conditionState{InternalObjectState: in.Release.Internal} + + if state.Ready() && !in.Release.ChartDeployed { + // The HelmRelease still reports the readiness of the previous revision: a + // chart-version change moves the artifact without touching its spec. Hold the + // verdict at Reconciling so the record of what was applied, and the conditions + // projected from here, do not advance ahead of the rollout. + return conditionState{InternalObjectState: services.InternalObjectState{ + Status: metav1.ConditionUnknown, + Reason: helmv1alpha1.ReasonReconciling, + }} + } + + return state +} + +func maintenanceState(out services.MaintenanceOutcome) conditionState { + if out.Err != nil { + return failureState(Failure{ + Reason: helmv1alpha1.ReasonFailed, + Message: "Failed to change maintenance mode", + Err: out.Err, + }) + } + + if out.Activated { + return conditionState{InternalObjectState: services.InternalObjectState{ + Observed: true, + Status: metav1.ConditionFalse, + Reason: helmv1alpha1.ReasonMaintenanceModeActive, + Message: "Maintenance mode enabled", + }} + } + + return conditionState{InternalObjectState: services.InternalObjectState{ + Observed: true, + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonMaintenanceModeInactive, + Message: "Maintenance mode disabled", + }} +} + +func failureState(failure Failure) conditionState { + return conditionState{ + InternalObjectState: services.InternalObjectState{ + Observed: true, + Status: metav1.ConditionFalse, + Reason: failure.Reason, + Message: failure.Message, + }, + Err: failure.Err, + } +} + +// reported turns the verdict into the failure the pass logs, if it failed at all. +func reported(state conditionState) *Failure { + if state.Err == nil { + return nil + } + + return &Failure{Reason: state.Reason, Message: state.Message, Err: state.Err} +} + +func retryErr(failure Failure) error { + if !failure.Retry || failure.Terminal { + return nil + } + + return failure.Err +} + +// artifactRevision is the revision the release would be installed from, and whether +// there is one at all. The two internal source kinds are mutually exclusive — a pass +// reconciles one or the other — so which one answered is not asked, and a failed +// pass leaves no artifact behind to be mistaken for one. +func artifactRevision(in Inputs) (string, bool) { + switch { + case in.Chart != nil && in.Chart.Artifact != nil && in.Chart.Internal.Observed: + return in.Chart.Artifact.Revision, true + case in.OCIRepo != nil && in.OCIRepo.Artifact != nil && in.OCIRepo.Internal.Observed: + return in.OCIRepo.Artifact.Revision, true + default: + return "", false + } +} + +// valuesDeployed reports whether the revision the release currently has deployed was +// installed with the values the spec asks for. +func valuesDeployed(in Inputs) bool { + if in.Release == nil { + return false + } + + latest := in.Release.History.Latest() + + return latest != nil && latest.Status == "deployed" && latest.ConfigDigest == in.ValuesDigest +} + +func probeRequeueAfter(in Inputs) time.Duration { + if in.OCIRepo == nil { + return 0 + } + + return in.OCIRepo.ProbeRequeueAfter +} + +func (d *Decision) set(in Inputs, conditionType string, state conditionState) { + if state.Status == "" || state.Reason == "" { + return + } + + apimeta.SetStatusCondition(&d.Conditions, metav1.Condition{ + Type: conditionType, + Status: state.Status, + Reason: state.Reason, + Message: state.Message, + ObservedGeneration: in.Generation, + LastTransitionTime: metav1.NewTime(in.Now), + }) +} + +// setAbnormal raises an abnormal-true condition, which by the kstatus convention is +// present only while it applies. The condition is dropped from RemoveConditions so +// the two do not contradict each other. +func (d *Decision) setAbnormal(in Inputs, conditionType string, failure Failure) { + d.RemoveConditions = slicesDelete(d.RemoveConditions, conditionType) + + apimeta.SetStatusCondition(&d.Conditions, metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionTrue, + Reason: failure.Reason, + Message: failure.Message, + ObservedGeneration: in.Generation, + LastTransitionTime: metav1.NewTime(in.Now), + }) +} + +func slicesDelete(values []string, value string) []string { + kept := values[:0] + for _, v := range values { + if v != value { + kept = append(kept, v) + } + } + + return kept +} diff --git a/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go b/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go new file mode 100644 index 00000000..cc0af5ef --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go @@ -0,0 +1,718 @@ +/* +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 release + +import ( + "errors" + "slices" + "testing" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/pkg/apis/meta" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/services" +) + +func baseInputs() Inputs { + return Inputs{ + Generation: 2, + Now: time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC), + ConditionTypes: []string{helmv1alpha1.ConditionTypeReady, helmv1alpha1.ConditionTypeInstalled}, + } +} + +func condition(t *testing.T, decision Decision, conditionType string) metav1.Condition { + t.Helper() + + cond := apimeta.FindStatusCondition(decision.Conditions, conditionType) + if cond == nil { + t.Fatalf("condition %q was not written, got %+v", conditionType, decision.Conditions) + } + + return *cond +} + +func readyInternal() services.InternalObjectState { + return services.InternalObjectState{ + Observed: true, + Status: metav1.ConditionTrue, + Reason: "Succeeded", + Message: "stored artifact", + } +} + +// TestEvaluateTerminalFailureStalls pins what tells a terminal failure apart from +// every other one: nothing this controller does resolves it, so it is reported as +// Stalled and the pass is not handed back to the work queue. +func TestEvaluateTerminalFailureStalls(t *testing.T) { + in := baseInputs() + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonFailed, + Message: "Target namespace cannot be a system namespace", + Err: errors.New("system namespace"), + Terminal: true, + } + + decision := Evaluate(in) + + ready := condition(t, decision, helmv1alpha1.ConditionTypeReady) + if ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonFailed { + t.Fatalf("Ready = %s/%s, want False/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonFailed) + } + + stalled := condition(t, decision, helmv1alpha1.ConditionTypeStalled) + if stalled.Status != metav1.ConditionTrue || stalled.Message != in.Step.Message { + t.Fatalf("Stalled = %s/%q, want True carrying the cause", stalled.Status, stalled.Message) + } + if slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeStalled) { + t.Fatal("Stalled must not be both written and removed") + } + if decision.Err != nil { + t.Fatalf("Err = %v, want none: retrying cannot change the cause", decision.Err) + } +} + +// TestEvaluateTerminalFailureIsNeverRetried pins that Terminal overrides a Retry the +// caller left set. The two contradict each other, and burning the work queue's rate +// limiter on an attempt whose outcome is already known is the worse reading. +func TestEvaluateTerminalFailureIsNeverRetried(t *testing.T) { + in := baseInputs() + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonForeignAccessObject, + Message: "role team-a/operator-helm-application already exists and is not managed by the operator", + Err: errors.New("foreign role"), + Terminal: true, + Retry: true, + } + + decision := Evaluate(in) + + if decision.Err != nil { + t.Fatalf("Err = %v, want none", decision.Err) + } + condition(t, decision, helmv1alpha1.ConditionTypeStalled) +} + +// TestEvaluateTerminalFailureDropsTheForceRequest pins why a terminal failure takes +// the force annotation away. It is matched on presence alone, so a request left +// behind makes the next one change nothing about the object and fire no event — +// which is exactly how whoever corrected the cause asks to be retried. The stamp is +// not written: it means the request was acted on, and this one was dropped. +func TestEvaluateTerminalFailureDropsTheForceRequest(t *testing.T) { + in := baseInputs() + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonForeignAccessObject, + Message: "role binding team-a/app already exists and is not managed by the operator", + Err: errors.New("foreign binding"), + Terminal: true, + } + + decision := Evaluate(in) + + if !decision.ConsumeForce { + t.Fatal("a terminal failure must drop the force request: no later pass will honour it") + } + if decision.ForceReconcileTime != nil { + t.Fatalf("ForceReconcileTime = %v, want none: the request was dropped, not acted on", decision.ForceReconcileTime) + } + if !slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeReconciling) { + t.Fatal("Reconciling must be taken away: no work is in flight behind a terminal verdict") + } +} + +// TestEvaluateRecoverableFailureKeepsTheForceRequest is the counterpart: a failure +// that can still come good leaves the request for the pass that can honour it. +func TestEvaluateRecoverableFailureKeepsTheForceRequest(t *testing.T) { + in := baseInputs() + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonAccessSetupFailed, + Message: "Failed to set up the release identity", + Err: errors.New("forbidden"), + Retry: true, + } + + if decision := Evaluate(in); decision.ConsumeForce { + t.Fatal("a recoverable failure must leave the force request for the pass that can honour it") + } +} + +// TestEvaluateTerminalProbeVerdictStalls pins the one terminal verdict that does not +// arrive as a Failure: the registry rejected the pull, or what it serves is not a +// chart. The probe already schedules nothing for it, and Stalled is what says so to +// a reader — without it the release looks like one still on its way up. +func TestEvaluateTerminalProbeVerdictStalls(t *testing.T) { + in := baseInputs() + in.OCIRepo = &services.OCIRepoOutcome{ + ProbeErr: errors.New("unauthorized"), + ProbeReason: helmv1alpha1.ReasonAuthenticationFailed, + ProbeMessage: "repository oci://example.com/charts rejected the credentials (HTTP 401)", + ProbeTerminal: true, + } + + decision := Evaluate(in) + + ready := condition(t, decision, helmv1alpha1.ConditionTypeReady) + if ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonAuthenticationFailed { + t.Fatalf("Ready = %s/%s, want False/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonAuthenticationFailed) + } + + stalled := condition(t, decision, helmv1alpha1.ConditionTypeStalled) + if stalled.Status != metav1.ConditionTrue || stalled.Message != in.OCIRepo.ProbeMessage { + t.Fatalf("Stalled = %s/%q, want True carrying the probe verdict", stalled.Status, stalled.Message) + } + if slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeStalled) { + t.Fatal("Stalled must not be both written and removed") + } +} + +// TestEvaluateRecoverableProbeFailureDoesNotStall is the counterpart: a probe that +// failed for a reason that may pass on its own leaves Stalled off and rides the +// timer the probe asked for. +func TestEvaluateRecoverableProbeFailureDoesNotStall(t *testing.T) { + in := baseInputs() + in.OCIRepo = &services.OCIRepoOutcome{ + ProbeErr: errors.New("connection refused"), + ProbeReason: helmv1alpha1.ReasonOCIFetchFailed, + ProbeMessage: "Failed to examine the chart artifact: connection refused", + ProbeRequeueAfter: time.Minute, + } + + decision := Evaluate(in) + + if !slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeStalled) { + t.Fatal("a recoverable probe failure must take Stalled away") + } + if decision.RequeueAfter != time.Minute { + t.Fatalf("RequeueAfter = %v, want the probe's own timer", decision.RequeueAfter) + } +} + +// TestEvaluateStepFailureOwnsReadyAlone pins the reach of a step failure: the pass +// never got as far as the chart, so of the projected conditions it may say the +// release is not ready and nothing more. It is not stalled — a watch on what the +// step needed, or the work queue, wakes the release — and saying so is what the +// progress condition beside Ready is for. +func TestEvaluateStepFailureOwnsReadyAlone(t *testing.T) { + in := baseInputs() + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonAccessSetupFailed, + Message: "Failed to set up the release identity", + Err: errors.New("forbidden"), + Retry: true, + } + + decision := Evaluate(in) + + condition(t, decision, helmv1alpha1.ConditionTypeReady) + for _, conditionType := range []string{ + helmv1alpha1.ConditionTypeInstalled, + helmv1alpha1.ConditionTypeUpdateInstalled, + helmv1alpha1.ConditionTypeConfigurationApplied, + } { + if apimeta.FindStatusCondition(decision.Conditions, conditionType) != nil { + t.Fatalf("%s must not be written by a pass that never reached the chart", conditionType) + } + } + + progress := condition(t, decision, helmv1alpha1.ConditionTypeReconciling) + if progress.Status != metav1.ConditionTrue || progress.Reason != helmv1alpha1.ReasonProgressingWithRetry { + t.Fatalf("Reconciling = %s/%s, want True/%s", + progress.Status, progress.Reason, helmv1alpha1.ReasonProgressingWithRetry) + } + if slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeReconciling) { + t.Fatal("Reconciling must not be both written and removed") + } + + if !slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeStalled) { + t.Fatal("a recoverable failure must take Stalled away") + } + if !errors.Is(decision.Err, in.Step.Err) { + t.Fatalf("Err = %v, want the step's error handed to the work queue", decision.Err) + } +} + +// TestEvaluateReportsWorkInFlight pins the progress condition on the ordinary path: +// an internal object that has not yet reported on the spec it was given is work in +// flight, and the release says so with that object's own words rather than a fixed +// message of its own. +func TestEvaluateReportsWorkInFlight(t *testing.T) { + in := baseInputs() + in.Chart = &services.ChartOutcome{ + Artifact: &meta.Artifact{Revision: "6.7.1"}, + Internal: services.InternalObjectState{ + Status: metav1.ConditionUnknown, + Reason: helmv1alpha1.ReasonReconciling, + Message: "pulling the chart", + }, + } + + decision := Evaluate(in) + + progress := condition(t, decision, helmv1alpha1.ConditionTypeReconciling) + if progress.Status != metav1.ConditionTrue || progress.Reason != helmv1alpha1.ReasonReconciling { + t.Fatalf("Reconciling = %s/%s, want True/%s", + progress.Status, progress.Reason, helmv1alpha1.ReasonReconciling) + } + if progress.Message != "pulling the chart" { + t.Fatalf("Reconciling message = %q, want the internal object's own", progress.Message) + } + if slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeReconciling) { + t.Fatal("Reconciling must not be both written and removed") + } +} + +// TestEvaluateReportsARetryAfterAFailedRollout pins the other half: a failure that +// is not terminal has something coming back to it, so the release keeps reporting +// progress beside the failure itself. +func TestEvaluateReportsARetryAfterAFailedRollout(t *testing.T) { + in := baseInputs() + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{Revision: "6.7.1"}, Internal: readyInternal()} + in.Release = &services.ReleaseOutcome{ + ChartDeployed: true, + Internal: services.InternalObjectState{ + Observed: true, + Status: metav1.ConditionFalse, + Reason: helmv1alpha1.ReasonReleaseFailed, + Message: "upgrade failed", + }, + } + + decision := Evaluate(in) + + progress := condition(t, decision, helmv1alpha1.ConditionTypeReconciling) + if progress.Status != metav1.ConditionTrue || progress.Reason != helmv1alpha1.ReasonProgressingWithRetry { + t.Fatalf("Reconciling = %s/%s, want True/%s", + progress.Status, progress.Reason, helmv1alpha1.ReasonProgressingWithRetry) + } +} + +// TestEvaluateGivenUpReleaseStallsInsteadOfReportingProgress is the case seen on a +// test cluster: values the chart cannot render leave the internal release Stalled +// after its remediation attempts are spent. It has stopped acting on that spec, so +// the verdict cannot change until an edit to the release gives it a new one. +// Reporting a retry there parks the addon at InProgress for good, because kstatus +// reads Reconciling before Ready. +func TestEvaluateGivenUpReleaseStallsInsteadOfReportingProgress(t *testing.T) { + const cause = "Helm upgrade failed for release default/podinfo: .spec.replicas: expected numeric, got string" + + in := baseInputs() + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{Revision: "6.15.0"}, Internal: readyInternal()} + in.Release = &services.ReleaseOutcome{ + Internal: services.InternalObjectState{ + Observed: true, + Stalled: true, + Status: metav1.ConditionFalse, + Reason: helmv1alpha1.ReasonReleaseFailed, + Message: cause, + }, + } + + decision := Evaluate(in) + + stalled := condition(t, decision, helmv1alpha1.ConditionTypeStalled) + if stalled.Status != metav1.ConditionTrue || stalled.Reason != helmv1alpha1.ReasonReleaseFailed { + t.Fatalf("Stalled = %s/%s, want True/%s", + stalled.Status, stalled.Reason, helmv1alpha1.ReasonReleaseFailed) + } + if stalled.Message != cause { + t.Fatalf("Stalled message = %q, want the fault the internal object named", stalled.Message) + } + if slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeStalled) { + t.Fatal("Stalled must not be both written and removed") + } + + if apimeta.FindStatusCondition(decision.Conditions, helmv1alpha1.ConditionTypeReconciling) != nil { + t.Fatal("a release whose internal object gave up must not report a retry that is not coming") + } + if !slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeReconciling) { + t.Fatal("Reconciling must be taken away once the internal object gave up") + } +} + +// TestEvaluateFailingReleaseStillReportsProgress is the counterpart that keeps the +// rule above from swallowing the ordinary case: the same failure, with the internal +// object still willing to retry, is progress. +func TestEvaluateFailingReleaseStillReportsProgress(t *testing.T) { + in := baseInputs() + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{Revision: "6.15.0"}, Internal: readyInternal()} + in.Release = &services.ReleaseOutcome{ + Internal: services.InternalObjectState{ + Observed: true, + Status: metav1.ConditionFalse, + Reason: helmv1alpha1.ReasonReleaseFailed, + Message: "Helm upgrade failed", + }, + } + + decision := Evaluate(in) + + if apimeta.FindStatusCondition(decision.Conditions, helmv1alpha1.ConditionTypeStalled) != nil { + t.Fatal("a failure the internal object has not given up on must not stall the release") + } + progress := condition(t, decision, helmv1alpha1.ConditionTypeReconciling) + if progress.Reason != helmv1alpha1.ReasonProgressingWithRetry { + t.Fatalf("Reconciling reason = %q, want %q", progress.Reason, helmv1alpha1.ReasonProgressingWithRetry) + } +} + +// TestEvaluateSettledReleaseReportsNoProgress pins the removal: nothing is in +// flight, so the abnormal-true condition has to go, or kstatus reads a healthy +// release as one that never finishes. +func TestEvaluateSettledReleaseReportsNoProgress(t *testing.T) { + in := baseInputs() + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{Revision: "6.7.1"}, Internal: readyInternal()} + in.Release = &services.ReleaseOutcome{ChartDeployed: true, Internal: readyInternal()} + + decision := Evaluate(in) + + if apimeta.FindStatusCondition(decision.Conditions, helmv1alpha1.ConditionTypeReconciling) != nil { + t.Fatal("a settled release must not report work in flight") + } + if !slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeReconciling) { + t.Fatal("Reconciling must be taken away once the release settled") + } +} + +// TestEvaluateStalledReleaseReportsNoProgress pins that Stalled outranks the +// progress condition, as it does in the repository package: a release that cannot +// proceed is not making progress, and reporting both would say two things at once. +func TestEvaluateStalledReleaseReportsNoProgress(t *testing.T) { + in := baseInputs() + in.OCIRepo = &services.OCIRepoOutcome{ + ProbeErr: errors.New("unauthorized"), + ProbeReason: helmv1alpha1.ReasonAuthenticationFailed, + ProbeMessage: "repository oci://example.com/charts rejected the credentials (HTTP 401)", + ProbeTerminal: true, + } + + decision := Evaluate(in) + + condition(t, decision, helmv1alpha1.ConditionTypeStalled) + if apimeta.FindStatusCondition(decision.Conditions, helmv1alpha1.ConditionTypeReconciling) != nil { + t.Fatal("a stalled release must not also report work in flight") + } + if !slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeReconciling) { + t.Fatal("Reconciling must be taken away behind a terminal verdict") + } +} + +func TestEvaluateStepFailureWithoutRetryIsNotReturned(t *testing.T) { + in := baseInputs() + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonChartClaimConflict, + Message: "chart is already used", + RequeueAfter: chartClaimConflictRequeueInterval, + } + + decision := Evaluate(in) + + if decision.Err != nil { + t.Fatalf("Err = %v, want none: the recovery rides on the timer", decision.Err) + } + if decision.RequeueAfter != chartClaimConflictRequeueInterval { + t.Fatalf("RequeueAfter = %v, want %v", decision.RequeueAfter, chartClaimConflictRequeueInterval) + } +} + +// TestEvaluateProjectsTheFirstFailingStep pins the rule a reader of the status +// applies: the verdict is the first step that did not succeed, and it reaches every +// condition type the release currently reports on. +func TestEvaluateProjectsTheFirstFailingStep(t *testing.T) { + in := baseInputs() + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{Revision: "6.7.1"}, Internal: readyInternal()} + in.Release = &services.ReleaseOutcome{ + Internal: services.InternalObjectState{ + Observed: true, + Status: metav1.ConditionFalse, + Reason: helmv1alpha1.ReasonReleaseFailed, + Message: "install retries exhausted", + }, + ChartDeployed: true, + } + + decision := Evaluate(in) + + for _, conditionType := range in.ConditionTypes { + cond := condition(t, decision, conditionType) + if cond.Status != metav1.ConditionFalse || cond.Reason != helmv1alpha1.ReasonReleaseFailed { + t.Fatalf("%s = %s/%s, want the failing release's verdict", conditionType, cond.Status, cond.Reason) + } + if cond.ObservedGeneration != in.Generation { + t.Fatalf("%s observedGeneration = %d, want %d", conditionType, cond.ObservedGeneration, in.Generation) + } + } +} + +// TestEvaluateHoldsAReleaseThatHasNotRolledOut pins why the HelmRelease's own Ready +// is not enough: a chart-version change moves the artifact without touching its +// spec, so it can go on reporting the previous revision ready. +func TestEvaluateHoldsAReleaseThatHasNotRolledOut(t *testing.T) { + in := baseInputs() + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{Revision: "6.7.2"}, Internal: readyInternal()} + in.Release = &services.ReleaseOutcome{Internal: readyInternal(), ChartDeployed: false} + in.ChartInfoOutdated = true + + decision := Evaluate(in) + + ready := condition(t, decision, helmv1alpha1.ConditionTypeReady) + if ready.Status != metav1.ConditionUnknown || ready.Reason != helmv1alpha1.ReasonReconciling { + t.Fatalf("Ready = %s/%s, want Unknown/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonReconciling) + } + if decision.ApplyChart { + t.Fatal("the record of what was applied must not advance ahead of the rollout") + } +} + +func TestEvaluateRecordsWhatWasApplied(t *testing.T) { + in := baseInputs() + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{Revision: "6.7.1"}, Internal: readyInternal()} + in.Release = &services.ReleaseOutcome{ + Internal: readyInternal(), + ChartDeployed: true, + History: helmv2.Snapshots{{Status: "deployed", ConfigDigest: "sha256:values"}}, + } + in.ChartInfoOutdated = true + in.ValuesDigest = "sha256:values" + + decision := Evaluate(in) + + if !decision.ApplyChart { + t.Fatal("a rolled-out chart must be recorded as applied") + } + if !decision.ApplyValues { + t.Fatal("values the deployed revision was installed with must be recorded as applied") + } + + in.ValuesDigest = "sha256:other" + if Evaluate(in).ApplyValues { + t.Fatal("values the deployed revision was not installed with must not be recorded") + } +} + +// TestEvaluateNamesARemovedVersion pins that the cause is named only while it +// explains something: the marker outlives the tag's absence, so a child that is +// healthy again must not be relabelled with it. +func TestEvaluateNamesARemovedVersion(t *testing.T) { + in := baseInputs() + in.OCIRepo = &services.OCIRepoOutcome{ + Internal: services.InternalObjectState{ + Observed: true, + Status: metav1.ConditionFalse, + Reason: "ArtifactPullFailed", + Message: "not found", + }, + VersionRemoved: true, + Version: "6.7.1", + RepositoryName: "stable", + } + + cond := condition(t, Evaluate(in), helmv1alpha1.ConditionTypeReady) + if cond.Reason != helmv1alpha1.ReasonChartVersionRemoved { + t.Fatalf("reason = %q, want %q", cond.Reason, helmv1alpha1.ReasonChartVersionRemoved) + } + + in.OCIRepo.Internal = readyInternal() + cond = condition(t, Evaluate(in), helmv1alpha1.ConditionTypeReady) + if cond.Reason != "Succeeded" { + t.Fatalf("reason = %q, want the healthy child's own reason untouched", cond.Reason) + } +} + +func TestEvaluateForcedPassStampsAndConsumesTheRequest(t *testing.T) { + in := baseInputs() + in.Forced = true + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{Revision: "6.7.1"}, Internal: readyInternal()} + + decision := Evaluate(in) + + if decision.ForceReconcileTime == nil || !decision.ForceReconcileTime.Time.Equal(in.Now) { + t.Fatalf("ForceReconcileTime = %v, want %v", decision.ForceReconcileTime, in.Now) + } + if !decision.ConsumeForce { + t.Fatal("a pass that acted on the request must take it away") + } + if !slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeReconciling) { + t.Fatal("the progress condition the forced pass raised must be removed again") + } +} + +// TestEvaluateMaintenanceReportsItselfAlone pins that a pass which only moved the +// release into maintenance says so on Managed and Ready and nothing else: it never +// touched the chart, so it has nothing to report about installing it. +func TestEvaluateMaintenanceReportsItselfAlone(t *testing.T) { + in := baseInputs() + in.Maintenance = &services.MaintenanceOutcome{Activated: true} + + decision := Evaluate(in) + + managed := condition(t, decision, helmv1alpha1.ConditionTypeManaged) + if managed.Status != metav1.ConditionFalse || managed.Reason != helmv1alpha1.ReasonMaintenanceModeActive { + t.Fatalf("Managed = %s/%s", managed.Status, managed.Reason) + } + ready := condition(t, decision, helmv1alpha1.ConditionTypeReady) + if ready.Reason != helmv1alpha1.ReasonMaintenanceModeActive { + t.Fatalf("Ready reason = %q, want the maintenance verdict", ready.Reason) + } + if apimeta.FindStatusCondition(decision.Conditions, helmv1alpha1.ConditionTypeInstalled) != nil { + t.Fatal("a maintenance pass must not speak about installing the chart") + } +} + +// TestEvaluateMaintenanceHoldLeavesTheObservedGenerationBehind pins the one pass +// that reports nothing about the spec it was given: a release already in maintenance +// stays there whatever its spec now says, and claiming the change was observed is +// what would make kstatus call it settled. +func TestEvaluateMaintenanceHoldLeavesTheObservedGenerationBehind(t *testing.T) { + in := baseInputs() + in.ObservedGeneration = 1 + in.DiscardForce = true + + decision := Evaluate(in) + + if decision.ObservedGeneration != nil { + t.Fatalf("ObservedGeneration = %d, want it left where it was", *decision.ObservedGeneration) + } + if !decision.ConsumeForce { + t.Fatal("a request that can never be acted on must be dropped") + } + if !slices.Contains(decision.RemoveConditions, helmv1alpha1.ConditionTypeReconciling) { + t.Fatal("Reconciling must not be left reporting work in flight forever") + } +} + +func TestEvaluateAdvancesTheObservedGeneration(t *testing.T) { + in := baseInputs() + in.ObservedGeneration = 1 + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{Revision: "6.7.1"}, Internal: readyInternal()} + + decision := Evaluate(in) + + if decision.ObservedGeneration == nil || *decision.ObservedGeneration != in.Generation { + t.Fatalf("ObservedGeneration = %v, want %d", decision.ObservedGeneration, in.Generation) + } +} + +// TestEvaluateReportsTheCauseOfEveryVerdict pins that whichever kind of failure +// produced the verdict, the pass carries the cause out to be logged. Most release +// failures never reach the work queue, and several report a fixed message, so a +// verdict whose cause is not carried out is a cause nobody can read. +func TestEvaluateReportsTheCauseOfEveryVerdict(t *testing.T) { + cause := errors.New("boom") + + tests := []struct { + name string + build func(Inputs) Inputs + }{ + { + name: "terminal failure", + build: func(in Inputs) Inputs { + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonFailed, Message: "bad spec", Err: cause, Terminal: true, + } + + return in + }, + }, + { + name: "step failure", + build: func(in Inputs) Inputs { + in.Step = &Failure{Reason: helmv1alpha1.ReasonFailed, Message: "step failed", Err: cause} + + return in + }, + }, + { + name: "maintenance write failure", + build: func(in Inputs) Inputs { + in.Maintenance = &services.MaintenanceOutcome{Err: cause} + + return in + }, + }, + { + name: "chart write failure", + build: func(in Inputs) Inputs { + in.Chart = &services.ChartOutcome{Err: cause} + + return in + }, + }, + { + name: "chart source could not be resolved", + build: func(in Inputs) Inputs { + in.ChartSource = &Failure{ + Reason: helmv1alpha1.ReasonChartFetchFailed, + Message: "Failed to resolve the desired chart version", + Err: cause, + } + + return in + }, + }, + { + name: "release write failure", + build: func(in Inputs) Inputs { + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{}, Internal: readyInternal()} + in.Release = &services.ReleaseOutcome{Err: cause} + + return in + }, + }, + { + name: "artifact probe failure", + build: func(in Inputs) Inputs { + in.OCIRepo = &services.OCIRepoOutcome{ + ProbeErr: cause, + ProbeReason: helmv1alpha1.ReasonOCIFetchFailed, + ProbeMessage: "Failed to examine the chart artifact", + } + + return in + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decision := Evaluate(tt.build(baseInputs())) + + if decision.Reported == nil { + t.Fatal("the cause behind the verdict was not carried out") + } + if !errors.Is(decision.Reported.Err, cause) { + t.Fatalf("Reported.Err = %v, want %v", decision.Reported.Err, cause) + } + if decision.Reported.Reason == "" || decision.Reported.Message == "" { + t.Fatalf("Reported = %+v, want it named", *decision.Reported) + } + }) + } +} + +func TestEvaluateReportsNothingWhenNothingFailed(t *testing.T) { + in := baseInputs() + in.Chart = &services.ChartOutcome{Artifact: &meta.Artifact{}, Internal: readyInternal()} + in.Release = &services.ReleaseOutcome{Internal: readyInternal(), ChartDeployed: true} + + if decision := Evaluate(in); decision.Reported != nil { + t.Fatalf("Reported = %+v, want none on a healthy pass", *decision.Reported) + } +} diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler.go b/images/operator-helm-controller/internal/reconcile/release/reconciler.go new file mode 100644 index 00000000..c595cebb --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler.go @@ -0,0 +1,598 @@ +/* +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 release + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/fluxcd/pkg/chartutil" + "github.com/opencontainers/go-digest" + helmcommon "helm.sh/helm/v4/pkg/chart/common" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/chartsource" + "github.com/deckhouse/operator-helm/internal/reconcile/pass" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +// chartClaimConflictRequeueInterval bounds how often a release that lost the claim +// on its repository/chart pair re-checks whether the owner has released it. There +// is no watch on the claim Lease, so this periodic requeue is what lets a duplicate +// recover once the conflicting release is deleted or repointed at another chart. +const chartClaimConflictRequeueInterval = 30 * time.Second + +// Deps are the collaborators of one release kind. The services are shared by every +// kind; the rest is what tells the families apart: how the API object is read +// (NewRelease), how its repository and catalog are found (Repositories), whether a +// repository/chart pair is claimed (Claim), whether the target namespace is created +// (Namespaces) and which identity the chart is applied with (Access). +type Deps struct { + NewRelease func() source.Release + Repositories RepositoryResolver + Chart ChartManager + OCI OCIRepoManager + Release ReleaseManager + Maintenance MaintenanceManager + Claim ChartClaim + Namespaces TargetNamespaceEnsurer + Access AccessManager + Status *status.Manager +} + +func New(c client.Client, deps Deps) *Reconciler { + return &Reconciler{Client: c, deps: deps} +} + +type Reconciler struct { + client.Client + + deps Deps +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + rel := r.deps.NewRelease() + if err := r.Get(ctx, req.NamespacedName, rel.Object()); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcile.Result{}, fmt.Errorf("getting release: %w", err) + } + + if !rel.Object().GetDeletionTimestamp().IsZero() { + return r.reconcileDelete(ctx, rel) + } + + in := Inputs{ + Generation: rel.Generation(), + ObservedGeneration: rel.Object().GetObservedGeneration(), + Now: time.Now().UTC(), + ConditionTypes: rel.Object().GetConditionTypesForUpdate(), + } + + // Claim the repository/chart pair before anything else, including adding the + // finalizer. The claim is the authoritative, race-free guard on uniqueness (the + // webhook only fast-rejects the obvious duplicate on CREATE and cannot stop + // concurrent creates from racing past it). It must run before the finalizer + // because a duplicate that loses the race must not accrue a finalizer it would + // otherwise have to clean up: it simply surfaces the conflict on its status and + // requeues, recovering on its own once the owner releases the pair. + acquired, holder, err := r.deps.Claim.Acquire(ctx, rel) + if err != nil { + return reconcile.Result{}, fmt.Errorf("acquiring chart claim: %w", err) + } + if !acquired { + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonChartClaimConflict, + Message: fmt.Sprintf("chart %q is already used by %s/%s", + rel.ChartRef().Chart, strings.ToLower(rel.Kind()), holder), + // No watch fires when the owner lets the pair go, so the recovery of a + // duplicate rides on this timer alone. + RequeueAfter: chartClaimConflictRequeueInterval, + } + + return r.finish(ctx, rel, in) + } + + if utils.IsSystemNamespace(rel.TargetNamespace()) { + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonFailed, + Message: "Target namespace cannot be a system namespace", + Err: fmt.Errorf("target namespace %q is a system namespace", rel.TargetNamespace()), + Terminal: true, + } + + return r.finish(ctx, rel, in) + } + + if !controllerutil.ContainsFinalizer(rel.Object(), helmv1alpha1.FinalizerName) { + controllerutil.AddFinalizer(rel.Object(), helmv1alpha1.FinalizerName) + if err := r.Update(ctx, rel.Object()); err != nil { + return reconcile.Result{}, fmt.Errorf("adding finalizer: %w", err) + } + // Continue reconciling in the same pass: adding a finalizer is a + // metadata-only change that does not bump generation, so the resulting + // update event is dropped by the generation/annotation predicates and + // would not trigger a follow-up reconcile. + } + + if err := r.deps.Claim.ReleaseStale(ctx, rel); err != nil { + return reconcile.Result{}, fmt.Errorf("releasing stale chart claims: %w", err) + } + + if r.deps.Maintenance.IsMaintenanceModeChangeRequired(rel) { + outcome := r.deps.Maintenance.EnsureMaintenanceMode(ctx, rel) + in.Maintenance = &outcome + // Maintenance being lifted leaves a pending force request in place for the + // pass that can honour it; a release settling into maintenance can never act + // on one, so it is dropped. + in.DiscardForce = rel.MaintenanceActivated() + + return r.finish(ctx, rel, in) + } + + if rel.MaintenanceActivated() { + in.DiscardForce = true + + return r.finish(ctx, rel, in) + } + + repo, catalog, err := r.deps.Repositories.Resolve(ctx, rel.ChartRef().Repository) + if err != nil { + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonFailed, + Message: "Failed to get internal repository", + Err: fmt.Errorf("getting internal repository: %w", err), + } + + return r.finish(ctx, rel, in) + } + + repoType, err := chartsource.KindOf(repo.URL()) + if err != nil { + // The repository's own url is what cannot be read, and the repository reports + // the same fault as Stalled. Retrying here would only rediscover it; the + // release comes back when the repository's generation changes, which is what + // correcting the url does. + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonUnsupportedRepositoryType, + Message: fmt.Sprintf("Failed to parse repository type: %s", err.Error()), + Err: err, + Terminal: true, + } + + return r.finish(ctx, rel, in) + } + + if err := r.deps.Namespaces.EnsureTargetNamespace(ctx, rel); err != nil { + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonFailed, + Message: fmt.Sprintf("Failed to reconcile target namespace: %s", err.Error()), + Err: err, + } + + return r.finish(ctx, rel, in) + } + + // The identity comes before the internal sources: helm-controller checks that + // the account named on the HelmRelease exists before it impersonates it, so a + // HelmRelease created ahead of its ServiceAccount would fail its first pass. + if access := r.deps.Access.EnsureAccess(ctx, rel); access.Err != nil { + in.Step = &Failure{ + Reason: access.Reason, + Message: access.Message, + Err: access.Err, + Terminal: access.Terminal, + // The watches on the Role and the RoleBinding only fire on a write that + // landed, so a step that failed before writing anything comes back through + // the work queue's rate limiter and nothing else. A terminal failure is the + // exception: the object in the way carries no managed-by label, so those + // watches never see it go either — only a force request or an edit to the + // release gets this pass run again. + Retry: !access.Terminal, + } + + return r.finish(ctx, rel, in) + } + + // From here on every path reaches finish, which is what consumes the force + // request. Marking earlier would leave the progress condition behind on a + // validation failure that never consumes it. + in.Forced = rel.ForceReconcileRequired() + if in.Forced { + if err := r.markForceReconcileInProgress(ctx, rel); err != nil { + return reconcile.Result{}, err + } + } + + _, chartVersion, chartErr := r.getChartVersion(ctx, catalog, repo, rel, repoType) + + // The source is resolved once, before the branches: which internal object a + // release needs is a property of the version it asks for, and a version whose + // source cannot be resolved is as unusable as a version that is missing. + var src chartsource.Source + if chartErr == nil { + src, chartErr = chartsource.Resolve(repo.URL(), chartVersion) + } + + names := rel.InternalNames() + + switch { + case chartErr != nil: + // One report for both branches: until the source is known, neither internal + // object may be touched, and which one would have been touched is precisely + // what could not be determined. + in.ChartSource = &Failure{ + Reason: helmv1alpha1.ReasonChartFetchFailed, + Message: "Failed to resolve the desired chart version", + Err: chartErr, + } + case src.Kind == chartsource.Helm: + // The version may have moved out of a registry — either because the user + // repointed the repository, or because the index re-published it as an + // archive. Either way the internal OCIRepository is no longer the source. + superseded, err := r.deps.OCI.RemoveOCIRepository(ctx, names) + if err != nil { + in.ChartSource = repositoryChangeFailure(err) + + break + } + + r.logSourceKindFlip(ctx, rel, src.Kind, superseded != nil) + + outcome := r.deps.Chart.EnsureHelmChart(ctx, rel, repo) + in.Chart = &outcome + case src.Kind == chartsource.OCI: + superseded, err := r.deps.Chart.CleanupHelmChart(ctx, names) + if err != nil { + in.ChartSource = repositoryChangeFailure(err) + + break + } + + r.logSourceKindFlip(ctx, rel, src.Kind, superseded != nil) + + outcome := r.deps.OCI.EnsureInternalOCIRepository(ctx, rel, repo, src, chartVersion) + in.OCIRepo = &outcome + default: + in.Step = &Failure{ + Reason: helmv1alpha1.ReasonFailed, + Message: fmt.Sprintf("Unsupported chart source: %s", src.Kind), + Err: fmt.Errorf("unsupported chart source: %s", src.Kind), + Terminal: true, + } + + return r.finish(ctx, rel, in) + } + + if revision, ok := artifactRevision(in); ok { + outcome := r.deps.Release.EnsureHelmRelease(ctx, rel, src.Kind, revision) + in.Release = &outcome + } + + in.ChartInfoOutdated = rel.IsChartStatusInfoOutdated() + in.ValuesDigest = valuesDigest(rel) + + return r.finish(ctx, rel, in) +} + +// finish applies the decision and consumes the force annotation. The annotation is +// removed after the status patch so a conflict does not lose the request. +func (r *Reconciler) finish(ctx context.Context, rel source.Release, in Inputs) (reconcile.Result, error) { + decision := Evaluate(in) + + if decision.Reported != nil { + // Only the access failure is handed to the work queue, which logs it on the + // way past; every other failure ends the pass quietly, so this is the one + // place its cause is written down. + log.FromContext(ctx).Error(decision.Reported.Err, decision.Reported.Message, + "reason", decision.Reported.Reason) + } + + if err := r.deps.Status.PatchStatus(ctx, rel.Object(), func() { + applyDecision(rel, decision) + }); client.IgnoreNotFound(err) != nil { + return reconcile.Result{}, fmt.Errorf("failed to update status: %w", err) + } + + if decision.ConsumeForce { + if err := pass.ConsumeForceAnnotation(ctx, r.Client, client.ObjectKeyFromObject(rel.Object()), r.deps.NewRelease().Object()); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to reconcile force annotation: %w", err) + } + } + + return reconcile.Result{RequeueAfter: decision.RequeueAfter}, decision.Err +} + +// applyDecision writes the evaluated status onto the release. Conditions are merged +// rather than replaced: a pass reports on the steps it ran, and the verdicts of the +// steps it did not run stay where they are. +func applyDecision(rel source.Release, decision Decision) { + conditions := rel.Object().GetConditions() + + for _, condition := range decision.Conditions { + apimeta.SetStatusCondition(conditions, condition) + } + + for _, conditionType := range decision.RemoveConditions { + apimeta.RemoveStatusCondition(conditions, conditionType) + } + + if decision.ObservedGeneration != nil { + rel.Object().SetObservedGeneration(*decision.ObservedGeneration) + } + + if decision.ForceReconcileTime != nil { + rel.SetLastForceReconcileTime(*decision.ForceReconcileTime) + } + + if decision.ApplyChart { + rel.SetLastAppliedChart(rel.ChartRef()) + } + + if decision.ApplyValues { + if rel.Values() == nil { + rel.SetLastAppliedValues(nil) + } else { + rel.SetLastAppliedValues(rel.Values().DeepCopy()) + } + } +} + +// repositoryChangeFailure reports a failure to remove the internal source the +// release no longer needs. Nothing may be installed while both kinds are present. +func repositoryChangeFailure(err error) *Failure { + return &Failure{Reason: helmv1alpha1.ReasonFailed, Message: "Repository change failed", Err: err} +} + +// valuesDigest is the digest of the values the spec asks for, in the form the +// deployed revision records the values it was installed with. +func valuesDigest(rel source.Release) string { + rawValues := []byte(`{}`) + if rel.Values() != nil { + rawValues = rel.Values().Raw + } + + values, _ := helmcommon.ReadValues(rawValues) + + return chartutil.DigestValues(digest.Canonical, values).String() +} + +func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (reconcile.Result, error) { + logger := log.FromContext(ctx) + + if !controllerutil.ContainsFinalizer(rel.Object(), helmv1alpha1.FinalizerName) { + return reconcile.Result{}, nil + } + + names := rel.InternalNames() + + // The finalizer must stay until the internal resources are actually gone. + // A Delete only sets a deletion timestamp; the downstream controllers keep + // their finalizers until they finish tearing the underlying release/source + // down. Removing our finalizer earlier would delete the release object and + // orphan a HelmRelease that helm-controller never managed to uninstall. + // + // The release is uninstalled first; only once it is gone do we remove the + // chart/repository sources it referenced, and only after those the identity + // helm-controller uninstalled with. Each step waits for the resource to + // actually disappear and surfaces the blocking resource's readiness on the + // release object so the reason a deletion stalls is observable. + release, err := r.deps.Release.CleanupHelmRelease(ctx, names) + if err != nil { + return reconcile.Result{}, err + } + if release != nil { + // The release object is a facade over the HelmRelease: a bad spec parameter + // that blocks helm uninstall is propagated into the release. Keep re-applying + // the (possibly corrected) spec to the still-present release so the + // uninstall can be fixed via the release object even while it is being deleted. + if err := r.deps.Release.SyncReleaseSpec(ctx, rel, release); err != nil { + return reconcile.Result{}, err + } + return pass.AwaitInternalResourceDeletion(ctx, r.deps.Status.MarkUninstallPending, rel.Object(), "internal release", release) + } + + chart, err := r.deps.Chart.CleanupHelmChart(ctx, names) + if err != nil { + return reconcile.Result{}, err + } + if chart != nil { + return pass.AwaitInternalResourceDeletion(ctx, r.deps.Status.MarkUninstallPending, rel.Object(), "internal chart", chart) + } + + ociRepo, err := r.deps.OCI.RemoveOCIRepository(ctx, names) + if err != nil { + return reconcile.Result{}, err + } + if ociRepo != nil { + return pass.AwaitInternalResourceDeletion(ctx, r.deps.Status.MarkUninstallPending, rel.Object(), "internal repository", ociRepo) + } + + // The identity goes last: helm-controller uninstalls as that account, so it + // has to outlive the HelmRelease. + if err := r.deps.Access.CleanupAccess(ctx, rel); err != nil { + // By this point the internal release and sources are already gone, so + // nothing else on the object would explain why the finalizer is still + // there. The write is best-effort, same as the internal-resource waits + // above: the returned error is what gets this retried. + _ = r.deps.Status.MarkDeletionFailed(ctx, rel.Object(), "release identity", err) + return reconcile.Result{}, fmt.Errorf("cleaning up release identity: %w", err) + } + + // Release the claim only once every downstream resource is gone: releasing it + // earlier would let another release start reconciling the same chart while this + // one's HelmRelease is still being uninstalled — exactly the collision the claim + // prevents. + if err := r.deps.Claim.Release(ctx, rel); err != nil { + return reconcile.Result{}, fmt.Errorf("releasing chart claim: %w", err) + } + + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := r.deps.NewRelease() + if err := r.Get(ctx, client.ObjectKeyFromObject(rel.Object()), latest.Object()); err != nil { + return client.IgnoreNotFound(err) + } + + if controllerutil.RemoveFinalizer(latest.Object(), helmv1alpha1.FinalizerName) { + if err := r.Update(ctx, latest.Object()); err != nil { + return err + } + } + return nil + }); err != nil { + return reconcile.Result{}, fmt.Errorf("removing finalizer: %w", err) + } + + logger.Info("Cleanup complete") + + return reconcile.Result{}, nil +} + +// markForceReconcileInProgress publishes Reconciling before the work a force +// request asks for begins. A forced pass is the one case where someone is +// watching: they annotated the object a moment ago and want to see it was picked +// up. The condition is removed again by the status update that ends the pass. +func (r *Reconciler) markForceReconcileInProgress(ctx context.Context, rel source.Release) error { + err := r.deps.Status.PatchStatus(ctx, rel.Object(), func() { + apimeta.SetStatusCondition(rel.Object().GetConditions(), metav1.Condition{ + Type: helmv1alpha1.ConditionTypeReconciling, + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonForceReconcile, + Message: "Forced reconciliation in progress", + ObservedGeneration: rel.Generation(), + }) + }) + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("publishing forced reconciliation progress: %w", err) + } + + return nil +} + +// getChartVersion resolves the catalog entry for the version the release asks for +// and rejects an entry that cannot be deployed. Two things make an entry unusable: +// an index reference that cannot be addressed, and — for a version of an oci:// +// repository — a missing media type, which is exactly "the catalog does not yet +// know enough to build the internal OCIRepository". A version published in a +// registry by a helm index carries no media type by design: its artifact is examined +// at deploy time, so the second rule does not apply to it. +// +// A version retained after its tag disappeared keeps both its media type and its +// reference, so this gate stays open for it and the release keeps reconciling +// everything else — its values, its maintenance mode, its removal. +func (r *Reconciler) getChartVersion( + ctx context.Context, + catalog source.Catalog, + repo source.Repository, + rel source.Release, + repoType chartsource.Kind, +) (client.Object, *helmv1alpha1.ChartVersion, error) { + ref := rel.ChartRef() + + obj, catalogStatus, err := catalog.Lookup(ctx, repo, ref.Chart) + if err != nil { + return nil, nil, fmt.Errorf("getting chart catalog entry: %w", err) + } + + for i := range catalogStatus.Versions { + version := &catalogStatus.Versions[i] + if version.Version != ref.Version { + continue + } + + if version.UnavailableReason == helmv1alpha1.UnavailableReasonInvalidChartReference { + // The index publishes this version in a registry at a reference that + // cannot be addressed. Without this the version would fall back to the + // helm path and fail on the very same url, reported by the source + // controller as an opaque fetch error. + return nil, nil, fmt.Errorf( + "chart version %q cannot be deployed: %s", + version.Version, versionUnavailableDetail(*version), + ) + } + + if repoType == chartsource.OCI && version.OCIRef == "" && version.MediaType == "" { + return nil, nil, fmt.Errorf( + "chart version %q cannot be deployed: %s", + version.Version, versionUnavailableDetail(*version), + ) + } + + return obj, version, nil + } + + return nil, nil, fmt.Errorf("chart catalog does not have version %q", ref.Version) +} + +// versionUnavailableDetail explains why a catalog entry is not deployable. +func versionUnavailableDetail(version helmv1alpha1.ChartVersion) string { + switch { + case version.UnavailableReason == "": + return "the repository catalog has not resolved it yet" + case version.UnavailableMessage == "": + return version.UnavailableReason + default: + return version.UnavailableReason + ": " + version.UnavailableMessage + } +} + +// logSourceKindFlip reports that the same chart version changed where it is +// published: the repository index moved it between a chart archive and a registry. +// Nothing else surfaces that — status records the applied version but not the source +// it came from — and it upgrades a running release nobody asked to upgrade, so it has +// to be findable in the log. superseded says whether an internal source of the other +// kind was actually removed in this pass. +func (r *Reconciler) logSourceKindFlip( + ctx context.Context, + rel source.Release, + kind chartsource.Kind, + superseded bool, +) { + if !superseded { + return + } + + last := rel.LastAppliedChart() + if last == nil || *last != rel.ChartRef() { + // Not a flip: the release is moving to another version (or another chart), + // and the superseded source belonged to the one it is leaving. The whole ref + // has to match: LastAppliedChart carries its own repository/chart identity + // and can lag behind the spec when the release is repointed at a different + // chart, so the version alone could match by coincidence while naming an + // entirely different chart's history. + return + } + + log.FromContext(ctx).Info( + "Chart version changed where it is published; the running release will be upgraded from the new source", + "version", rel.ChartRef().Version, + "source", kind, + ) +} diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go new file mode 100644 index 00000000..1e4b4f52 --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -0,0 +1,1552 @@ +/* +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 release + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + fluxmeta "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/go-logr/logr/funcr" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + 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" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/operator-helm/api/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/chartsource" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" + "github.com/deckhouse/operator-helm/internal/index" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" + "github.com/deckhouse/operator-helm/internal/utils" +) + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("registering client-go scheme: %v", err) + } + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + return scheme +} + +func newTestReconciler(t *testing.T, objects ...client.Object) (*Reconciler, client.Client) { + t.Helper() + + scheme := testScheme(t) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + return &Reconciler{Client: c}, c +} + +func testAddon() *helmv1alpha1.HelmClusterAddon { + return &helmv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{Name: "consumer", Generation: 1}, + Spec: helmv1alpha1.HelmClusterAddonSpec{ + Namespace: "app", + Chart: helmv1alpha1.HelmClusterAddonChartRef{ + HelmClusterAddonRepository: "example", + HelmClusterAddonChartName: "podinfo", + Version: "6.7.1", + }, + }, + } +} + +func addonChartFixture(repoName, chartName string, versions ...helmv1alpha1.ChartVersion) *helmv1alpha1.HelmClusterAddonChart { + return &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.HelmClusterAddonChartName(repoName, chartName), + }, + Status: helmv1alpha1.ChartCatalogStatus{Versions: versions}, + } +} + +// TestGetHelmClusterAddonChart pins the gate that decides whether an addon has +// enough information to be deployed. For an OCI repository, a catalog entry is +// usable exactly when it carries a media type; for a Helm repository the media +// type is never checked, so an entry is usable as soon as the version is present. +func TestGetHelmClusterAddonChart(t *testing.T) { + addon := testAddon() + + tests := []struct { + name string + // version is the sole entry seeded into the HelmClusterAddonChart's + // Status.Versions. Its own Version field decides whether the lookup by + // addon.Spec.Chart.Version ("6.7.1") hits or misses. + version helmv1alpha1.ChartVersion + repoType chartsource.Kind + wantErr bool + wantErrContain string + }{ + { + name: "oci version with a media type passes", + version: helmv1alpha1.ChartVersion{ + Version: "6.7.1", + MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + }, + repoType: chartsource.OCI, + }, + { + // Deliberate: the tag disappeared from the repository, but the entry is + // retained with its media type so the addon keeps reconciling everything + // else. The real pull failure is reported by the source controller. + name: "oci version removed from repository but with a media type still passes", + version: helmv1alpha1.ChartVersion{ + Version: "6.7.1", + MediaType: "application/tar+gzip", + UnavailableReason: helmv1alpha1.UnavailableReasonRemovedFromRepository, + }, + repoType: chartsource.OCI, + }, + { + name: "oci version stuck resolving is rejected with reason and message", + version: helmv1alpha1.ChartVersion{ + Version: "6.7.1", + UnavailableReason: helmv1alpha1.UnavailableReasonResolvePending, + UnavailableMessage: "manifest request failed", + }, + repoType: chartsource.OCI, + wantErr: true, + wantErrContain: "ResolvePending: manifest request failed", + }, + { + name: "oci version with unsupported media type and no message is rejected with reason alone", + version: helmv1alpha1.ChartVersion{ + Version: "6.7.1", + UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, + }, + repoType: chartsource.OCI, + wantErr: true, + wantErrContain: "UnsupportedMediaType", + }, + { + // Same empty-media-type entry as above, but a Helm repository's versions + // never carry a media type: a stricter gate here would break every Helm + // addon, so the presence check alone must let it through. + name: "the same empty media type entry passes for a helm repository", + version: helmv1alpha1.ChartVersion{ + Version: "6.7.1", + UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, + }, + repoType: chartsource.Helm, + }, + { + // The repository's URL just switched from oci:// to https://: the + // catalog entry is still OCI-era (it carries a media type from the last + // OCI sync), but the Helm gate never reads the media type, so it passes. + name: "oci-era entry with a media type still passes right after switching to a helm repository", + version: helmv1alpha1.ChartVersion{ + Version: "6.7.1", + MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + }, + repoType: chartsource.Helm, + }, + { + // The repository's URL just switched from https:// to oci://, but the + // first OCI sync has not resolved the tag's media type yet: the entry is + // still Helm-era (no media type, no reason), so the OCI gate must reject + // it rather than let an unresolved layer through. + name: "helm-era entry with no media type is rejected right after switching to an oci repository", + version: helmv1alpha1.ChartVersion{ + Version: "6.7.1", + }, + repoType: chartsource.OCI, + wantErr: true, + wantErrContain: "has not resolved it yet", + }, + { + name: "a version the addon does not reference is rejected", + version: helmv1alpha1.ChartVersion{Version: "9.9.9"}, + repoType: chartsource.OCI, + wantErr: true, + wantErrContain: `does not have version "6.7.1"`, + }, + { + // The hybrid case: the version lives in a registry, so its media type is + // resolved at deploy time and is deliberately absent here. The gate must + // not read that absence as "unresolved". + name: "helm repository version published in a registry passes without a media type", + version: helmv1alpha1.ChartVersion{ + Version: "6.7.1", + OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", + }, + repoType: chartsource.Helm, + }, + { + // Left through, this version would be sent down the helm path and would + // fail on the same unusable url with an opaque source controller error. + name: "version with an unusable index reference is rejected", + version: helmv1alpha1.ChartVersion{ + Version: "6.7.1", + UnavailableReason: helmv1alpha1.UnavailableReasonInvalidChartReference, + UnavailableMessage: "oci reference \"oci://BAD_HOST//:::\" is not a valid tagged reference", + }, + repoType: chartsource.Helm, + wantErr: true, + wantErrContain: "InvalidChartReference", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chart := addonChartFixture( + addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName, tt.version, + ) + r, c := newTestReconciler(t, chart) + + gotChart, gotVersion, err := r.getChartVersion(context.Background(), adapter.NewAddonCatalog(c), adapter.NewAddonRepository(helmRepositoryFixture()), adapter.NewAddonRelease(addon), tt.repoType) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got version %+v", gotVersion) + } + if !strings.Contains(err.Error(), tt.wantErrContain) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErrContain) + } + if gotChart != nil || gotVersion != nil { + t.Fatalf("expected nil chart and version on error, got chart=%v version=%v", gotChart, gotVersion) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotChart == nil { + t.Fatal("expected the chart to be returned") + } + if gotVersion == nil { + t.Fatal("expected the matched version to be returned") + } + if gotVersion.Version != tt.version.Version { + t.Fatalf("returned version = %q, want %q", gotVersion.Version, tt.version.Version) + } + if gotVersion.MediaType != tt.version.MediaType { + t.Fatalf("returned version media type = %q, want %q", gotVersion.MediaType, tt.version.MediaType) + } + }) + } +} + +func TestGetHelmClusterAddonChartMissingChart(t *testing.T) { + addon := testAddon() + r, c := newTestReconciler(t) + + gotChart, gotVersion, err := r.getChartVersion(context.Background(), adapter.NewAddonCatalog(c), adapter.NewAddonRepository(helmRepositoryFixture()), adapter.NewAddonRelease(addon), chartsource.OCI) + if err == nil { + t.Fatalf("expected an error when the addon chart does not exist, got version %+v", gotVersion) + } + if gotChart != nil || gotVersion != nil { + t.Fatalf("expected nil chart and version on error, got chart=%v version=%v", gotChart, gotVersion) + } +} + +// stubChartResolver stands in for the registry so a reconcile never leaves the +// process. +type stubChartResolver struct { + mediaType string + err error +} + +func (r *stubChartResolver) ResolveChartArtifact(_ context.Context, _ string, _ *repoclient.RepoConfig) (string, error) { + return r.mediaType, r.err +} + +func helmRepositoryFixture() *helmv1alpha1.HelmClusterAddonRepository { + return &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/stable"}, + } +} + +func newForceTestReconciler( + t *testing.T, + interceptors interceptor.Funcs, + objects ...client.Object, +) (*Reconciler, client.Client) { + t.Helper() + + return newFullReconciler(t, nil, interceptors, objects...) +} + +// newFullReconciler builds a reconciler with the full service set, so a test can +// drive a complete pass rather than a single helper. resolver is handed to the OCI +// service; nil selects the real one, which tests that never reach the hybrid path can +// use safely. +func newFullReconciler( + t *testing.T, + resolver repoclient.ChartResolverInterface, + interceptors interceptor.Funcs, + objects ...client.Object, +) (*Reconciler, client.Client) { + t.Helper() + + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + clientgoscheme.AddToScheme, + helmv1alpha1.AddToScheme, + sourcev1.AddToScheme, + helmv2.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatalf("registering scheme: %v", err) + } + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&helmv1alpha1.HelmClusterAddon{}). + WithInterceptorFuncs(interceptors). + Build() + + return New(c, Deps{ + NewRelease: adapter.EmptyAddonRelease, + Repositories: adapter.NewAddonRepositoryResolver(c), + Chart: services.NewChartService(c, scheme, helmv1alpha1.TargetNamespace), + OCI: services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, resolver), + Release: services.NewReleaseService(c, scheme, helmv1alpha1.TargetNamespace), + Maintenance: services.NewMaintenanceService(c, scheme, helmv1alpha1.TargetNamespace), + Claim: services.NewClaimService(c, c, helmv1alpha1.TargetNamespace), + Namespaces: services.NewNamespaceService(c, c), + Access: NoAccess{}, + Status: status.NewManager(c), + }), c +} + +// newApplicationFullReconciler is the sibling of newFullReconciler for the +// application family: the same services, wired to the namespaced adapters. It is +// what lets a test prove that one reconciler serves both families. access is the +// identity manager; nil selects the real one over the same fake client. +func newApplicationFullReconciler( + t *testing.T, + access AccessManager, + objects ...client.Object, +) (*Reconciler, client.Client) { + t.Helper() + + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + clientgoscheme.AddToScheme, + helmv1alpha1.AddToScheme, + sourcev1.AddToScheme, + helmv2.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatalf("registering scheme: %v", err) + } + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&helmv1alpha1.HelmApplication{}). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationRepository, index.ApplicationRepositoryIndexer). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). + Build() + + if access == nil { + access = services.NewAccessService(c, helmv1alpha1.TargetNamespace) + } + + return New(c, Deps{ + NewRelease: adapter.EmptyApplicationRelease, + Repositories: adapter.NewApplicationRepositoryResolver(c), + Chart: services.NewChartService(c, scheme, helmv1alpha1.TargetNamespace), + OCI: services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, nil), + Release: services.NewReleaseService(c, scheme, helmv1alpha1.TargetNamespace), + Maintenance: services.NewMaintenanceService(c, scheme, helmv1alpha1.TargetNamespace), + Claim: NoChartClaim{}, + Namespaces: ExistingTargetNamespace{}, + Access: access, + Status: status.NewManager(c), + }), c +} + +func testApplication() *helmv1alpha1.HelmApplication { + return &helmv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "team-a", Generation: 1}, + Spec: helmv1alpha1.HelmApplicationSpec{ + Chart: helmv1alpha1.HelmApplicationChartRef{ + Repository: "stable", + Name: "podinfo", + Version: "6.7.1", + }, + }, + } +} + +// applicationFixtures builds the objects an application needs to reconcile: the +// namespaced repository it points at and the catalog entry offering its version. +func applicationFixtures() []client.Object { + return []client.Object{ + &helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a", Generation: 1}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/stable"}, + }, + &helmv1alpha1.HelmApplicationChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.ApplicationChartName("stable", "podinfo"), + Namespace: "team-a", + Labels: map[string]string{ + helmv1alpha1.LabelRepositoryName: "stable", + helmv1alpha1.LabelChartName: "podinfo", + }, + }, + Status: helmv1alpha1.ChartCatalogStatus{ + Versions: []helmv1alpha1.ChartVersion{{Version: "6.7.1"}}, + }, + }, + } +} + +func reconcileApplication(t *testing.T, r *Reconciler, app *helmv1alpha1.HelmApplication) { + t.Helper() + + if _, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Namespace: app.Namespace, Name: app.Name}, + }); err != nil { + t.Fatalf("Reconcile returned %v", err) + } +} + +// markInternalChartReady stands in for source-controller: the internal +// HelmChart only reports an artifact once that controller has pulled it, and the +// release stage is reached only when it has. +func markInternalChartReady(t *testing.T, c client.Client, name string) { + t.Helper() + + chart := &sourcev1.HelmChart{} + key := client.ObjectKey{Name: name, Namespace: helmv1alpha1.TargetNamespace} + if err := c.Get(context.Background(), key, chart); err != nil { + t.Fatalf("the internal helm chart must exist before it can report an artifact: %v", err) + } + + chart.Status.Artifact = &fluxmeta.Artifact{Revision: "6.7.1"} + chart.Status.Conditions = []metav1.Condition{{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "Succeeded", + ObservedGeneration: chart.Generation, + LastTransitionTime: metav1.Now(), + }} + // A plain Update, not Status().Update: the fake client is not told to give + // HelmChart a status subresource, and the controller never writes that status + // itself, so there is nothing for a subresource to protect here. + if err := c.Update(context.Background(), chart); err != nil { + t.Fatalf("updating internal helm chart status: %v", err) + } +} + +// markInternalReleaseDeployed stands in for helm-controller: the internal +// HelmRelease reports the revision it installed only once that controller has run. +func markInternalReleaseDeployed(t *testing.T, c client.Client, name, version string) { + t.Helper() + + release := &helmv2.HelmRelease{} + key := client.ObjectKey{Name: name, Namespace: helmv1alpha1.TargetNamespace} + if err := c.Get(context.Background(), key, release); err != nil { + t.Fatalf("the internal release must exist before it can report a revision: %v", err) + } + + release.Status.History = helmv2.Snapshots{{Status: "deployed", ChartVersion: version}} + release.Status.Conditions = []metav1.Condition{{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "InstallSucceeded", + ObservedGeneration: release.Generation, + LastTransitionTime: metav1.Now(), + }} + if err := c.Update(context.Background(), release); err != nil { + t.Fatalf("updating internal helm release status: %v", err) + } +} + +// TestReconcileReportsProgressUntilTheReleaseSettles walks the progress condition +// through one install. It is raised while an internal object still has work to do, +// carries that object's own verdict rather than a fixed message, and is taken away +// by the pass that finds the release settled — without which kstatus would read a +// healthy release as one that never finishes. The passes in between are driven by +// the watches on the internal objects, which is what a status write on either of +// them produces in the real controller. +func TestReconcileReportsProgressUntilTheReleaseSettles(t *testing.T) { + app := testApplication() + + r, c := newApplicationFullReconciler(t, nil, append(applicationFixtures(), app)...) + + names := adapter.NewApplicationRelease(app).InternalNames() + key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} + + progressOf := func(t *testing.T) *metav1.Condition { + t.Helper() + + settled := &helmv1alpha1.HelmApplication{} + if err := c.Get(context.Background(), key, settled); err != nil { + t.Fatalf("getting application: %v", err) + } + + return apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling) + } + + reconcileApplication(t, r, app) + + progress := progressOf(t) + if progress == nil || progress.Status != metav1.ConditionTrue { + t.Fatalf("Reconciling = %+v, want True while the chart is being pulled", progress) + } + if progress.Reason != helmv1alpha1.ReasonReconciling { + t.Fatalf("Reconciling reason = %q, want %q", progress.Reason, helmv1alpha1.ReasonReconciling) + } + + markInternalChartReady(t, c, names.HelmChart) + reconcileApplication(t, r, app) + + if progress := progressOf(t); progress == nil || progress.Status != metav1.ConditionTrue { + t.Fatalf("Reconciling = %+v, want True while the release is rolling out", progress) + } + + markInternalReleaseDeployed(t, c, names.HelmRelease, app.Spec.Chart.Version) + reconcileApplication(t, r, app) + + if progress := progressOf(t); progress != nil { + t.Fatalf("Reconciling = %+v, want it gone once the release settled", progress) + } +} + +// TestReconcileStallsWhenTheInternalReleaseGivesUp wires the two halves of the +// verdict together on the path a test cluster actually took: bad values leave the +// internal release Stalled once its remediation attempts are spent. The services +// layer has to carry that out of the object's conditions and the evaluation has to +// act on it; each is covered on its own, and this is what proves they meet. +func TestReconcileStallsWhenTheInternalReleaseGivesUp(t *testing.T) { + const cause = "Helm upgrade failed: .spec.replicas: expected numeric (int or float), got string" + + app := testApplication() + + r, c := newApplicationFullReconciler(t, nil, append(applicationFixtures(), app)...) + + names := adapter.NewApplicationRelease(app).InternalNames() + key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} + + reconcileApplication(t, r, app) + markInternalChartReady(t, c, names.HelmChart) + reconcileApplication(t, r, app) + + release := &helmv2.HelmRelease{} + releaseKey := client.ObjectKey{Name: names.HelmRelease, Namespace: helmv1alpha1.TargetNamespace} + if err := c.Get(context.Background(), releaseKey, release); err != nil { + t.Fatalf("the internal release must exist before it can give up: %v", err) + } + release.Status.Conditions = []metav1.Condition{ + { + Type: helmv1alpha1.ConditionTypeStalled, + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonRetriesExceeded, + Message: "Failed to upgrade after 1 attempt(s)", + ObservedGeneration: release.Generation, + LastTransitionTime: metav1.Now(), + }, + { + Type: "Released", + Status: metav1.ConditionFalse, + Reason: "UpgradeFailed", + Message: cause, + ObservedGeneration: release.Generation, + LastTransitionTime: metav1.Now(), + }, + } + if err := c.Update(context.Background(), release); err != nil { + t.Fatalf("updating internal helm release status: %v", err) + } + + reconcileApplication(t, r, app) + + settled := &helmv1alpha1.HelmApplication{} + if err := c.Get(context.Background(), key, settled); err != nil { + t.Fatalf("getting application: %v", err) + } + + stalled := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeStalled) + if stalled == nil || stalled.Status != metav1.ConditionTrue { + t.Fatalf("Stalled = %+v, want True once the internal release gave up", stalled) + } + if stalled.Reason != helmv1alpha1.ReasonReleaseFailed || stalled.Message != cause { + t.Fatalf("Stalled = %s/%q, want the fault the internal release named", stalled.Reason, stalled.Message) + } + + if cond := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling); cond != nil { + t.Fatalf("Reconciling = %+v, want no retry reported that is not coming", cond) + } +} + +// TestReconcileApplicationAppliesTheChartAsItsOwnIdentity is the central claim of +// the namespaced family: the release is created in the operator namespace, but it +// is applied as an account of the application's own making, and its storage lives +// in the application's namespace — so an application can never reach outside it. +func TestReconcileApplicationAppliesTheChartAsItsOwnIdentity(t *testing.T) { + app := testApplication() + + r, c := newApplicationFullReconciler(t, nil, append(applicationFixtures(), app)...) + + names := adapter.NewApplicationRelease(app).InternalNames() + + // The first pass adds the finalizer and creates the internal chart; the second + // one reaches the release, once that chart reports an artifact. + reconcileApplication(t, r, app) + markInternalChartReady(t, c, names.HelmChart) + reconcileApplication(t, r, app) + + account := &corev1.ServiceAccount{} + accountKey := client.ObjectKey{Name: names.ServiceAccount, Namespace: helmv1alpha1.TargetNamespace} + if err := c.Get(context.Background(), accountKey, account); err != nil { + t.Fatalf("the identity must be created next to the release: %v", err) + } + if account.AutomountServiceAccountToken == nil || *account.AutomountServiceAccountToken { + t.Fatal("the account is only a subject name: no token must be mounted for it") + } + + release := &helmv2.HelmRelease{} + releaseKey := client.ObjectKey{Name: names.HelmRelease, Namespace: helmv1alpha1.TargetNamespace} + if err := c.Get(context.Background(), releaseKey, release); err != nil { + t.Fatalf("the internal release was not created: %v", err) + } + if release.Spec.ServiceAccountName != names.ServiceAccount { + t.Fatalf("serviceAccountName = %q, want %q", release.Spec.ServiceAccountName, names.ServiceAccount) + } + if release.Spec.StorageNamespace != "team-a" { + t.Fatalf("storageNamespace = %q, want the application namespace", release.Spec.StorageNamespace) + } + + chartKey := client.ObjectKey{Name: names.HelmChart, Namespace: helmv1alpha1.TargetNamespace} + if err := c.Get(context.Background(), chartKey, &sourcev1.HelmChart{}); err != nil { + t.Fatalf("the internal chart was not created: %v", err) + } +} + +// TestReconcileApplicationCreatesNothingElseInTheApplicationNamespace is the other +// half of the same claim: everything the controller runs on lives in the operator +// namespace. The application namespace only ever receives the two RBAC objects +// that grant the identity its rights there. +func TestReconcileApplicationCreatesNothingElseInTheApplicationNamespace(t *testing.T) { + app := testApplication() + + r, c := newApplicationFullReconciler(t, nil, append(applicationFixtures(), app)...) + + names := adapter.NewApplicationRelease(app).InternalNames() + + reconcileApplication(t, r, app) + markInternalChartReady(t, c, names.HelmChart) + reconcileApplication(t, r, app) + + inNamespace := client.InNamespace("team-a") + + empty := []struct { + name string + list client.ObjectList + }{ + {"config maps", &corev1.ConfigMapList{}}, + {"secrets", &corev1.SecretList{}}, + {"helm charts", &sourcev1.HelmChartList{}}, + {"helm releases", &helmv2.HelmReleaseList{}}, + {"oci repositories", &sourcev1.OCIRepositoryList{}}, + {"service accounts", &corev1.ServiceAccountList{}}, + } + for _, tt := range empty { + if err := c.List(context.Background(), tt.list, inNamespace); err != nil { + t.Fatalf("listing %s: %v", tt.name, err) + } + items, err := apimeta.ExtractList(tt.list) + if err != nil { + t.Fatalf("extracting %s: %v", tt.name, err) + } + if len(items) != 0 { + t.Fatalf("%d %s were created in the application namespace, want none: %v", len(items), tt.name, items) + } + } + + var roles rbacv1.RoleList + if err := c.List(context.Background(), &roles, inNamespace); err != nil { + t.Fatalf("listing roles: %v", err) + } + if len(roles.Items) != 1 || roles.Items[0].Name != services.ApplicationRoleName { + t.Fatalf("roles = %+v, want only %s", roles.Items, services.ApplicationRoleName) + } + + var bindings rbacv1.RoleBindingList + if err := c.List(context.Background(), &bindings, inNamespace); err != nil { + t.Fatalf("listing role bindings: %v", err) + } + if len(bindings.Items) != 1 || bindings.Items[0].Name != names.ServiceAccount { + t.Fatalf("role bindings = %+v, want only %s", bindings.Items, names.ServiceAccount) + } +} + +// failingAccess is an AccessManager whose identity setup never succeeds. +type failingAccess struct{} + +func (failingAccess) EnsureAccess(context.Context, source.Release) services.AccessOutcome { + return services.AccessOutcome{ + Err: errors.New("service account is forbidden"), + Reason: helmv1alpha1.ReasonAccessSetupFailed, + Message: "Failed to set up the release identity", + } +} + +func (failingAccess) CleanupAccess(context.Context, source.Release) error { return nil } + +// TestReconcileApplicationReportsAccessSetupFailure pins that the pass stops at the +// identity. A release applied without one would run as helm-controller itself, +// which is exactly the privilege the namespaced family exists to avoid, so the +// failure has to be reported instead of worked around. It must also be returned +// as an error: nothing watches the ServiceAccount/RoleBinding this step manages, +// so the work queue's rate limiter is the only thing that will retry it. +func TestReconcileApplicationReportsAccessSetupFailure(t *testing.T) { + app := testApplication() + + r, c := newApplicationFullReconciler(t, failingAccess{}, append(applicationFixtures(), app)...) + + key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} + if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: key}); err == nil { + t.Fatal("a failed identity setup must be returned as an error so the work queue retries it") + } + + settled := &helmv1alpha1.HelmApplication{} + if err := c.Get(context.Background(), key, settled); err != nil { + t.Fatalf("getting application: %v", err) + } + + ready := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReady) + if ready == nil { + t.Fatalf("Ready must be reported, conditions: %v", settled.Status.Conditions) + } + if ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonAccessSetupFailed { + t.Fatalf("Ready is %s/%s, want False/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonAccessSetupFailed) + } + + names := adapter.NewApplicationRelease(app).InternalNames() + releaseKey := client.ObjectKey{Name: names.HelmRelease, Namespace: helmv1alpha1.TargetNamespace} + if err := c.Get(context.Background(), releaseKey, &helmv2.HelmRelease{}); err == nil { + t.Fatal("no release must be created for an application whose identity could not be set up") + } +} + +// intermittentAccess fails the first call to EnsureAccess and delegates to a real +// AccessManager afterwards, standing in for a transient failure (an API hiccup, a +// momentary admission rejection) that clears on its own by the next pass. real is +// set after construction, once the reconciler's own client exists. +type intermittentAccess struct { + real AccessManager + calls int +} + +func (a *intermittentAccess) EnsureAccess(ctx context.Context, rel source.Release) services.AccessOutcome { + a.calls++ + if a.calls == 1 { + return services.AccessOutcome{ + Err: errors.New("service account is forbidden"), + Reason: helmv1alpha1.ReasonAccessSetupFailed, + Message: "Failed to set up the release identity", + } + } + return a.real.EnsureAccess(ctx, rel) +} + +func (a *intermittentAccess) CleanupAccess(ctx context.Context, rel source.Release) error { + return a.real.CleanupAccess(ctx, rel) +} + +// TestReconcileApplicationRecoversAfterTransientAccessSetupFailure pins the fix for +// the gap in TestReconcileApplicationReportsAccessSetupFailure: a failed +// EnsureAccess must not just be reported, it must get the application requeued, so +// a transient failure recovers on its own once the cause is gone. +func TestReconcileApplicationRecoversAfterTransientAccessSetupFailure(t *testing.T) { + app := testApplication() + + access := &intermittentAccess{} + r, c := newApplicationFullReconciler(t, access, append(applicationFixtures(), app)...) + access.real = services.NewAccessService(c, helmv1alpha1.TargetNamespace) + + key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} + + if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: key}); err == nil { + t.Fatal("the first pass must report the transient failure as an error") + } + + settled := &helmv1alpha1.HelmApplication{} + if err := c.Get(context.Background(), key, settled); err != nil { + t.Fatalf("getting application: %v", err) + } + ready := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonAccessSetupFailed { + t.Fatalf("Ready = %+v, want False/%s after the first pass", ready, helmv1alpha1.ReasonAccessSetupFailed) + } + + if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: key}); err != nil { + t.Fatalf("the retried pass must succeed once the identity can be set up: %v", err) + } + + names := adapter.NewApplicationRelease(app).InternalNames() + account := &corev1.ServiceAccount{} + accountKey := client.ObjectKey{Name: names.ServiceAccount, Namespace: helmv1alpha1.TargetNamespace} + if err := c.Get(context.Background(), accountKey, account); err != nil { + t.Fatalf("the identity must be created once EnsureAccess stops failing: %v", err) + } + + if err := c.Get(context.Background(), key, settled); err != nil { + t.Fatalf("getting application: %v", err) + } + if reason := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReady).Reason; reason == helmv1alpha1.ReasonAccessSetupFailed { + t.Fatal("Ready must move on from AccessSetupFailed once the retried pass sets up the identity") + } +} + +// TestReconcileStallsOnAnUnreadableRepositoryURL pins the release-side mirror of the +// repository's own configuration verdict. The fault is in the repository, not here, +// and the repository reports it as Stalled too; retrying from this side would only +// rediscover it, so the release says so and waits for the repository's generation to +// move — which is what correcting the url does. +func TestReconcileStallsOnAnUnreadableRepositoryURL(t *testing.T) { + app := testApplication() + fixtures := applicationFixtures() + fixtures[0] = &helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a", Generation: 1}, + Spec: helmv1alpha1.RepositorySpec{URL: "ftp://charts.example.invalid/stable"}, + } + + r, c := newApplicationFullReconciler(t, nil, append(fixtures, app)...) + + key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} + result, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: key}) + if err != nil { + t.Fatalf("a terminal failure must not be handed to the work queue: %v", err) + } + if result.RequeueAfter != 0 { + t.Fatalf("RequeueAfter = %v, want none", result.RequeueAfter) + } + + settled := &helmv1alpha1.HelmApplication{} + if err := c.Get(context.Background(), key, settled); err != nil { + t.Fatalf("getting application: %v", err) + } + + stalled := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeStalled) + if stalled == nil { + t.Fatalf("Stalled must be reported, conditions: %v", settled.Status.Conditions) + } + if stalled.Status != metav1.ConditionTrue || stalled.Reason != helmv1alpha1.ReasonUnsupportedRepositoryType { + t.Fatalf("Stalled is %s/%s, want True/%s", + stalled.Status, stalled.Reason, helmv1alpha1.ReasonUnsupportedRepositoryType) + } +} + +// TestReconcileApplicationStallsOnAForeignRole pins the end of the terminal path an +// application reaches when the namespace already holds a Role under the name the +// identity needs. Nothing observes such an object — the informer behind the watch on +// the kind selects on the very label it lacks — so the pass must say so on the status +// rather than come back through the work queue and rediscover it. +func TestReconcileApplicationStallsOnAForeignRole(t *testing.T) { + app := testApplication() + rules := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get"}}} + foreign := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: services.ApplicationRoleName, Namespace: app.Namespace}, + Rules: rules, + } + + r, c := newApplicationFullReconciler(t, nil, append(applicationFixtures(), app, foreign)...) + + key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} + result, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: key}) + if err != nil { + t.Fatalf("a terminal failure must not be handed to the work queue: %v", err) + } + if result.RequeueAfter != 0 { + t.Fatalf("RequeueAfter = %v, want none", result.RequeueAfter) + } + + settled := &helmv1alpha1.HelmApplication{} + if err := c.Get(context.Background(), key, settled); err != nil { + t.Fatalf("getting application: %v", err) + } + + stalled := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeStalled) + if stalled == nil { + t.Fatalf("Stalled must be reported, conditions: %v", settled.Status.Conditions) + } + if stalled.Status != metav1.ConditionTrue || stalled.Reason != helmv1alpha1.ReasonForeignAccessObject { + t.Fatalf("Stalled is %s/%s, want True/%s", stalled.Status, stalled.Reason, helmv1alpha1.ReasonForeignAccessObject) + } + + ready := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonForeignAccessObject { + t.Fatalf("Ready = %+v, want False/%s", ready, helmv1alpha1.ReasonForeignAccessObject) + } + + stored := &rbacv1.Role{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(foreign), stored); err != nil { + t.Fatalf("the foreign role must survive: %v", err) + } + if !reflect.DeepEqual(stored.Rules, rules) { + t.Fatalf("rules = %+v, want them untouched", stored.Rules) + } + + names := adapter.NewApplicationRelease(app).InternalNames() + releaseKey := client.ObjectKey{Name: names.HelmRelease, Namespace: helmv1alpha1.TargetNamespace} + if err := c.Get(context.Background(), releaseKey, &helmv2.HelmRelease{}); err == nil { + t.Fatal("no release must be created for an application whose identity could not be built") + } +} + +// accessCleanupFails is an AccessManager whose teardown never succeeds, standing in +// for an API failure while removing the release's ServiceAccount or RoleBinding. +type accessCleanupFails struct{} + +func (accessCleanupFails) EnsureAccess(context.Context, source.Release) services.AccessOutcome { + return services.AccessOutcome{} +} + +func (accessCleanupFails) CleanupAccess(context.Context, source.Release) error { + return errors.New("role binding deletion forbidden") +} + +// TestReconcileDeleteReportsAccessCleanupFailure pins that a failed CleanupAccess +// leaves the status saying so. By the time this step runs the internal release and +// sources are already gone, so nothing else on the object would otherwise explain +// why the finalizer is still there. +func TestReconcileDeleteReportsAccessCleanupFailure(t *testing.T) { + now := metav1.Now() + app := &helmv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "team-a", + Generation: 1, + Finalizers: []string{helmv1alpha1.FinalizerName}, + DeletionTimestamp: &now, + }, + Spec: testApplication().Spec, + } + + r, c := newApplicationFullReconciler(t, accessCleanupFails{}, app) + + key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} + if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: key}); err == nil { + t.Fatal("a failed identity cleanup must be returned as an error so the finalizer's retry fires") + } + + settled := &helmv1alpha1.HelmApplication{} + if err := c.Get(context.Background(), key, settled); err != nil { + t.Fatalf("getting application: %v", err) + } + + if !controllerutil.ContainsFinalizer(settled, helmv1alpha1.FinalizerName) { + t.Fatal("the finalizer must stay until the identity is actually cleaned up") + } + + ready := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReady) + if ready == nil { + t.Fatalf("Ready must be reported, conditions: %v", settled.Status.Conditions) + } + if ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonFailed { + t.Fatalf("Ready is %s/%s, want False/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonFailed) + } +} + +func ociRepositoryFixture() *helmv1alpha1.HelmClusterAddonRepository { + return &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, + Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/podinfo"}, + } +} + +func forceTestFixtures() []client.Object { + return []client.Object{ + ociRepositoryFixture(), + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ + Version: "6.7.1", + MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + }), + } +} + +func reconcileAddon(t *testing.T, r *Reconciler, name string) { + t.Helper() + + if _, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: name}, + }); err != nil { + t.Fatalf("Reconcile returned %v", err) + } +} + +// TestReconcileHybridVersionUsesInternalOCIRepository is the end-to-end shape of the +// feature: the repository is a classic helm one, and only the index entry of the +// version points at a registry. The addon must be served by an internal +// OCIRepository addressed by that entry, and no internal HelmChart must be created. +func TestReconcileHybridVersionUsesInternalOCIRepository(t *testing.T) { + addon := testAddon() + resolver := &stubChartResolver{mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"} + + r, c := newFullReconciler(t, resolver, interceptor.Funcs{}, + addon, + helmRepositoryFixture(), + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ + Version: "6.7.1", + OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", + }), + ) + + reconcileAddon(t, r, addon.Name) + + ociRepo := &sourcev1.OCIRepository{} + ociKey := client.ObjectKey{ + Name: utils.GetInternalOCIRepositoryName(addon.Name), + Namespace: helmv1alpha1.TargetNamespace, + } + if err := c.Get(context.Background(), ociKey, ociRepo); err != nil { + t.Fatalf("a version published in a registry must be served by an internal oci repository: %v", err) + } + if ociRepo.Spec.URL != "oci://registry.example.com/charts/podinfo" { + t.Fatalf("url = %q, want the address from the index entry", ociRepo.Spec.URL) + } + if ociRepo.Spec.Reference == nil || ociRepo.Spec.Reference.Tag != "6.7.1" { + t.Fatalf("reference = %+v, want tag 6.7.1", ociRepo.Spec.Reference) + } + if ociRepo.Spec.LayerSelector == nil || ociRepo.Spec.LayerSelector.MediaType != resolver.mediaType { + t.Fatalf("layer selector = %+v, want the examined media type", ociRepo.Spec.LayerSelector) + } + + chart := &sourcev1.HelmChart{} + chartKey := client.ObjectKey{ + Name: utils.GetInternalHelmChartName(addon.Name), + Namespace: helmv1alpha1.TargetNamespace, + } + if err := c.Get(context.Background(), chartKey, chart); err == nil { + t.Fatal("no internal helm chart must be created for a version published in a registry") + } +} + +// TestReconcileArchiveVersionOfHelmRepositoryStaysOnTheHelmPath is the complement: +// the same repository, a version without an index reference, and nothing about the +// hybrid path must engage. +func TestReconcileArchiveVersionOfHelmRepositoryStaysOnTheHelmPath(t *testing.T) { + addon := testAddon() + + r, c := newFullReconciler(t, &stubChartResolver{}, interceptor.Funcs{}, + addon, + helmRepositoryFixture(), + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ + Version: "6.7.1", + }), + ) + + reconcileAddon(t, r, addon.Name) + + chart := &sourcev1.HelmChart{} + chartKey := client.ObjectKey{ + Name: utils.GetInternalHelmChartName(addon.Name), + Namespace: helmv1alpha1.TargetNamespace, + } + if err := c.Get(context.Background(), chartKey, chart); err != nil { + t.Fatalf("an archive version must be served by an internal helm chart: %v", err) + } + + ociRepo := &sourcev1.OCIRepository{} + ociKey := client.ObjectKey{ + Name: utils.GetInternalOCIRepositoryName(addon.Name), + Namespace: helmv1alpha1.TargetNamespace, + } + if err := c.Get(context.Background(), ociKey, ociRepo); err == nil { + t.Fatal("no internal oci repository must be created for an archive version") + } +} + +// TestReconcileVersionMovedOutOfRegistrySupersedesTheOCIRepository is the mirror flip: +// the index re-published a version the addon is already running as an archive from an +// internal OCIRepository, either because the user repointed the repository or because +// the index re-published it out of the registry. The superseded internal OCIRepository +// is removed even though the new source has not produced an artifact yet, for the same +// reason as its HelmChart counterpart: a repository retracting a location is a fact +// the addon state has to reflect, and keeping the old source would let the addon keep +// deploying from a place the repository no longer offers. +func TestReconcileVersionMovedOutOfRegistrySupersedesTheOCIRepository(t *testing.T) { + addon := testAddon() + addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonRepository: "example", + HelmClusterAddonChartName: "podinfo", + Version: "6.7.1", + } + + supersededOCIRepo := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetInternalOCIRepositoryName(addon.Name), + Namespace: helmv1alpha1.TargetNamespace, + }, + } + + r, c := newFullReconciler(t, &stubChartResolver{}, interceptor.Funcs{}, + addon, + helmRepositoryFixture(), + supersededOCIRepo, + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ + Version: "6.7.1", + }), + ) + + reconcileAddon(t, r, addon.Name) + + ociRepo := &sourcev1.OCIRepository{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(supersededOCIRepo), ociRepo); err == nil { + t.Error("the superseded internal oci repository must be removed") + } + + chart := &sourcev1.HelmChart{} + chartKey := client.ObjectKey{ + Name: utils.GetInternalHelmChartName(addon.Name), + Namespace: helmv1alpha1.TargetNamespace, + } + if err := c.Get(context.Background(), chartKey, chart); err != nil { + t.Fatalf("the new source must be created in the same pass: %v", err) + } +} + +// TestReconcileVersionMovedIntoRegistrySupersedesTheHelmChart is the flip: the index +// re-published a version the addon is already running as an OCI artifact. The +// superseded internal HelmChart is removed even though the new source has not +// produced an artifact yet — a repository retracting a location is a fact the addon +// state has to reflect, and keeping the old source would let the addon keep deploying +// from a place the repository no longer offers. The running release is not torn down +// by that: helm-controller does not uninstall a release because its source is gone. +func TestReconcileVersionMovedIntoRegistrySupersedesTheHelmChart(t *testing.T) { + addon := testAddon() + addon.Status.LastAppliedChart = &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonRepository: "example", + HelmClusterAddonChartName: "podinfo", + Version: "6.7.1", + } + + supersededChart := &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetInternalHelmChartName(addon.Name), + Namespace: helmv1alpha1.TargetNamespace, + }, + } + + r, c := newFullReconciler(t, &stubChartResolver{mediaType: "application/tar+gzip"}, interceptor.Funcs{}, + addon, + helmRepositoryFixture(), + supersededChart, + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ + Version: "6.7.1", + OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", + }), + ) + + reconcileAddon(t, r, addon.Name) + + chart := &sourcev1.HelmChart{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(supersededChart), chart); err == nil { + t.Error("the superseded internal helm chart must be removed") + } + + ociRepo := &sourcev1.OCIRepository{} + ociKey := client.ObjectKey{ + Name: utils.GetInternalOCIRepositoryName(addon.Name), + Namespace: helmv1alpha1.TargetNamespace, + } + if err := c.Get(context.Background(), ociKey, ociRepo); err != nil { + t.Fatalf("the new source must be created in the same pass: %v", err) + } + if ociRepo.Spec.URL != "oci://registry.example.com/charts/podinfo" { + t.Fatalf("url = %q, want the address from the index entry", ociRepo.Spec.URL) + } +} + +// TestLogSourceKindFlipIgnoresStaleEntryFromADifferentChartOrRepository pins the +// guard added to logSourceKindFlip: LastAppliedChart carries its own repository/chart +// identity and can lag behind Spec.Chart, so a version string that happens to match +// is not enough on its own — the repository and chart name have to match too, or an +// addon that switched to an unrelated chart reusing the same version string would be +// misreported as its current chart having changed where it is published. +func TestLogSourceKindFlipIgnoresStaleEntryFromADifferentChartOrRepository(t *testing.T) { + tests := []struct { + name string + last *helmv1alpha1.HelmClusterAddonLastAppliedChartRef + wantLogged bool + }{ + { + name: "same repository, chart and version is a flip", + last: &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonRepository: "example", + HelmClusterAddonChartName: "podinfo", + Version: "6.7.1", + }, + wantLogged: true, + }, + { + // The version string coincides, but it belongs to a different chart's + // history: the addon was repointed, not flipped. + name: "same version but a different chart name is not a flip", + last: &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonRepository: "example", + HelmClusterAddonChartName: "other-chart", + Version: "6.7.1", + }, + wantLogged: false, + }, + { + // Same reasoning, the other field: the version string coincides, but it + // belongs to a different repository's history. + name: "same version but a different repository is not a flip", + last: &helmv1alpha1.HelmClusterAddonLastAppliedChartRef{ + HelmClusterAddonRepository: "other-repo", + HelmClusterAddonChartName: "podinfo", + Version: "6.7.1", + }, + wantLogged: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addon := testAddon() + addon.Status.LastAppliedChart = tt.last + + var logged bool + logger := funcr.New(func(prefix, args string) { + logged = true + }, funcr.Options{}) + ctx := log.IntoContext(context.Background(), logger) + + r := &Reconciler{} + r.logSourceKindFlip(ctx, adapter.NewAddonRelease(addon), chartsource.OCI, true) + + if logged != tt.wantLogged { + t.Fatalf("logged = %v, want %v", logged, tt.wantLogged) + } + }) + } +} + +// TestReconcileForcedAddonReportsProgressBeforeWorking pins that Reconciling is +// published before the internal source is touched. The user annotated the addon a +// moment ago and is watching it; a condition written only after the release has +// been reconciled would report progress that is already over. +func TestReconcileForcedAddonReportsProgressBeforeWorking(t *testing.T) { + addon := testAddon() + addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} + + var inFlight *metav1.Condition + var c client.Client + + // The internal source is reconciled with CreateOrPatch, so the first write to + // it is a Create on a fresh addon and a Patch on an existing one; hook both so + // the test does not depend on which one this fixture takes. + captureAddonStatus := func(ctx context.Context) { + if inFlight != nil { + return + } + + observed := &helmv1alpha1.HelmClusterAddon{} + if err := c.Get(ctx, types.NamespacedName{Name: addon.Name}, observed); err == nil { + inFlight = apimeta.FindStatusCondition( + observed.Status.Conditions, helmv1alpha1.ConditionTypeReconciling, + ) + } + } + + observe := interceptor.Funcs{ + Create: func( + ctx context.Context, + inner client.WithWatch, + obj client.Object, + opts ...client.CreateOption, + ) error { + if _, isSource := obj.(*sourcev1.OCIRepository); isSource { + captureAddonStatus(ctx) + } + + return inner.Create(ctx, obj, opts...) + }, + Patch: func( + ctx context.Context, + inner client.WithWatch, + obj client.Object, + patch client.Patch, + opts ...client.PatchOption, + ) error { + if _, isSource := obj.(*sourcev1.OCIRepository); isSource { + captureAddonStatus(ctx) + } + + return inner.Patch(ctx, obj, patch, opts...) + }, + } + + r, built := newForceTestReconciler(t, observe, append(forceTestFixtures(), addon)...) + c = built + + reconcileAddon(t, r, addon.Name) + + if inFlight == nil { + t.Fatal("Reconciling must be published before the internal source is reconciled") + } + if inFlight.Status != metav1.ConditionTrue || inFlight.Reason != helmv1alpha1.ReasonForceReconcile { + t.Fatalf("Reconciling is %s/%s, want True/%s", + inFlight.Status, inFlight.Reason, helmv1alpha1.ReasonForceReconcile) + } +} + +// TestReconcileForcedAddonRecordsCompletion covers the other end of the same pass. +func TestReconcileForcedAddonRecordsCompletion(t *testing.T) { + addon := testAddon() + addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} + + r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) + + // metav1.Time serialises at second precision, so the stored stamp can land + // just before an untruncated wall-clock reading of the same second. + before := time.Now().UTC().Truncate(time.Second) + + reconcileAddon(t, r, addon.Name) + + settled := &helmv1alpha1.HelmClusterAddon{} + if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { + t.Fatalf("getting addon: %v", err) + } + + // The request itself is over, so the reason it raised must be gone; the pass + // kicked off a rollout that is still running, and the progress condition is + // handed over to the ordinary verdict rather than taken away. + cond := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling) + if cond == nil { + t.Fatalf("Reconciling must still report the rollout, conditions: %v", settled.Status.Conditions) + } + if cond.Reason == helmv1alpha1.ReasonForceReconcile { + t.Fatal("Reconciling must stop carrying ForceReconcile once the forced pass finished") + } + if settled.Status.LastForceReconcileTime == nil { + t.Fatal("lastForceReconcileTime must be recorded by the forced pass") + } + if settled.Status.LastForceReconcileTime.Time.Before(before) { + t.Fatalf("lastForceReconcileTime is %v, want at or after %v", + settled.Status.LastForceReconcileTime.Time, before) + } + if _, found := settled.Annotations[helmv1alpha1.AnnotationForceReconcile]; found { + t.Fatal("the force annotation must be consumed by the pass it triggered") + } +} + +// TestReconcileUnforcedAddonRecordsNoForceReconcile is the complement: an ordinary +// pass must not report a force request that was never made. +func TestReconcileUnforcedAddonRecordsNoForceReconcile(t *testing.T) { + addon := testAddon() + + r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) + + reconcileAddon(t, r, addon.Name) + + settled := &helmv1alpha1.HelmClusterAddon{} + if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { + t.Fatalf("getting addon: %v", err) + } + + if settled.Status.LastForceReconcileTime != nil { + t.Fatalf("lastForceReconcileTime is %v, want it unset without a force request", + settled.Status.LastForceReconcileTime) + } + if cond := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling); cond != nil && + cond.Reason == helmv1alpha1.ReasonForceReconcile { + t.Fatalf("an unforced pass must not report %s", helmv1alpha1.ReasonForceReconcile) + } +} + +// maintainedAddon builds an addon asking for maintenance mode, carrying a force +// request and the progress condition a forced pass publishes before it works. That +// is the state a pass interrupted between the two writes leaves behind. +func maintainedAddon() *helmv1alpha1.HelmClusterAddon { + addon := testAddon() + addon.Spec.Maintenance = string(helmv1alpha1.NoResourceReconciliation) + addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} + addon.Status.Conditions = []metav1.Condition{{ + Type: helmv1alpha1.ConditionTypeReconciling, + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonForceReconcile, + Message: "Forced reconciliation in progress", + LastTransitionTime: metav1.Now(), + }} + + return addon +} + +// TestReconcileEnteringMaintenanceDiscardsForceReconcile covers the pass that puts +// the addon into maintenance. The controller has just decided to stop reconciling +// it, so a force request it will never act on must not be left claiming progress — +// kstatus reads a standing Reconciling as work in flight. +func TestReconcileEnteringMaintenanceDiscardsForceReconcile(t *testing.T) { + addon := maintainedAddon() + + r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) + + reconcileAddon(t, r, addon.Name) + + settled := &helmv1alpha1.HelmClusterAddon{} + if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { + t.Fatalf("getting addon: %v", err) + } + + if !settled.MaintenanceModeEnabled() { + t.Fatalf("the fixture must reach maintenance mode first, conditions: %v", settled.Status.Conditions) + } + if cond := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling); cond != nil { + t.Fatalf("Reconciling must be dropped when the addon enters maintenance, got %+v", cond) + } + if _, found := settled.Annotations[helmv1alpha1.AnnotationForceReconcile]; found { + t.Fatal("the force annotation must be discarded: maintenance will never act on it") + } + if settled.Status.LastForceReconcileTime != nil { + t.Fatalf("lastForceReconcileTime is %v, want it unset: the request was discarded, not processed", + settled.Status.LastForceReconcileTime) + } +} + +// TestReconcileSittingInMaintenanceDiscardsForceReconcile is the same guarantee for +// an addon already in maintenance, which takes the early return instead of the +// maintenance-change branch. Without it a request annotated onto a maintained addon +// would sit on the object forever. +func TestReconcileSittingInMaintenanceDiscardsForceReconcile(t *testing.T) { + addon := maintainedAddon() + addon.Status.Conditions = append(addon.Status.Conditions, metav1.Condition{ + Type: helmv1alpha1.ConditionTypeManaged, + Status: metav1.ConditionFalse, + Reason: helmv1alpha1.ReasonMaintenanceModeActive, + Message: "Maintenance mode enabled", + LastTransitionTime: metav1.Now(), + }) + + r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) + + if !addon.MaintenanceModeEnabled() || r.deps.Maintenance.IsMaintenanceModeChangeRequired(adapter.NewAddonRelease(addon)) { + t.Fatal("the fixture must already be in maintenance, otherwise the test takes the wrong branch") + } + + reconcileAddon(t, r, addon.Name) + + settled := &helmv1alpha1.HelmClusterAddon{} + if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { + t.Fatalf("getting addon: %v", err) + } + + if cond := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReconciling); cond != nil { + t.Fatalf("Reconciling must be dropped on a maintained addon, got %+v", cond) + } + if _, found := settled.Annotations[helmv1alpha1.AnnotationForceReconcile]; found { + t.Fatal("the force annotation must be discarded: maintenance will never act on it") + } +} + +// TestReconcileLeavingMaintenanceKeepsForceReconcile is the complement. Lifting +// maintenance also returns early, but reconciliation is resuming, so the request is +// about to become actionable and must survive to the pass that can honour it. +func TestReconcileLeavingMaintenanceKeepsForceReconcile(t *testing.T) { + addon := testAddon() + addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} + addon.Status.Conditions = []metav1.Condition{{ + Type: helmv1alpha1.ConditionTypeManaged, + Status: metav1.ConditionFalse, + Reason: helmv1alpha1.ReasonMaintenanceModeActive, + Message: "Maintenance mode enabled", + LastTransitionTime: metav1.Now(), + }} + + r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) + + if addon.MaintenanceModeActivated() || !r.deps.Maintenance.IsMaintenanceModeChangeRequired(adapter.NewAddonRelease(addon)) { + t.Fatal("the fixture must be leaving maintenance, otherwise the test proves nothing") + } + + reconcileAddon(t, r, addon.Name) + + settled := &helmv1alpha1.HelmClusterAddon{} + if err := c.Get(context.Background(), types.NamespacedName{Name: addon.Name}, settled); err != nil { + t.Fatalf("getting addon: %v", err) + } + + if _, found := settled.Annotations[helmv1alpha1.AnnotationForceReconcile]; !found { + t.Fatal("the force annotation must survive the pass that lifts maintenance") + } +} + +// TestReconcileLogsAFailureItDoesNotHandBack pins the one report a quietly failing +// pass leaves behind. Only the identity failure is handed to the work queue, which +// logs it on the way past; every other failure ends the pass with no error at all, +// and several of them report a fixed message, so without this line the cause is +// written down nowhere. +func TestReconcileLogsAFailureItDoesNotHandBack(t *testing.T) { + addon := testAddon() + // No repository object is created, so resolving the one the addon names fails. + r, _ := newFullReconciler(t, &stubChartResolver{}, interceptor.Funcs{}, addon) + + var logged []string + logger := funcr.New(func(prefix, args string) { + logged = append(logged, args) + }, funcr.Options{}) + ctx := log.IntoContext(context.Background(), logger) + + res, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: addon.Name}}) + if err != nil { + t.Fatalf("Reconcile returned %v, want the failure reported rather than handed back", err) + } + if res.RequeueAfter != 0 { + t.Fatalf("RequeueAfter = %v, want none: a watch on the repository wakes the addon", res.RequeueAfter) + } + + var found bool + for _, line := range logged { + if strings.Contains(line, "Failed to get internal repository") && + strings.Contains(line, helmv1alpha1.ReasonFailed) { + found = true + } + } + if !found { + t.Fatalf("the failure was not logged, got %q", logged) + } +} diff --git a/images/operator-helm-controller/internal/reconcile/repository/collaborators.go b/images/operator-helm-controller/internal/reconcile/repository/collaborators.go new file mode 100644 index 00000000..405899aa --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/repository/collaborators.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 repository + +import ( + "context" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" + + "github.com/deckhouse/operator-helm/internal/chartsource" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" +) + +// The collaborators of one reconcile pass, declared where they are used rather than +// where they are implemented, as the release package declares its own. + +// SecretManager owns the auxiliary secrets a repository needs to be read: the +// credentials of its registry and, for an oci:// one, the TLS material. +type SecretManager interface { + Ensure(ctx context.Context, repo source.Repository, repoType chartsource.Kind) error + Cleanup(ctx context.Context, names source.InternalNames) error +} + +// InternalRepositoryManager owns the internal HelmRepository, which exists only for +// a repository that hands out packaged archives. +type InternalRepositoryManager interface { + EnsureInternalHelmRepository(ctx context.Context, repo source.Repository) (services.InternalRepositoryState, error) + // RemoveHelmRepository drops the object a repository no longer needs, after its + // url moved from helm to oci. + RemoveHelmRepository(ctx context.Context, names source.InternalNames) error + // CleanupHelmRepository returns the object while it is still present, so the + // caller can wait for it to actually go away. + CleanupHelmRepository(ctx context.Context, names source.InternalNames) (*sourcev1.HelmRepository, error) +} + +// CatalogSynchronizer reads the remote and writes the chart catalog of one +// repository. +type CatalogSynchronizer interface { + Sync(ctx context.Context, repo source.Repository, repoType chartsource.Kind) services.SyncOutcome + // MigrateNames moves the catalog objects to their current names. + // + // TRANSITIONAL: remove together with the catalog's own migration. + MigrateNames(ctx context.Context, repo source.Repository) error +} diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate.go b/images/operator-helm-controller/internal/reconcile/repository/evaluate.go similarity index 91% rename from images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate.go rename to images/operator-helm-controller/internal/reconcile/repository/evaluate.go index 936aee5b..f590d4bb 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate.go +++ b/images/operator-helm-controller/internal/reconcile/repository/evaluate.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddonrepository +package repository import ( "fmt" @@ -23,6 +23,7 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/deckhouse/operator-helm/internal/services" @@ -35,13 +36,20 @@ type Inputs struct { Generation int64 Now time.Time Jitter float64 - Current helmv1alpha1.HelmClusterAddonRepositoryStatus + Current helmv1alpha1.RepositoryStatus SecretsErr error InternalRepositoryErr error InternalRepository services.InternalRepositoryState ConfigErr *services.ConfigOutcome + // MigrateErr reports a failure to move the catalog objects to their current + // names. It is not tied to a synchronization attempt: the rename runs on every + // pass, whether or not the remote was read. + // + // TRANSITIONAL: remove together with the catalog's own migration. + MigrateErr error + // Forced reports whether this pass was requested through the force reconcile // annotation. Forced bool @@ -56,7 +64,7 @@ type Inputs struct { // Decision is the full desired status plus the scheduling verdict. Removing a // condition is expressed by its absence from Status.Conditions. type Decision struct { - Status helmv1alpha1.HelmClusterAddonRepositoryStatus + Status helmv1alpha1.RepositoryStatus RequeueAfter time.Duration Err error } @@ -71,7 +79,7 @@ type abnormalCondition struct { // Evaluate derives the desired repository status from the results of a single // reconcile pass. func Evaluate(in Inputs) Decision { - var status helmv1alpha1.HelmClusterAddonRepositoryStatus + var status helmv1alpha1.RepositoryStatus in.Current.DeepCopyInto(&status) status.ObservedGeneration = in.Generation @@ -88,7 +96,7 @@ func Evaluate(in Inputs) Decision { fetchFailed := in.Attempted && in.Fetch != nil && in.Fetch.Err != nil fetchSucceeded := in.Attempted && in.Fetch != nil && in.Fetch.Err == nil - catalogFailed := in.Attempted && in.Catalog != nil && in.Catalog.Err != nil + catalogFailed := in.MigrateErr != nil || (in.Attempted && in.Catalog != nil && in.Catalog.Err != nil) failures := in.Current.ConsecutiveFetchFailures if in.Generation != in.Current.ObservedGeneration { @@ -103,7 +111,10 @@ func Evaluate(in Inputs) Decision { setCondition(&status, in, helmv1alpha1.ConditionTypeReady, ready.Status, ready.Reason, ready.Message) - if in.Attempted { + // A rename failure is reported even on a pass that attempted no synchronization: + // the rename runs on every pass by design, so gating its verdict on an attempt + // would leave the previous Synced=True standing while consumers cannot resolve. + if in.Attempted || in.MigrateErr != nil { syncedStatus, syncedReason, syncedMessage := evaluateSynced(in, fetchFailed, catalogFailed) setCondition(&status, in, helmv1alpha1.ConditionTypeSynced, syncedStatus, syncedReason, syncedMessage) } @@ -121,6 +132,13 @@ func Evaluate(in Inputs) Decision { status.LastForceReconcileTime = &metav1.Time{Time: in.Now} } + if fetchSucceeded { + // Written on every successful read, not only on a complete pass: it + // describes what the repository offers, and a pass left incomplete by a + // pending tag still read the full list of charts. + status.ChartCount = ptr.To(int32(in.Fetch.Charts)) + } + if fetchSucceeded && !catalogFailed && in.Fetch.Pending == 0 { status.LastSuccessfulSyncTime = &metav1.Time{Time: in.Now} } @@ -140,7 +158,7 @@ func Evaluate(in Inputs) Decision { return Decision{ Status: status, RequeueAfter: requeueAfter, - Err: firstErr(in.SecretsErr, in.InternalRepositoryErr, catalogErr(in)), + Err: firstErr(in.SecretsErr, in.InternalRepositoryErr, in.MigrateErr, catalogErr(in)), } } @@ -187,8 +205,13 @@ func evaluateSynced(in Inputs, fetchFailed, catalogFailed bool) (metav1.Conditio case fetchFailed: return metav1.ConditionFalse, helmv1alpha1.ReasonSyncFailed, in.Fetch.Message case catalogFailed: + err := in.MigrateErr + if err == nil { + err = in.Catalog.Err + } + return metav1.ConditionFalse, helmv1alpha1.ReasonCatalogUpdateFailed, - "Failed to update the chart catalog: " + in.Catalog.Err.Error() + "Failed to update the chart catalog: " + err.Error() case in.Fetch != nil && in.Fetch.Pending > 0 && in.Current.LastSuccessfulSyncTime == nil: // On the very first pass there is no other signal that the read was incomplete: // lastSuccessfulSyncTime is empty either way, so a user who just created the @@ -310,7 +333,7 @@ func carriesCatalogFailure(in Inputs) bool { // hasEvidence reports whether the repository is already proven usable on the // current generation: a fetch succeeded for this spec. -func hasEvidence(current helmv1alpha1.HelmClusterAddonRepositoryStatus, generation int64) bool { +func hasEvidence(current helmv1alpha1.RepositoryStatus, generation int64) bool { // Evidence is "a fetch succeeded on this spec". Ready alone cannot carry it: // a higher-priority rule (an unhealthy internal repository, a failed secret) // owns Ready on the very pass where the fetch succeeded, overwriting it. @@ -328,7 +351,7 @@ func hasEvidence(current helmv1alpha1.HelmClusterAddonRepositoryStatus, generati } func setCondition( - status *helmv1alpha1.HelmClusterAddonRepositoryStatus, + status *helmv1alpha1.RepositoryStatus, in Inputs, conditionType string, conditionStatus metav1.ConditionStatus, @@ -345,7 +368,7 @@ func setCondition( } func applyAbnormal( - status *helmv1alpha1.HelmClusterAddonRepositoryStatus, + status *helmv1alpha1.RepositoryStatus, in Inputs, conditionType string, cond abnormalCondition, @@ -399,7 +422,7 @@ const ( // ShouldAttempt reports whether a synchronization attempt is due. The caller // additionally requires the auxiliary resources to be in place. func ShouldAttempt( - current helmv1alpha1.HelmClusterAddonRepositoryStatus, + current helmv1alpha1.RepositoryStatus, generation int64, now time.Time, forced bool, diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate_test.go b/images/operator-helm-controller/internal/reconcile/repository/evaluate_test.go similarity index 92% rename from images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate_test.go rename to images/operator-helm-controller/internal/reconcile/repository/evaluate_test.go index 42e35ea8..146ceaf3 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/evaluate_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddonrepository +package repository import ( "errors" @@ -31,8 +31,8 @@ import ( var testNow = time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) // readyStatus builds a status that already carries proven Ready for the given generation. -func readyStatus(generation int64) helmv1alpha1.HelmClusterAddonRepositoryStatus { - return helmv1alpha1.HelmClusterAddonRepositoryStatus{ +func readyStatus(generation int64) helmv1alpha1.RepositoryStatus { + return helmv1alpha1.RepositoryStatus{ ObservedGeneration: generation, Conditions: []metav1.Condition{ { @@ -56,7 +56,7 @@ func readyStatus(generation int64) helmv1alpha1.HelmClusterAddonRepositoryStatus // stalledStatus builds a status like readyStatus, plus a Stalled=True condition // recorded for staleGeneration — used to test that a generation bump voids a // carried-forward Stalled reason that described the previous spec. -func stalledStatus(generation, staleGeneration int64, reason string) helmv1alpha1.HelmClusterAddonRepositoryStatus { +func stalledStatus(generation, staleGeneration int64, reason string) helmv1alpha1.RepositoryStatus { status := readyStatus(generation) status.Conditions = append(status.Conditions, metav1.Condition{ Type: helmv1alpha1.ConditionTypeStalled, @@ -73,8 +73,8 @@ func stalledStatus(generation, staleGeneration int64, reason string) helmv1alpha // first fetch succeeded while the internal repository was still unhealthy: // Synced records the successful read, but Ready was written False by the // higher-priority internal-repository rule, so Ready alone carries no evidence. -func syncedNotReadyStatus(generation int64) helmv1alpha1.HelmClusterAddonRepositoryStatus { - return helmv1alpha1.HelmClusterAddonRepositoryStatus{ +func syncedNotReadyStatus(generation int64) helmv1alpha1.RepositoryStatus { + return helmv1alpha1.RepositoryStatus{ ObservedGeneration: generation, Conditions: []metav1.Condition{ { @@ -107,7 +107,7 @@ func syncedNotReadyStatus(generation int64) helmv1alpha1.HelmClusterAddonReposit // catalogFailedStatus builds the status left behind by a pass whose fetch // succeeded and whose catalog write failed: Ready stays latched True, Synced is // False with CatalogUpdateFailed and Reconciling carries the retry. -func catalogFailedStatus(generation int64) helmv1alpha1.HelmClusterAddonRepositoryStatus { +func catalogFailedStatus(generation int64) helmv1alpha1.RepositoryStatus { status := readyStatus(generation) apimeta.SetStatusCondition(&status.Conditions, metav1.Condition{ Type: helmv1alpha1.ConditionTypeSynced, @@ -129,7 +129,7 @@ func catalogFailedStatus(generation int64) helmv1alpha1.HelmClusterAddonReposito return status } -func conditionOf(t *testing.T, status helmv1alpha1.HelmClusterAddonRepositoryStatus, conditionType string) *metav1.Condition { +func conditionOf(t *testing.T, status helmv1alpha1.RepositoryStatus, conditionType string) *metav1.Condition { t.Helper() return apimeta.FindStatusCondition(status.Conditions, conditionType) @@ -489,7 +489,7 @@ func TestEvaluatePreservesFailuresWhenNoFetchWasAttempted(t *testing.T) { in := Inputs{ Generation: 1, Now: testNow, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ + Current: helmv1alpha1.RepositoryStatus{ ObservedGeneration: 1, ConsecutiveFetchFailures: 3, }, @@ -568,7 +568,7 @@ func TestEvaluatePartialSyncAfterFullOneStaysSynced(t *testing.T) { Attempted: true, Fetch: &services.FetchOutcome{Pending: 1}, Catalog: &services.CatalogOutcome{}, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ + Current: helmv1alpha1.RepositoryStatus{ ObservedGeneration: 1, LastSuccessfulSyncTime: &earlier, }, @@ -586,7 +586,7 @@ func TestEvaluatePartialSyncAfterFullOneStaysSynced(t *testing.T) { } func TestEvaluatePartialSyncNeverStalls(t *testing.T) { - current := helmv1alpha1.HelmClusterAddonRepositoryStatus{ObservedGeneration: 1} + current := helmv1alpha1.RepositoryStatus{ObservedGeneration: 1} for range MaxFetchFailures + 2 { decision := Evaluate(Inputs{ @@ -623,7 +623,48 @@ func TestEvaluateFullSyncAdvancesLastSuccessfulSyncTime(t *testing.T) { } } -func assertAbnormal(t *testing.T, status helmv1alpha1.HelmClusterAddonRepositoryStatus, conditionType, wantReason string) { +// TestEvaluateChartCount pins what the field means: how many charts the repository +// offered when it was last read successfully. A pointer, so that a repository never +// read apart from one offering nothing is not reported as offering nothing, and a +// failed read leaves the last known answer rather than replacing it with zero. +func TestEvaluateChartCount(t *testing.T) { + never := Evaluate(Inputs{Generation: 1, Now: testNow}) + if never.Status.ChartCount != nil { + t.Fatalf("chartCount is %v before any read, want nil", *never.Status.ChartCount) + } + + read := Evaluate(Inputs{ + Generation: 1, Now: testNow, + Attempted: true, + Fetch: &services.FetchOutcome{Charts: 3}, + Catalog: &services.CatalogOutcome{}, + }) + if read.Status.ChartCount == nil || *read.Status.ChartCount != 3 { + t.Fatalf("chartCount is %v, want 3", read.Status.ChartCount) + } + + empty := Evaluate(Inputs{ + Generation: 1, Now: testNow, + Attempted: true, + Fetch: &services.FetchOutcome{}, + Catalog: &services.CatalogOutcome{}, + }) + if empty.Status.ChartCount == nil || *empty.Status.ChartCount != 0 { + t.Fatalf("chartCount is %v for a repository offering nothing, want 0", empty.Status.ChartCount) + } + + failed := Evaluate(Inputs{ + Generation: 1, Now: testNow, + Current: read.Status, + Attempted: true, + Fetch: &services.FetchOutcome{Err: errors.New("connection refused"), Reason: helmv1alpha1.ReasonSyncFailed}, + }) + if failed.Status.ChartCount == nil || *failed.Status.ChartCount != 3 { + t.Fatalf("chartCount is %v after a failed read, want the last successful 3", failed.Status.ChartCount) + } +} + +func assertAbnormal(t *testing.T, status helmv1alpha1.RepositoryStatus, conditionType, wantReason string) { t.Helper() cond := conditionOf(t, status, conditionType) diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/kstatus_test.go b/images/operator-helm-controller/internal/reconcile/repository/kstatus_test.go similarity index 99% rename from images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/kstatus_test.go rename to images/operator-helm-controller/internal/reconcile/repository/kstatus_test.go index 2594c6f7..da4bb704 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/kstatus_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/kstatus_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddonrepository +package repository import ( "errors" diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go new file mode 100644 index 00000000..014eb208 --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go @@ -0,0 +1,296 @@ +/* +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" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/chartsource" + "github.com/deckhouse/operator-helm/internal/reconcile/pass" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" +) + +// Deps are the collaborators of one repository kind, as the release package's Deps +// are of one release kind. NewRepository returns an empty adapter of that kind for +// the API object to be read into; Consumers pushes a force request onto the internal +// sources of whatever consumes the repository in that family. +type Deps struct { + NewRepository func() source.Repository + Secrets SecretManager + Internal InternalRepositoryManager + Consumers source.ConsumerForcer + Catalog CatalogSynchronizer + Status *status.Manager +} + +func New(c client.Client, deps Deps) *Reconciler { + return &Reconciler{Client: c, deps: deps} +} + +type Reconciler struct { + client.Client + + deps Deps +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + repo := r.deps.NewRepository() + if err := r.Get(ctx, req.NamespacedName, repo.Object()); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + + return reconcile.Result{}, fmt.Errorf("getting repository: %w", err) + } + + repoType, repoTypeErr := chartsource.KindOf(repo.URL()) + + if !repo.Object().GetDeletionTimestamp().IsZero() { + return r.reconcileDelete(ctx, repo, repoType) + } + + if !controllerutil.ContainsFinalizer(repo.Object(), helmv1alpha1.FinalizerName) { + controllerutil.AddFinalizer(repo.Object(), helmv1alpha1.FinalizerName) + + if err := r.Update(ctx, repo.Object()); err != nil { + return reconcile.Result{}, fmt.Errorf("adding finalizer: %w", err) + } + // Continue reconciling in the same pass: adding a finalizer is a + // metadata-only change that does not bump generation, so the resulting + // update event is dropped by the generation/annotation predicates and + // would not trigger a follow-up reconcile. + } + + in := Inputs{ + Generation: repo.Generation(), + Now: time.Now().UTC(), + Jitter: NewJitter(), + Current: *repo.Status().DeepCopy(), + } + + // TRANSITIONAL: the catalog object names moved, and a consumer resolves the new + // name from the moment this controller starts. Renaming here rather than inside + // the synchronization keeps it independent of the fetch: a repository that is not + // due for a sync yet, or whose remote is gone for good, still gets its objects + // moved. A failure travels to finish() rather than out of Reconcile, or the + // repository would report nothing at all while its consumers cannot resolve. + in.MigrateErr = r.deps.Catalog.MigrateNames(ctx, repo) + + if repoTypeErr != nil { + in.ConfigErr = &services.ConfigOutcome{ + Reason: helmv1alpha1.ReasonUnsupportedRepositoryType, + Message: repoTypeErr.Error(), + Err: repoTypeErr, + } + + return r.finish(ctx, repo, in, false) + } + + in.SecretsErr = r.deps.Secrets.Ensure(ctx, repo, repoType) + + if in.SecretsErr == nil { + switch repoType { + case chartsource.Helm: + in.InternalRepository, in.InternalRepositoryErr = r.deps.Internal.EnsureInternalHelmRepository(ctx, repo) + case chartsource.OCI: + // The url may have changed from helm to oci: drop the internal object + // that is no longer used. OCI repositories have none of their own. + in.InternalRepositoryErr = r.deps.Internal.RemoveHelmRepository(ctx, repo.InternalNames()) + } + } + + in.Forced = repo.ForceReconcileRequired() + + // A rename left unfinished must stop the synchronization: the object under the + // new name still has an empty status, and writing the fetched versions into it + // would make the next pass skip the status carry-over and delete the old object, + // taking with it the only copy of a version a consumer still holds. + if in.MigrateErr == nil && in.SecretsErr == nil && in.InternalRepositoryErr == nil && + ShouldAttempt(in.Current, in.Generation, in.Now, in.Forced) { + if err := r.markSyncInProgress(ctx, repo, in.Forced); err != nil { + return reconcile.Result{}, err + } + + outcome := r.deps.Catalog.Sync(ctx, repo, repoType) + + in.Attempted = true + if outcome.FetchAttempted { + // A cluster-side failure before the fetch (see RepoSyncService.Sync) + // leaves outcome.FetchAttempted false; in.Fetch must stay nil then, or + // its zero-value Err == nil would be read as a successful fetch and + // reset ConsecutiveFetchFailures / mark Ready=True off nothing. + in.Fetch = &outcome.Fetch + } + in.Catalog = &outcome.Catalog + } + + return r.finish(ctx, repo, in, in.Attempted) +} + +// finish applies the decision and consumes the force annotation when an attempt +// actually ran. The annotation is removed after the status patch so a conflict +// does not lose the request. +func (r *Reconciler) finish( + ctx context.Context, + repo source.Repository, + in Inputs, + attempted bool, +) (reconcile.Result, error) { + decision := Evaluate(in) + + if in.Fetch != nil && in.Fetch.Err != nil { + // A repository read failure is not returned to the work queue — its retry + // is carried by nextSyncTime — so this is the only place it is logged. + log.FromContext(ctx).Error(in.Fetch.Err, in.Fetch.Message) + } + + if err := r.deps.Status.PatchStatus(ctx, repo.Object(), func() { + *repo.Status() = decision.Status + }); client.IgnoreNotFound(err) != nil { + return reconcile.Result{}, err + } + + if attempted { + // A force request reaches a consumer's artifact only through the consumer's + // own internal OCIRepository, and any repository can have those: an oci:// one + // for every consumer, a helm one for every version its index publishes in a + // registry. This runs before the annotation is consumed: a failure leaves the + // request in place to be retried. The versions a helm repository serves as + // archives need no equivalent — there the internal HelmRepository carries the + // request and its HelmCharts follow the re-indexed source on their own. + if repo.ForceReconcileRequired() { + if err := r.deps.Consumers.ForceReconcileConsumers(ctx, repo); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to force reconcile internal oci repositories: %w", err) + } + } + + if err := pass.ConsumeForceAnnotation(ctx, r.Client, client.ObjectKeyFromObject(repo.Object()), r.deps.NewRepository().Object()); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to reconcile force annotation: %w", err) + } + } + + if decision.Err != nil { + // Cluster write failures are handed to the work queue rate limiter; the + // schedule is re-established on the next pass. + return reconcile.Result{}, decision.Err + } + + return reconcile.Result{RequeueAfter: decision.RequeueAfter}, nil +} + +func (r *Reconciler) reconcileDelete(ctx context.Context, repo source.Repository, repoType chartsource.Kind) (reconcile.Result, error) { + logger := log.FromContext(ctx) + + if !controllerutil.ContainsFinalizer(repo.Object(), helmv1alpha1.FinalizerName) { + return reconcile.Result{}, nil + } + + names := repo.InternalNames() + + if err := r.deps.Secrets.Cleanup(ctx, names); err != nil && !apierrors.IsNotFound(err) { + _ = r.deps.Status.MarkDeletionFailed(ctx, repo.Object(), "auxiliary secrets", err) + return reconcile.Result{}, err + } + + // Only a helm repository has an internal object of its own; for an oci one the + // secrets above were everything. An unknown repository type takes the helm path + // too, because it is a state a real repository can reach: the url validation + // regex on the CRD is looser than url.Parse, so a repository whose internal + // objects already exist can be edited to a url that no longer parses and then + // deleted. Cleaning up the helm way tolerates a missing internal repository, and + // leaving it out would orphan one. + if repoType != chartsource.OCI { + helmRepo, err := r.deps.Internal.CleanupHelmRepository(ctx, names) + if err != nil && !apierrors.IsNotFound(err) { + _ = r.deps.Status.MarkDeletionFailed(ctx, repo.Object(), "internal repository", err) + return reconcile.Result{}, err + } + if helmRepo != nil { + return pass.AwaitInternalResourceDeletion(ctx, r.deps.Status.MarkDeletionPending, repo.Object(), "internal repository", helmRepo) + } + } + + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := r.deps.NewRepository() + if err := r.Get(ctx, client.ObjectKeyFromObject(repo.Object()), latest.Object()); err != nil { + return client.IgnoreNotFound(err) + } + + if controllerutil.RemoveFinalizer(latest.Object(), helmv1alpha1.FinalizerName) { + if err := r.Update(ctx, latest.Object()); 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") + + return reconcile.Result{}, nil +} + +// markSyncInProgress publishes Reconciling before the synchronization starts, so +// a pass that is about to read the repository says so while the read is running +// instead of only once it is over — a read can take a while, and until it +// returns nothing else on the status moves. The reason distinguishes the two ways +// a pass is triggered: ForceReconcile is the case someone is actively watching, +// having annotated the repository a moment ago to see it picked up, while +// Synchronization is the ordinary scheduled cadence. The condition is +// deliberately written outside the Inputs snapshot Evaluate works from, so the +// status computed at the end of the pass removes it again without a rule of its +// own. +func (r *Reconciler) markSyncInProgress( + ctx context.Context, + repo source.Repository, + forced bool, +) error { + reason, message := helmv1alpha1.ReasonSynchronization, "Repository synchronization in progress" + if forced { + reason, message = helmv1alpha1.ReasonForceReconcile, "Forced reconciliation in progress" + } + + err := r.deps.Status.PatchStatus(ctx, repo.Object(), func() { + apimeta.SetStatusCondition(&repo.Status().Conditions, metav1.Condition{ + Type: helmv1alpha1.ConditionTypeReconciling, + Status: metav1.ConditionTrue, + Reason: reason, + Message: message, + ObservedGeneration: repo.Generation(), + }) + }) + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("publishing synchronization progress: %w", err) + } + + return nil +} diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go similarity index 60% rename from images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go rename to images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 21d8f404..82b3fc6e 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -14,16 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddonrepository +package repository import ( "context" + "errors" + "reflect" + "strings" "testing" "time" "github.com/Masterminds/semver/v3" - "github.com/werf/3p-fluxcd-pkg/apis/meta" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" @@ -33,13 +36,18 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/operator-helm/api/naming" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/chartsource" repoclient "github.com/deckhouse/operator-helm/internal/client/repository" "github.com/deckhouse/operator-helm/internal/index" - "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -65,6 +73,17 @@ func (s *stubRepoClient) FetchCharts(_ context.Context, _ string, _ *repoclient. func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) (*Reconciler, client.Client) { t.Helper() + return newReconcilerWithInterceptor(t, interceptor.Funcs{}, stub, objects...) +} + +func newReconcilerWithInterceptor( + t *testing.T, + funcs interceptor.Funcs, + stub *stubRepoClient, + objects ...client.Object, +) (*Reconciler, client.Client) { + t.Helper() + scheme := runtime.NewScheme() for _, add := range []func(*runtime.Scheme) error{ clientgoscheme.AddToScheme, @@ -78,6 +97,7 @@ func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) c := fake.NewClientBuilder(). WithScheme(scheme). + WithInterceptorFuncs(funcs). WithObjects(objects...). WithStatusSubresource( &helmv1alpha1.HelmClusterAddonRepository{}, @@ -98,17 +118,62 @@ func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) }). Build() - factory := func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) { + factory := func(_ chartsource.Kind) (repoclient.ClientInterface, error) { return stub, nil } - r := New( - c, - services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), - services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, nil), - services.NewRepoSyncService(c, scheme, factory), - status.NewManager(c), - ) + r := New(c, Deps{ + NewRepository: adapter.EmptyAddonRepository, + Secrets: services.NewRepoSecretsService(c, scheme, helmv1alpha1.TargetNamespace), + Internal: services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), + Consumers: services.NewForceService(c, helmv1alpha1.TargetNamespace, adapter.ListAddonReleases(c)), + Catalog: services.NewRepoSyncService(c, scheme, factory, adapter.NewAddonCatalog(c)), + Status: status.NewManager(c), + }) + + return r, c +} + +// newApplicationReconciler wires the reconciler for the namespaced kind. The +// addon fixture above stays the default; this one exists to prove the same +// reconciler composes with a namespaced adapter and catalog. +func newApplicationReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) (*Reconciler, client.Client) { + t.Helper() + + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + clientgoscheme.AddToScheme, + helmv1alpha1.AddToScheme, + sourcev1.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatalf("registering scheme: %v", err) + } + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource( + &helmv1alpha1.HelmApplicationRepository{}, + &helmv1alpha1.HelmApplicationChart{}, + ). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationRepository, index.ApplicationRepositoryIndexer). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). + Build() + + factory := func(_ chartsource.Kind) (repoclient.ClientInterface, error) { + return stub, nil + } + + r := New(c, Deps{ + NewRepository: adapter.EmptyApplicationRepository, + Secrets: services.NewRepoSecretsService(c, scheme, helmv1alpha1.TargetNamespace), + Internal: services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), + Consumers: services.NewForceService(c, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(c)), + Catalog: services.NewRepoSyncService(c, scheme, factory, adapter.NewApplicationCatalog(c)), + Status: status.NewManager(c), + }) return r, c } @@ -116,14 +181,14 @@ func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) func ociRepository() *helmv1alpha1.HelmClusterAddonRepository { return &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "oci://ghcr.io/example/podinfo"}, + Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/podinfo"}, } } func helmRepository() *helmv1alpha1.HelmClusterAddonRepository { return &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://charts.example.invalid/stable"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/stable"}, } } @@ -215,6 +280,281 @@ func TestReconcileSkipsFetchBeforeSchedule(t *testing.T) { } } +// TestReconcileMigratesCatalogNamesWithoutAFetch pins that the catalog rename does +// not wait on the remote. A consumer resolves the current name from the moment this +// controller starts, so a repository that is not due for a sync yet, or whose +// registry is gone for good, must still get its objects moved — otherwise its +// consumers never resolve their chart again. +// +// TRANSITIONAL: remove together with the catalog's own migration. +func TestReconcileMigratesCatalogNamesWithoutAFetch(t *testing.T) { + repo := ociRepository() + legacy := &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-repo-chart-podinfo", + Labels: map[string]string{ + helmv1alpha1.LabelDeckhouseHeritage: helmv1alpha1.LabelDeckhouseHeritageValue, + helmv1alpha1.LabelRepositoryName: repo.Name, + helmv1alpha1.LabelChartName: "podinfo", + }, + }, + Status: helmv1alpha1.ChartCatalogStatus{ + Versions: []helmv1alpha1.ChartVersion{{Version: "1.0.0", MediaType: "application/tar+gzip"}}, + }, + } + + stub := &stubRepoClient{err: &repoclient.TerminalError{ + Reason: helmv1alpha1.ReasonAuthenticationFailed, + Message: "repository rejected the credentials (HTTP 401)", + }} + + r, c := newReconciler(t, stub, repo, legacy) + reconcileUntilStable(t, r, repo.Name) + + moved := &helmv1alpha1.HelmClusterAddonChart{} + key := client.ObjectKey{Name: naming.HelmClusterAddonChartName(repo.Name, "podinfo")} + if err := c.Get(context.Background(), key, moved); err != nil { + t.Fatalf("catalog object was not renamed while the fetch was failing: %v", err) + } + if len(moved.Status.Versions) != 1 || moved.Status.Versions[0].MediaType == "" { + t.Fatalf("versions = %+v, want the legacy status carried over", moved.Status.Versions) + } + + err := c.Get(context.Background(), client.ObjectKey{Name: legacy.Name}, &helmv1alpha1.HelmClusterAddonChart{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("legacy object err = %v, want NotFound", err) + } +} + +// TestReconcileReportsAFailedMigration pins that a rename the cluster refuses does +// not silence the repository. Returning the failure out of Reconcile would skip the +// status write entirely, leaving an object with no conditions at all while its +// consumers cannot resolve their chart — the one state nobody can diagnose. +// +// TRANSITIONAL: remove together with the catalog's own migration. +func TestReconcileReportsAFailedMigration(t *testing.T) { + repo := ociRepository() + legacy := &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-repo-chart-podinfo", + Labels: map[string]string{ + helmv1alpha1.LabelDeckhouseHeritage: helmv1alpha1.LabelDeckhouseHeritageValue, + helmv1alpha1.LabelRepositoryName: repo.Name, + helmv1alpha1.LabelChartName: "podinfo", + }, + }, + } + + r, c := newReconcilerWithInterceptor(t, interceptor.Funcs{ + // Only the rename's own delete is refused; the ordinary pruning below must + // stay reachable, or the test would be about something else. + Delete: func(ctx context.Context, cl client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { + if obj.GetName() == "e2e-repo-chart-podinfo" { + return errors.New("forbidden") + } + + return cl.Delete(ctx, obj, opts...) + }, + }, &stubRepoClient{}, repo, legacy) + + // The refused object also survives into the pruning loop, so the pass may report + // either failure. What matters is that the status was written regardless. + for range 2 { + _, _ = r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: repo.Name}, + }) + } + + updated := &helmv1alpha1.HelmClusterAddonRepository{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(repo), updated); err != nil { + t.Fatalf("getting repository: %v", err) + } + + if len(updated.Status.Conditions) == 0 { + t.Fatal("the repository carries no conditions at all: the failure never reached the status write") + } + + synced := apimeta.FindStatusCondition(updated.Status.Conditions, helmv1alpha1.ConditionTypeSynced) + if synced == nil || synced.Status != metav1.ConditionFalse || synced.Reason != helmv1alpha1.ReasonCatalogUpdateFailed { + t.Fatalf("Synced = %v, want False with %s", synced, helmv1alpha1.ReasonCatalogUpdateFailed) + } + if updated.Status.ObservedGeneration != updated.Generation { + t.Fatalf("observedGeneration is %d, want %d", updated.Status.ObservedGeneration, updated.Generation) + } +} + +// TestReconcileReportsAFailedMigrationWithoutAnAttempt pins the half of the rename +// path the previous test cannot reach. The rename runs on every pass, including one +// that is not due for a synchronization, and on such a pass the repository would +// otherwise keep the Synced=True its last successful sync left behind — reporting +// health while its consumers cannot resolve their chart. +// +// TRANSITIONAL: remove together with the catalog's own migration. +func TestReconcileReportsAFailedMigrationWithoutAnAttempt(t *testing.T) { + repo := ociRepository() + refuse := false + + stub := &stubRepoClient{charts: []repoclient.Chart{{ + Name: "podinfo", + Versions: []repoclient.ChartVersion{{Version: semver.MustParse("6.7.1")}}, + }}} + + r, c := newReconcilerWithInterceptor(t, interceptor.Funcs{ + Delete: func(ctx context.Context, cl client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { + if refuse && obj.GetName() == "e2e-repo-chart-podinfo" { + return errors.New("forbidden") + } + + return cl.Delete(ctx, obj, opts...) + }, + }, stub, repo) + + // A clean run first, so the repository ends up healthy with a schedule ahead of + // it: only then is the next pass one that attempts nothing. + reconcileUntilStable(t, r, repo.Name) + + synced := &helmv1alpha1.HelmClusterAddonRepository{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(repo), synced); err != nil { + t.Fatalf("getting repository: %v", err) + } + if !apimeta.IsStatusConditionTrue(synced.Status.Conditions, helmv1alpha1.ConditionTypeSynced) { + t.Fatalf("the first run must leave Synced=True, conditions: %v", synced.Status.Conditions) + } + if synced.Status.NextSyncTime == nil || !synced.Status.NextSyncTime.After(time.Now()) { + t.Fatalf("nextSyncTime = %v, want a schedule in the future", synced.Status.NextSyncTime) + } + + refuse = true + legacy := &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-repo-chart-podinfo", + Labels: map[string]string{ + helmv1alpha1.LabelDeckhouseHeritage: helmv1alpha1.LabelDeckhouseHeritageValue, + helmv1alpha1.LabelRepositoryName: repo.Name, + helmv1alpha1.LabelChartName: "podinfo", + }, + }, + } + if err := c.Create(context.Background(), legacy); err != nil { + t.Fatalf("creating the legacy object: %v", err) + } + + if _, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: repo.Name}, + }); err == nil { + t.Fatal("the pass must report the refused rename, or the work queue waits for the next sync instead of retrying") + } + + updated := &helmv1alpha1.HelmClusterAddonRepository{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(repo), updated); err != nil { + t.Fatalf("getting repository: %v", err) + } + + cond := apimeta.FindStatusCondition(updated.Status.Conditions, helmv1alpha1.ConditionTypeSynced) + if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != helmv1alpha1.ReasonCatalogUpdateFailed { + t.Fatalf("Synced = %v, want False with %s: the pass attempted no sync, so the stale True would stand", cond, helmv1alpha1.ReasonCatalogUpdateFailed) + } + if !strings.Contains(cond.Message, "forbidden") { + t.Fatalf("Synced message = %q, want the refusal in it", cond.Message) + } +} + +// TestUnfinishedRenameDoesNotOverwriteTheCatalog pins the sequencing that makes the +// rename safe. A version the repository no longer offers survives only in the status +// of the object under the old name; writing the fetched versions into the new, +// still-empty object would make the next pass consider the carry-over done and +// delete that last copy. So a pass whose rename did not finish must not synchronize. +// +// This goes through Reconcile rather than the catalog directly: the defect lived in +// the reconciler's gate, and a test double that stops at the first error cannot see +// it. +// +// TRANSITIONAL: remove together with the catalog's own migration. +func TestUnfinishedRenameDoesNotOverwriteTheCatalog(t *testing.T) { + repo := ociRepository() + legacy := &helmv1alpha1.HelmClusterAddonChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-chart-podinfo", + Labels: map[string]string{ + helmv1alpha1.LabelDeckhouseHeritage: helmv1alpha1.LabelDeckhouseHeritageValue, + helmv1alpha1.LabelRepositoryName: repo.Name, + helmv1alpha1.LabelChartName: "podinfo", + }, + }, + Status: helmv1alpha1.ChartCatalogStatus{ + Versions: []helmv1alpha1.ChartVersion{{ + Version: "1.0.0", + MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + }}, + }, + } + consumer := &helmv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{Name: "consumer"}, + Spec: helmv1alpha1.HelmClusterAddonSpec{ + Namespace: "app", + Chart: helmv1alpha1.HelmClusterAddonChartRef{ + HelmClusterAddonRepository: repo.Name, + HelmClusterAddonChartName: "podinfo", + Version: "1.0.0", + }, + }, + } + + // The repository dropped 1.0.0: only the legacy object still knows about it. + stub := &stubRepoClient{charts: []repoclient.Chart{{ + Name: "podinfo", + Versions: []repoclient.ChartVersion{{Version: semver.MustParse("2.0.0")}}, + }}} + + failed := false + r, c := newReconcilerWithInterceptor(t, interceptor.Funcs{ + SubResourcePatch: func( + ctx context.Context, + cl client.Client, + sub string, + obj client.Object, + patch client.Patch, + opts ...client.SubResourcePatchOption, + ) error { + if _, ok := obj.(*helmv1alpha1.HelmClusterAddonChart); ok && !failed { + failed = true + + return errors.New("transient status patch failure") + } + + return cl.SubResource(sub).Patch(ctx, obj, patch, opts...) + }, + }, stub, repo, legacy, consumer) + + for range 4 { + _, _ = r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: repo.Name}, + }) + } + + moved := &helmv1alpha1.HelmClusterAddonChart{} + key := client.ObjectKey{Name: naming.HelmClusterAddonChartName(repo.Name, "podinfo")} + if err := c.Get(context.Background(), key, moved); err != nil { + t.Fatalf("the chart was never moved to its current name: %v", err) + } + + var retained *helmv1alpha1.ChartVersion + for i := range moved.Status.Versions { + if moved.Status.Versions[i].Version == "1.0.0" { + retained = &moved.Status.Versions[i] + } + } + if retained == nil { + t.Fatalf("versions = %+v, want 1.0.0 carried over: a consumer still holds it and the repository no longer offers it", moved.Status.Versions) + } + if retained.MediaType == "" { + t.Fatal("1.0.0 lost its media type, so the consumer's internal source cannot be built") + } + if retained.UnavailableReason != helmv1alpha1.UnavailableReasonRemovedFromRepository { + t.Fatalf("1.0.0 reason = %q, want %q", retained.UnavailableReason, helmv1alpha1.UnavailableReasonRemovedFromRepository) + } +} + func TestReconcileTerminalFetchFailureStalls(t *testing.T) { repo := ociRepository() stub := &stubRepoClient{err: &repoclient.TerminalError{ @@ -322,10 +662,10 @@ func TestReconcileDeleteCleansUpWhenURLNoLongerParses(t *testing.T) { DeletionTimestamp: &now, }, // Passes the CRD rule ^(https?|oci)://.+$ and fails url.Parse. - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://exa mple.invalid/charts"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://exa mple.invalid/charts"}, } - if _, err := utils.GetRepositoryType(repo.Spec.URL); err == nil { + if _, err := chartsource.KindOf(repo.Spec.URL); err == nil { t.Fatal("the fixture url must be unparsable, otherwise the test proves nothing") } @@ -729,3 +1069,80 @@ func TestReconcileSkippedSynchronizationReportsNoProgress(t *testing.T) { t.Fatalf("a pass without an attempt must not report Reconciling, got %+v", cond) } } + +// TestReconcileNamespacedRepositoryDerivesNamespacedInternalObjects proves the +// composition the per-package tests cannot: a namespaced repository read through +// its adapter yields internal objects in the operator namespace under derived, +// namespace-aware names with both source labels, a catalog object next to the +// repository, and a status patched on the namespaced object. +func TestReconcileNamespacedRepositoryDerivesNamespacedInternalObjects(t *testing.T) { + repo := &helmv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "stable", Namespace: "team-a", Generation: 1}, + Spec: helmv1alpha1.RepositorySpec{ + URL: "https://charts.example.invalid/stable", + Auth: &helmv1alpha1.RepositoryAuth{Username: "user", Password: "secret"}, + }, + } + stub := &stubRepoClient{charts: []repoclient.Chart{{ + Name: "podinfo", + Versions: []repoclient.ChartVersion{{Version: semver.MustParse("6.7.1")}}, + }}} + + r, c := newApplicationReconciler(t, stub, repo) + + for range 2 { + if _, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Namespace: "team-a", Name: "stable"}, + }); err != nil { + t.Fatalf("Reconcile returned %v", err) + } + } + + wantLabels := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: "stable", + helmv1alpha1.LabelSourceNamespace: "team-a", + } + + helmRepo := &sourcev1.HelmRepository{} + if err := c.Get(context.Background(), client.ObjectKey{ + Namespace: helmv1alpha1.TargetNamespace, Name: "hapr-team-a-stable-42df68033b1e", + }, helmRepo); err != nil { + t.Fatalf("internal helm repository with a derived name was not created in the operator namespace: %v", err) + } + if !reflect.DeepEqual(helmRepo.Labels, wantLabels) { + t.Fatalf("internal helm repository labels = %v, want %v", helmRepo.Labels, wantLabels) + } + if helmRepo.Spec.SecretRef == nil || helmRepo.Spec.SecretRef.Name != adapter.NewApplicationRepository(repo).InternalNames().AuthSecret { + t.Fatalf("internal helm repository must reference the derived auth secret, got %+v", helmRepo.Spec.SecretRef) + } + + authSecret := &corev1.Secret{} + if err := c.Get(context.Background(), client.ObjectKey{ + Namespace: helmv1alpha1.TargetNamespace, Name: helmRepo.Spec.SecretRef.Name, + }, authSecret); err != nil { + t.Fatalf("derived auth secret was not created in the operator namespace: %v", err) + } + if !reflect.DeepEqual(authSecret.Labels, wantLabels) { + t.Fatalf("auth secret labels = %v, want %v", authSecret.Labels, wantLabels) + } + + var charts helmv1alpha1.HelmApplicationChartList + if err := c.List(context.Background(), &charts, client.InNamespace("team-a")); err != nil { + t.Fatalf("listing charts: %v", err) + } + if len(charts.Items) != 1 || charts.Items[0].Labels[helmv1alpha1.LabelChartName] != "podinfo" { + t.Fatalf("charts in team-a = %v, want exactly podinfo", charts.Items) + } + + updated := &helmv1alpha1.HelmApplicationRepository{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(repo), updated); err != nil { + t.Fatalf("getting repository: %v", err) + } + if !controllerutil.ContainsFinalizer(updated, helmv1alpha1.FinalizerName) { + t.Fatal("finalizer must be added to the namespaced repository") + } + if updated.Status.ObservedGeneration != 1 { + t.Fatalf("status was not patched on the namespaced object: %+v", updated.Status) + } +} diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/schedule_test.go b/images/operator-helm-controller/internal/reconcile/repository/schedule_test.go similarity index 87% rename from images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/schedule_test.go rename to images/operator-helm-controller/internal/reconcile/repository/schedule_test.go index 68913615..f72168c7 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/schedule_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/schedule_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddonrepository +package repository import ( "errors" @@ -49,7 +49,7 @@ func TestBackoffProgression(t *testing.T) { got := Evaluate(Inputs{ Generation: 1, Now: testNow, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ + Current: helmv1alpha1.RepositoryStatus{ ObservedGeneration: 1, ConsecutiveFetchFailures: tc.failuresIn, }, @@ -78,7 +78,7 @@ func TestSuccessResetsCounterAndRecordsSyncTime(t *testing.T) { got := Evaluate(Inputs{ Generation: 1, Now: testNow, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ + Current: helmv1alpha1.RepositoryStatus{ ObservedGeneration: 1, ConsecutiveFetchFailures: 3, }, @@ -105,7 +105,7 @@ func TestCatalogFailureDoesNotRecordSyncTime(t *testing.T) { got := Evaluate(Inputs{ Generation: 1, Now: testNow, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ + Current: helmv1alpha1.RepositoryStatus{ ObservedGeneration: 1, LastSuccessfulSyncTime: &previous, }, @@ -130,7 +130,7 @@ func TestTerminalFetchSaturatesCounter(t *testing.T) { got := Evaluate(Inputs{ Generation: 1, Now: testNow, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ObservedGeneration: 1}, + Current: helmv1alpha1.RepositoryStatus{ObservedGeneration: 1}, InternalRepository: services.InternalRepositoryState{Present: true, Ready: true}, Attempted: true, Fetch: &services.FetchOutcome{ @@ -151,7 +151,7 @@ func TestConfigErrorDoesNotRequeue(t *testing.T) { got := Evaluate(Inputs{ Generation: 1, Now: testNow, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ObservedGeneration: 1}, + Current: helmv1alpha1.RepositoryStatus{ObservedGeneration: 1}, ConfigErr: &services.ConfigOutcome{ Reason: helmv1alpha1.ReasonUnsupportedRepositoryType, Message: "unsupported repository schema in use: ftp", @@ -167,7 +167,7 @@ func TestGenerationBumpResetsCounter(t *testing.T) { got := Evaluate(Inputs{ Generation: 2, Now: testNow, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ + Current: helmv1alpha1.RepositoryStatus{ ObservedGeneration: 1, ConsecutiveFetchFailures: 4, }, @@ -188,7 +188,7 @@ func TestPassWithoutAttemptKeepsSchedule(t *testing.T) { got := Evaluate(Inputs{ Generation: 1, Now: testNow, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ + Current: helmv1alpha1.RepositoryStatus{ ObservedGeneration: 1, NextSyncTime: &next, ConsecutiveFetchFailures: 2, @@ -222,7 +222,7 @@ func TestOverdueScheduleWithoutAttemptFloorsRequeue(t *testing.T) { got := Evaluate(Inputs{ Generation: 1, Now: testNow, - Current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ + Current: helmv1alpha1.RepositoryStatus{ ObservedGeneration: 1, NextSyncTime: &overdue, }, @@ -242,40 +242,40 @@ func TestShouldAttempt(t *testing.T) { cases := []struct { name string - current helmv1alpha1.HelmClusterAddonRepositoryStatus + current helmv1alpha1.RepositoryStatus generation int64 forced bool want bool }{ - {name: "fresh object", current: helmv1alpha1.HelmClusterAddonRepositoryStatus{}, generation: 1, want: true}, + {name: "fresh object", current: helmv1alpha1.RepositoryStatus{}, generation: 1, want: true}, { name: "schedule not reached", - current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ObservedGeneration: 1, NextSyncTime: &future}, + current: helmv1alpha1.RepositoryStatus{ObservedGeneration: 1, NextSyncTime: &future}, generation: 1, want: false, }, { name: "schedule reached", - current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ObservedGeneration: 1, NextSyncTime: &past}, + current: helmv1alpha1.RepositoryStatus{ObservedGeneration: 1, NextSyncTime: &past}, generation: 1, want: true, }, { name: "forced beats the schedule", - current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ObservedGeneration: 1, NextSyncTime: &future}, + current: helmv1alpha1.RepositoryStatus{ObservedGeneration: 1, NextSyncTime: &future}, generation: 1, forced: true, want: true, }, { name: "spec change beats the schedule", - current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ObservedGeneration: 1, NextSyncTime: &future}, + current: helmv1alpha1.RepositoryStatus{ObservedGeneration: 1, NextSyncTime: &future}, generation: 2, want: true, }, { name: "schedule never set on a matching generation", - current: helmv1alpha1.HelmClusterAddonRepositoryStatus{ObservedGeneration: 1, NextSyncTime: nil}, + current: helmv1alpha1.RepositoryStatus{ObservedGeneration: 1, NextSyncTime: nil}, generation: 1, want: true, }, diff --git a/images/operator-helm-controller/internal/services/access_service.go b/images/operator-helm-controller/internal/services/access_service.go new file mode 100644 index 00000000..a689714c --- /dev/null +++ b/images/operator-helm-controller/internal/services/access_service.go @@ -0,0 +1,354 @@ +/* +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" + "errors" + "fmt" + "maps" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/source" +) + +// ApplicationRoleName is the Role every application release of a namespace is bound +// to. One Role per namespace: every application there is bound to the same object, +// and it outlives the applications that are bound to it. +const ApplicationRoleName = "operator-helm-application" + +// AccessService provides the identity a release is applied with: a ServiceAccount in +// the operator namespace (next to the HelmRelease, where helm-controller looks it +// up), a Role in the target namespace and a RoleBinding tying the two together. +// +// The Role is bound through a RoleBinding, so whatever it grants stops at the +// namespace boundary: cluster-scoped resources are unreachable regardless of its +// content. +// +// All three are reconciled on every pass: the rules of the Role and the subjects of +// the binding are ours and are written back, so an edit made out of band does not +// survive. The Role and the binding are also watched (see +// internal/controller/helmapplication), which is what turns such an edit into a +// reconcile of the applications concerned rather than a drift that lasts until the +// next pass happens for another reason. All three kinds are excluded from the +// manager's client cache (see cmd/operator-helm-controller), so the reads here go +// to the API server and never depend on the label-scoped informers those watches +// run on. +type AccessService struct { + BaseService + + TargetNamespace string +} + +func NewAccessService(c client.Client, targetNamespace string) *AccessService { + return &AccessService{ + BaseService: BaseService{Client: c}, + TargetNamespace: targetNamespace, + } +} + +// EnsureAccess reconciles the account, the Role and the binding. A release without a +// service account name belongs to a family that does not impersonate; nothing is +// created for it. +func (s *AccessService) EnsureAccess(ctx context.Context, rel source.Release) AccessOutcome { + name := rel.InternalNames().ServiceAccount + if name == "" { + return AccessOutcome{} + } + + namespace := rel.TargetNamespace() + + if err := s.ensureServiceAccount(ctx, rel, name); err != nil { + return accessFailure(fmt.Errorf("ensuring service account: %w", err)) + } + + if err := s.ensureRole(ctx, namespace); err != nil { + return accessFailure(fmt.Errorf("ensuring role: %w", err)) + } + + if err := s.ensureRoleBinding(ctx, rel, namespace, name); err != nil { + return accessFailure(fmt.Errorf("ensuring role binding: %w", err)) + } + + return AccessOutcome{} +} + +// foreignObjectError marks an object occupying a name the operator derives that is +// not the operator's to touch. The derived names are fully computable by anyone, so +// finding something under one of them says nothing about who put it there; the +// managed-by label does. +type foreignObjectError struct { + message string +} + +func (e *foreignObjectError) Error() string { return e.message } + +// accessFailure names the failure the release reports. A foreign object is terminal: +// it leaves the way only by being removed or labelled, and neither is something a +// retry brings about — the informers behind the watches on both kinds select on the +// managed-by label, so an object without it is not even observed going away. +func accessFailure(err error) AccessOutcome { + var foreign *foreignObjectError + if errors.As(err, &foreign) { + return AccessOutcome{ + Err: err, + Terminal: true, + Reason: helmv1alpha1.ReasonForeignAccessObject, + Message: foreign.message, + } + } + + return AccessOutcome{ + Err: err, + Reason: helmv1alpha1.ReasonAccessSetupFailed, + Message: "Failed to set up the release identity: " + err.Error(), + } +} + +// managedByOperator reports whether an object found under a derived name is one of +// ours. It is the single ownership test: everything that carries the label is ours +// to shape, everything that does not is left untouched. +func managedByOperator(labels map[string]string) bool { + return labels[helmv1alpha1.LabelManagedBy] == helmv1alpha1.LabelManagedByValue +} + +// CleanupAccess removes the account and the binding. The Role stays: it is shared by +// every application of the namespace, and on its own — with no binding left naming +// it — it grants nothing. +func (s *AccessService) CleanupAccess(ctx context.Context, rel source.Release) error { + name := rel.InternalNames().ServiceAccount + if name == "" { + return nil + } + + binding := types.NamespacedName{Namespace: rel.TargetNamespace(), Name: name} + if err := s.ensureOwnedRoleBindingDeleted(ctx, binding); err != nil { + return fmt.Errorf("deleting role binding: %w", err) + } + + // Deleted by name alone, unlike the binding above: the account lives in + // s.TargetNamespace (the operator's own namespace), where a namespace owner + // has no access to pre-create anything under our name. There is no foreign + // object to protect here, so the ownership check the binding needs does not + // apply. + account := types.NamespacedName{Namespace: s.TargetNamespace, Name: name} + if err := s.ensureResourceDeleted(ctx, account, &corev1.ServiceAccount{}); err != nil { + return fmt.Errorf("deleting service account: %w", err) + } + + return nil +} + +// ensureServiceAccount keeps the account in its desired shape. The account exists +// only as a subject name — helm-controller impersonates it with headers on top of +// its own identity — so no token is ever mounted for it. +func (s *AccessService) ensureServiceAccount(ctx context.Context, rel source.Release, name string) error { + account := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: s.TargetNamespace}, + } + + _, err := controllerutil.CreateOrPatch(ctx, s.Client, account, func() error { + // Merge rather than replace: the account may carry labels put there by + // someone else (a policy engine, a cost allocator), and dropping them on + // every pass would fight whoever set them. + if account.Labels == nil { + account.Labels = map[string]string{} + } + maps.Copy(account.Labels, rel.SourceLabels()) + account.AutomountServiceAccountToken = ptr.To(false) + + return nil + }) + + return err +} + +// ensureRole keeps the namespace Role in its desired shape: full rights inside the +// namespace, and nothing outside it. Unlike the account and the binding the Role is +// shared by every application of the namespace, so it is not derived from one +// release — its rules are ours whatever put it there, and a narrowed Role is written +// back to them on the next pass. Its name is fixed and computable by anyone, though, +// so a Role found under it that is not ours is refused rather than seized: granting +// every application of the namespace full rights through an object someone else owns +// is not a decision this controller gets to make on their behalf. +// +// Being shared is also what makes the create race real: two applications of one +// namespace reconciling at once both read the Role as missing and both create it, +// and the loser is refused. They write the same content, so the second attempt is +// the patch the loser would have made had it read the Role a moment later. +func (s *AccessService) ensureRole(ctx context.Context, namespace string) error { + err := s.applyRole(ctx, namespace) + if apierrors.IsAlreadyExists(err) { + err = s.applyRole(ctx, namespace) + } + + return err +} + +func (s *AccessService) applyRole(ctx context.Context, namespace string) error { + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: ApplicationRoleName, Namespace: namespace}, + } + + _, err := controllerutil.CreateOrPatch(ctx, s.Client, role, func() error { + // A resource version is set only on an object that was read back, so it is + // what tells a Role that is already there from one about to be created. + if role.ResourceVersion != "" && !managedByOperator(role.Labels) { + return &foreignObjectError{message: fmt.Sprintf( + "role %s/%s already exists and is not managed by the operator", + namespace, ApplicationRoleName, + )} + } + + // Merge rather than replace, for the same reason the account and the binding + // merge theirs: the Role lives in the user's namespace, where a policy engine + // or a cost allocator is most likely to add a label of its own. + if role.Labels == nil { + role.Labels = map[string]string{} + } + maps.Copy(role.Labels, applicationRBACLabels()) + + role.Rules = []rbacv1.PolicyRule{{ + APIGroups: []string{"*"}, + Resources: []string{"*"}, + Verbs: []string{"*"}, + }} + + return nil + }) + + return err +} + +// applicationRBACLabels mark the namespace Role and every role binding we own. The +// managed-by label is both the ownership test and what the informers behind the +// watches on both kinds select on, so losing it reads as a deletion, brings the +// object back here and has it refused as foreign; heritage is what the rest of +// Deckhouse recognizes a module's object by. +func applicationRBACLabels() map[string]string { + return map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.LabelDeckhouseHeritage: helmv1alpha1.LabelDeckhouseHeritageValue, + } +} + +// applicationRoleRef is the roleRef every role binding we own carries. +func applicationRoleRef() rbacv1.RoleRef { + return rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: ApplicationRoleName, + } +} + +// ensureRoleBinding binds the account to the namespace Role. A binding that is ours +// is held to the shape we want whatever state it is found in: its subjects and +// labels are reconciled on every pass, and a roleRef naming some other role is +// corrected too — by replacing the object, because roleRef is immutable in +// Kubernetes and cannot be patched. +// +// A binding that is not ours is a different matter. The name is derived and fully +// computable, so it may well have been taken by someone else; adding our account to +// such a binding would grant it whatever that binding grants. It is reported and +// left exactly as it is — never patched, never deleted — so its owner decides what +// happens to it. +func (s *AccessService) ensureRoleBinding(ctx context.Context, rel source.Release, namespace, name string) error { + desiredRef := applicationRoleRef() + + existing := &rbacv1.RoleBinding{} + switch err := s.Client.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, existing); { + case apierrors.IsNotFound(err): + case err != nil: + return err + case !managedByOperator(existing.Labels): + return &foreignObjectError{message: fmt.Sprintf( + "role binding %s/%s already exists and is not managed by the operator", namespace, name, + )} + case existing.RoleRef != desiredRef: + // The precondition keeps the delete off a binding that was replaced between + // the read and here: that one has to be judged on its own labels, and the + // conflict brings this pass back to do it. A binding that is simply gone by + // now needs no deleting — the create below is what it was heading for. + err := s.Client.Delete(ctx, existing, client.Preconditions{UID: &existing.UID}) + if client.IgnoreNotFound(err) != nil { + return err + } + } + + binding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } + + _, err := controllerutil.CreateOrPatch(ctx, s.Client, binding, func() error { + // CreateOrPatch reads the object again, so the ownership test is repeated on + // what it found: between the read above and this one a binding of someone + // else's may have taken the name, and merging our labels onto it is the + // adoption the test exists to prevent. + if binding.ResourceVersion != "" && !managedByOperator(binding.Labels) { + return &foreignObjectError{message: fmt.Sprintf( + "role binding %s/%s already exists and is not managed by the operator", namespace, name, + )} + } + + // Merge rather than replace: the binding lives in the user's namespace, where + // a cluster's own policy or cost labelling is most likely to land, and + // dropping such labels on every pass would fight whoever set them. + if binding.Labels == nil { + binding.Labels = map[string]string{} + } + maps.Copy(binding.Labels, rel.SourceLabels()) + maps.Copy(binding.Labels, applicationRBACLabels()) + binding.RoleRef = desiredRef + + binding.Subjects = []rbacv1.Subject{{ + Kind: rbacv1.ServiceAccountKind, + Name: name, + Namespace: s.TargetNamespace, + }} + + return nil + }) + + return err +} + +// ensureOwnedRoleBindingDeleted deletes the role binding at nn only when it carries +// our managed-by label. The derived name is fully computable by anyone, so a binding +// found under it may belong to someone else — the same ownership test ensureRoleBinding +// applies before it touches anything. A binding that is not ours is left alone; that +// is not an error and must not block the rest of the cleanup. +func (s *AccessService) ensureOwnedRoleBindingDeleted(ctx context.Context, nn types.NamespacedName) error { + binding := &rbacv1.RoleBinding{} + if err := s.Client.Get(ctx, nn, binding); err != nil { + return client.IgnoreNotFound(err) + } + + if !managedByOperator(binding.Labels) { + return nil + } + + return client.IgnoreNotFound(s.Client.Delete(ctx, binding)) +} diff --git a/images/operator-helm-controller/internal/services/access_service_test.go b/images/operator-helm-controller/internal/services/access_service_test.go new file mode 100644 index 00000000..ce00124d --- /dev/null +++ b/images/operator-helm-controller/internal/services/access_service_test.go @@ -0,0 +1,468 @@ +/* +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" + "reflect" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" +) + +func testApplication() *helmv1alpha1.HelmApplication { + return &helmv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "team-a", Generation: 1}, + Spec: helmv1alpha1.HelmApplicationSpec{ + Chart: helmv1alpha1.HelmApplicationChartRef{Name: "podinfo", Repository: "stable", Version: "6.7.1"}, + }, + } +} + +func newAccessService(t *testing.T, objects ...client.Object) (*AccessService, client.Client) { + t.Helper() + + c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithObjects(objects...).Build() + + return NewAccessService(c, testNamespace), c +} + +// TestEnsureAccessCreatesTheIdentity pins the three objects and their shape: the +// account lives in the operator namespace without a token, the Role and the binding +// live in the application namespace, and the binding names the account by its +// operator-namespace identity — helm-controller impersonates +// system:serviceaccount::. +func TestEnsureAccessCreatesTheIdentity(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + service, c := newAccessService(t) + + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureAccess returned %v", out.Err) + } + + names := rel.InternalNames() + + sa := &corev1.ServiceAccount{} + if err := c.Get(context.Background(), client.ObjectKey{Namespace: testNamespace, Name: names.ServiceAccount}, sa); err != nil { + t.Fatalf("service account was not created in the operator namespace: %v", err) + } + if sa.AutomountServiceAccountToken == nil || *sa.AutomountServiceAccountToken { + t.Fatal("the account is only a subject name: no token must be mounted for it") + } + if !reflect.DeepEqual(sa.Labels, rel.SourceLabels()) { + t.Fatalf("service account labels = %v, want %v", sa.Labels, rel.SourceLabels()) + } + + role := &rbacv1.Role{} + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: ApplicationRoleName}, role); err != nil { + t.Fatalf("role was not created in the application namespace: %v", err) + } + wantRules := []rbacv1.PolicyRule{{APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}} + if !reflect.DeepEqual(role.Rules, wantRules) { + t.Fatalf("role rules = %+v, want full rights inside the namespace", role.Rules) + } + if !reflect.DeepEqual(role.Labels, applicationRBACLabels()) { + t.Fatalf("role labels = %v, want %v", role.Labels, applicationRBACLabels()) + } + + binding := &rbacv1.RoleBinding{} + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: names.ServiceAccount}, binding); err != nil { + t.Fatalf("role binding was not created in the application namespace: %v", err) + } + if binding.RoleRef != (rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: ApplicationRoleName}) { + t.Fatalf("roleRef = %+v", binding.RoleRef) + } + wantSubjects := []rbacv1.Subject{{Kind: rbacv1.ServiceAccountKind, Name: names.ServiceAccount, Namespace: testNamespace}} + if !reflect.DeepEqual(binding.Subjects, wantSubjects) { + t.Fatalf("subjects = %+v, want %+v", binding.Subjects, wantSubjects) + } + for key, want := range applicationRBACLabels() { + if binding.Labels[key] != want { + t.Fatalf("role binding label %q = %q, want %q", key, binding.Labels[key], want) + } + } + for key, want := range rel.SourceLabels() { + if binding.Labels[key] != want { + t.Fatalf("role binding label %q = %q, want %q", key, binding.Labels[key], want) + } + } +} + +// TestEnsureAccessRewritesAnEditedRole pins that a Role of ours is reconciled rather +// than seeded: rules narrowed out of band are written back, and so is the heritage +// label. A label someone else put there survives, as on the account and the binding. +func TestEnsureAccessRewritesAnEditedRole(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + edited := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: ApplicationRoleName, + Namespace: "team-a", + Labels: map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + "cost-center": "platform", + }, + }, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get"}}}, + } + service, c := newAccessService(t, edited) + + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureAccess returned %v", out.Err) + } + + role := &rbacv1.Role{} + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: ApplicationRoleName}, role); err != nil { + t.Fatalf("getting role: %v", err) + } + wantRules := []rbacv1.PolicyRule{{APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}} + if !reflect.DeepEqual(role.Rules, wantRules) { + t.Fatalf("a narrowed role must be written back, rules = %+v", role.Rules) + } + for key, want := range applicationRBACLabels() { + if role.Labels[key] != want { + t.Fatalf("role label %q = %q, want %q", key, role.Labels[key], want) + } + } + if role.Labels["cost-center"] != "platform" { + t.Fatalf("role labels = %v, want the foreign label kept", role.Labels) + } +} + +// TestEnsureAccessRefusesARoleThatIsNotOurs pins the ownership test on the shared +// Role. Its name is fixed, so a namespace owner can have put their own Role there; +// widening it to full rights and binding every application of the namespace to it +// is not a decision to make on their behalf. The verdict is terminal because the +// watch on the kind selects on the very label the object lacks: nothing observes it +// being removed either. +func TestEnsureAccessRefusesARoleThatIsNotOurs(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + rules := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get"}}} + foreign := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: ApplicationRoleName, Namespace: "team-a"}, + Rules: rules, + } + service, c := newAccessService(t, foreign) + + out := service.EnsureAccess(context.Background(), rel) + if out.Err == nil { + t.Fatal("a role that is not ours must be reported, not seized") + } + if !out.Terminal { + t.Fatalf("outcome = %+v, want it terminal", out) + } + if out.Reason != helmv1alpha1.ReasonForeignAccessObject { + t.Fatalf("reason = %q, want %q", out.Reason, helmv1alpha1.ReasonForeignAccessObject) + } + if !strings.Contains(out.Message, "team-a/"+ApplicationRoleName) { + t.Fatalf("message %q must name the role", out.Message) + } + + stored := &rbacv1.Role{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(foreign), stored); err != nil { + t.Fatalf("the foreign role must survive: %v", err) + } + if !reflect.DeepEqual(stored.Rules, rules) { + t.Fatalf("rules = %+v, want them untouched", stored.Rules) + } + if len(stored.Labels) != 0 { + t.Fatalf("labels = %v, want the foreign role left unlabelled", stored.Labels) + } +} + +// TestEnsureAccessRefusesABindingThatIsNotOurs pins the same ownership test on the +// binding. The derived name is fully computable by anyone, so a binding found under +// it may belong to someone else; adding our account to it would grant that account +// whatever the binding grants. The object is left exactly as it was — deleting a +// binding we did not create is not ours to do either. +func TestEnsureAccessRefusesABindingThatIsNotOurs(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + foreign := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: rel.InternalNames().ServiceAccount, Namespace: "team-a"}, + RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: "someone-elses-role"}, + Subjects: []rbacv1.Subject{{Kind: rbacv1.UserKind, Name: "someone-else"}}, + } + service, c := newAccessService(t, foreign) + + out := service.EnsureAccess(context.Background(), rel) + if out.Err == nil { + t.Fatal("a binding that is not ours must be reported, not adopted") + } + if !out.Terminal { + t.Fatalf("outcome = %+v, want it terminal", out) + } + if out.Reason != helmv1alpha1.ReasonForeignAccessObject { + t.Fatalf("reason = %q, want %q", out.Reason, helmv1alpha1.ReasonForeignAccessObject) + } + if !strings.Contains(out.Message, "team-a/"+rel.InternalNames().ServiceAccount) { + t.Fatalf("message %q must name the binding", out.Message) + } + + stored := &rbacv1.RoleBinding{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(foreign), stored); err != nil { + t.Fatalf("the foreign binding must survive: %v", err) + } + if stored.RoleRef != foreign.RoleRef { + t.Fatalf("roleRef = %+v, want it untouched", stored.RoleRef) + } + if !reflect.DeepEqual(stored.Subjects, foreign.Subjects) { + t.Fatalf("subjects = %+v, want them untouched", stored.Subjects) + } + if len(stored.Labels) != 0 { + t.Fatalf("labels = %v, want the foreign binding left unlabelled", stored.Labels) + } +} + +// TestEnsureAccessReplacesOurBindingThatNamesAnotherRole pins the other side of the +// ownership test: a binding carrying our label is ours to shape whatever state it is +// found in, and a roleRef naming some other role is drift like any other. roleRef is +// immutable, so putting it right means replacing the object rather than patching it. +func TestEnsureAccessReplacesOurBindingThatNamesAnotherRole(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + names := rel.InternalNames() + misbound := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.ServiceAccount, + Namespace: "team-a", + Labels: applicationRBACLabels(), + }, + RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: "some-other-role"}, + Subjects: []rbacv1.Subject{{Kind: rbacv1.UserKind, Name: "someone-else"}}, + } + service, c := newAccessService(t, misbound) + + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureAccess returned %v", out.Err) + } + + stored := &rbacv1.RoleBinding{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(misbound), stored); err != nil { + t.Fatalf("getting role binding: %v", err) + } + if stored.RoleRef != applicationRoleRef() { + t.Fatalf("roleRef = %+v, want %+v", stored.RoleRef, applicationRoleRef()) + } + wantSubjects := []rbacv1.Subject{{ + Kind: rbacv1.ServiceAccountKind, Name: names.ServiceAccount, Namespace: testNamespace, + }} + if !reflect.DeepEqual(stored.Subjects, wantSubjects) { + t.Fatalf("subjects = %+v, want %+v", stored.Subjects, wantSubjects) + } + for key, want := range applicationRBACLabels() { + if stored.Labels[key] != want { + t.Fatalf("binding label %q = %q, want %q", key, stored.Labels[key], want) + } + } +} + +// TestEnsureAccessSurvivesLosingTheRoleCreateRace pins the one object of the three +// that two applications can race for: the Role is shared by the namespace, so the +// application that reads it as missing a moment too late is refused the create. It +// must reach the same end state, not report a failure. +func TestEnsureAccessSurvivesLosingTheRoleCreateRace(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + + var refused bool + c := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Create: func(ctx context.Context, cl client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + role, ok := obj.(*rbacv1.Role) + if !ok || refused { + return cl.Create(ctx, obj, opts...) + } + + // The winner of the race stores the Role; this pass learns of it the + // way the API server would report it. + refused = true + if err := cl.Create(ctx, role.DeepCopy(), opts...); err != nil { + return err + } + + return apierrors.NewAlreadyExists(rbacv1.Resource("roles"), role.Name) + }, + }). + Build() + service := NewAccessService(c, testNamespace) + + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureAccess returned %v", out.Err) + } + if !refused { + t.Fatal("the test did not exercise the refused create") + } + + role := &rbacv1.Role{} + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: ApplicationRoleName}, role); err != nil { + t.Fatalf("getting role: %v", err) + } + if !reflect.DeepEqual(role.Labels, applicationRBACLabels()) { + t.Fatalf("role labels = %v, want %v", role.Labels, applicationRBACLabels()) + } +} + +func TestEnsureAccessRecreatesADeletedRole(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + service, c := newAccessService(t) + + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("first EnsureAccess returned %v", out.Err) + } + if err := c.Delete(context.Background(), &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: ApplicationRoleName, Namespace: "team-a"}}); err != nil { + t.Fatalf("deleting role: %v", err) + } + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("second EnsureAccess returned %v", out.Err) + } + + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: ApplicationRoleName}, &rbacv1.Role{}); err != nil { + t.Fatalf("a deleted role must be recreated: %v", err) + } +} + +// TestEnsureAccessKeepsForeignLabelsOnTheServiceAccountAndBinding pins that a label +// put there by someone else (a policy engine, a cost allocator) survives a +// reconcile: only the keys we own are kept authoritative, mirroring how +// applyHelmReleaseSpec and applyHelmChartSpec merge their labels. +func TestEnsureAccessKeepsForeignLabelsOnTheServiceAccountAndBinding(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + names := rel.InternalNames() + service, c := newAccessService(t) + + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("first EnsureAccess returned %v", out.Err) + } + + sa := &corev1.ServiceAccount{} + if err := c.Get(context.Background(), client.ObjectKey{Namespace: testNamespace, Name: names.ServiceAccount}, sa); err != nil { + t.Fatalf("getting service account: %v", err) + } + sa.Labels["cost-center"] = "platform" + if err := c.Update(context.Background(), sa); err != nil { + t.Fatalf("labelling service account: %v", err) + } + + binding := &rbacv1.RoleBinding{} + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: names.ServiceAccount}, binding); err != nil { + t.Fatalf("getting role binding: %v", err) + } + binding.Labels["cost-center"] = "platform" + if err := c.Update(context.Background(), binding); err != nil { + t.Fatalf("labelling role binding: %v", err) + } + + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("second EnsureAccess returned %v", out.Err) + } + + if err := c.Get(context.Background(), client.ObjectKey{Namespace: testNamespace, Name: names.ServiceAccount}, sa); err != nil { + t.Fatalf("getting service account: %v", err) + } + if sa.Labels["cost-center"] != "platform" { + t.Fatalf("service account labels = %v, want the foreign label kept", sa.Labels) + } + + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: names.ServiceAccount}, binding); err != nil { + t.Fatalf("getting role binding: %v", err) + } + if binding.Labels["cost-center"] != "platform" { + t.Fatalf("role binding labels = %v, want the foreign label kept", binding.Labels) + } +} + +func TestEnsureAccessIsANoopForAFamilyWithoutAServiceAccount(t *testing.T) { + service, c := newAccessService(t) + + if out := service.EnsureAccess(context.Background(), adapter.NewAddonRelease(testAddon())); out.Err != nil { + t.Fatalf("EnsureAccess returned %v", out.Err) + } + + var accounts corev1.ServiceAccountList + if err := c.List(context.Background(), &accounts); err != nil { + t.Fatalf("listing service accounts: %v", err) + } + if len(accounts.Items) != 0 { + t.Fatalf("no identity must be created for a release without a service account name, got %v", accounts.Items) + } +} + +// TestCleanupAccessRemovesTheAccountAndBindingButKeepsTheRole pins spec 9.4: the +// account and the binding belong to one application; the Role belongs to the +// namespace and may have been edited by its owner, so it is never deleted. +func TestCleanupAccessRemovesTheAccountAndBindingButKeepsTheRole(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + service, c := newAccessService(t) + + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureAccess returned %v", out.Err) + } + if err := service.CleanupAccess(context.Background(), rel); err != nil { + t.Fatalf("CleanupAccess returned %v", err) + } + + names := rel.InternalNames() + if err := c.Get(context.Background(), client.ObjectKey{Namespace: testNamespace, Name: names.ServiceAccount}, &corev1.ServiceAccount{}); !apierrors.IsNotFound(err) { + t.Fatalf("service account must be gone, got %v", err) + } + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: names.ServiceAccount}, &rbacv1.RoleBinding{}); !apierrors.IsNotFound(err) { + t.Fatalf("role binding must be gone, got %v", err) + } + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: ApplicationRoleName}, &rbacv1.Role{}); err != nil { + t.Fatalf("the role must survive the application: %v", err) + } + + // Cleaning up twice is fine: nothing is left to delete. + if err := service.CleanupAccess(context.Background(), rel); err != nil { + t.Fatalf("second CleanupAccess returned %v", err) + } +} + +// TestCleanupAccessLeavesAForeignRoleBindingAlone pins the delete-side mirror of +// TestEnsureAccessRefusesToAdoptAForeignRoleBinding: the derived name is fully +// computable by anyone, so a binding found under it may belong to someone else. +// Such a binding is left alone, and that must not block the rest of the cleanup. +func TestCleanupAccessLeavesAForeignRoleBindingAlone(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + names := rel.InternalNames() + foreign := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: names.ServiceAccount, Namespace: "team-a"}, + RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: "someone-elses-role"}, + Subjects: []rbacv1.Subject{{Kind: rbacv1.UserKind, Name: "someone-else"}}, + } + service, c := newAccessService(t, foreign) + + if err := service.CleanupAccess(context.Background(), rel); err != nil { + t.Fatalf("CleanupAccess returned %v", err) + } + + stored := &rbacv1.RoleBinding{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(foreign), stored); err != nil { + t.Fatalf("a foreign role binding must survive cleanup: %v", err) + } + if stored.RoleRef != foreign.RoleRef { + t.Fatalf("roleRef = %+v, want it untouched", stored.RoleRef) + } +} diff --git a/images/operator-helm-controller/internal/services/base.go b/images/operator-helm-controller/internal/services/base.go index 87f51ff9..00da6a4b 100644 --- a/images/operator-helm-controller/internal/services/base.go +++ b/images/operator-helm-controller/internal/services/base.go @@ -21,17 +21,11 @@ import ( "fmt" "time" - "github.com/werf/3p-fluxcd-pkg/apis/meta" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" + "github.com/fluxcd/pkg/apis/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/controller/controllerutil" - - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/utils" ) type BaseService struct { @@ -46,7 +40,7 @@ func (s *BaseService) ensureResourceDeleted(ctx context.Context, nn types.Namesp // deleteAndCheck issues a delete for the object if it is still present and reports // whether it still exists. A deletion may stay pending because a downstream -// controller (helm-controller, nelm-source-controller) holds a finalizer and has +// controller (helm-controller, source-controller) holds a finalizer and has // not finished tearing the resource down yet, so callers that must not proceed // until the resource is actually gone should keep requeuing while exists is true. func (s *BaseService) deleteAndCheck(ctx context.Context, nn types.NamespacedName, obj client.Object) (exists bool, err error) { @@ -67,209 +61,6 @@ type BaseRepoService struct { TargetNamespace string } -// EnsureSecrets reconciles every auxiliary secret the repository needs. Its -// success is the gate for attempting a catalog synchronization: without -// credentials there is nothing to try. -// -// The auth secret's shape depends on the repository kind and the two are not -// interchangeable: HelmRepository resolves HTTP basic auth from an Opaque -// secret, while OCIRepository accepts only a kubernetes.io/dockerconfigjson -// one. An unknown type is treated as helm, mirroring the deletion path — it is -// unreachable here anyway, because an unparsable url is reported before any -// secret is touched. -func (s *BaseRepoService) EnsureSecrets( - ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, - repoType utils.InternalRepositoryType, -) error { - var err error - - switch repoType { - case utils.InternalOCIRepository: - err = s.reconcileDockerConfigAuthSecret(ctx, repo) - default: - err = s.reconcileBasicAuthSecret(ctx, repo) - } - - if err != nil { - return fmt.Errorf("reconciling auth secret: %w", err) - } - - if err := s.reconcileTLSSecret(ctx, repo); err != nil { - return fmt.Errorf("reconciling tls secret: %w", err) - } - - return nil -} - -// reconcileBasicAuthSecret reconciles the internal auth secret as an Opaque secret -// holding username/password keys, the shape HelmRepository expects for HTTP basic -// auth. -func (s *BaseRepoService) reconcileBasicAuthSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) error { - return s.reconcileAuthSecret(ctx, repo, corev1.SecretTypeOpaque, - func(auth *helmv1alpha1.HelmClusterAddonRepositoryAuth) (map[string]string, error) { - return map[string]string{ - "username": auth.Username, - "password": auth.Password, - }, nil - }, - ) -} - -// reconcileDockerConfigAuthSecret reconciles the internal auth secret as a -// kubernetes.io/dockerconfigjson secret, the only shape OCIRepository accepts in -// its spec.secretRef. -func (s *BaseRepoService) reconcileDockerConfigAuthSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) error { - return s.reconcileAuthSecret(ctx, repo, corev1.SecretTypeDockerConfigJson, - func(auth *helmv1alpha1.HelmClusterAddonRepositoryAuth) (map[string]string, error) { - config, err := utils.BuildDockerConfigJSON(repo.Spec.URL, auth.Username, auth.Password) - if err != nil { - return nil, fmt.Errorf("building docker config: %w", err) - } - - return map[string]string{corev1.DockerConfigJsonKey: config}, nil - }, - ) -} - -func (s *BaseRepoService) reconcileAuthSecret( - ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, - secretType corev1.SecretType, - buildData func(auth *helmv1alpha1.HelmClusterAddonRepositoryAuth) (map[string]string, error), -) error { - secretName := utils.GetInternalRepositoryAuthSecretName(repo.Name) - nn := types.NamespacedName{Name: secretName, Namespace: s.TargetNamespace} - - if repo.Spec.Auth == nil { - if err := s.ensureResourceDeleted(ctx, nn, &corev1.Secret{}); err != nil { - return fmt.Errorf("deleting obsolete auth secret: %w", err) - } - return nil - } - - stringData, err := buildData(repo.Spec.Auth) - if err != nil { - return fmt.Errorf("building auth secret data: %w", err) - } - - labels := map[string]string{ - helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repo.Name, - } - - staleRemoved, err := s.removeAuthSecretOfOtherType(ctx, nn, secretType) - if err != nil { - return fmt.Errorf("ensuring auth secret type %q: %w", secretType, err) - } - - authSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: s.TargetNamespace, - Labels: labels, - }, - Type: secretType, - StringData: stringData, - } - - if staleRemoved { - // The informer cache can still serve the secret that was just deleted, which - // would turn CreateOrPatch into a patch of a missing object, so create the - // replacement outright. - if err := s.Client.Create(ctx, authSecret); client.IgnoreAlreadyExists(err) != nil { - return fmt.Errorf("creating auth secret: %w", err) - } - - return nil - } - - if _, err := controllerutil.CreateOrPatch(ctx, s.Client, authSecret, func() error { - authSecret.Labels = labels - authSecret.Type = secretType - // Drop the keys already stored so that credentials removed from the desired - // data do not linger in the secret. - authSecret.Data = nil - authSecret.StringData = stringData - - return nil - }); err != nil { - return fmt.Errorf("creating auth secret: %w", err) - } - - return nil -} - -// removeAuthSecretOfOtherType deletes the auth secret when it exists with a type -// other than the wanted one and reports whether it did. A secret type is immutable, -// so a secret written with the wrong type (an Opaque one left by a version that fed -// plain credentials to OCIRepository, say) can only be replaced, not patched. -func (s *BaseRepoService) removeAuthSecretOfOtherType( - ctx context.Context, nn types.NamespacedName, secretType corev1.SecretType, -) (bool, error) { - existing := &corev1.Secret{} - if err := s.Client.Get(ctx, nn, existing); err != nil { - if apierrors.IsNotFound(err) { - return false, nil - } - - return false, fmt.Errorf("getting auth secret: %w", err) - } - - existingType := existing.Type - if existingType == "" { - existingType = corev1.SecretTypeOpaque - } - - if existingType == secretType { - return false, nil - } - - if err := s.Client.Delete(ctx, existing); client.IgnoreNotFound(err) != nil { - return false, fmt.Errorf("deleting auth secret of type %q: %w", existingType, err) - } - - return true, nil -} - -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} - 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.HelmClusterAddonRepositoryLabelSourceName: 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 -} - // setReconcileRequestAnnotations stamps the flux reconcile/force request // annotations so the controller owning obj reconciles it immediately instead of // waiting for its next interval. Both are stamped with the same timestamp: diff --git a/images/operator-helm-controller/internal/services/base_test.go b/images/operator-helm-controller/internal/services/base_test.go index 316f6a1c..8d2bfb82 100644 --- a/images/operator-helm-controller/internal/services/base_test.go +++ b/images/operator-helm-controller/internal/services/base_test.go @@ -5,7 +5,7 @@ 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 + 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, @@ -13,25 +13,15 @@ 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" - "strings" "testing" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/utils" ) const testNamespace = "d8-operator-helm" @@ -49,109 +39,3 @@ func testScheme(t *testing.T) *runtime.Scheme { return scheme } - -func newBaseRepoService(t *testing.T, objects ...client.Object) (*BaseRepoService, client.Client) { - t.Helper() - - scheme := testScheme(t) - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - - return &BaseRepoService{ - BaseService: BaseService{Client: c, Scheme: scheme}, - TargetNamespace: testNamespace, - }, c -} - -func TestEnsureSecretsCreatesAuthAndTLS(t *testing.T) { - repo := &helmv1alpha1.HelmClusterAddonRepository{ - ObjectMeta: metav1.ObjectMeta{Name: "example"}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{ - URL: "https://example.invalid/charts", - Auth: &helmv1alpha1.HelmClusterAddonRepositoryAuth{Username: "user", Password: "secret"}, - CACertificate: "-----BEGIN CERTIFICATE-----", - }, - } - - service, c := newBaseRepoService(t, repo) - - if err := service.EnsureSecrets(context.Background(), repo, utils.InternalHelmRepository); err != nil { - t.Fatalf("EnsureSecrets returned %v", err) - } - - auth := &corev1.Secret{} - authKey := types.NamespacedName{Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), Namespace: testNamespace} - if err := c.Get(context.Background(), authKey, auth); err != nil { - t.Fatalf("auth secret was not created: %v", err) - } - // The fake client stores what the controller wrote: unlike the API server it - // does not fold StringData into Data. - if got := auth.StringData["username"]; got != "user" { - t.Fatalf("auth secret username is %q, want %q", got, "user") - } - - tls := &corev1.Secret{} - tlsKey := types.NamespacedName{Name: utils.GetInternalRepositoryTLSSecretName(repo.Name), Namespace: testNamespace} - if err := c.Get(context.Background(), tlsKey, tls); err != nil { - t.Fatalf("tls secret was not created: %v", err) - } -} - -func TestEnsureSecretsRemovesObsoleteSecrets(t *testing.T) { - repo := &helmv1alpha1.HelmClusterAddonRepository{ - ObjectMeta: metav1.ObjectMeta{Name: "example"}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://example.invalid/charts"}, - } - obsolete := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), - Namespace: testNamespace, - }, - } - - service, c := newBaseRepoService(t, repo, obsolete) - - if err := service.EnsureSecrets(context.Background(), repo, utils.InternalHelmRepository); err != nil { - t.Fatalf("EnsureSecrets returned %v", err) - } - - err := c.Get(context.Background(), client.ObjectKeyFromObject(obsolete), &corev1.Secret{}) - if !apierrors.IsNotFound(err) { - t.Fatalf("obsolete auth secret must be deleted, got %v", err) - } -} - -func TestEnsureSecretsUsesDockerConfigForOCIRepositories(t *testing.T) { - repo := &helmv1alpha1.HelmClusterAddonRepository{ - ObjectMeta: metav1.ObjectMeta{Name: "example"}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{ - URL: "oci://ghcr.io/example/podinfo", - Auth: &helmv1alpha1.HelmClusterAddonRepositoryAuth{Username: "user", Password: "secret"}, - }, - } - - service, c := newBaseRepoService(t, repo) - - if err := service.EnsureSecrets(context.Background(), repo, utils.InternalOCIRepository); err != nil { - t.Fatalf("EnsureSecrets returned %v", err) - } - - auth := &corev1.Secret{} - key := types.NamespacedName{Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), Namespace: testNamespace} - if err := c.Get(context.Background(), key, auth); err != nil { - t.Fatalf("auth secret was not created: %v", err) - } - - // OCIRepository resolves credentials only from a dockerconfigjson secret; - // an Opaque username/password pair is silently ignored by the source controller. - if auth.Type != corev1.SecretTypeDockerConfigJson { - t.Fatalf("auth secret type is %q, want %q", auth.Type, corev1.SecretTypeDockerConfigJson) - } - - config, found := auth.StringData[corev1.DockerConfigJsonKey] - if !found { - t.Fatalf("auth secret has no %q key, got keys %v", corev1.DockerConfigJsonKey, auth.StringData) - } - if !strings.Contains(config, "ghcr.io") { - t.Fatalf("docker config does not mention the registry host: %s", config) - } -} diff --git a/images/operator-helm-controller/internal/services/chart_claim_service.go b/images/operator-helm-controller/internal/services/chart_claim_service.go index ccccdee1..1bc2f981 100644 --- a/images/operator-helm-controller/internal/services/chart_claim_service.go +++ b/images/operator-helm-controller/internal/services/chart_claim_service.go @@ -27,6 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -37,6 +38,11 @@ import ( // server's only atomic cross-object primitive — the uniqueness of an object name. // Each repository/chart pair maps to a single Lease name; whoever creates that // Lease first owns the pair, and every other addon reconciles into a conflict. +// +// It is addon-only in substance, not just by convention: the Lease name carries +// neither the release kind nor a namespace, and the holder recorded on it is read +// back as a HelmClusterAddon. Another family needing uniqueness would have to +// bring its own implementation of source.ChartClaim rather than reuse this one. type ClaimService struct { // reader reads from the API server directly (mgr.GetAPIReader()), bypassing the // controller cache: an acquisition decision must never be made against stale data. @@ -56,19 +62,19 @@ func NewClaimService(c client.Client, reader client.Reader, namespace string) *C // Acquire ensures the addon owns the claim for its repository/chart pair. It // reports whether the claim is held by this addon; when it is not, holder names // the addon that currently owns it so the caller can surface a conflict. -func (s *ClaimService) Acquire(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (acquired bool, holder string, err error) { - nn := s.leaseKey(addon) +func (s *ClaimService) Acquire(ctx context.Context, rel source.Release) (acquired bool, holder string, err error) { + nn := s.leaseKey(rel) lease := &coordinationv1.Lease{} getErr := s.reader.Get(ctx, nn, lease) switch { case apierrors.IsNotFound(getErr): - created, createErr := s.create(ctx, nn, addon) + created, createErr := s.create(ctx, nn, rel) if createErr != nil { return false, "", createErr } if created { - return true, addon.Name, nil + return true, rel.Name(), nil } // Lost the create race against a concurrent reconcile; re-read the winning // Lease authoritatively and fall through to the ownership check. @@ -80,7 +86,7 @@ func (s *ClaimService) Acquire(ctx context.Context, addon *helmv1alpha1.HelmClus } holder = leaseHolder(lease) - if holder == addon.Name { + if holder == rel.Name() { return true, holder, nil } @@ -95,22 +101,22 @@ func (s *ClaimService) Acquire(ctx context.Context, addon *helmv1alpha1.HelmClus // The recorded holder is gone or no longer uses this chart; take the Lease over. // The update is optimistic: if another addon takes it over first, our write is // rejected with a conflict and we report the pair as claimed and requeue. - if err := s.takeOver(ctx, lease, addon); err != nil { + if err := s.takeOver(ctx, lease, rel); err != nil { if apierrors.IsConflict(err) { return false, holder, nil } return false, "", err } - return true, addon.Name, nil + return true, rel.Name(), nil } // OwnedBy reports whether the addon currently holds the claim Lease for its // repository/chart pair. It reads through the direct reader for the same reason // Acquire does: an ownership decision must not be made against stale cache data. -func (s *ClaimService) OwnedBy(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (bool, error) { +func (s *ClaimService) OwnedBy(ctx context.Context, rel source.Release) (bool, error) { lease := &coordinationv1.Lease{} - err := s.reader.Get(ctx, s.leaseKey(addon), lease) + err := s.reader.Get(ctx, s.leaseKey(rel), lease) if apierrors.IsNotFound(err) { return false, nil } @@ -118,20 +124,20 @@ func (s *ClaimService) OwnedBy(ctx context.Context, addon *helmv1alpha1.HelmClus return false, fmt.Errorf("getting chart claim lease: %w", err) } - return leaseHolder(lease) == addon.Name, nil + return leaseHolder(lease) == rel.Name(), nil } // Release deletes the claim Lease, but only if this addon still owns it, so a // duplicate addon that never acquired the pair cannot free the real owner's claim. -func (s *ClaimService) Release(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { - nn := s.leaseKey(addon) +func (s *ClaimService) Release(ctx context.Context, rel source.Release) error { + nn := s.leaseKey(rel) lease := &coordinationv1.Lease{} if err := s.reader.Get(ctx, nn, lease); err != nil { return client.IgnoreNotFound(err) } - if leaseHolder(lease) != addon.Name { + if leaseHolder(lease) != rel.Name() { return nil } @@ -155,25 +161,22 @@ func (s *ClaimService) Release(ctx context.Context, addon *helmv1alpha1.HelmClus // orphans are found instead by the source-name label every claim already carries. // Only the addon's own claims (by HolderIdentity) other than the current one are // removed, so a pair taken over by another addon is left intact. -func (s *ClaimService) ReleaseStale(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { - current := s.leaseKey(addon).Name +func (s *ClaimService) ReleaseStale(ctx context.Context, rel source.Release) error { + current := s.leaseKey(rel).Name // Direct reader, not the cached client: claim Leases are not watched, so the // informer cache has no data for them (same reason Acquire/Release use reader). leases := &coordinationv1.LeaseList{} if err := s.reader.List(ctx, leases, client.InNamespace(s.namespace), - client.MatchingLabels{ - helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonLabelSourceName: addon.Name, - }, + client.MatchingLabels(rel.SourceLabels()), ); err != nil { return fmt.Errorf("listing chart claim leases: %w", err) } for i := range leases.Items { lease := &leases.Items[i] - if lease.Name == current || leaseHolder(lease) != addon.Name { + if lease.Name == current || leaseHolder(lease) != rel.Name() { continue } @@ -191,13 +194,13 @@ func (s *ClaimService) ReleaseStale(ctx context.Context, addon *helmv1alpha1.Hel return nil } -func (s *ClaimService) create(ctx context.Context, nn types.NamespacedName, addon *helmv1alpha1.HelmClusterAddon) (created bool, err error) { - holder := addon.Name +func (s *ClaimService) create(ctx context.Context, nn types.NamespacedName, rel source.Release) (created bool, err error) { + holder := rel.Name() lease := &coordinationv1.Lease{ ObjectMeta: metav1.ObjectMeta{ Name: nn.Name, Namespace: nn.Namespace, - Labels: claimLabels(addon), + Labels: rel.SourceLabels(), }, Spec: coordinationv1.LeaseSpec{ HolderIdentity: &holder, @@ -215,14 +218,14 @@ func (s *ClaimService) create(ctx context.Context, nn types.NamespacedName, addo return true, nil } -func (s *ClaimService) takeOver(ctx context.Context, lease *coordinationv1.Lease, addon *helmv1alpha1.HelmClusterAddon) error { - holder := addon.Name +func (s *ClaimService) takeOver(ctx context.Context, lease *coordinationv1.Lease, rel source.Release) error { + holder := rel.Name() lease.Spec.HolderIdentity = &holder if lease.Labels == nil { lease.Labels = map[string]string{} } - for k, v := range claimLabels(addon) { + for k, v := range rel.SourceLabels() { lease.Labels[k] = v } @@ -253,9 +256,10 @@ func (s *ClaimService) isHolderStale(ctx context.Context, holder, leaseName stri return holderLease != leaseName, nil } -func (s *ClaimService) leaseKey(addon *helmv1alpha1.HelmClusterAddon) types.NamespacedName { +func (s *ClaimService) leaseKey(rel source.Release) types.NamespacedName { + ref := rel.ChartRef() return types.NamespacedName{ - Name: utils.GetChartClaimLeaseName(addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName), + Name: utils.GetChartClaimLeaseName(ref.Repository.Name, ref.Chart), Namespace: s.namespace, } } @@ -267,10 +271,3 @@ func leaseHolder(lease *coordinationv1.Lease) string { return *lease.Spec.HolderIdentity } - -func claimLabels(addon *helmv1alpha1.HelmClusterAddon) map[string]string { - return map[string]string{ - helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonLabelSourceName: addon.Name, - } -} diff --git a/images/operator-helm-controller/internal/services/chart_service.go b/images/operator-helm-controller/internal/services/chart_service.go index eeb1a058..9066be1c 100644 --- a/images/operator-helm-controller/internal/services/chart_service.go +++ b/images/operator-helm-controller/internal/services/chart_service.go @@ -19,9 +19,9 @@ package services import ( "context" "fmt" + "maps" - "github.com/werf/3p-fluxcd-pkg/apis/meta" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -29,13 +29,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" - "github.com/deckhouse/operator-helm/api/naming" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/manager/status" - "github.com/deckhouse/operator-helm/internal/utils" + "github.com/deckhouse/operator-helm/internal/source" ) -var helmChartErrorRules = []status.ErrorConditionRule{ +var helmChartErrorRules = []ErrorConditionRule{ {Type: "FetchFailed", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonChartFetchFailed}, {Type: "StorageOperationFailed", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonChartStorageFailed}, } @@ -56,77 +54,49 @@ func NewChartService(client client.Client, scheme *runtime.Scheme, targetNamespa } } -var _ status.Provider = (*ChartResult)(nil) - -type ChartResult struct { - Status status.Status - Artifact *meta.Artifact -} - -func (r ChartResult) GetStatus() status.Status { - return r.Status -} - -func (r ChartResult) IsReady() bool { - return r.Artifact != nil && r.Status.Observed && r.Status.Status == metav1.ConditionTrue -} - -func (r ChartResult) HasArtifact() bool { - return r.Artifact != nil && r.Status.Observed -} - -func (r ChartResult) GetConditionType() string { - return helmv1alpha1.ConditionTypeReady -} - -func (s *ChartService) EnsureHelmChart(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) ChartResult { +func (s *ChartService) EnsureHelmChart(ctx context.Context, rel source.Release, repo source.Repository) ChartOutcome { logger := log.FromContext(ctx) existing := &sourcev1.HelmChart{ ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetInternalHelmChartName(addon.Name), + Name: rel.InternalNames().HelmChart, Namespace: s.TargetNamespace, }, } op, err := controllerutil.CreateOrPatch(ctx, s.Client, existing, func() error { - applyHelmChartSpec(addon, existing) + applyHelmChartSpec(rel, repo, existing) return nil }) if err != nil { - return ChartResult{Status: status.Failed( - addon, - helmv1alpha1.ReasonHelmChartFailed, - "Failed to create helm chart", - fmt.Errorf("creating or updating helm chart: %w", err), - )} + return ChartOutcome{Err: fmt.Errorf("creating or updating helm chart: %w", err)} } if op != controllerutil.OperationResultNone { - logger.Info("Reconciled helm chart", "operation", op) + logger.Info("Reconciled helm chart", "operation", op, + "internalObject", client.ObjectKeyFromObject(existing)) } - processedStatus := status.ProcessChildConditions( - existing.GetConditions(), existing.Generation, addon, helmChartErrorRules, - ) + internal := reduceInternalConditions(existing.GetConditions(), existing.Generation, helmChartErrorRules) - if processedStatus.IsReady() { - logger.Info("Successfully reconciled helm chart", "operation", op, "chart", addon.Spec.Chart.HelmClusterAddonChartName) + if internal.Ready() { + logger.Info("Successfully reconciled helm chart", "operation", op, "chart", rel.ChartRef().Chart, + "internalObject", client.ObjectKeyFromObject(existing)) } - return ChartResult{ + return ChartOutcome{ Artifact: existing.Status.Artifact, - Status: processedStatus, + Internal: internal, } } // CleanupHelmChart issues a delete for the internal HelmChart and returns it // while it is still present, so the caller can inspect its conditions and wait -// for nelm-source-controller to finish removing it. It returns nil once the +// for source-controller to finish removing it. It returns nil once the // HelmChart is gone. -func (s *ChartService) CleanupHelmChart(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (*sourcev1.HelmChart, error) { - nn := types.NamespacedName{Name: utils.GetInternalHelmChartName(addon.Name), Namespace: s.TargetNamespace} +func (s *ChartService) CleanupHelmChart(ctx context.Context, names source.ReleaseNames) (*sourcev1.HelmChart, error) { + nn := types.NamespacedName{Name: names.HelmChart, Namespace: s.TargetNamespace} chart := &sourcev1.HelmChart{} exists, err := s.deleteAndCheck(ctx, nn, chart) if err != nil { @@ -139,26 +109,25 @@ func (s *ChartService) CleanupHelmChart(ctx context.Context, addon *helmv1alpha1 return chart, nil } -func applyHelmChartSpec(addon *helmv1alpha1.HelmClusterAddon, existing *sourcev1.HelmChart) { - if addon.ForceReconcileRequired() { +func applyHelmChartSpec(rel source.Release, repo source.Repository, existing *sourcev1.HelmChart) { + if rel.ForceReconcileRequired() { setReconcileRequestAnnotations(existing) } + // Merge rather than replace: the internal HelmChart may carry labels put there + // by someone else (a policy engine, a cost allocator), and dropping them on + // every pass would fight whoever set them. if existing.Labels == nil { existing.Labels = map[string]string{} } + maps.Copy(existing.Labels, rel.HelmChartLabels()) - existing.Labels[helmv1alpha1.LabelManagedBy] = helmv1alpha1.LabelManagedByValue - existing.Labels[helmv1alpha1.HelmClusterAddonLabelSourceName] = addon.Name - existing.Labels[helmv1alpha1.HelmClusterAddonChartLabelSourceName] = naming.HelmClusterAddonChartName( - addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName, - ) - - existing.Spec.Chart = addon.Spec.Chart.HelmClusterAddonChartName - existing.Spec.Version = addon.Spec.Chart.Version + ref := rel.ChartRef() + existing.Spec.Chart = ref.Chart + existing.Spec.Version = ref.Version existing.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ Kind: sourcev1.HelmRepositoryKind, - Name: utils.GetInternalHelmRepositoryName(addon.Spec.Chart.HelmClusterAddonRepository), + Name: repo.InternalNames().HelmRepository, } } diff --git a/images/operator-helm-controller/internal/services/chart_service_test.go b/images/operator-helm-controller/internal/services/chart_service_test.go index f5e844b6..12546f76 100644 --- a/images/operator-helm-controller/internal/services/chart_service_test.go +++ b/images/operator-helm-controller/internal/services/chart_service_test.go @@ -20,12 +20,14 @@ import ( "context" "testing" - "github.com/werf/3p-fluxcd-pkg/apis/meta" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -42,6 +44,15 @@ func newChartService(t *testing.T, objects ...client.Object) (*ChartService, cli return NewChartService(c, scheme, testNamespace), c } +// chartTestRepository is the repository the test addon references; the chart +// service reads the internal HelmRepository name from it. +func chartTestRepository() *helmv1alpha1.HelmClusterAddonRepository { + return &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "example"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.invalid/stable"}, + } +} + // TestEnsureHelmChartForcesReconcileFromAddon covers the force reconcile // annotation applied to the HelmClusterAddon: on the internal Helm repository // path it must reach the HelmChart, so that a forced addon re-pulls its source @@ -51,7 +62,7 @@ func TestEnsureHelmChartForcesReconcileFromAddon(t *testing.T) { addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} service, c := newChartService(t, addon) - service.EnsureHelmChart(context.Background(), addon) + service.EnsureHelmChart(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(chartTestRepository())) chart := &sourcev1.HelmChart{} key := client.ObjectKey{Name: utils.GetInternalHelmChartName(addon.Name), Namespace: testNamespace} @@ -74,7 +85,7 @@ func TestEnsureHelmChartDoesNotForceReconcileWithoutAnnotation(t *testing.T) { addon := testAddon() service, c := newChartService(t, addon) - service.EnsureHelmChart(context.Background(), addon) + service.EnsureHelmChart(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(chartTestRepository())) chart := &sourcev1.HelmChart{} key := client.ObjectKey{Name: utils.GetInternalHelmChartName(addon.Name), Namespace: testNamespace} @@ -86,3 +97,36 @@ func TestEnsureHelmChartDoesNotForceReconcileWithoutAnnotation(t *testing.T) { t.Errorf("%s must not be stamped without a force request", meta.ReconcileRequestAnnotation) } } + +// TestEnsureHelmChartKeepsForeignLabels pins that our labels are merged into the +// internal chart rather than replacing what is there. Something else in the +// cluster may label the object — a policy engine, a cost allocator — and wiping +// those labels on every pass would fight whoever set them. +func TestEnsureHelmChartKeepsForeignLabels(t *testing.T) { + addon := testAddon() + existing := &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetInternalHelmChartName(addon.Name), + Namespace: testNamespace, + Labels: map[string]string{"cost-center": "team-a"}, + }, + } + service, c := newChartService(t, addon, existing) + + rel := adapter.NewAddonRelease(addon) + service.EnsureHelmChart(context.Background(), rel, adapter.NewAddonRepository(chartTestRepository())) + + chart := &sourcev1.HelmChart{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(existing), chart); err != nil { + t.Fatalf("getting helm chart: %v", err) + } + + if chart.Labels["cost-center"] != "team-a" { + t.Fatalf("labels = %v, want the foreign label preserved", chart.Labels) + } + for key, want := range rel.HelmChartLabels() { + if chart.Labels[key] != want { + t.Fatalf("label %q = %q, want %q", key, chart.Labels[key], want) + } + } +} diff --git a/images/operator-helm-controller/internal/services/force_service.go b/images/operator-helm-controller/internal/services/force_service.go new file mode 100644 index 00000000..b24347d9 --- /dev/null +++ b/images/operator-helm-controller/internal/services/force_service.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 services + +import ( + "context" + "fmt" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/operator-helm/internal/source" +) + +var _ source.ConsumerForcer = (*ForceService)(nil) + +// ForceService pushes a repository's force reconcile request onto the internal +// OCIRepository of every release consuming it. Which releases those are is the +// family's business: the lister is injected. +type ForceService struct { + client client.Client + targetNamespace string + list source.ReleaseLister +} + +func NewForceService(c client.Client, targetNamespace string, list source.ReleaseLister) *ForceService { + return &ForceService{client: c, targetNamespace: targetNamespace, list: list} +} + +// ForceReconcileConsumers stamps the reconcile request annotations on the internal +// OCIRepository of every release that references repo. +// +// An artifact pulled per release has no internal source object shared by the +// repository, so a force request reaches it only through the releases' own +// OCIRepositories. That is every release of an oci:// repository, and every release +// of a helm repository whose version the index publishes in a registry. +// +// A release whose internal OCIRepository does not exist yet is skipped: the force +// request must not be blocked by a release that has not reached the point of +// building one. +func (s *ForceService) ForceReconcileConsumers(ctx context.Context, repo source.Repository) error { + releases, err := s.list(ctx, repo, "") + if err != nil { + return err + } + + for _, rel := range releases { + name := rel.InternalNames().OCIRepository + nn := types.NamespacedName{Name: name, Namespace: s.targetNamespace} + + ociRepo := &sourcev1.OCIRepository{} + if err := s.client.Get(ctx, nn, ociRepo); err != nil { + if apierrors.IsNotFound(err) { + continue + } + + return fmt.Errorf("getting internal oci repository %s: %w", name, err) + } + + base := ociRepo.DeepCopy() + setReconcileRequestAnnotations(ociRepo) + + // The internal repository may be removed between the get and the patch, + // which is the same case as the one skipped above. + if err := s.client.Patch(ctx, ociRepo, client.MergeFrom(base)); client.IgnoreNotFound(err) != nil { + return fmt.Errorf("requesting reconciliation of internal oci repository %s: %w", name, err) + } + } + + return nil +} diff --git a/images/operator-helm-controller/internal/services/force_service_test.go b/images/operator-helm-controller/internal/services/force_service_test.go new file mode 100644 index 00000000..25275e14 --- /dev/null +++ b/images/operator-helm-controller/internal/services/force_service_test.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 services + +import ( + "context" + "testing" + + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/utils" +) + +// TestForceReconcileConsumersStampsOnlyItsOwnAddons covers the force +// reconcile annotation applied to an oci:// HelmClusterAddonRepository: unlike the +// helm:// path, where the internal HelmRepository re-indexes and the HelmCharts +// follow, an OCI repository has no intermediate source object, so the request must +// be pushed onto the internal OCIRepository of each addon that references it - and +// only of those addons. +func TestForceReconcileConsumersStampsOnlyItsOwnAddons(t *testing.T) { + addon := testAddon() + foreign := testAddon() + foreign.Name = "foreign" + foreign.Spec.Chart.HelmClusterAddonRepository = "another" + + _, c := newOCIRepoService(t, + addon, foreign, + internalOCIRepository(addon.Name), internalOCIRepository(foreign.Name), + ) + service := NewForceService(c, testNamespace, adapter.ListAddonReleases(c)) + + if err := service.ForceReconcileConsumers(context.Background(), adapter.NewAddonRepository(ociTestRepository())); err != nil { + t.Fatalf("forcing internal repositories: %v", err) + } + + ociRepo := &sourcev1.OCIRepository{} + key := client.ObjectKey{Name: utils.GetInternalOCIRepositoryName(addon.Name), Namespace: testNamespace} + if err := c.Get(context.Background(), key, ociRepo); err != nil { + t.Fatalf("getting oci repository: %v", err) + } + if ociRepo.Annotations[meta.ReconcileRequestAnnotation] == "" { + t.Errorf("%s must be stamped on the oci repository of the addon", meta.ReconcileRequestAnnotation) + } + if ociRepo.Annotations[meta.ForceRequestAnnotation] == "" { + t.Errorf("%s must be stamped on the oci repository of the addon", meta.ForceRequestAnnotation) + } + + foreignRepo := &sourcev1.OCIRepository{} + key = client.ObjectKey{Name: utils.GetInternalOCIRepositoryName(foreign.Name), Namespace: testNamespace} + if err := c.Get(context.Background(), key, foreignRepo); err != nil { + t.Fatalf("getting foreign oci repository: %v", err) + } + if _, found := foreignRepo.Annotations[meta.ReconcileRequestAnnotation]; found { + t.Errorf("%s must not be stamped on an addon of another repository", meta.ReconcileRequestAnnotation) + } +} + +// TestForceReconcileConsumersToleratesMissingSource covers the addon +// that has no internal OCIRepository yet - it has just been created, or it never +// reached the point of building one. A force on the repository must not fail +// because of it, otherwise the request is retried forever. +func TestForceReconcileConsumersToleratesMissingSource(t *testing.T) { + addon := testAddon() + _, c := newOCIRepoService(t, addon) + service := NewForceService(c, testNamespace, adapter.ListAddonReleases(c)) + + if err := service.ForceReconcileConsumers(context.Background(), adapter.NewAddonRepository(ociTestRepository())); err != nil { + t.Fatalf("a missing internal oci repository must not fail the force request: %v", err) + } +} diff --git a/images/operator-helm-controller/internal/services/helm_repo_service.go b/images/operator-helm-controller/internal/services/helm_repo_service.go index 64f89763..62b4f919 100644 --- a/images/operator-helm-controller/internal/services/helm_repo_service.go +++ b/images/operator-helm-controller/internal/services/helm_repo_service.go @@ -21,9 +21,8 @@ import ( "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" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -33,8 +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" + "github.com/deckhouse/operator-helm/internal/source" ) const InternalRepositoryInterval = 5 * time.Minute @@ -61,13 +59,13 @@ func NewHelmRepoService(client client.Client, scheme *runtime.Scheme, namespace // error and is reported through the state instead. func (s *HelmRepoService) EnsureInternalHelmRepository( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, + repo source.Repository, ) (InternalRepositoryState, error) { logger := log.FromContext(ctx) existing := &sourcev1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetInternalHelmRepositoryName(repo.Name), + Name: repo.InternalNames().HelmRepository, Namespace: s.TargetNamespace, }, } @@ -82,7 +80,8 @@ func (s *HelmRepoService) EnsureInternalHelmRepository( } if op != controllerutil.OperationResultNone { - logger.Info("Reconciled helm repository", "operation", op) + logger.Info("Reconciled helm repository", "operation", op, + "internalObject", client.ObjectKeyFromObject(existing)) } state := InternalRepositoryState{Present: true} @@ -96,7 +95,7 @@ func (s *HelmRepoService) EnsureInternalHelmRepository( return state, nil } - cond, observed := status.IsConditionObserved(existing.Status.Conditions, helmv1alpha1.ConditionTypeReady, existing.Generation) + cond, observed := conditionObserved(existing.Status.Conditions, helmv1alpha1.ConditionTypeReady, existing.Generation) if !observed { state.Reason = helmv1alpha1.ReasonReconciling state.Message = "Waiting for the internal repository to be reconciled" @@ -111,9 +110,8 @@ func (s *HelmRepoService) EnsureInternalHelmRepository( return state, nil } -func (s *HelmRepoService) RemoveHelmRepository(ctx context.Context, repoName string) error { - name := utils.GetInternalHelmRepositoryName(repoName) - nn := types.NamespacedName{Name: name, Namespace: s.TargetNamespace} +func (s *HelmRepoService) RemoveHelmRepository(ctx context.Context, names source.InternalNames) error { + nn := types.NamespacedName{Name: names.HelmRepository, Namespace: s.TargetNamespace} if err := s.ensureResourceDeleted(ctx, nn, &sourcev1.HelmRepository{}); err != nil { return fmt.Errorf("removing helm repository: %w", err) } @@ -121,25 +119,12 @@ func (s *HelmRepoService) RemoveHelmRepository(ctx context.Context, repoName str return nil } -// CleanupHelmRepository removes the auth/TLS secrets (which have no finalizers -// and disappear immediately) and issues a delete for the internal HelmRepository, -// returning it while it is still present so the caller can inspect its conditions -// and wait for nelm-source-controller to finish removing it. It returns nil once +// CleanupHelmRepository issues a delete for the internal HelmRepository and +// returns it while it is still present, so the caller can inspect its conditions +// and wait for source-controller to finish removing it. It returns nil once // the HelmRepository is gone. -func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, repoName string) (*sourcev1.HelmRepository, error) { - secrets := []string{ - utils.GetInternalRepositoryAuthSecretName(repoName), - utils.GetInternalRepositoryTLSSecretName(repoName), - } - - for _, name := range secrets { - nn := types.NamespacedName{Name: name, Namespace: s.TargetNamespace} - if err := s.ensureResourceDeleted(ctx, nn, &corev1.Secret{}); err != nil { - return nil, fmt.Errorf("cleaning up secret %s: %w", name, err) - } - } - - nn := types.NamespacedName{Name: utils.GetInternalHelmRepositoryName(repoName), Namespace: s.TargetNamespace} +func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, names source.InternalNames) (*sourcev1.HelmRepository, error) { + nn := types.NamespacedName{Name: names.HelmRepository, Namespace: s.TargetNamespace} helmRepo := &sourcev1.HelmRepository{} exists, err := s.deleteAndCheck(ctx, nn, helmRepo) if err != nil { @@ -152,7 +137,7 @@ func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, repoName st return helmRepo, nil } -func applyHelmRepositorySpec(repo *helmv1alpha1.HelmClusterAddonRepository, existing *sourcev1.HelmRepository) { +func applyHelmRepositorySpec(repo source.Repository, existing *sourcev1.HelmRepository) { if repo.ForceReconcileRequired() { if existing.Annotations == nil { existing.Annotations = map[string]string{} @@ -162,27 +147,26 @@ func applyHelmRepositorySpec(repo *helmv1alpha1.HelmClusterAddonRepository, exis existing.Annotations[meta.ReconcileRequestAnnotation] = ts } - existing.Spec.URL = repo.Spec.URL + names := repo.InternalNames() + + existing.Spec.URL = repo.URL() existing.Spec.Interval = metav1.Duration{Duration: InternalRepositoryInterval} - existing.Spec.Insecure = repo.Spec.InsecureSkipVerify + existing.Spec.Insecure = repo.InsecureSkipVerify() existing.Spec.CertSecretRef = nil existing.Spec.SecretRef = nil - if repo.Spec.Auth != nil { + if repo.Auth() != nil { existing.Spec.SecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), + Name: names.AuthSecret, } existing.Spec.PassCredentials = true } - if repo.Spec.CACertificate != "" { + if repo.CACertificate() != "" { existing.Spec.CertSecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryTLSSecretName(repo.Name), + Name: names.TLSSecret, } } - existing.Labels = map[string]string{ - helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repo.Name, - } + existing.Labels = repo.SourceLabels() } diff --git a/images/operator-helm-controller/internal/services/helm_repo_service_test.go b/images/operator-helm-controller/internal/services/helm_repo_service_test.go index 6fb33b2c..2a03be93 100644 --- a/images/operator-helm-controller/internal/services/helm_repo_service_test.go +++ b/images/operator-helm-controller/internal/services/helm_repo_service_test.go @@ -21,7 +21,7 @@ import ( "errors" "testing" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/interceptor" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -62,7 +63,7 @@ func newHelmRepoService(t *testing.T, objects ...client.Object) *HelmRepoService func testRepository() *helmv1alpha1.HelmClusterAddonRepository { return &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://example.invalid/charts"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://example.invalid/charts"}, } } @@ -70,7 +71,7 @@ func TestEnsureInternalHelmRepositoryReportsNotObservedAsNotReady(t *testing.T) repo := testRepository() service := newHelmRepoService(t, repo) - state, err := service.EnsureInternalHelmRepository(context.Background(), repo) + state, err := service.EnsureInternalHelmRepository(context.Background(), adapter.NewAddonRepository(repo)) if err != nil { t.Fatalf("EnsureInternalHelmRepository returned %v", err) } @@ -118,7 +119,7 @@ func TestEnsureInternalHelmRepositoryMirrorsConditions(t *testing.T) { // The fixture is created with generation 0 and the condition observes 0, so // the state must mirror the condition rather than report "not observed yet". - state, err := service.EnsureInternalHelmRepository(context.Background(), repo) + state, err := service.EnsureInternalHelmRepository(context.Background(), adapter.NewAddonRepository(repo)) if err != nil { t.Fatalf("EnsureInternalHelmRepository returned %v", err) } @@ -154,7 +155,7 @@ func TestEnsureInternalHelmRepositoryReportsStalled(t *testing.T) { service := newHelmRepoService(t, repo, internal) - state, err := service.EnsureInternalHelmRepository(context.Background(), repo) + state, err := service.EnsureInternalHelmRepository(context.Background(), adapter.NewAddonRepository(repo)) if err != nil { t.Fatalf("EnsureInternalHelmRepository returned %v", err) } @@ -212,7 +213,7 @@ func TestEnsureInternalHelmRepositoryStalledPrecedesReady(t *testing.T) { service := newHelmRepoService(t, repo, internal) - state, err := service.EnsureInternalHelmRepository(context.Background(), repo) + state, err := service.EnsureInternalHelmRepository(context.Background(), adapter.NewAddonRepository(repo)) if err != nil { t.Fatalf("EnsureInternalHelmRepository returned %v", err) } @@ -251,7 +252,7 @@ func TestEnsureInternalHelmRepositoryReturnsAPIError(t *testing.T) { service := NewHelmRepoService(c, scheme, testNamespace) - state, err := service.EnsureInternalHelmRepository(context.Background(), repo) + state, err := service.EnsureInternalHelmRepository(context.Background(), adapter.NewAddonRepository(repo)) if err == nil { t.Fatal("EnsureInternalHelmRepository must return an error when the API call fails") } diff --git a/images/operator-helm-controller/internal/services/internal_object.go b/images/operator-helm-controller/internal/services/internal_object.go new file mode 100644 index 00000000..deb6d785 --- /dev/null +++ b/images/operator-helm-controller/internal/services/internal_object.go @@ -0,0 +1,152 @@ +/* +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 ( + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +// InternalObjectState describes the observed state of one internal FluxCD object a +// release owns — its HelmChart, its OCIRepository, its HelmRelease. It is the +// release-side counterpart of InternalRepositoryState: the service reads the +// object's conditions and reduces them to this, so the knowledge of which FluxCD +// condition means what stays where the object is written. +// +// An object the pass never reconciled has no state at all: the outcome carrying it +// is absent instead. Observed is false while the object has not yet reported a +// verdict for the spec it was given, which is why Status is kept whole rather than +// reduced to a Ready boolean — an object may report Unknown for a generation it has +// observed, and that is neither readiness nor failure. +type InternalObjectState struct { + Observed bool + // Stalled reports that the internal object gave up on the spec it was given: + // it exhausted its remediation attempts, or reached a fault it will not retry. + // Nothing on this side brings it back — the release has to be changed, which + // gives the internal object a new spec to act on — so the release reports it as + // Stalled rather than as work still in flight. It is kept apart from Status + // because the two answer different questions: Status is the verdict, Stalled is + // whether anything more is coming. + Stalled bool + Status metav1.ConditionStatus + Reason string + Message string +} + +// Ready reports the state every consumer means by it: the object has observed the +// spec it was given and calls itself ready for it. +func (s InternalObjectState) Ready() bool { + return s.Observed && s.Status == metav1.ConditionTrue +} + +// ErrorConditionRule maps a condition an internal FluxCD object raises about itself +// to the reason the release reports because of it. The rules of one kind are checked +// in order, so a more specific cause listed first wins over a general one. +type ErrorConditionRule struct { + Type string + TriggerStatus metav1.ConditionStatus + Reason string +} + +// reduceInternalConditions collapses the conditions of an internal object into the +// state the release evaluation works from. Having given up outranks everything: an +// object that will not act on the spec it was given is not going to reach a verdict +// about it either, so its Reconciling condition — left behind by the attempt that +// gave up — must not be read as work in flight. Otherwise a running reconciliation +// comes first: whatever the object still says about the previous spec is stale until +// it settles, and ProgressingWithRetry is excluded there because it is not a +// reconciliation in flight but a failure that has scheduled one, and the failure is +// what the release has to report. After that the error rules speak, and only then +// the object's own Ready — which counts only once observed for the generation that +// was written. +// +// Whether the object gave up is carried alongside the verdict rather than replacing +// it: the error rules name the actual fault ("server-side apply failed …"), which is +// what someone reading the release needs, while the Stalled condition only says how +// many attempts were spent. The reason from Stalled is used solely where there is no +// other verdict to report. +func reduceInternalConditions( + conditions []metav1.Condition, + generation int64, + errorRules []ErrorConditionRule, +) InternalObjectState { + stalled := apimeta.FindStatusCondition(conditions, helmv1alpha1.ConditionTypeStalled) + gaveUp := stalled != nil && stalled.Status == metav1.ConditionTrue + + reconciling := InternalObjectState{ + Status: metav1.ConditionUnknown, + Reason: helmv1alpha1.ReasonReconciling, + } + + if !gaveUp { + cond := apimeta.FindStatusCondition(conditions, helmv1alpha1.ConditionTypeReconciling) + if cond != nil && cond.Status == metav1.ConditionTrue && cond.Reason != helmv1alpha1.ReasonProgressingWithRetry { + return reconciling + } + } + + for _, rule := range errorRules { + if cond := apimeta.FindStatusCondition(conditions, rule.Type); cond != nil && cond.Status == rule.TriggerStatus { + return InternalObjectState{ + Observed: true, + Stalled: gaveUp, + Status: metav1.ConditionFalse, + Reason: rule.Reason, + Message: cond.Message, + } + } + } + + ready, observed := conditionObserved(conditions, helmv1alpha1.ConditionTypeReady, generation) + if !observed { + if gaveUp { + // No rule matched and no verdict was reached for this generation, so the + // object's own account of why it stopped is all there is to report. + return InternalObjectState{ + Observed: true, + Stalled: true, + Status: metav1.ConditionFalse, + Reason: stalled.Reason, + Message: stalled.Message, + } + } + + return reconciling + } + + return InternalObjectState{ + Observed: true, + Stalled: gaveUp, + Status: ready.Status, + Reason: ready.Reason, + Message: ready.Message, + } +} + +// conditionObserved returns the named condition only when it was set for the +// generation given, so a verdict about a spec that has since changed is not read as +// one about the current spec. +func conditionObserved(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-controller/internal/services/internal_object_test.go b/images/operator-helm-controller/internal/services/internal_object_test.go new file mode 100644 index 00000000..b582d2bb --- /dev/null +++ b/images/operator-helm-controller/internal/services/internal_object_test.go @@ -0,0 +1,163 @@ +/* +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 ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +// TestReduceInternalConditionsCarriesGivingUp reproduces an internal release that +// spent its remediation attempts on values the chart cannot render. The reduced +// state has to say two things at once: what the fault was, which only the error +// rules know, and that nothing more is coming, which only the Stalled condition +// knows. Losing the second is what lets a release report a retry that never runs. +func TestReduceInternalConditionsCarriesGivingUp(t *testing.T) { + const cause = "Helm upgrade failed for release default/podinfo-app-helm: " + + ".spec.replicas: expected numeric (int or float), got string" + + state := reduceInternalConditions([]metav1.Condition{ + { + Type: helmv1alpha1.ConditionTypeStalled, + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonRetriesExceeded, + Message: "Failed to upgrade after 1 attempt(s)", + ObservedGeneration: 4, + }, + { + Type: helmv1alpha1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: "UpgradeFailed", + Message: cause, + ObservedGeneration: 4, + }, + { + Type: "Released", + Status: metav1.ConditionFalse, + Reason: "UpgradeFailed", + Message: cause, + ObservedGeneration: 4, + }, + }, 4, helmReleaseErrorRules) + + if !state.Stalled { + t.Fatalf("state = %+v, want it to carry that the object gave up", state) + } + if state.Status != metav1.ConditionFalse || state.Reason != helmv1alpha1.ReasonReleaseFailed { + t.Fatalf("state = %s/%s, want False/%s", state.Status, state.Reason, helmv1alpha1.ReasonReleaseFailed) + } + if state.Message != cause { + t.Fatalf("message = %q, want the fault the error rule named", state.Message) + } +} + +// TestReduceInternalConditionsIgnoresProgressOfAnObjectThatGaveUp pins the one +// ordering that matters. A stalled object can still carry the Reconciling condition +// of the attempt that gave up; read as work in flight it would hide the failure +// behind Unknown and keep the release waiting for a verdict that never comes. +func TestReduceInternalConditionsIgnoresProgressOfAnObjectThatGaveUp(t *testing.T) { + state := reduceInternalConditions([]metav1.Condition{ + { + Type: helmv1alpha1.ConditionTypeStalled, + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonRetriesExceeded, + Message: "Failed to upgrade after 1 attempt(s)", + ObservedGeneration: 2, + }, + { + Type: helmv1alpha1.ConditionTypeReconciling, + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonReconciling, + Message: "Running 'upgrade' action", + ObservedGeneration: 2, + }, + { + Type: "Released", + Status: metav1.ConditionFalse, + Reason: "UpgradeFailed", + Message: "Helm upgrade failed", + ObservedGeneration: 2, + }, + }, 2, helmReleaseErrorRules) + + if state.Status != metav1.ConditionFalse { + t.Fatalf("state = %+v, want the failure rather than work in flight", state) + } + if !state.Stalled { + t.Fatalf("state = %+v, want it to carry that the object gave up", state) + } +} + +// TestReduceInternalConditionsFallsBackToTheStallReason covers a stalled object no +// error rule matches and whose Ready is about an older spec: its own account of why +// it stopped is then the only verdict there is. +func TestReduceInternalConditionsFallsBackToTheStallReason(t *testing.T) { + state := reduceInternalConditions([]metav1.Condition{ + { + Type: helmv1alpha1.ConditionTypeStalled, + Status: metav1.ConditionTrue, + Reason: helmv1alpha1.ReasonRetriesExceeded, + Message: "Failed to install after 3 attempt(s)", + ObservedGeneration: 7, + }, + { + Type: helmv1alpha1.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "InstallSucceeded", + ObservedGeneration: 6, + }, + }, 7, helmReleaseErrorRules) + + if !state.Observed || !state.Stalled || state.Status != metav1.ConditionFalse { + t.Fatalf("state = %+v, want an observed, stalled failure", state) + } + if state.Reason != helmv1alpha1.ReasonRetriesExceeded { + t.Fatalf("reason = %q, want %q", state.Reason, helmv1alpha1.ReasonRetriesExceeded) + } +} + +// TestReduceInternalConditionsLeavesAnOrdinaryFailureRetriable is the counterpart: +// the same failure without a Stalled condition is one the object still means to +// retry, and the release must go on reporting progress for it. +func TestReduceInternalConditionsLeavesAnOrdinaryFailureRetriable(t *testing.T) { + state := reduceInternalConditions([]metav1.Condition{ + { + Type: helmv1alpha1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: "UpgradeFailed", + Message: "Helm upgrade failed", + ObservedGeneration: 3, + }, + { + Type: "Released", + Status: metav1.ConditionFalse, + Reason: "UpgradeFailed", + Message: "Helm upgrade failed", + ObservedGeneration: 3, + }, + }, 3, helmReleaseErrorRules) + + if state.Stalled { + t.Fatalf("state = %+v, want it left retriable", state) + } + if state.Reason != helmv1alpha1.ReasonReleaseFailed { + t.Fatalf("reason = %q, want %q", state.Reason, helmv1alpha1.ReasonReleaseFailed) + } +} diff --git a/images/operator-helm-controller/internal/services/maintenance_service.go b/images/operator-helm-controller/internal/services/maintenance_service.go index dc50495c..e51f0be8 100644 --- a/images/operator-helm-controller/internal/services/maintenance_service.go +++ b/images/operator-helm-controller/internal/services/maintenance_service.go @@ -20,7 +20,7 @@ import ( "context" "fmt" - helmv2 "github.com/werf/3p-helm-controller/api/v2" + helmv2 "github.com/fluxcd/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" @@ -30,8 +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" + "github.com/deckhouse/operator-helm/internal/source" ) type MaintenanceService struct { @@ -50,75 +49,40 @@ func NewMaintenanceService(client client.Client, scheme *runtime.Scheme, targetN } } -var _ statusmgr.Provider = (*MaintenanceResult)(nil) - -type MaintenanceResult struct { - Status statusmgr.Status -} - -func (r MaintenanceResult) GetStatus() statusmgr.Status { - 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 { +func (s *MaintenanceService) EnsureMaintenanceMode(ctx context.Context, rel source.Release) MaintenanceOutcome { logger := log.FromContext(ctx) - suspendState := addon.MaintenanceModeActivated() - status := metav1.ConditionTrue - reason := helmv1alpha1.ReasonMaintenanceModeInactive - - var message string - - if suspendState { + activated := rel.MaintenanceActivated() + if activated { logger.Info("Enabling maintenance mode") - message = "Maintenance mode enabled" - status = metav1.ConditionFalse - reason = helmv1alpha1.ReasonMaintenanceModeActive } else { logger.Info("Disabling maintenance mode") - message = "Maintenance mode disabled" } - err := s.updateHelmReleaseSuspendState(ctx, addon, suspendState) - if err != nil { - return MaintenanceResult{Status: statusmgr.Failed(addon, helmv1alpha1.ReasonFailed, "Failed to change maintenance mode", err)} - } - return MaintenanceResult{ - Status: statusmgr.Status{ - Observed: true, - Status: status, - ObservedGeneration: addon.Generation, - Message: message, - Reason: reason, - }, + if err := s.updateHelmReleaseSuspendState(ctx, rel.InternalNames(), activated); err != nil { + return MaintenanceOutcome{Err: err, Activated: activated} } + + return MaintenanceOutcome{Activated: activated} } -func (s *MaintenanceService) IsMaintenanceModeChangeRequired(addon *helmv1alpha1.HelmClusterAddon) bool { - if addon.MaintenanceModeActivated() && !addon.MaintenanceModeEnabled() { +func (s *MaintenanceService) IsMaintenanceModeChangeRequired(rel source.Release) bool { + if rel.MaintenanceActivated() && !rel.MaintenanceEnabled() { return true } - if !addon.MaintenanceModeActivated() && (addon.MaintenanceModeEnabled() || - apimeta.IsStatusConditionPresentAndEqual(addon.Status.Conditions, helmv1alpha1.ConditionTypeManaged, metav1.ConditionUnknown)) { + if !rel.MaintenanceActivated() && (rel.MaintenanceEnabled() || + apimeta.IsStatusConditionPresentAndEqual(*rel.Object().GetConditions(), helmv1alpha1.ConditionTypeManaged, metav1.ConditionUnknown)) { return true } return false } -func (s *MaintenanceService) updateHelmReleaseSuspendState(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon, suspend bool) error { +func (s *MaintenanceService) updateHelmReleaseSuspendState(ctx context.Context, names source.ReleaseNames, suspend bool) error { helmRelease := &helmv2.HelmRelease{} if err := s.Client.Get(ctx, types.NamespacedName{ - Name: utils.GetInternalHelmReleaseName(addon.Name), + Name: names.HelmRelease, Namespace: s.TargetNamespace, }, helmRelease); err != nil { if apierrors.IsNotFound(err) { diff --git a/images/operator-helm-controller/internal/services/namespace_service.go b/images/operator-helm-controller/internal/services/namespace_service.go new file mode 100644 index 00000000..416bad95 --- /dev/null +++ b/images/operator-helm-controller/internal/services/namespace_service.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. +*/ + +package services + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/operator-helm/internal/source" +) + +// NamespaceService implements source.TargetNamespaceEnsurer: it creates a +// release's target namespace when it does not exist yet, and never modifies an +// existing one — the namespace belongs to whoever created it. Today only the addon +// controller wires it in; other families deploy into a namespace that must already +// exist (their own). +type NamespaceService struct { + // reader reads from the API server directly (mgr.GetAPIReader()), bypassing the + // controller cache: nothing watches Namespaces, so a cached typed Get would start + // an informer the ClusterRole has no watch permission for. + reader client.Reader + client client.Client +} + +func NewNamespaceService(c client.Client, reader client.Reader) *NamespaceService { + return &NamespaceService{client: c, reader: reader} +} + +func (s *NamespaceService) EnsureTargetNamespace(ctx context.Context, rel source.Release) error { + ns := &corev1.Namespace{} + + err := s.reader.Get(ctx, client.ObjectKey{Name: rel.TargetNamespace()}, ns) + if err == nil { + return nil + } + if !apierrors.IsNotFound(err) { + return fmt.Errorf("getting namespace: %w", err) + } + + ns = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: rel.TargetNamespace(), + }, + } + + if err := s.client.Create(ctx, ns); err != nil && !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("creating namespace: %w", err) + } + + return nil +} diff --git a/images/operator-helm-controller/internal/services/namespace_service_test.go b/images/operator-helm-controller/internal/services/namespace_service_test.go new file mode 100644 index 00000000..e7bfbe96 --- /dev/null +++ b/images/operator-helm-controller/internal/services/namespace_service_test.go @@ -0,0 +1,63 @@ +/* +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" + "testing" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/operator-helm/internal/adapter" +) + +// TestEnsureTargetNamespaceCreatesItOnceAndLeavesItAlone pins the addon rule moved +// here from the reconciler: a missing target namespace is created, an existing one +// — labels included — is not touched. +func TestEnsureTargetNamespaceCreatesItOnceAndLeavesItAlone(t *testing.T) { + addon := testAddon() + c := fake.NewClientBuilder().WithScheme(testScheme(t)).Build() + service := NewNamespaceService(c, c) + + if err := service.EnsureTargetNamespace(context.Background(), adapter.NewAddonRelease(addon)); err != nil { + t.Fatalf("EnsureTargetNamespace returned %v", err) + } + + ns := &corev1.Namespace{} + if err := c.Get(context.Background(), client.ObjectKey{Name: "app"}, ns); err != nil { + t.Fatalf("target namespace was not created: %v", err) + } + + ns.Labels = map[string]string{"owner": "team"} + if err := c.Update(context.Background(), ns); err != nil { + t.Fatalf("labelling namespace: %v", err) + } + + if err := service.EnsureTargetNamespace(context.Background(), adapter.NewAddonRelease(addon)); err != nil { + t.Fatalf("second EnsureTargetNamespace returned %v", err) + } + + again := &corev1.Namespace{} + if err := c.Get(context.Background(), client.ObjectKey{Name: "app"}, again); err != nil { + t.Fatalf("getting namespace: %v", err) + } + if again.Labels["owner"] != "team" { + t.Fatalf("an existing namespace must not be rewritten, labels = %v", again.Labels) + } +} diff --git a/images/operator-helm-controller/internal/services/oci_repo_service.go b/images/operator-helm-controller/internal/services/oci_repo_service.go index b9287e68..a031165e 100644 --- a/images/operator-helm-controller/internal/services/oci_repo_service.go +++ b/images/operator-helm-controller/internal/services/oci_repo_service.go @@ -21,10 +21,8 @@ import ( "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" - apierrors "k8s.io/apimachinery/pkg/api/errors" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -33,13 +31,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/chartsource" repoclient "github.com/deckhouse/operator-helm/internal/client/repository" - "github.com/deckhouse/operator-helm/internal/index" - "github.com/deckhouse/operator-helm/internal/manager/status" + "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) -var ociRepositoryErrorRules = []status.ErrorConditionRule{ +var ociRepositoryErrorRules = []ErrorConditionRule{ {Type: "FetchFailed", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonOCIFetchFailed}, {Type: "FetchFailed", TriggerStatus: metav1.ConditionTrue, Reason: "OCIArtifactPullFailed"}, {Type: "IncludeUnavailable", TriggerStatus: metav1.ConditionTrue, Reason: helmv1alpha1.ReasonOCIIncludeUnavailable}, @@ -83,93 +81,49 @@ func NewOCIRepoService( } } -var _ status.Provider = (*OCIRepoResult)(nil) - -type OCIRepoResult struct { - Status status.Status - Artifact *meta.Artifact - // RequeueAfter asks the caller to schedule another pass. It is set only when the - // artifact could not be examined for a reason that may pass on its own: there is - // no watch on a foreign registry. - RequeueAfter time.Duration -} - -func (r OCIRepoResult) GetStatus() status.Status { - return r.Status -} - -func (r OCIRepoResult) IsReady() bool { - return r.Artifact != nil && r.Status.Observed && r.Status.Status == metav1.ConditionTrue -} - -func (r OCIRepoResult) HasArtifact() bool { - return r.Artifact != nil && r.Status.Observed -} - -func (r OCIRepoResult) GetConditionType() string { - return helmv1alpha1.ConditionTypeReady -} - func (s *OCIRepoService) EnsureInternalOCIRepository( ctx context.Context, - addon *helmv1alpha1.HelmClusterAddon, - repo *helmv1alpha1.HelmClusterAddonRepository, - source utils.ChartSource, - version *helmv1alpha1.HelmClusterAddonChartVersion, -) OCIRepoResult { + rel source.Release, + repo source.Repository, + src chartsource.Source, + version *helmv1alpha1.ChartVersion, +) OCIRepoOutcome { logger := log.FromContext(ctx) - mediaType, failure := s.resolveMediaType(ctx, addon, repo, source, version) + mediaType, failure := s.resolveMediaType(ctx, rel, repo, src, version) if failure != nil { return *failure } existing := &sourcev1.OCIRepository{ ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetInternalOCIRepositoryName(addon.Name), + Name: rel.InternalNames().OCIRepository, Namespace: s.TargetNamespace, }, } op, err := controllerutil.CreateOrPatch(ctx, s.Client, existing, func() error { - applyOCIRepositorySpec(addon, repo, source, mediaType, existing) + applyOCIRepositorySpec(rel, repo, src, mediaType, existing) return nil }) if err != nil { - return OCIRepoResult{ - Status: status.Failed( - addon, - helmv1alpha1.ReasonFailed, - "Failed to reconcile oci repository", - fmt.Errorf("creating oci repository: %w", err), - ), - } + return OCIRepoOutcome{Err: fmt.Errorf("creating oci repository: %w", err)} } if op != controllerutil.OperationResultNone { - logger.Info("Reconciled oci repository", "operation", op) + logger.Info("Reconciled oci repository", "operation", op, + "internalObject", client.ObjectKeyFromObject(existing)) } - processedStatus := status.ProcessChildConditions( - existing.Status.Conditions, existing.Generation, addon, ociRepositoryErrorRules, - ) - - if version.UnavailableReason == helmv1alpha1.UnavailableReasonRemovedFromRepository && - processedStatus.Status != metav1.ConditionTrue { - // The version is still recorded — that is what keeps this addon reconcilable — - // but the repository no longer offers the tag, so the pull cannot succeed. Name - // that cause instead of leaving only the source controller's "not found". - processedStatus.Reason = helmv1alpha1.ReasonChartVersionRemoved - processedStatus.Message = fmt.Sprintf( - "Version %s is no longer offered by repository %s: %s", - version.Version, repo.Name, processedStatus.Message, - ) - } + internal := reduceInternalConditions(existing.Status.Conditions, existing.Generation, ociRepositoryErrorRules) - return OCIRepoResult{ - Artifact: existing.Status.Artifact, - Status: processedStatus, + return OCIRepoOutcome{ + Artifact: existing.Status.Artifact, + Internal: internal, + VersionRemoved: version.UnavailableReason == helmv1alpha1.UnavailableReasonRemovedFromRepository, + Version: version.Version, + RepositoryName: repo.Name(), } } @@ -185,40 +139,40 @@ func (s *OCIRepoService) EnsureInternalOCIRepository( // — or when a force request asks for a re-examination. func (s *OCIRepoService) resolveMediaType( ctx context.Context, - addon *helmv1alpha1.HelmClusterAddon, - repo *helmv1alpha1.HelmClusterAddonRepository, - source utils.ChartSource, - version *helmv1alpha1.HelmClusterAddonChartVersion, -) (string, *OCIRepoResult) { + rel source.Release, + repo source.Repository, + src chartsource.Source, + version *helmv1alpha1.ChartVersion, +) (string, *OCIRepoOutcome) { if version.MediaType != "" { return version.MediaType, nil } - if !addon.ForceReconcileRequired() { - if cached := s.cachedMediaType(ctx, addon, source); cached != "" { + if !rel.ForceReconcileRequired() { + if cached := s.cachedMediaType(ctx, rel.InternalNames(), src); cached != "" { return cached, nil } } - mediaType, err := s.resolver.ResolveChartArtifact(ctx, source.URL+":"+source.Tag, artifactRepoConfig(repo, source)) + mediaType, err := s.resolver.ResolveChartArtifact(ctx, src.URL+":"+src.Tag, artifactRepoConfig(repo, src)) if err == nil { return mediaType, nil } if terminal, ok := repoclient.AsTerminal(err); ok { - return "", &OCIRepoResult{ - Status: status.Failed(addon, terminal.Reason, terminal.Message, err), + return "", &OCIRepoOutcome{ + ProbeErr: err, + ProbeReason: terminal.Reason, + ProbeMessage: terminal.Message, + ProbeTerminal: true, } } - return "", &OCIRepoResult{ - Status: status.Failed( - addon, - helmv1alpha1.ReasonOCIFetchFailed, - "Failed to examine the chart artifact: "+err.Error(), - err, - ), - RequeueAfter: chartArtifactProbeRequeueInterval, + return "", &OCIRepoOutcome{ + ProbeErr: err, + ProbeReason: helmv1alpha1.ReasonOCIFetchFailed, + ProbeMessage: "Failed to examine the chart artifact: " + err.Error(), + ProbeRequeueAfter: chartArtifactProbeRequeueInterval, } } @@ -226,11 +180,11 @@ func (s *OCIRepoService) resolveMediaType( // is a verdict about this exact artifact. func (s *OCIRepoService) cachedMediaType( ctx context.Context, - addon *helmv1alpha1.HelmClusterAddon, - source utils.ChartSource, + names source.ReleaseNames, + src chartsource.Source, ) string { nn := types.NamespacedName{ - Name: utils.GetInternalOCIRepositoryName(addon.Name), + Name: names.OCIRepository, Namespace: s.TargetNamespace, } @@ -239,10 +193,10 @@ func (s *OCIRepoService) cachedMediaType( return "" } - if existing.Spec.URL != source.URL { + if existing.Spec.URL != src.URL { return "" } - if existing.Spec.Reference == nil || existing.Spec.Reference.Tag != source.Tag { + if existing.Spec.Reference == nil || existing.Spec.Reference.Tag != src.Tag { return "" } @@ -253,96 +207,27 @@ func (s *OCIRepoService) cachedMediaType( // the host the repository names gets them, and credentials are never included: the // internal OCIRepository pulls a foreign registry anonymously, and a probe that // authenticated would report a chart the pull could not fetch. -func artifactRepoConfig(repo *helmv1alpha1.HelmClusterAddonRepository, source utils.ChartSource) *repoclient.RepoConfig { - if !sameRegistryHost(repo.Spec.URL, source.URL) { +func artifactRepoConfig(repo source.Repository, src chartsource.Source) *repoclient.RepoConfig { + if !sameRegistryHost(repo.URL(), src.URL) { return nil } - if repo.Spec.CACertificate == "" && !repo.Spec.InsecureSkipVerify { + if repo.CACertificate() == "" && !repo.InsecureSkipVerify() { return nil } return &repoclient.RepoConfig{ - CACertificate: repo.Spec.CACertificate, - Insecure: repo.Spec.InsecureSkipVerify, + CACertificate: repo.CACertificate(), + Insecure: repo.InsecureSkipVerify(), } } -// ForceReconcileInternalRepositories stamps the reconcile request annotations on -// the internal OCIRepository of every addon that references repoName. -// -// An artifact pulled per addon has no internal source object shared by the -// repository, so a force request reaches it only through the addons' own -// OCIRepositories. That is every addon of an oci:// repository, and every addon of a -// helm repository whose version the index publishes in a registry. -// -// An addon whose internal OCIRepository does not exist yet is skipped: the force -// request must not be blocked by an addon that has not reached the point of -// building one. -func (s *OCIRepoService) ForceReconcileInternalRepositories(ctx context.Context, repoName string) error { - addons := &helmv1alpha1.HelmClusterAddonList{} - if err := s.Client.List(ctx, addons, client.MatchingFields{index.AddonRepository: repoName}); err != nil { - return fmt.Errorf("listing addons of repository %s: %w", repoName, err) - } - - for i := range addons.Items { - name := utils.GetInternalOCIRepositoryName(addons.Items[i].Name) - nn := types.NamespacedName{Name: name, Namespace: s.TargetNamespace} - - ociRepo := &sourcev1.OCIRepository{} - if err := s.Client.Get(ctx, nn, ociRepo); err != nil { - if apierrors.IsNotFound(err) { - continue - } - - return fmt.Errorf("getting internal oci repository %s: %w", name, err) - } - - base := ociRepo.DeepCopy() - setReconcileRequestAnnotations(ociRepo) - - // The internal repository may be removed between the get and the patch, - // which is the same case as the one skipped above. - if err := s.Client.Patch(ctx, ociRepo, client.MergeFrom(base)); client.IgnoreNotFound(err) != nil { - return fmt.Errorf("requesting reconciliation of internal oci repository %s: %w", name, err) - } - } - - return nil -} - -func (s *OCIRepoService) CleanupOCIRepository(ctx context.Context, repoName string) error { - resources := []struct { - name string - obj client.Object - }{ - { - name: utils.GetInternalRepositoryAuthSecretName(repoName), - obj: &corev1.Secret{}, - }, - { - name: utils.GetInternalRepositoryTLSSecretName(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 -} - // RemoveOCIRepository issues a delete for the internal OCIRepository and returns // it while it is still present, so the caller can inspect its conditions and wait -// for nelm-source-controller to finish removing it. It returns nil once the +// for source-controller to finish removing it. It returns nil once the // OCIRepository is gone. -func (s *OCIRepoService) RemoveOCIRepository(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (*sourcev1.OCIRepository, error) { - name := utils.GetInternalOCIRepositoryName(addon.Name) - nn := types.NamespacedName{Name: name, Namespace: s.TargetNamespace} +func (s *OCIRepoService) RemoveOCIRepository(ctx context.Context, names source.ReleaseNames) (*sourcev1.OCIRepository, error) { + nn := types.NamespacedName{Name: names.OCIRepository, Namespace: s.TargetNamespace} ociRepo := &sourcev1.OCIRepository{} exists, err := s.deleteAndCheck(ctx, nn, ociRepo) if err != nil { @@ -356,34 +241,36 @@ func (s *OCIRepoService) RemoveOCIRepository(ctx context.Context, addon *helmv1a } func applyOCIRepositorySpec( - addon *helmv1alpha1.HelmClusterAddon, - repo *helmv1alpha1.HelmClusterAddonRepository, - source utils.ChartSource, + rel source.Release, + repo source.Repository, + src chartsource.Source, mediaType string, existing *sourcev1.OCIRepository, ) { - if addon.ForceReconcileRequired() { + if rel.ForceReconcileRequired() { setReconcileRequestAnnotations(existing) } - existing.Spec.URL = source.URL - existing.Spec.Reference = &sourcev1.OCIRepositoryRef{Tag: source.Tag} + existing.Spec.URL = src.URL + existing.Spec.Reference = &sourcev1.OCIRepositoryRef{Tag: src.Tag} existing.Spec.Interval = metav1.Duration{Duration: InternalRepositoryInterval} existing.Spec.Insecure = false existing.Spec.CertSecretRef = nil existing.Spec.SecretRef = nil + names := repo.InternalNames() + // The repository's transport settings and credentials describe the host it // names. An artifact its index points at somewhere else is reached as a public // registry: the settings do not describe that host, and the credentials must not // be sent to it. For an oci:// repository the two hosts are the same one, so this // is where its existing behaviour lives. - if sameRegistryHost(repo.Spec.URL, source.URL) { - existing.Spec.Insecure = repo.Spec.InsecureSkipVerify + if sameRegistryHost(repo.URL(), src.URL) { + existing.Spec.Insecure = repo.InsecureSkipVerify() - if repo.Spec.CACertificate != "" { + if repo.CACertificate() != "" { existing.Spec.CertSecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryTLSSecretName(repo.Name), + Name: names.TLSSecret, } } @@ -391,9 +278,9 @@ func applyOCIRepositorySpec( // secret OCIRepository requires. A helm repository's secret is an Opaque // username/password one, and referencing it here would break the pull with a // less obvious error than not authenticating at all. - if repo.Spec.Auth != nil && repositoryIsOCI(repo) { + if repo.Auth() != nil && repositoryIsOCI(repo) { existing.Spec.SecretRef = &meta.LocalObjectReference{ - Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), + Name: names.AuthSecret, } } } @@ -407,10 +294,7 @@ func applyOCIRepositorySpec( Operation: "copy", } - existing.Labels = map[string]string{ - helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonLabelSourceName: addon.Name, - } + existing.Labels = rel.SourceLabels() } // sameRegistryHost reports whether the artifact lives on the host the repository @@ -430,8 +314,8 @@ func sameRegistryHost(repoURL, artifactURL string) bool { return repoHost == artifactHost } -func repositoryIsOCI(repo *helmv1alpha1.HelmClusterAddonRepository) bool { - repoType, err := utils.GetRepositoryType(repo.Spec.URL) +func repositoryIsOCI(repo source.Repository) bool { + repoType, err := chartsource.KindOf(repo.URL()) - return err == nil && repoType == utils.InternalOCIRepository + return err == nil && repoType == chartsource.OCI } diff --git a/images/operator-helm-controller/internal/services/oci_repo_service_test.go b/images/operator-helm-controller/internal/services/oci_repo_service_test.go index 0ff4bd55..90eb03c9 100644 --- a/images/operator-helm-controller/internal/services/oci_repo_service_test.go +++ b/images/operator-helm-controller/internal/services/oci_repo_service_test.go @@ -21,13 +21,15 @@ import ( "errors" "testing" - "github.com/werf/3p-fluxcd-pkg/apis/meta" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/chartsource" repoclient "github.com/deckhouse/operator-helm/internal/client/repository" "github.com/deckhouse/operator-helm/internal/index" "github.com/deckhouse/operator-helm/internal/utils" @@ -109,16 +111,16 @@ func testAddon() *helmv1alpha1.HelmClusterAddon { func ociTestRepository() *helmv1alpha1.HelmClusterAddonRepository { return &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "oci://example.invalid/podinfo"}, + Spec: helmv1alpha1.RepositorySpec{URL: "oci://example.invalid/podinfo"}, } } // ociSource resolves the source the way the reconciler does, so the tests exercise // the real mapping instead of a hand-built one. -func ociSource(t *testing.T, repo *helmv1alpha1.HelmClusterAddonRepository, version *helmv1alpha1.HelmClusterAddonChartVersion) utils.ChartSource { +func ociSource(t *testing.T, repo *helmv1alpha1.HelmClusterAddonRepository, version *helmv1alpha1.ChartVersion) chartsource.Source { t.Helper() - source, err := utils.ResolveChartSource(repo, version) + source, err := chartsource.Resolve(repo.Spec.URL, version) if err != nil { t.Fatalf("resolving chart source: %v", err) } @@ -131,9 +133,9 @@ func ociSource(t *testing.T, repo *helmv1alpha1.HelmClusterAddonRepository, vers func hybridTestRepository() *helmv1alpha1.HelmClusterAddonRepository { return &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: helmv1alpha1.RepositorySpec{ URL: "https://charts.example.invalid/stable", - Auth: &helmv1alpha1.HelmClusterAddonRepositoryAuth{Username: "u", Password: "p"}, + Auth: &helmv1alpha1.RepositoryAuth{Username: "u", Password: "p"}, CACertificate: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----", InsecureSkipVerify: true, }, @@ -144,12 +146,12 @@ func TestEnsureInternalOCIRepositoryUsesRecordedMediaType(t *testing.T) { addon, repo := testAddon(), ociTestRepository() service, c := newOCIRepoService(t, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", } - service.EnsureInternalOCIRepository(context.Background(), addon, repo, ociSource(t, repo, version), version) + service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version) ociRepo := &sourcev1.OCIRepository{} key := client.ObjectKey{Name: utils.GetInternalOCIRepositoryName(addon.Name), Namespace: testNamespace} @@ -169,19 +171,19 @@ func TestEnsureInternalOCIRepositoryReportsRemovedVersion(t *testing.T) { addon, repo := testAddon(), ociTestRepository() service, _ := newOCIRepoService(t, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", UnavailableReason: helmv1alpha1.UnavailableReasonRemovedFromRepository, } - result := service.EnsureInternalOCIRepository(context.Background(), addon, repo, ociSource(t, repo, version), version) + result := service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version) - if result.Status.Reason != helmv1alpha1.ReasonChartVersionRemoved { - t.Fatalf("reason is %q, want %q", result.Status.Reason, helmv1alpha1.ReasonChartVersionRemoved) + if !result.VersionRemoved { + t.Fatal("the outcome must report that the repository no longer offers the tag") } - if result.Status.Message == "" { - t.Fatal("a removed version must be explained in the message") + if result.Version != "6.7.1" || result.RepositoryName != repo.Name { + t.Fatalf("outcome names %s/%s, want the pair the message is built from", result.RepositoryName, result.Version) } } @@ -200,7 +202,7 @@ func TestEnsureInternalOCIRepositoryReportsRemovedVersion(t *testing.T) { func TestEnsureInternalOCIRepositoryDoesNotRelabelReadyChildOnRemovedVersion(t *testing.T) { addon, repo := testAddon(), ociTestRepository() - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", UnavailableReason: helmv1alpha1.UnavailableReasonRemovedFromRepository, @@ -241,19 +243,16 @@ func TestEnsureInternalOCIRepositoryDoesNotRelabelReadyChildOnRemovedVersion(t * service, _ := newOCIRepoService(t, addon, repo, internal) - result := service.EnsureInternalOCIRepository(context.Background(), addon, repo, ociSource(t, repo, version), version) + result := service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version) - if result.Status.Status != metav1.ConditionTrue { - t.Fatalf("expected the ready child's status to be mirrored as True, got %v", result.Status.Status) + if !result.Internal.Ready() { + t.Fatalf("expected the ready child to be mirrored as ready, got %+v", result.Internal) } - if result.Status.Reason == helmv1alpha1.ReasonChartVersionRemoved { - t.Fatalf("a ready child must not be relabeled with %q", helmv1alpha1.ReasonChartVersionRemoved) + if result.Internal.Reason != "Succeeded" { + t.Fatalf("reason is %q, want the child's own %q untouched", result.Internal.Reason, "Succeeded") } - if result.Status.Reason != "Succeeded" { - t.Fatalf("reason is %q, want the child's own %q untouched", result.Status.Reason, "Succeeded") - } - if result.Status.Message != "stored artifact for revision 6.7.1" { - t.Fatalf("message is %q, want the child's own message untouched", result.Status.Message) + if result.Internal.Message != "stored artifact for revision 6.7.1" { + t.Fatalf("message is %q, want the child's own message untouched", result.Internal.Message) } } @@ -266,12 +265,12 @@ func TestEnsureInternalOCIRepositoryForcesReconcileFromAddon(t *testing.T) { addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} service, c := newOCIRepoService(t, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", } - service.EnsureInternalOCIRepository(context.Background(), addon, repo, ociSource(t, repo, version), version) + service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version) ociRepo := &sourcev1.OCIRepository{} key := client.ObjectKey{Name: utils.GetInternalOCIRepositoryName(addon.Name), Namespace: testNamespace} @@ -294,12 +293,12 @@ func TestEnsureInternalOCIRepositoryDoesNotForceReconcileWithoutAnnotation(t *te addon, repo := testAddon(), ociTestRepository() service, c := newOCIRepoService(t, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", } - service.EnsureInternalOCIRepository(context.Background(), addon, repo, ociSource(t, repo, version), version) + service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version) ociRepo := &sourcev1.OCIRepository{} key := client.ObjectKey{Name: utils.GetInternalOCIRepositoryName(addon.Name), Namespace: testNamespace} @@ -312,62 +311,6 @@ func TestEnsureInternalOCIRepositoryDoesNotForceReconcileWithoutAnnotation(t *te } } -// TestForceReconcileInternalRepositoriesStampsOnlyItsOwnAddons covers the force -// reconcile annotation applied to an oci:// HelmClusterAddonRepository: unlike the -// helm:// path, where the internal HelmRepository re-indexes and the HelmCharts -// follow, an OCI repository has no intermediate source object, so the request must -// be pushed onto the internal OCIRepository of each addon that references it - and -// only of those addons. -func TestForceReconcileInternalRepositoriesStampsOnlyItsOwnAddons(t *testing.T) { - addon := testAddon() - foreign := testAddon() - foreign.Name = "foreign" - foreign.Spec.Chart.HelmClusterAddonRepository = "another" - - service, c := newOCIRepoService(t, - addon, foreign, - internalOCIRepository(addon.Name), internalOCIRepository(foreign.Name), - ) - - if err := service.ForceReconcileInternalRepositories(context.Background(), "example"); err != nil { - t.Fatalf("forcing internal repositories: %v", err) - } - - ociRepo := &sourcev1.OCIRepository{} - key := client.ObjectKey{Name: utils.GetInternalOCIRepositoryName(addon.Name), Namespace: testNamespace} - if err := c.Get(context.Background(), key, ociRepo); err != nil { - t.Fatalf("getting oci repository: %v", err) - } - if ociRepo.Annotations[meta.ReconcileRequestAnnotation] == "" { - t.Errorf("%s must be stamped on the oci repository of the addon", meta.ReconcileRequestAnnotation) - } - if ociRepo.Annotations[meta.ForceRequestAnnotation] == "" { - t.Errorf("%s must be stamped on the oci repository of the addon", meta.ForceRequestAnnotation) - } - - foreignRepo := &sourcev1.OCIRepository{} - key = client.ObjectKey{Name: utils.GetInternalOCIRepositoryName(foreign.Name), Namespace: testNamespace} - if err := c.Get(context.Background(), key, foreignRepo); err != nil { - t.Fatalf("getting foreign oci repository: %v", err) - } - if _, found := foreignRepo.Annotations[meta.ReconcileRequestAnnotation]; found { - t.Errorf("%s must not be stamped on an addon of another repository", meta.ReconcileRequestAnnotation) - } -} - -// TestForceReconcileInternalRepositoriesToleratesMissingSource covers the addon -// that has no internal OCIRepository yet - it has just been created, or it never -// reached the point of building one. A force on the repository must not fail -// because of it, otherwise the request is retried forever. -func TestForceReconcileInternalRepositoriesToleratesMissingSource(t *testing.T) { - addon := testAddon() - service, _ := newOCIRepoService(t, addon) - - if err := service.ForceReconcileInternalRepositories(context.Background(), "example"); err != nil { - t.Fatalf("a missing internal oci repository must not fail the force request: %v", err) - } -} - // TestEnsureInternalOCIRepositoryAddressesTheIndexReference pins that the artifact // address comes from the version, not from the repository: for a hybrid version the // registry is a different host entirely. @@ -376,13 +319,13 @@ func TestEnsureInternalOCIRepositoryAddressesTheIndexReference(t *testing.T) { resolver := &countingResolver{mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"} service, c := newOCIRepoServiceWithResolver(t, resolver, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", } service.EnsureInternalOCIRepository( - context.Background(), addon, repo, ociSource(t, repo, version), version, + context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version, ) ociRepo := &sourcev1.OCIRepository{} @@ -421,13 +364,13 @@ func TestEnsureInternalOCIRepositoryCarriesTLSOnTheSameHost(t *testing.T) { resolver := &countingResolver{mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"} service, c := newOCIRepoServiceWithResolver(t, resolver, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://charts.example.invalid/charts/podinfo:6.7.1", } service.EnsureInternalOCIRepository( - context.Background(), addon, repo, ociSource(t, repo, version), version, + context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version, ) ociRepo := &sourcev1.OCIRepository{} @@ -452,18 +395,18 @@ func TestEnsureInternalOCIRepositoryCarriesTLSOnTheSameHost(t *testing.T) { // repository host, so its auth and CA still apply. func TestEnsureInternalOCIRepositoryKeepsOCIRepositoryCredentials(t *testing.T) { addon, repo := testAddon(), ociTestRepository() - repo.Spec.Auth = &helmv1alpha1.HelmClusterAddonRepositoryAuth{Username: "u", Password: "p"} + repo.Spec.Auth = &helmv1alpha1.RepositoryAuth{Username: "u", Password: "p"} repo.Spec.CACertificate = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----" service, c := newOCIRepoService(t, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", } service.EnsureInternalOCIRepository( - context.Background(), addon, repo, ociSource(t, repo, version), version, + context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version, ) ociRepo := &sourcev1.OCIRepository{} @@ -488,13 +431,13 @@ func TestEnsureInternalOCIRepositoryProbesHybridVersion(t *testing.T) { resolver := &countingResolver{mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"} service, c := newOCIRepoServiceWithResolver(t, resolver, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", } service.EnsureInternalOCIRepository( - context.Background(), addon, repo, ociSource(t, repo, version), version, + context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version, ) if resolver.calls != 1 { @@ -522,14 +465,14 @@ func TestEnsureInternalOCIRepositoryReusesTheInternalObjectAsCache(t *testing.T) resolver := &countingResolver{mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"} service, _ := newOCIRepoServiceWithResolver(t, resolver, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", } source := ociSource(t, repo, version) - service.EnsureInternalOCIRepository(context.Background(), addon, repo, source, version) - service.EnsureInternalOCIRepository(context.Background(), addon, repo, source, version) + service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), source, version) + service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), source, version) if resolver.calls != 1 { t.Fatalf("resolver calls = %d, want 1: the internal object is the cache", resolver.calls) @@ -544,17 +487,17 @@ func TestEnsureInternalOCIRepositoryReprobesChangedReference(t *testing.T) { resolver := &countingResolver{mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"} service, _ := newOCIRepoServiceWithResolver(t, resolver, addon, repo) - first := &helmv1alpha1.HelmClusterAddonChartVersion{ + first := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", } - service.EnsureInternalOCIRepository(context.Background(), addon, repo, ociSource(t, repo, first), first) + service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, first), first) - second := &helmv1alpha1.HelmClusterAddonChartVersion{ + second := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://mirror.example.com/charts/podinfo:6.7.1", } - service.EnsureInternalOCIRepository(context.Background(), addon, repo, ociSource(t, repo, second), second) + service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, second), second) if resolver.calls != 2 { t.Fatalf("resolver calls = %d, want 2: the reference changed", resolver.calls) @@ -568,16 +511,16 @@ func TestEnsureInternalOCIRepositoryForceBypassesCache(t *testing.T) { resolver := &countingResolver{mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip"} service, _ := newOCIRepoServiceWithResolver(t, resolver, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", } source := ociSource(t, repo, version) - service.EnsureInternalOCIRepository(context.Background(), addon, repo, source, version) + service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), source, version) addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"} - service.EnsureInternalOCIRepository(context.Background(), addon, repo, source, version) + service.EnsureInternalOCIRepository(context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), source, version) if resolver.calls != 2 { t.Fatalf("resolver calls = %d, want 2: a force request re-examines the artifact", resolver.calls) @@ -591,13 +534,13 @@ func TestEnsureInternalOCIRepositoryNeverProbesRecordedMediaType(t *testing.T) { resolver := &countingResolver{mediaType: "should-not-be-used"} service, _ := newOCIRepoServiceWithResolver(t, resolver, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", } service.EnsureInternalOCIRepository( - context.Background(), addon, repo, ociSource(t, repo, version), version, + context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version, ) if resolver.calls != 0 { @@ -613,20 +556,23 @@ func TestEnsureInternalOCIRepositoryReportsTerminalProbeFailure(t *testing.T) { }} service, c := newOCIRepoServiceWithResolver(t, resolver, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", } result := service.EnsureInternalOCIRepository( - context.Background(), addon, repo, ociSource(t, repo, version), version, + context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version, ) - if result.Status.Reason != helmv1alpha1.ReasonUnsupportedChartArtifact { - t.Fatalf("reason = %q, want %q", result.Status.Reason, helmv1alpha1.ReasonUnsupportedChartArtifact) + if result.ProbeReason != helmv1alpha1.ReasonUnsupportedChartArtifact { + t.Fatalf("reason = %q, want %q", result.ProbeReason, helmv1alpha1.ReasonUnsupportedChartArtifact) + } + if !result.ProbeTerminal { + t.Fatal("a verdict about the artifact must be marked terminal so the release reports it as Stalled") } - if result.RequeueAfter != 0 { - t.Fatalf("requeue = %v, want none: the artifact will not become a chart on its own", result.RequeueAfter) + if result.ProbeRequeueAfter != 0 { + t.Fatalf("requeue = %v, want none: the artifact will not become a chart on its own", result.ProbeRequeueAfter) } // Nothing must be created from a verdict that says the artifact is unusable: an @@ -644,19 +590,22 @@ func TestEnsureInternalOCIRepositoryRequeuesRetriableProbeFailure(t *testing.T) resolver := &countingResolver{err: errors.New("429 Too Many Requests")} service, _ := newOCIRepoServiceWithResolver(t, resolver, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", } result := service.EnsureInternalOCIRepository( - context.Background(), addon, repo, ociSource(t, repo, version), version, + context.Background(), adapter.NewAddonRelease(addon), adapter.NewAddonRepository(repo), ociSource(t, repo, version), version, ) - if result.Status.Status != metav1.ConditionFalse { - t.Fatalf("status = %q, want False", result.Status.Status) + if result.ProbeErr == nil { + t.Fatal("a probe that could not reach the registry must be reported") + } + if result.ProbeTerminal { + t.Fatal("a failure that may pass on its own must not be marked terminal") } - if result.RequeueAfter != chartArtifactProbeRequeueInterval { - t.Fatalf("requeue = %v, want %v", result.RequeueAfter, chartArtifactProbeRequeueInterval) + if result.ProbeRequeueAfter != chartArtifactProbeRequeueInterval { + t.Fatalf("requeue = %v, want %v", result.ProbeRequeueAfter, chartArtifactProbeRequeueInterval) } } diff --git a/images/operator-helm-controller/internal/services/outcomes_release.go b/images/operator-helm-controller/internal/services/outcomes_release.go new file mode 100644 index 00000000..0fdb0556 --- /dev/null +++ b/images/operator-helm-controller/internal/services/outcomes_release.go @@ -0,0 +1,95 @@ +/* +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 ( + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/pkg/apis/meta" +) + +// ChartOutcome is the result of reconciling the internal HelmChart of a release. +// Err is a cluster write failure; Internal is what the object reported afterwards. +type ChartOutcome struct { + Err error + Artifact *meta.Artifact + Internal InternalObjectState +} + +// OCIRepoOutcome is the result of reconciling the internal OCIRepository of a +// release. Besides the write failure and the object's own state it carries the +// verdict of the artifact probe, which runs before the object is written and can +// fail on its own. +type OCIRepoOutcome struct { + Err error + Artifact *meta.Artifact + Internal InternalObjectState + + ProbeErr error + ProbeReason string + ProbeMessage string + // ProbeTerminal marks a verdict about the artifact itself — the registry rejected + // the request, or what it serves is not a packaged chart. Nothing about the + // release will make such a pull succeed, so it is reported as Stalled. + ProbeTerminal bool + // ProbeRequeueAfter asks for another pass when the probe failed for a reason that + // may pass on its own. It is left unset by a verdict about the artifact itself, + // which no amount of retrying will change; there is no watch on a foreign + // registry, so this timer is the only thing that brings the other kind back. + ProbeRequeueAfter time.Duration + + // VersionRemoved reports that the catalog still records the version — which is + // what keeps the release reconcilable — while the repository no longer offers the + // tag. The pull cannot succeed, and naming that cause is better than leaving only + // the source controller's "not found". Version and RepositoryName are set with it, + // to name the pair in the message. + VersionRemoved bool + Version string + RepositoryName string +} + +// ReleaseOutcome is the result of reconciling the internal HelmRelease. +// ChartDeployed reports whether the revision the release currently has deployed is +// the one the spec asks for: a chart-version change moves the artifact without +// touching the HelmRelease spec or generation, so the object can still report the +// previous revision as ready. +type ReleaseOutcome struct { + Err error + History helmv2.Snapshots + Internal InternalObjectState + ChartDeployed bool +} + +// AccessOutcome is the result of reconciling the identity a release is applied +// with. Terminal marks a failure that will not resolve by retrying: an object the +// identity needs already occupies its name and is not the operator's to touch. +// Reason and Message are what the release reports, filled in by the service so both +// classes of failure are named where they are recognized. +type AccessOutcome struct { + Err error + Terminal bool + Reason string + Message string +} + +// MaintenanceOutcome is the result of moving the internal HelmRelease into or out of +// maintenance mode. Activated is the state that was applied. +type MaintenanceOutcome struct { + Err error + Activated bool +} diff --git a/images/operator-helm-controller/internal/services/outcomes.go b/images/operator-helm-controller/internal/services/outcomes_repository.go similarity index 91% rename from images/operator-helm-controller/internal/services/outcomes.go rename to images/operator-helm-controller/internal/services/outcomes_repository.go index deb756a8..5c9996b5 100644 --- a/images/operator-helm-controller/internal/services/outcomes.go +++ b/images/operator-helm-controller/internal/services/outcomes_repository.go @@ -37,6 +37,10 @@ type FetchOutcome struct { Reason string Message string Pending int + // Charts is how many charts the repository listed. It is meaningful only when the + // read succeeded; a failed one leaves it zero, which is why the status field it + // feeds is only written on success. + Charts int } // CatalogOutcome is the result of writing the chart catalog into the cluster. diff --git a/images/operator-helm-controller/internal/services/release_service.go b/images/operator-helm-controller/internal/services/release_service.go index e8fe7637..8e54a029 100644 --- a/images/operator-helm-controller/internal/services/release_service.go +++ b/images/operator-helm-controller/internal/services/release_service.go @@ -19,11 +19,12 @@ package services import ( "context" "fmt" + "maps" "strings" "time" - helmv2 "github.com/werf/3p-helm-controller/api/v2" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -32,13 +33,13 @@ 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" + "github.com/deckhouse/operator-helm/internal/chartsource" + "github.com/deckhouse/operator-helm/internal/source" ) const releaseDriftDetectionInterval = 5 * time.Minute -var helmReleaseErrorRules = []status.ErrorConditionRule{ +var helmReleaseErrorRules = []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}, @@ -60,68 +61,40 @@ func NewReleaseService(client client.Client, scheme *runtime.Scheme, targetNames } } -var _ status.Provider = (*ReleaseResult)(nil) - -type ReleaseResult struct { - Status status.Status - History helmv2.Snapshots -} - -func (r ReleaseResult) GetStatus() status.Status { - 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, sourceKind utils.InternalRepositoryType, artifactRevision string) ReleaseResult { +func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, rel source.Release, sourceKind chartsource.Kind, artifactRevision string) ReleaseOutcome { logger := log.FromContext(ctx) existing := &helmv2.HelmRelease{ ObjectMeta: metav1.ObjectMeta{ - Name: utils.GetInternalHelmReleaseName(addon.Name), + Name: rel.InternalNames().HelmRelease, Namespace: s.TargetNamespace, }, } op, err := controllerutil.CreateOrPatch(ctx, s.Client, existing, func() error { - return applyHelmReleaseSpec(addon, existing, sourceKind, s.TargetNamespace) + return applyHelmReleaseSpec(rel, existing, sourceKind, s.TargetNamespace) }) if err != nil { - return ReleaseResult{Status: status.Failed( - addon, - helmv1alpha1.ReasonReleaseFailed, - "Failed to create helm release", - fmt.Errorf("reconciling helm release: %w", err), - )} + return ReleaseOutcome{Err: fmt.Errorf("reconciling helm release: %w", err)} } - processedStatus := status.ProcessChildConditions( - existing.GetConditions(), existing.Generation, addon, helmReleaseErrorRules, - ) + internal := reduceInternalConditions(existing.GetConditions(), existing.Generation, helmReleaseErrorRules) - // A chart-version change updates only the referenced HelmChart artifact, not - // the HelmRelease spec/generation. The HelmRelease can therefore still report - // the readiness of the previous revision until it observes the new artifact. - // Downgrade the status to Reconciling until the deployed revision actually - // reflects the requested chart, so downstream consumers (lastAppliedChart and - // the projected Ready/UpdateInstalled conditions) do not advance prematurely. - if processedStatus.IsReady() && !isDesiredChartDeployed(addon, existing.Status.History.Latest(), artifactRevision) { - processedStatus = status.Unknown(addon, helmv1alpha1.ReasonReconciling) - } + // Whether the deployed revision is the one the spec asks for is a fact about the + // object, so it is reported rather than acted on here: a chart-version change + // updates the referenced HelmChart artifact without touching the HelmRelease spec + // or generation, so the object can go on reporting the previous revision ready. + deployed := isDesiredChartDeployed(rel, existing.Status.History.Latest(), artifactRevision) - if processedStatus.IsReady() { - logger.Info("Successfully reconciled helm release", "operation", op) + if internal.Ready() && deployed { + logger.Info("Successfully reconciled helm release", "operation", op, + "internalObject", client.ObjectKeyFromObject(existing)) } - return ReleaseResult{ - History: existing.Status.History, - Status: processedStatus, + return ReleaseOutcome{ + History: existing.Status.History, + Internal: internal, + ChartDeployed: deployed, } } @@ -129,8 +102,8 @@ func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, addon *helmv1alp // while it is still present, so the caller can inspect its conditions and wait // for helm-controller to finish uninstalling before proceeding. It returns nil // once the HelmRelease is gone. -func (s *ReleaseService) CleanupHelmRelease(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (*helmv2.HelmRelease, error) { - nn := types.NamespacedName{Name: utils.GetInternalHelmReleaseName(addon.Name), Namespace: s.TargetNamespace} +func (s *ReleaseService) CleanupHelmRelease(ctx context.Context, names source.ReleaseNames) (*helmv2.HelmRelease, error) { + nn := types.NamespacedName{Name: names.HelmRelease, Namespace: s.TargetNamespace} release := &helmv2.HelmRelease{} exists, err := s.deleteAndCheck(ctx, nn, release) if err != nil { @@ -152,12 +125,12 @@ func (s *ReleaseService) CleanupHelmRelease(ctx context.Context, addon *helmv1al // failed uninstall may be external (e.g. kube-apiserver issues) and leave the // spec unchanged, so helm-controller must be nudged out of its error backoff to // retry on every pass regardless. -func (s *ReleaseService) SyncReleaseSpec(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon, release *helmv2.HelmRelease) error { +func (s *ReleaseService) SyncReleaseSpec(ctx context.Context, rel source.Release, release *helmv2.HelmRelease) error { base := release.DeepCopy() - release.Spec.TargetNamespace = addon.Spec.Namespace - release.Spec.Values = addon.Spec.Values - release.Spec.Suspend = addon.Spec.Maintenance == string(helmv1alpha1.NoResourceReconciliation) + release.Spec.TargetNamespace = rel.TargetNamespace() + release.Spec.Values = rel.Values() + release.Spec.Suspend = rel.MaintenanceActivated() setReconcileRequestAnnotations(release) @@ -171,27 +144,26 @@ func (s *ReleaseService) SyncReleaseSpec(ctx context.Context, addon *helmv1alpha return nil } -func applyHelmReleaseSpec(addon *helmv1alpha1.HelmClusterAddon, existing *helmv2.HelmRelease, sourceKind utils.InternalRepositoryType, targetNamespace string) error { - if addon.ForceReconcileRequired() { +func applyHelmReleaseSpec(rel source.Release, existing *helmv2.HelmRelease, sourceKind chartsource.Kind, targetNamespace string) error { + if rel.ForceReconcileRequired() { setReconcileRequestAnnotations(existing) } + // Merge rather than replace: the internal HelmRelease may carry labels put + // there by someone else (a policy engine, a cost allocator), and dropping them + // on every pass would fight whoever set them. if existing.Labels == nil { existing.Labels = map[string]string{} } + maps.Copy(existing.Labels, rel.SourceLabels()) - existing.Labels[helmv1alpha1.LabelManagedBy] = helmv1alpha1.LabelManagedByValue - existing.Labels[helmv1alpha1.HelmClusterAddonLabelSourceName] = addon.Name + names := rel.InternalNames() - existing.Spec.ReleaseName = addon.Name - existing.Spec.TargetNamespace = addon.Spec.Namespace - existing.Spec.Values = addon.Spec.Values + existing.Spec.ReleaseName = rel.ReleaseName() + existing.Spec.TargetNamespace = rel.TargetNamespace() + existing.Spec.Values = rel.Values() - existing.Spec.Suspend = false - - if addon.Spec.Maintenance == string(helmv1alpha1.NoResourceReconciliation) { - existing.Spec.Suspend = true - } + existing.Spec.Suspend = rel.MaintenanceActivated() existing.Spec.Interval = metav1.Duration{Duration: releaseDriftDetectionInterval} @@ -199,17 +171,28 @@ func applyHelmReleaseSpec(addon *helmv1alpha1.HelmClusterAddon, existing *helmv2 Mode: helmv2.DriftDetectionEnabled, } + // A family that impersonates applies the chart as its own ServiceAccount, which + // lives in the operator namespace next to the HelmRelease; helm-controller then + // performs every cluster operation — the storage writes included — as that + // account, so the release storage has to sit where the account has rights: the + // target namespace. A family without a service account keeps helm-controller's + // own identity and the default storage location. + if names.ServiceAccount != "" { + existing.Spec.ServiceAccountName = names.ServiceAccount + existing.Spec.StorageNamespace = rel.TargetNamespace() + } + switch sourceKind { - case utils.InternalHelmRepository: + case chartsource.Helm: existing.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ Kind: sourcev1.HelmChartKind, - Name: utils.GetInternalHelmChartName(addon.Name), + Name: names.HelmChart, Namespace: targetNamespace, } - case utils.InternalOCIRepository: + case chartsource.OCI: existing.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ Kind: sourcev1.OCIRepositoryKind, - Name: utils.GetInternalOCIRepositoryName(addon.Name), + Name: names.OCIRepository, Namespace: targetNamespace, } default: @@ -220,22 +203,24 @@ func applyHelmReleaseSpec(addon *helmv1alpha1.HelmClusterAddon, existing *helmv2 } // isDesiredChartDeployed reports whether the latest release revision in history -// is actually deployed and corresponds to the chart requested by the addon spec. -func isDesiredChartDeployed(addon *helmv1alpha1.HelmClusterAddon, latest *helmv2.Snapshot, artifactRevision string) bool { +// is actually deployed and corresponds to the chart requested by the release spec. +func isDesiredChartDeployed(rel source.Release, latest *helmv2.Snapshot, artifactRevision string) bool { if latest == nil || latest.Status != "deployed" { return false } + desired := rel.ChartRef().Version + if latest.OCIDigest != "" { ociDigestParts := strings.Split(artifactRevision, "@") latestDigest := ociDigestParts[1] - desiredVersion := addon.Spec.Chart.Version + "+" + latestDigest[7:19] + desiredVersion := desired + "+" + latestDigest[7:19] return latest.OCIDigest == latestDigest && latest.ChartVersion == desiredVersion } - if addon.Spec.Chart.Version != "" { - return latest.ChartVersion == addon.Spec.Chart.Version + if desired != "" { + return latest.ChartVersion == desired } return false diff --git a/images/operator-helm-controller/internal/services/release_service_test.go b/images/operator-helm-controller/internal/services/release_service_test.go new file mode 100644 index 00000000..a0ebde0b --- /dev/null +++ b/images/operator-helm-controller/internal/services/release_service_test.go @@ -0,0 +1,128 @@ +/* +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" + "testing" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/chartsource" + "github.com/deckhouse/operator-helm/internal/source" +) + +func newReleaseService(t *testing.T, objects ...client.Object) (*ReleaseService, client.Client) { + t.Helper() + + scheme := testScheme(t) + for _, add := range []func(*runtime.Scheme) error{ + sourcev1.AddToScheme, + helmv2.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatalf("registering scheme: %v", err) + } + } + + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + return NewReleaseService(c, scheme, testNamespace), c +} + +func ensureRelease(t *testing.T, service *ReleaseService, c client.Client, rel source.Release) *helmv2.HelmRelease { + t.Helper() + + if res := service.EnsureHelmRelease(context.Background(), rel, chartsource.Helm, ""); res.Err != nil { + t.Fatalf("EnsureHelmRelease returned %v", res.Err) + } + + release := &helmv2.HelmRelease{} + key := client.ObjectKey{Namespace: testNamespace, Name: rel.InternalNames().HelmRelease} + if err := c.Get(context.Background(), key, release); err != nil { + t.Fatalf("helm release was not created: %v", err) + } + + return release +} + +// TestEnsureHelmReleaseAppliesAnApplicationAsItsOwnIdentity pins the two fields +// that make an application release run as itself: helm-controller impersonates the +// named account, and because that account only has rights inside the application's +// namespace, the release storage has to live there too. +func TestEnsureHelmReleaseAppliesAnApplicationAsItsOwnIdentity(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + service, c := newReleaseService(t) + + release := ensureRelease(t, service, c, rel) + + if want := rel.InternalNames().ServiceAccount; release.Spec.ServiceAccountName != want { + t.Fatalf("serviceAccountName = %q, want %q", release.Spec.ServiceAccountName, want) + } + if release.Spec.StorageNamespace != "team-a" { + t.Fatalf("storageNamespace = %q, want the application namespace", release.Spec.StorageNamespace) + } +} + +// TestEnsureHelmReleaseLeavesTheAddonIdentityAlone is the complement: an addon +// family names no service account, so helm-controller keeps its own identity and +// the default storage location. Setting either field for it would change where +// every existing addon's release history is kept. +func TestEnsureHelmReleaseLeavesTheAddonIdentityAlone(t *testing.T) { + rel := adapter.NewAddonRelease(testAddon()) + service, c := newReleaseService(t) + + release := ensureRelease(t, service, c, rel) + + if release.Spec.ServiceAccountName != "" { + t.Fatalf("serviceAccountName = %q, want it unset", release.Spec.ServiceAccountName) + } + if release.Spec.StorageNamespace != "" { + t.Fatalf("storageNamespace = %q, want it unset", release.Spec.StorageNamespace) + } +} + +// TestEnsureHelmReleaseKeepsForeignLabels is the same guarantee on the release: +// our labels are merged in, not written over what someone else put there. +func TestEnsureHelmReleaseKeepsForeignLabels(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + existing := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: rel.InternalNames().HelmRelease, + Namespace: testNamespace, + Labels: map[string]string{"cost-center": "team-a"}, + }, + } + service, c := newReleaseService(t, existing) + + release := ensureRelease(t, service, c, rel) + + if release.Labels["cost-center"] != "team-a" { + t.Fatalf("labels = %v, want the foreign label preserved", release.Labels) + } + for key, want := range rel.SourceLabels() { + if release.Labels[key] != want { + t.Fatalf("label %q = %q, want %q", key, release.Labels[key], want) + } + } +} diff --git a/images/operator-helm-controller/internal/services/repo_secrets.go b/images/operator-helm-controller/internal/services/repo_secrets.go new file mode 100644 index 00000000..9d70dd9c --- /dev/null +++ b/images/operator-helm-controller/internal/services/repo_secrets.go @@ -0,0 +1,265 @@ +/* +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" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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/chartsource" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/utils" +) + +// RepoSecretsService owns the auth and TLS secrets a repository needs in the +// target namespace, for either repository kind. The secrets outlive neither the +// repository nor its internal source object, so the service that writes them also +// removes them, and the repository services are left with the objects they own. +type RepoSecretsService struct { + BaseRepoService +} + +func NewRepoSecretsService(client client.Client, scheme *runtime.Scheme, namespace string) *RepoSecretsService { + return &RepoSecretsService{ + BaseRepoService: BaseRepoService{ + BaseService: BaseService{ + Client: client, + Scheme: scheme, + }, + TargetNamespace: namespace, + }, + } +} + +// Ensure reconciles every auxiliary secret the repository needs. Its success is +// the gate for attempting a catalog synchronization: without credentials there is +// nothing to try. +// +// The auth secret's shape depends on the repository kind and the two are not +// interchangeable: HelmRepository resolves HTTP basic auth from an Opaque +// secret, while OCIRepository accepts only a kubernetes.io/dockerconfigjson +// one. An unknown type is treated as helm, mirroring the deletion path — it is +// unreachable here anyway, because an unparsable url is reported before any +// secret is touched. +func (s *RepoSecretsService) Ensure( + ctx context.Context, + repo source.Repository, + repoType chartsource.Kind, +) error { + var err error + + switch repoType { + case chartsource.OCI: + err = s.reconcileDockerConfigAuthSecret(ctx, repo) + default: + err = s.reconcileBasicAuthSecret(ctx, repo) + } + + if err != nil { + return fmt.Errorf("reconciling auth secret: %w", err) + } + + if err := s.reconcileTLSSecret(ctx, repo); err != nil { + return fmt.Errorf("reconciling tls secret: %w", err) + } + + return nil +} + +// reconcileBasicAuthSecret reconciles the internal auth secret as an Opaque secret +// holding username/password keys, the shape HelmRepository expects for HTTP basic +// auth. +func (s *RepoSecretsService) reconcileBasicAuthSecret(ctx context.Context, repo source.Repository) error { + return s.reconcileAuthSecret(ctx, repo, corev1.SecretTypeOpaque, + func(auth *helmv1alpha1.RepositoryAuth) (map[string]string, error) { + return map[string]string{ + "username": auth.Username, + "password": auth.Password, + }, nil + }, + ) +} + +// reconcileDockerConfigAuthSecret reconciles the internal auth secret as a +// kubernetes.io/dockerconfigjson secret, the only shape OCIRepository accepts in +// its spec.secretRef. +func (s *RepoSecretsService) reconcileDockerConfigAuthSecret(ctx context.Context, repo source.Repository) error { + return s.reconcileAuthSecret(ctx, repo, corev1.SecretTypeDockerConfigJson, + func(auth *helmv1alpha1.RepositoryAuth) (map[string]string, error) { + config, err := utils.BuildDockerConfigJSON(repo.URL(), auth.Username, auth.Password) + if err != nil { + return nil, fmt.Errorf("building docker config: %w", err) + } + + return map[string]string{corev1.DockerConfigJsonKey: config}, nil + }, + ) +} + +func (s *RepoSecretsService) reconcileAuthSecret( + ctx context.Context, + repo source.Repository, + secretType corev1.SecretType, + buildData func(auth *helmv1alpha1.RepositoryAuth) (map[string]string, error), +) error { + secretName := repo.InternalNames().AuthSecret + nn := types.NamespacedName{Name: secretName, Namespace: s.TargetNamespace} + + if repo.Auth() == nil { + if err := s.ensureResourceDeleted(ctx, nn, &corev1.Secret{}); err != nil { + return fmt.Errorf("deleting obsolete auth secret: %w", err) + } + return nil + } + + stringData, err := buildData(repo.Auth()) + if err != nil { + return fmt.Errorf("building auth secret data: %w", err) + } + + labels := repo.SourceLabels() + + staleRemoved, err := s.removeAuthSecretOfOtherType(ctx, nn, secretType) + if err != nil { + return fmt.Errorf("ensuring auth secret type %q: %w", secretType, err) + } + + authSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: s.TargetNamespace, + Labels: labels, + }, + Type: secretType, + StringData: stringData, + } + + if staleRemoved { + // The informer cache can still serve the secret that was just deleted, which + // would turn CreateOrPatch into a patch of a missing object, so create the + // replacement outright. + if err := s.Client.Create(ctx, authSecret); client.IgnoreAlreadyExists(err) != nil { + return fmt.Errorf("creating auth secret: %w", err) + } + + return nil + } + + if _, err := controllerutil.CreateOrPatch(ctx, s.Client, authSecret, func() error { + authSecret.Labels = labels + authSecret.Type = secretType + // Drop the keys already stored so that credentials removed from the desired + // data do not linger in the secret. + authSecret.Data = nil + authSecret.StringData = stringData + + return nil + }); err != nil { + return fmt.Errorf("creating auth secret: %w", err) + } + + return nil +} + +// removeAuthSecretOfOtherType deletes the auth secret when it exists with a type +// other than the wanted one and reports whether it did. A secret type is immutable, +// so a secret written with the wrong type (an Opaque one left by a version that fed +// plain credentials to OCIRepository, say) can only be replaced, not patched. +func (s *RepoSecretsService) removeAuthSecretOfOtherType( + ctx context.Context, nn types.NamespacedName, secretType corev1.SecretType, +) (bool, error) { + existing := &corev1.Secret{} + if err := s.Client.Get(ctx, nn, existing); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + + return false, fmt.Errorf("getting auth secret: %w", err) + } + + existingType := existing.Type + if existingType == "" { + existingType = corev1.SecretTypeOpaque + } + + if existingType == secretType { + return false, nil + } + + if err := s.Client.Delete(ctx, existing); client.IgnoreNotFound(err) != nil { + return false, fmt.Errorf("deleting auth secret of type %q: %w", existingType, err) + } + + return true, nil +} + +func (s *RepoSecretsService) reconcileTLSSecret(ctx context.Context, repo source.Repository) error { + secretName := repo.InternalNames().TLSSecret + + if repo.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 = repo.SourceLabels() + + tlsSecret.StringData = map[string]string{ + "ca.crt": repo.CACertificate(), + } + + return nil + }); err != nil { + return fmt.Errorf("cannot reconcile tls secret: %w", err) + } + + return nil +} + +// Cleanup removes both secrets. They carry no finalizers, so they disappear at +// once and the caller need not wait for them. +func (s *RepoSecretsService) Cleanup(ctx context.Context, names source.InternalNames) error { + for _, name := range []string{names.AuthSecret, names.TLSSecret} { + nn := types.NamespacedName{Name: name, Namespace: s.TargetNamespace} + if err := s.ensureResourceDeleted(ctx, nn, &corev1.Secret{}); err != nil { + return fmt.Errorf("cleaning up secret %s: %w", name, err) + } + } + + return nil +} diff --git a/images/operator-helm-controller/internal/services/repo_secrets_test.go b/images/operator-helm-controller/internal/services/repo_secrets_test.go new file mode 100644 index 00000000..dbd1b054 --- /dev/null +++ b/images/operator-helm-controller/internal/services/repo_secrets_test.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 services + +import ( + "context" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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/client/fake" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/chartsource" + "github.com/deckhouse/operator-helm/internal/utils" +) + +func newRepoSecretsService(t *testing.T, objects ...client.Object) (*RepoSecretsService, client.Client) { + t.Helper() + + scheme := testScheme(t) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + return NewRepoSecretsService(c, scheme, testNamespace), c +} + +func TestEnsureCreatesAuthAndTLS(t *testing.T) { + repo := &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "example"}, + Spec: helmv1alpha1.RepositorySpec{ + URL: "https://example.invalid/charts", + Auth: &helmv1alpha1.RepositoryAuth{Username: "user", Password: "secret"}, + CACertificate: "-----BEGIN CERTIFICATE-----", + }, + } + + service, c := newRepoSecretsService(t, repo) + + if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm); err != nil { + t.Fatalf("Ensure returned %v", err) + } + + auth := &corev1.Secret{} + authKey := types.NamespacedName{Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), Namespace: testNamespace} + if err := c.Get(context.Background(), authKey, auth); err != nil { + t.Fatalf("auth secret was not created: %v", err) + } + // The fake client stores what the controller wrote: unlike the API server it + // does not fold StringData into Data. + if got := auth.StringData["username"]; got != "user" { + t.Fatalf("auth secret username is %q, want %q", got, "user") + } + + tls := &corev1.Secret{} + tlsKey := types.NamespacedName{Name: utils.GetInternalRepositoryTLSSecretName(repo.Name), Namespace: testNamespace} + if err := c.Get(context.Background(), tlsKey, tls); err != nil { + t.Fatalf("tls secret was not created: %v", err) + } +} + +func TestEnsureRemovesObsoleteSecrets(t *testing.T) { + repo := &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "example"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://example.invalid/charts"}, + } + obsolete := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), + Namespace: testNamespace, + }, + } + + service, c := newRepoSecretsService(t, repo, obsolete) + + if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm); err != nil { + t.Fatalf("Ensure returned %v", err) + } + + err := c.Get(context.Background(), client.ObjectKeyFromObject(obsolete), &corev1.Secret{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("obsolete auth secret must be deleted, got %v", err) + } +} + +func TestEnsureUsesDockerConfigForOCIRepositories(t *testing.T) { + repo := &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "example"}, + Spec: helmv1alpha1.RepositorySpec{ + URL: "oci://ghcr.io/example/podinfo", + Auth: &helmv1alpha1.RepositoryAuth{Username: "user", Password: "secret"}, + }, + } + + service, c := newRepoSecretsService(t, repo) + + if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI); err != nil { + t.Fatalf("Ensure returned %v", err) + } + + auth := &corev1.Secret{} + key := types.NamespacedName{Name: utils.GetInternalRepositoryAuthSecretName(repo.Name), Namespace: testNamespace} + if err := c.Get(context.Background(), key, auth); err != nil { + t.Fatalf("auth secret was not created: %v", err) + } + + // OCIRepository resolves credentials only from a dockerconfigjson secret; + // an Opaque username/password pair is silently ignored by the source controller. + if auth.Type != corev1.SecretTypeDockerConfigJson { + t.Fatalf("auth secret type is %q, want %q", auth.Type, corev1.SecretTypeDockerConfigJson) + } + + config, found := auth.StringData[corev1.DockerConfigJsonKey] + if !found { + t.Fatalf("auth secret has no %q key, got keys %v", corev1.DockerConfigJsonKey, auth.StringData) + } + if !strings.Contains(config, "ghcr.io") { + t.Fatalf("docker config does not mention the registry host: %s", config) + } +} + +func TestCleanupRemovesBothSecrets(t *testing.T) { + repo := &helmv1alpha1.HelmClusterAddonRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "example"}, + Spec: helmv1alpha1.RepositorySpec{ + URL: "https://example.invalid/charts", + Auth: &helmv1alpha1.RepositoryAuth{Username: "user", Password: "secret"}, + CACertificate: "-----BEGIN CERTIFICATE-----", + }, + } + + service, c := newRepoSecretsService(t, repo) + names := adapter.NewAddonRepository(repo).InternalNames() + + if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm); err != nil { + t.Fatalf("Ensure returned %v", err) + } + + if err := service.Cleanup(context.Background(), names); err != nil { + t.Fatalf("Cleanup returned %v", err) + } + + for _, name := range []string{names.AuthSecret, names.TLSSecret} { + key := types.NamespacedName{Name: name, Namespace: testNamespace} + if err := c.Get(context.Background(), key, &corev1.Secret{}); !apierrors.IsNotFound(err) { + t.Fatalf("secret %s must be deleted, got %v", name, err) + } + } +} diff --git a/images/operator-helm-controller/internal/services/repo_sync_service.go b/images/operator-helm-controller/internal/services/repo_sync_service.go index 77dc56e3..33d675ad 100644 --- a/images/operator-helm-controller/internal/services/repo_sync_service.go +++ b/images/operator-helm-controller/internal/services/repo_sync_service.go @@ -18,36 +18,30 @@ package services import ( "context" - "fmt" - "sort" - "github.com/Masterminds/semver/v3" - 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" - "github.com/deckhouse/operator-helm/api/naming" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/chartsource" repoclient "github.com/deckhouse/operator-helm/internal/client/repository" - "github.com/deckhouse/operator-helm/internal/index" - "github.com/deckhouse/operator-helm/internal/utils" + "github.com/deckhouse/operator-helm/internal/source" ) type RepoSyncService struct { BaseService clientFactory RepoClientFactory + catalog source.Catalog } // RepoClientFactory builds the client used to read a repository catalog. It is // injected so the synchronization can be tested without a live repository. -type RepoClientFactory func(repoType utils.InternalRepositoryType) (repoclient.ClientInterface, error) +type RepoClientFactory func(repoType chartsource.Kind) (repoclient.ClientInterface, error) -func NewRepoSyncService(client client.Client, scheme *runtime.Scheme, factory RepoClientFactory) *RepoSyncService { +// NewRepoSyncService builds the synchronization for one repository kind: the +// catalog decides which chart catalog kind the fetched charts are mirrored into. +func NewRepoSyncService(client client.Client, scheme *runtime.Scheme, factory RepoClientFactory, catalog source.Catalog) *RepoSyncService { if factory == nil { factory = repoclient.NewClient } @@ -58,18 +52,30 @@ func NewRepoSyncService(client client.Client, scheme *runtime.Scheme, factory Re Scheme: scheme, }, clientFactory: factory, + catalog: catalog, } } -// Sync reads the repository catalog and reconciles the HelmClusterAddonChart -// resources that mirror it. The two phases are reported separately: a fetch -// failure is about the remote, a catalog failure is about this cluster. +// MigrateNames moves the repository's catalog objects to the names the current +// scheme derives. It is separate from Sync because it must run whether or not the +// remote can be reached: a consumer resolves a chart by the current name as soon as +// the new controller starts, while the first fetch may be minutes away or never +// succeed again. +// +// TRANSITIONAL: remove together with the catalog's own migration. +func (s *RepoSyncService) MigrateNames(ctx context.Context, repo source.Repository) error { + return s.catalog.MigrateNames(ctx, repo) +} + +// Sync reads the repository catalog and reconciles the chart catalog objects that +// mirror it. The two phases are reported separately: a fetch failure is about the +// remote, a catalog failure is about this cluster. func (s *RepoSyncService) Sync( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, - repoType utils.InternalRepositoryType, + repo source.Repository, + repoType chartsource.Kind, ) SyncOutcome { - known, err := s.knownCharts(ctx, repo) + known, err := s.catalog.Known(ctx, repo) if err != nil { // The registry was never contacted: FetchAttempted stays false so the // caller does not mistake this cluster-side read failure for a fetch that @@ -85,56 +91,17 @@ func (s *RepoSyncService) Sync( return SyncOutcome{FetchAttempted: true, Fetch: fetch} } - return SyncOutcome{FetchAttempted: true, Fetch: fetch, Catalog: s.reconcileCatalog(ctx, repo, charts)} -} - -// knownCharts collects the verdicts recorded by previous passes, so the client can -// skip the tags it has already examined. The chart objects are the only store of that -// state: keeping a separate fingerprint would be one more thing to drift. -func (s *RepoSyncService) knownCharts( - ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, -) (repoclient.KnownCharts, error) { - var charts helmv1alpha1.HelmClusterAddonChartList - if err := s.Client.List(ctx, &charts, client.MatchingLabels{helmv1alpha1.LabelRepositoryName: repo.Name}); err != nil { - return nil, fmt.Errorf("listing charts of repository %q: %w", repo.Name, err) + return SyncOutcome{ + FetchAttempted: true, + Fetch: fetch, + Catalog: CatalogOutcome{Err: s.catalog.Reconcile(ctx, repo, charts)}, } - - logger := log.FromContext(ctx) - known := make(repoclient.KnownCharts, len(charts.Items)) - - for _, chart := range charts.Items { - chartName := chart.Labels[helmv1alpha1.LabelChartName] - if chartName == "" { - // The chart label is the only way back from the object name (a - // truncated hash) to the chart name it belongs to. Without it the - // recorded verdicts for this chart cannot be looked up here, so every - // tag is re-examined on the next fetch; that is safe but not free, so - // it is worth surfacing. - logger.Info("Chart object has no chart label, dropping its recorded verdicts", "addonChartName", chart.Name) - - continue - } - - versions := make(repoclient.KnownVersions, len(chart.Status.Versions)) - for _, version := range chart.Status.Versions { - versions[version.Version] = repoclient.KnownVersion{ - MediaType: version.MediaType, - UnavailableReason: version.UnavailableReason, - UnavailableMessage: version.UnavailableMessage, - } - } - - known[chartName] = versions - } - - return known, nil } func (s *RepoSyncService) fetchCharts( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, - repoType utils.InternalRepositoryType, + repo source.Repository, + repoType chartsource.Kind, opts repoclient.FetchOptions, ) ([]repoclient.Chart, FetchOutcome) { repoClient, err := s.clientFactory(repoType) @@ -147,9 +114,9 @@ func (s *RepoSyncService) fetchCharts( } } - charts, err := repoClient.FetchCharts(ctx, repo.Spec.URL, buildRepoConfig(repo), opts) + charts, err := repoClient.FetchCharts(ctx, repo.URL(), buildRepoConfig(repo), opts) if err == nil { - return charts, FetchOutcome{Pending: countPending(charts)} + return charts, FetchOutcome{Pending: countPending(charts), Charts: len(charts)} } if terminal, ok := repoclient.AsTerminal(err); ok { @@ -182,262 +149,20 @@ func countPending(charts []repoclient.Chart) int { return pending } -func buildRepoConfig(repo *helmv1alpha1.HelmClusterAddonRepository) *repoclient.RepoConfig { - if repo.Spec.Auth == nil && repo.Spec.CACertificate == "" && !repo.Spec.InsecureSkipVerify { +func buildRepoConfig(repo source.Repository) *repoclient.RepoConfig { + if repo.Auth() == nil && repo.CACertificate() == "" && !repo.InsecureSkipVerify() { return nil } config := &repoclient.RepoConfig{ - Insecure: repo.Spec.InsecureSkipVerify, - CACertificate: repo.Spec.CACertificate, + Insecure: repo.InsecureSkipVerify(), + CACertificate: repo.CACertificate(), } - if repo.Spec.Auth != nil { - config.Username = repo.Spec.Auth.Username - config.Password = repo.Spec.Auth.Password + if auth := repo.Auth(); auth != nil { + config.Username = auth.Username + config.Password = auth.Password } return config } - -func (s *RepoSyncService) reconcileCatalog( - ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, - charts []repoclient.Chart, -) CatalogOutcome { - logger := log.FromContext(ctx) - - desiredCharts := make(map[string]struct{}, len(charts)) - - for _, chart := range charts { - addonChartName := naming.HelmClusterAddonChartName(repo.Name, chart.Name) - // A chart with no usable version is still created: it carries the reason each of - // its versions is unusable, and skipping it here would let the pruning loop below - // delete a chart whose tags merely failed to resolve. - 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, - helmv1alpha1.LabelRepositoryName: repo.Name, - helmv1alpha1.LabelChartName: chart.Name, - } - - return nil - }) - if err != nil { - return CatalogOutcome{Err: fmt.Errorf("creating or updating chart %q: %w", addonChartName, err)} - } - - if op != controllerutil.OperationResultNone { - logger.Info("Reconciled HelmClusterAddonChart", "operation", op, "addonChartName", addonChartName) - } - - inUse, err := s.inUseVersions(ctx, repo.Name, chart.Name) - if err != nil { - return CatalogOutcome{Err: err} - } - - base := existing.DeepCopy() - - if len(chart.Versions) > 0 { - existing.Status.IconURL = chart.Versions[0].IconURL - } - existing.Status.Versions = mergeChartVersions(chart.Versions, existing.Status.Versions, inUse) - - if err := s.Client.Status().Patch(ctx, existing, client.MergeFrom(base)); err != nil { - return CatalogOutcome{Err: fmt.Errorf("updating versions of chart %q: %w", addonChartName, err)} - } - } - - var existingCharts helmv1alpha1.HelmClusterAddonChartList - if err := s.Client.List(ctx, &existingCharts, client.MatchingLabels{helmv1alpha1.LabelRepositoryName: repo.Name}); err != nil { - return CatalogOutcome{Err: fmt.Errorf("listing charts for pruning: %w", err)} - } - - for _, chart := range existingCharts.Items { - if _, wanted := desiredCharts[chart.Name]; wanted { - continue - } - - chartName := chart.Labels[helmv1alpha1.LabelChartName] - if chartName == "" { - // The chart label is the only way back from the object name (a - // truncated hash) to the chart name an addon references, so - // inUseVersions cannot find anything to protect and this chart is - // pruned even if an addon still uses it. That fail-open is unavoidable - // as written, so at least make it diagnosable. - logger.Info("Pruning a chart with no chart label; in-use protection could not be checked", "addonChartName", chart.Name) - } - - inUse, err := s.inUseVersions(ctx, repo.Name, chartName) - if err != nil { - return CatalogOutcome{Err: err} - } - if len(inUse) > 0 { - // An addon still references this chart: deleting the object would make the - // addon's own reconciliation fail on a missing chart and block every change - // to it, including its removal. - logger.Info("Keeping a chart referenced by an addon", "addonChartName", chart.Name) - - continue - } - - if err := s.ensureResourceDeleted(ctx, types.NamespacedName{Name: chart.Name}, &chart); err != nil { - return CatalogOutcome{Err: fmt.Errorf("deleting stale charts: %w", err)} - } - } - - return CatalogOutcome{} -} - -// inUseVersions returns the chart versions referenced by the addon that uses this -// repository/chart pair. The webhook and the claim Lease enforce one addon per pair, -// so at most one is found; both its desired and its last applied version count, since -// they differ during an upgrade. -func (s *RepoSyncService) inUseVersions(ctx context.Context, repoName, chartName string) (map[string]struct{}, error) { - if chartName == "" { - return nil, nil - } - - var addons helmv1alpha1.HelmClusterAddonList - if err := s.Client.List(ctx, &addons, client.MatchingFields{ - index.AddonChart: index.AddonChartValue(repoName, chartName), - }); err != nil { - return nil, fmt.Errorf("listing addons of chart %q: %w", chartName, err) - } - - inUse := make(map[string]struct{}, 2) - - for _, addon := range addons.Items { - inUse[addon.Spec.Chart.Version] = struct{}{} - - // LastAppliedChart carries its own repository/chart identity and can lag - // behind Spec.Chart when an addon is switched to a different chart: only - // credit it here when it still names this repository/chart pair, or a - // stale entry would protect a phantom version on the new chart while no - // longer protecting the version actually applied on the old one. - if last := addon.Status.LastAppliedChart; last != nil && - last.HelmClusterAddonChartName == chartName && last.HelmClusterAddonRepository == repoName { - inUse[last.Version] = struct{}{} - } - } - - return inUse, nil -} - -// mergeChartVersions builds the desired version list from the fetched entries and the -// ones already recorded. A recorded version the registry no longer lists is dropped, -// unless an addon still references it: then it is retained with RemovedFromRepository -// and keeps both its media type and its recorded OCI reference, without either of -// which the addon's internal OCIRepository could not be built at all. -// -// The same protection applies to a version that is still listed but whose tag was -// re-pushed as a non-chart artifact: the fresh verdict carries no media type, but if an -// addon still references the version, its previously recorded media type is carried -// forward alongside the fresh UnsupportedMediaType reason and message. Without the old -// media type the internal OCIRepository could not be built at all, which would block -// every change to the running addon (values, maintenance mode, ...) rather than just -// the pull that the new artifact actually breaks; the real pull failure is reported by -// the source controller instead. -func mergeChartVersions( - fetched []repoclient.ChartVersion, - current []helmv1alpha1.HelmClusterAddonChartVersion, - inUse map[string]struct{}, -) []helmv1alpha1.HelmClusterAddonChartVersion { - merged := make([]helmv1alpha1.HelmClusterAddonChartVersion, 0, len(fetched)+len(current)) - listed := make(map[string]struct{}, len(fetched)) - - currentByVersion := make(map[string]helmv1alpha1.HelmClusterAddonChartVersion, len(current)) - for _, version := range current { - currentByVersion[version.Version] = version - } - - for _, version := range fetched { - name := version.Version.Original() - listed[name] = struct{}{} - - mediaType := version.MediaType - // The carry-forward only makes sense for a version that still resolves to an - // archive: a fresh entry that now carries an OCIRef must probe its own layer - // media type from scratch, or a stale value stamped here would be read by - // resolveMediaType before the force-reconcile cache bypass and the pull would - // fail forever with no way to correct it. - if mediaType == "" && version.OCIRef == "" { - if _, referenced := inUse[name]; referenced { - if old, recorded := currentByVersion[name]; recorded && old.MediaType != "" { - mediaType = old.MediaType - } - } - } - - merged = append(merged, helmv1alpha1.HelmClusterAddonChartVersion{ - Version: name, - OCIRef: version.OCIRef, - MediaType: mediaType, - UnavailableReason: version.UnavailableReason, - UnavailableMessage: version.UnavailableMessage, - }) - } - - for _, version := range current { - if _, stillListed := listed[version.Version]; stillListed { - continue - } - if _, referenced := inUse[version.Version]; !referenced { - continue - } - - version.UnavailableReason = helmv1alpha1.UnavailableReasonRemovedFromRepository - version.UnavailableMessage = "the repository no longer offers this version" - merged = append(merged, version) - } - - sortChartVersions(merged) - - return merged -} - -// sortChartVersions orders versions by descending semver, breaking ties by a reverse -// string comparison. A version that does not parse as semver sorts after every -// version that does, ordered among themselves by the same reverse string comparison. -// Parsability has to be the primary key: comparing a parsable and an unparsable -// version by semver on one pair and by string on another can produce a cycle (e.g. -// "6.10.0" > "6.9.0" by semver, "6.9.0" > "6.5.x" and "6.5.x" > "6.10.0" by string), -// which is not a valid ordering for sort.SliceStable. Today's clients never write an -// unparsable version, but legacy status data can still carry one, and the order has to -// be deterministic regardless: the merge goes through maps, and an unstable order -// would produce a status patch on every synchronization for a catalog that did not -// change. -func sortChartVersions(versions []helmv1alpha1.HelmClusterAddonChartVersion) { - sort.SliceStable(versions, func(i, j int) bool { - left, leftErr := semver.NewVersion(versions[i].Version) - right, rightErr := semver.NewVersion(versions[j].Version) - - leftParses, rightParses := leftErr == nil, rightErr == nil - - if leftParses != rightParses { - return leftParses - } - - if leftParses && !left.Equal(right) { - return left.GreaterThan(right) - } - - return versions[i].Version > versions[j].Version - }) -} diff --git a/images/operator-helm-controller/internal/services/repo_sync_service_test.go b/images/operator-helm-controller/internal/services/repo_sync_service_test.go index 2f1fa6be..19a93097 100644 --- a/images/operator-helm-controller/internal/services/repo_sync_service_test.go +++ b/images/operator-helm-controller/internal/services/repo_sync_service_test.go @@ -29,9 +29,10 @@ import ( "github.com/deckhouse/operator-helm/api/naming" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/chartsource" repoclient "github.com/deckhouse/operator-helm/internal/client/repository" "github.com/deckhouse/operator-helm/internal/index" - "github.com/deckhouse/operator-helm/internal/utils" ) type stubRepoClient struct { @@ -72,24 +73,24 @@ func newRepoSyncService(t *testing.T, stub stubRepoClient, objects ...client.Obj }). Build() - factory := func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) { + factory := func(_ chartsource.Kind) (repoclient.ClientInterface, error) { return stub, nil } - return NewRepoSyncService(c, scheme, factory), c + return NewRepoSyncService(c, scheme, factory, adapter.NewAddonCatalog(c)), c } -func ociVersion(version, mediaType string) repoclient.ChartVersion { +func ociVersion(version, mediaType string) repoclient.ChartVersion { //nolint:unparam // the parameter names the value the assertions read; inlining it would hide what the fixture stands for return repoclient.ChartVersion{Version: semver.MustParse(version), MediaType: mediaType} } -func existingChart(repoName, chartName string, versions ...helmv1alpha1.HelmClusterAddonChartVersion) *helmv1alpha1.HelmClusterAddonChart { +func existingChart(repoName, chartName string, versions ...helmv1alpha1.ChartVersion) *helmv1alpha1.HelmClusterAddonChart { //nolint:unparam // the parameter names the value the assertions read; inlining it would hide what the fixture stands for return &helmv1alpha1.HelmClusterAddonChart{ ObjectMeta: metav1.ObjectMeta{ Name: naming.HelmClusterAddonChartName(repoName, chartName), Labels: map[string]string{helmv1alpha1.LabelRepositoryName: repoName, helmv1alpha1.LabelChartName: chartName}, }, - Status: helmv1alpha1.HelmClusterAddonChartStatus{Versions: versions}, + Status: helmv1alpha1.ChartCatalogStatus{Versions: versions}, } } @@ -107,7 +108,7 @@ func addonUsing(repoName, chartName, version string) *helmv1alpha1.HelmClusterAd } } -func chartStatus(t *testing.T, c client.Client, repoName, chartName string) helmv1alpha1.HelmClusterAddonChartStatus { +func chartStatus(t *testing.T, c client.Client, repoName, chartName string) helmv1alpha1.ChartCatalogStatus { //nolint:unparam // the parameter names the value the assertions read; inlining it would hide what the fixture stands for t.Helper() chart := &helmv1alpha1.HelmClusterAddonChart{} @@ -130,7 +131,7 @@ func TestSyncCreatesChartsAndRecordsVersions(t *testing.T) { repo := testRepository() service, c := newRepoSyncService(t, stubRepoClient{charts: []repoclient.Chart{chartFixture("podinfo", "6.7.1")}}, repo) - outcome := service.Sync(context.Background(), repo, utils.InternalHelmRepository) + outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm) if outcome.Fetch.Err != nil { t.Fatalf("fetch failed: %v", outcome.Fetch.Err) } @@ -159,7 +160,7 @@ func TestSyncPrunesStaleCharts(t *testing.T) { service, c := newRepoSyncService(t, stubRepoClient{charts: []repoclient.Chart{chartFixture("podinfo", "6.7.1")}}, repo, stale) - outcome := service.Sync(context.Background(), repo, utils.InternalHelmRepository) + outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm) if outcome.Catalog.Err != nil { t.Fatalf("catalog update failed: %v", outcome.Catalog.Err) } @@ -179,7 +180,7 @@ func TestSyncReportsTerminalFetchFailure(t *testing.T) { service, _ := newRepoSyncService(t, stubRepoClient{err: terminal}, repo) - outcome := service.Sync(context.Background(), repo, utils.InternalHelmRepository) + outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm) if outcome.Fetch.Err == nil { t.Fatal("expected a fetch failure") } @@ -195,7 +196,7 @@ func TestSyncReportsTransientFetchFailure(t *testing.T) { repo := testRepository() service, _ := newRepoSyncService(t, stubRepoClient{err: errors.New("connection refused")}, repo) - outcome := service.Sync(context.Background(), repo, utils.InternalHelmRepository) + outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm) if outcome.Fetch.Err == nil { t.Fatal("expected a fetch failure") } @@ -210,8 +211,8 @@ func TestSyncReportsTransientFetchFailure(t *testing.T) { func TestSyncPassesKnownVersionsToTheClient(t *testing.T) { repo := testRepository() chart := existingChart(repo.Name, "podinfo", - helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, - helmv1alpha1.HelmClusterAddonChartVersion{ + helmv1alpha1.ChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, + helmv1alpha1.ChartVersion{ Version: "6.7.2", UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, UnavailableMessage: "layer media type application/vnd.example is not a chart", @@ -224,11 +225,11 @@ func TestSyncPassesKnownVersionsToTheClient(t *testing.T) { }}} service, _ := newRepoSyncService(t, stubRepoClient{}, repo, chart) - service.clientFactory = func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) { + service.clientFactory = func(_ chartsource.Kind) (repoclient.ClientInterface, error) { return stub, nil } - if outcome := service.Sync(context.Background(), repo, utils.InternalOCIRepository); outcome.Fetch.Err != nil { + if outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI); outcome.Fetch.Err != nil { t.Fatalf("fetch failed: %v", outcome.Fetch.Err) } @@ -257,11 +258,11 @@ func TestSyncRequestsFullPassOnForceReconcile(t *testing.T) { }}} service, _ := newRepoSyncService(t, stubRepoClient{}, repo) - service.clientFactory = func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) { + service.clientFactory = func(_ chartsource.Kind) (repoclient.ClientInterface, error) { return stub, nil } - service.Sync(context.Background(), repo, utils.InternalOCIRepository) + service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI) if !stub.opts.Full { t.Fatal("force reconcile must request a full re-index") @@ -271,8 +272,8 @@ func TestSyncRequestsFullPassOnForceReconcile(t *testing.T) { func TestSyncRetainsReferencedVersionRemovedFromRepository(t *testing.T) { repo := testRepository() chart := existingChart(repo.Name, "podinfo", - helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, - helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.0", MediaType: "application/tar+gzip"}, + helmv1alpha1.ChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, + helmv1alpha1.ChartVersion{Version: "6.7.0", MediaType: "application/tar+gzip"}, ) addon := addonUsing(repo.Name, "podinfo", "6.7.1") @@ -282,7 +283,7 @@ func TestSyncRetainsReferencedVersionRemovedFromRepository(t *testing.T) { }}} service, c := newRepoSyncService(t, stub, repo, chart, addon) - if outcome := service.Sync(context.Background(), repo, utils.InternalOCIRepository); outcome.Catalog.Err != nil { + if outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI); outcome.Catalog.Err != nil { t.Fatalf("catalog update failed: %v", outcome.Catalog.Err) } @@ -321,8 +322,8 @@ func TestSyncRetainsReferencedVersionRemovedFromRepository(t *testing.T) { func TestSyncRetainsMediaTypeForReferencedUnsupportedVersion(t *testing.T) { repo := testRepository() chart := existingChart(repo.Name, "podinfo", - helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, - helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.0", MediaType: "application/tar+gzip"}, + helmv1alpha1.ChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, + helmv1alpha1.ChartVersion{Version: "6.7.0", MediaType: "application/tar+gzip"}, ) addon := addonUsing(repo.Name, "podinfo", "6.7.1") @@ -343,7 +344,7 @@ func TestSyncRetainsMediaTypeForReferencedUnsupportedVersion(t *testing.T) { }}} service, c := newRepoSyncService(t, stub, repo, chart, addon) - if outcome := service.Sync(context.Background(), repo, utils.InternalOCIRepository); outcome.Catalog.Err != nil { + if outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI); outcome.Catalog.Err != nil { t.Fatalf("catalog update failed: %v", outcome.Catalog.Err) } @@ -391,7 +392,7 @@ func TestSyncOrdersVersionsBySemverDescending(t *testing.T) { }}} service, c := newRepoSyncService(t, stub, repo) - service.Sync(context.Background(), repo, utils.InternalOCIRepository) + service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI) got := chartStatus(t, c, repo.Name, "podinfo").Versions want := []string{"6.10.0", "6.8.0", "6.7.1"} @@ -417,7 +418,7 @@ func TestSyncCreatesChartWithNoUsableVersions(t *testing.T) { }}} service, c := newRepoSyncService(t, stub, repo) - outcome := service.Sync(context.Background(), repo, utils.InternalOCIRepository) + outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI) if outcome.Catalog.Err != nil { t.Fatalf("catalog update failed: %v", outcome.Catalog.Err) @@ -435,12 +436,12 @@ func TestSyncCreatesChartWithNoUsableVersions(t *testing.T) { func TestSyncKeepsChartReferencedByAddon(t *testing.T) { repo := testRepository() chart := existingChart(repo.Name, "podinfo", - helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, + helmv1alpha1.ChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, ) addon := addonUsing(repo.Name, "podinfo", "6.7.1") service, c := newRepoSyncService(t, stubRepoClient{charts: nil}, repo, chart, addon) - if outcome := service.Sync(context.Background(), repo, utils.InternalOCIRepository); outcome.Catalog.Err != nil { + if outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI); outcome.Catalog.Err != nil { t.Fatalf("catalog update failed: %v", outcome.Catalog.Err) } @@ -475,12 +476,12 @@ func TestSyncReportsNoFetchAttemptOnClusterReadFailure(t *testing.T) { }). Build() - factory := func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) { + factory := func(_ chartsource.Kind) (repoclient.ClientInterface, error) { return stubRepoClient{}, nil } - service := NewRepoSyncService(c, scheme, factory) + service := NewRepoSyncService(c, scheme, factory, adapter.NewAddonCatalog(c)) - outcome := service.Sync(context.Background(), repo, utils.InternalOCIRepository) + outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI) if outcome.FetchAttempted { t.Fatal("a cluster-side read failure before the fetch must not report FetchAttempted") @@ -495,80 +496,3 @@ func TestSyncReportsNoFetchAttemptOnClusterReadFailure(t *testing.T) { t.Fatalf("catalog error must wrap the underlying failure, got %v", outcome.Catalog.Err) } } - -// TestMergeChartVersionsCarriesOCIRef pins the three things that can happen to a -// recorded reference. Fresh index data always wins, which is how a version -// re-published as an archive loses its reference; a version the index no longer -// offers keeps it, without which the addon still using it could not build its -// internal OCIRepository and would be blocked from every change, including its own -// removal. -func TestMergeChartVersionsCarriesOCIRef(t *testing.T) { - fetched := []repoclient.ChartVersion{ - {Version: semver.MustParse("3.0.0"), OCIRef: "oci://registry.example.com/charts/podinfo:3.0.0"}, - {Version: semver.MustParse("2.0.0")}, - } - - current := []helmv1alpha1.HelmClusterAddonChartVersion{ - {Version: "2.0.0", OCIRef: "oci://registry.example.com/charts/podinfo:2.0.0"}, - {Version: "1.0.0", OCIRef: "oci://registry.example.com/charts/podinfo:1.0.0"}, - } - - inUse := map[string]struct{}{"1.0.0": {}} - - merged := mergeChartVersions(fetched, current, inUse) - - byVersion := map[string]helmv1alpha1.HelmClusterAddonChartVersion{} - for _, version := range merged { - byVersion[version.Version] = version - } - - if got := byVersion["3.0.0"].OCIRef; got != "oci://registry.example.com/charts/podinfo:3.0.0" { - t.Fatalf("3.0.0 oci ref = %q, want the fetched one", got) - } - if got := byVersion["2.0.0"].OCIRef; got != "" { - t.Fatalf("2.0.0 oci ref = %q, want empty: the index now offers an archive", got) - } - - retained, ok := byVersion["1.0.0"] - if !ok { - t.Fatal("a version still referenced by an addon must be retained") - } - if retained.OCIRef != "oci://registry.example.com/charts/podinfo:1.0.0" { - t.Fatalf("retained oci ref = %q, want the recorded one", retained.OCIRef) - } - if retained.UnavailableReason != helmv1alpha1.UnavailableReasonRemovedFromRepository { - t.Fatalf("retained reason = %q, want %q", retained.UnavailableReason, helmv1alpha1.UnavailableReasonRemovedFromRepository) - } -} - -// TestMergeChartVersionsDoesNotCarryMediaTypeOntoOCIRef pins the invariant the API -// documentation asserts: MediaType stays empty for a version carrying OCIRef. A -// version that now resolves to an OCI artifact must probe its own layer media type -// from scratch even though an addon still references it and a previous pass (back -// when the version was an archive) recorded one: resolveMediaType checks -// version.MediaType != "" before the force-reconcile cache bypass, so a stale -// carried-forward value would use the wrong layer selector and no force reconcile -// could ever correct it. -func TestMergeChartVersionsDoesNotCarryMediaTypeOntoOCIRef(t *testing.T) { - fetched := []repoclient.ChartVersion{ - {Version: semver.MustParse("6.7.1"), OCIRef: "oci://other-registry.example.com/x/podinfo:6.7.1"}, - } - - current := []helmv1alpha1.HelmClusterAddonChartVersion{ - {Version: "6.7.1", MediaType: "application/tar+gzip"}, - } - - inUse := map[string]struct{}{"6.7.1": {}} - - merged := mergeChartVersions(fetched, current, inUse) - - if len(merged) != 1 { - t.Fatalf("merged = %+v, want exactly one version", merged) - } - if merged[0].OCIRef != "oci://other-registry.example.com/x/podinfo:6.7.1" { - t.Fatalf("OCIRef = %q, want the fetched one", merged[0].OCIRef) - } - if merged[0].MediaType != "" { - t.Fatalf("MediaType = %q, want empty: a version carrying OCIRef must not carry a stale media type forward", merged[0].MediaType) - } -} diff --git a/images/operator-helm-controller/internal/source/catalog.go b/images/operator-helm-controller/internal/source/catalog.go new file mode 100644 index 00000000..2dd54dca --- /dev/null +++ b/images/operator-helm-controller/internal/source/catalog.go @@ -0,0 +1,53 @@ +/* +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 source + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + repoclient "github.com/deckhouse/operator-helm/internal/client/repository" +) + +// Catalog mirrors the chart list of a repository into the chart catalog kind of +// its family. One Catalog serves every repository of one kind; the repository is +// passed to each call because the objects it writes are owned by, labelled with +// and (for a namespaced kind) placed next to that repository. +type Catalog interface { + // Known returns the verdicts recorded for the repository by previous passes, + // so the repository client can skip tags it already examined. + Known(ctx context.Context, repo Repository) (repoclient.KnownCharts, error) + // MigrateNames moves the repository's catalog objects to the names the current + // scheme derives. It reads nothing from the repository, so it must run on every + // reconcile, not only when a fetch is attempted or succeeds. + // + // TRANSITIONAL: remove once every cluster has reconciled each repository once + // under the current scheme. + MigrateNames(ctx context.Context, repo Repository) error + // Reconcile writes the fetched charts into the catalog and prunes objects the + // repository no longer lists, keeping any version a consumer still references. + Reconcile(ctx context.Context, repo Repository, charts []repoclient.Chart) error + // InUseVersions reports the versions of one chart still referenced by the + // consumers of this family. An empty chart name yields no versions. + InUseVersions(ctx context.Context, repo Repository, chartName string) (map[string]struct{}, error) + // Lookup returns the catalog object of one chart and its status, so a release + // can find the version it asks for. The error is a NotFound when the repository + // does not offer the chart. + Lookup(ctx context.Context, repo Repository, chartName string) (client.Object, *helmv1alpha1.ChartCatalogStatus, error) +} diff --git a/images/operator-helm-controller/internal/source/doc.go b/images/operator-helm-controller/internal/source/doc.go new file mode 100644 index 00000000..861d3835 --- /dev/null +++ b/images/operator-helm-controller/internal/source/doc.go @@ -0,0 +1,27 @@ +/* +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 source declares the contracts through which the services and the +// reconcilers see a user-facing resource without knowing its kind. +// +// A Repository is any of the three repository kinds of the module; a Catalog +// writes the chart catalog kind that mirrors one repository family. The +// implementations live in internal/adapter and internal/catalog. This package +// depends only on the API types and the status manager so that services, +// adapters and catalogs can all import it without a cycle — which is also why the +// contracts are not declared next to their consumer in internal/services: the +// services' own tests build adapters. +package source diff --git a/images/operator-helm-controller/internal/source/release.go b/images/operator-helm-controller/internal/source/release.go new file mode 100644 index 00000000..b5519693 --- /dev/null +++ b/images/operator-helm-controller/internal/source/release.go @@ -0,0 +1,102 @@ +/* +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 source + +import ( + "context" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/operator-helm/internal/status" +) + +// RepositoryRef names the repository a release takes its chart from. Kind is one +// of the repository kinds of the API; Namespace is empty for a cluster-scoped kind. +// The adapter resolves the release's spec into this one shape — a single field for +// the addon, a pair of mutually exclusive fields for the application — so no +// service ever sees the difference. +type RepositoryRef struct { + Kind string + Namespace string + Name string +} + +// ChartRef identifies one chart version of one repository: what a release asks +// for, and what its status records as last applied. +type ChartRef struct { + Repository RepositoryRef + Chart string + Version string +} + +// ReleaseNames are the names of the internal objects derived from one release. +// ServiceAccount is empty for a family that does not impersonate. +type ReleaseNames struct { + HelmChart string + HelmRelease string + OCIRepository string + ServiceAccount string +} + +// Release is a release resource of any kind as the services see it. Object returns +// the API object itself — the only thing ever handed to the client; the adapter is +// not registered in the scheme. Values and the status setters point into that +// object, so a write through them lands on it. +type Release interface { + Object() status.ObjectWithConditions + // Kind is the API kind of the object, for messages and log lines. + Kind() string + Name() string + Namespace() string + Generation() int64 + + ChartRef() ChartRef + // TargetNamespace is where the release is deployed. + TargetNamespace() string + Values() *apiextensionsv1.JSON + MaintenanceActivated() bool + MaintenanceEnabled() bool + ForceReconcileRequired() bool + // ReleaseName is the Helm release name, at most 53 characters. + ReleaseName() string + + // SourceLabels are carried by every internal object derived from this release, + // including managed-by; the watch mappers read them back. + SourceLabels() map[string]string + // HelmChartLabels are SourceLabels plus the label naming the catalog object the + // chart is taken from. + HelmChartLabels() map[string]string + InternalNames() ReleaseNames + + // LastAppliedChart is nil until a first successful deployment. + LastAppliedChart() *ChartRef + // SetLastAppliedChart replaces the record wholesale: a merge would leave a stale + // repository field next to a new one on a kind with two reference fields. + SetLastAppliedChart(ChartRef) + LastAppliedValues() *apiextensionsv1.JSON + SetLastAppliedValues(*apiextensionsv1.JSON) + SetLastForceReconcileTime(metav1.Time) + // IsChartStatusInfoOutdated reports whether the desired chart differs from the + // last applied one. + IsChartStatusInfoOutdated() bool +} + +// ReleaseLister lists the releases of one family that consume a repository, or +// only those consuming one chart of it when chartName is not empty. It is how a +// repository finds its consumers without knowing their kind. +type ReleaseLister func(ctx context.Context, repo Repository, chartName string) ([]Release, error) diff --git a/images/operator-helm-controller/internal/source/repository.go b/images/operator-helm-controller/internal/source/repository.go new file mode 100644 index 00000000..f2a69c3e --- /dev/null +++ b/images/operator-helm-controller/internal/source/repository.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 source + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime/schema" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/status" +) + +// InternalNames are the names of the internal objects derived from one +// repository. They are computed by the adapter: the scheme differs between +// the addon family (frozen, hash only on truncation) and the application family +// (namespace-aware, hash always present), and no service should know which is +// which. +type InternalNames struct { + HelmRepository string + AuthSecret string + TLSSecret string +} + +// Repository is a repository resource of any kind as the services see it. Object +// returns the API object itself, which is what the client reads, updates and +// patches: the adapter is not registered in the scheme and must never be handed to +// the client directly. +type Repository interface { + Object() status.ObjectWithConditions + Name() string + Namespace() string + Generation() int64 + // OwnerGVK is the GroupVersionKind stamped into the owner reference of the + // catalog objects this repository produces. + OwnerGVK() schema.GroupVersionKind + + URL() string + Auth() *helmv1alpha1.RepositoryAuth + CACertificate() string + InsecureSkipVerify() bool + + // Status points into the object, so a write through it lands on the object. + Status() *helmv1alpha1.RepositoryStatus + ForceReconcileRequired() bool + + // SourceLabels are the labels every internal object derived from this + // repository carries, including managed-by; the watch mappers read them back. + SourceLabels() map[string]string + InternalNames() InternalNames +} + +// ConsumerForcer pushes a force reconcile request onto the internal sources of +// the resources that consume a repository. Which resources those are depends on the +// family, so the reconciler is handed an implementation instead of looking them up. +type ConsumerForcer interface { + ForceReconcileConsumers(ctx context.Context, repo Repository) error +} + +// NoConsumers is the ConsumerForcer of a family whose consumers do not exist yet. +// The application family uses it until HelmApplication is reconciled by the +// controller; that implementation replaces it. +type NoConsumers struct{} + +func (NoConsumers) ForceReconcileConsumers(context.Context, Repository) error { + return nil +} diff --git a/images/operator-helm-controller/internal/manager/status/deletion.go b/images/operator-helm-controller/internal/status/deletion.go similarity index 59% rename from images/operator-helm-controller/internal/manager/status/deletion.go rename to images/operator-helm-controller/internal/status/deletion.go index fe56891a..00eee926 100644 --- a/images/operator-helm-controller/internal/manager/status/deletion.go +++ b/images/operator-helm-controller/internal/status/deletion.go @@ -22,63 +22,20 @@ import ( apimeta "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" ) // DeletingResource is an internal resource that is being deleted; its deletion // timestamp and conditions are used to derive the owner's status while the -// deletion is pending. +// deletion is pending. It is a client.Object so that a caller waiting on one can +// name it in the log; the resourceName passed alongside stays abstract because that +// one reaches the user through the owner's status. type DeletingResource interface { - GetDeletionTimestamp() *metav1.Time - GetConditions() []metav1.Condition -} - -// readyResult sets the Ready condition directly from a Status. -type readyResult struct { - status Status -} - -var _ Provider = readyResult{} - -func (r readyResult) GetStatus() Status { return r.status } - -func (r readyResult) GetConditionType() string { return helmv1alpha1.ConditionTypeReady } - -// uninstallFailedResult carries the state of the UninstallFailed condition, an -// abnormal-true condition that is True when a Helm release fails to uninstall -// while its owner is being deleted. The same result is reflected onto the Ready -// condition with inverted polarity (see reflectUninstallFailedToReady). -type uninstallFailedResult struct { - status Status -} - -var _ Provider = uninstallFailedResult{} - -func (r uninstallFailedResult) GetStatus() Status { return r.status } + client.Object -func (r uninstallFailedResult) GetConditionType() string { - return helmv1alpha1.ConditionTypeUninstallFailed -} - -// reflectUninstallFailedToReady inverts the condition status when the -// UninstallFailed result is reflected onto the Ready condition: an -// UninstallFailed=True (an error occurred) must read as Ready=False, while the -// reason and message are preserved. Unknown stays Unknown, so an in-progress -// uninstall reflects as Ready=Unknown/Reconciling. -func reflectUninstallFailedToReady(conditionType string, s Status) Status { - if conditionType != helmv1alpha1.ConditionTypeReady { - return s - } - - switch s.Status { - case metav1.ConditionTrue: - s.Status = metav1.ConditionFalse - case metav1.ConditionFalse: - s.Status = metav1.ConditionTrue - } - - return s + GetConditions() []metav1.Condition } // MarkUninstallPending records that a Helm release is still being uninstalled @@ -88,29 +45,30 @@ func reflectUninstallFailedToReady(conditionType string, s Status) Status { // Reconciling until the release disappears. resourceName is a user-facing, // abstract name so the internal resource type is not leaked. func (s *Manager) MarkUninstallPending(ctx context.Context, obj ObjectWithConditions, resourceName string, resource DeletingResource) error { - var st Status - if failing, message := deletionFailure(resourceName, resource); failing { - st = Status{ - Observed: true, - Status: metav1.ConditionTrue, - Reason: helmv1alpha1.ReasonUninstallFailed, - Message: message, - ObservedGeneration: obj.GetGeneration(), - } - } else { - st = reconcilingDeletionStatus(obj, message) + failing, message := deletionFailure(resourceName, resource) + + uninstallFailed := metav1.Condition{ + Type: helmv1alpha1.ConditionTypeUninstallFailed, + Status: metav1.ConditionUnknown, + Reason: helmv1alpha1.ReasonReconciling, + Message: message, + ObservedGeneration: obj.GetGeneration(), } - result := uninstallFailedResult{status: st} + // Ready carries the same verdict with the polarity a reader expects: an + // uninstall that failed is an owner that is not ready, while one still running + // leaves both Unknown. + ready := uninstallFailed + ready.Type = helmv1alpha1.ConditionTypeReady + + if failing { + uninstallFailed.Status = metav1.ConditionTrue + uninstallFailed.Reason = helmv1alpha1.ReasonUninstallFailed + ready.Status = metav1.ConditionFalse + ready.Reason = helmv1alpha1.ReasonUninstallFailed + } - return s.Update( - ctx, - obj, - NoopStatusMutator, - reflectUninstallFailedToReady, - result, - AsCondition(result, helmv1alpha1.ConditionTypeReady), - ) + return s.patchConditions(ctx, obj, uninstallFailed, ready) } // MarkDeletionPending records that an internal resource is still being deleted, @@ -119,49 +77,59 @@ func (s *Manager) MarkUninstallPending(ctx context.Context, obj ObjectWithCondit // Failed and its message; otherwise Ready stays Unknown/Reconciling until the // resource disappears. resourceName is a user-facing, abstract name. func (s *Manager) MarkDeletionPending(ctx context.Context, obj ObjectWithConditions, resourceName string, resource DeletingResource) error { - var st Status - if failing, message := deletionFailure(resourceName, resource); failing { - st = Status{ - Observed: true, - Status: metav1.ConditionFalse, - Reason: helmv1alpha1.ReasonFailed, - Message: message, - ObservedGeneration: obj.GetGeneration(), - } - } else { - st = reconcilingDeletionStatus(obj, message) + failing, message := deletionFailure(resourceName, resource) + + ready := metav1.Condition{ + Type: helmv1alpha1.ConditionTypeReady, + Status: metav1.ConditionUnknown, + Reason: helmv1alpha1.ReasonReconciling, + Message: message, + ObservedGeneration: obj.GetGeneration(), } - return s.Update(ctx, obj, NoopStatusMutator, NoopStatusMapper, readyResult{status: st}) + if failing { + ready.Status = metav1.ConditionFalse + ready.Reason = helmv1alpha1.ReasonFailed + } + + return s.patchConditions(ctx, obj, ready) } // MarkDeletionFailed sets Ready=False with reason Failed when an internal // resource could not be deleted because of a hard error that is not reported // through the resource's own conditions (e.g. an API error while deleting a // dependency). The message uses the same abstract wording as MarkDeletionPending. +// err itself is not logged here: every caller hands it back to the work queue, +// which is where it is reported. func (s *Manager) MarkDeletionFailed(ctx context.Context, obj ObjectWithConditions, resourceName string, err error) error { message := fmt.Sprintf("Failed to delete %s", resourceName) if err != nil { message = fmt.Sprintf("%s: %s", message, err.Error()) } - return s.Update(ctx, obj, NoopStatusMutator, NoopStatusMapper, readyResult{status: Status{ - Observed: true, + return s.patchConditions(ctx, obj, metav1.Condition{ + Type: helmv1alpha1.ConditionTypeReady, Status: metav1.ConditionFalse, Reason: helmv1alpha1.ReasonFailed, Message: message, ObservedGeneration: obj.GetGeneration(), - Err: err, - }}) + }) } -func reconcilingDeletionStatus(obj ObjectWithConditions, message string) Status { - return Status{ - Status: metav1.ConditionUnknown, - Reason: helmv1alpha1.ReasonReconciling, - Message: message, - ObservedGeneration: obj.GetGeneration(), - } +// patchConditions writes the conditions of a deletion pass. observedGeneration is +// advanced with them: a spec edited while the object is being deleted is still a +// spec this controller has seen, and leaving it behind would report the teardown as +// work that has not started. +func (s *Manager) patchConditions(ctx context.Context, obj ObjectWithConditions, conditions ...metav1.Condition) error { + return s.PatchStatus(ctx, obj, func() { + for _, condition := range conditions { + apimeta.SetStatusCondition(obj.GetConditions(), condition) + } + + if generation := obj.GetGeneration(); generation > obj.GetObservedGeneration() { + obj.SetObservedGeneration(generation) + } + }) } // deletionFailure reports whether the deleted resource is failing, together with diff --git a/images/operator-helm-controller/internal/status/deletion_test.go b/images/operator-helm-controller/internal/status/deletion_test.go new file mode 100644 index 00000000..aeb90a01 --- /dev/null +++ b/images/operator-helm-controller/internal/status/deletion_test.go @@ -0,0 +1,270 @@ +/* +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 ( + "context" + "errors" + "strings" + "testing" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +func newDeletionManager(t *testing.T, owner *helmv1alpha1.HelmClusterAddon) (*Manager, client.Client) { + t.Helper() + + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + clientgoscheme.AddToScheme, + helmv1alpha1.AddToScheme, + helmv2.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatalf("registering scheme: %v", err) + } + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(owner). + WithStatusSubresource(&helmv1alpha1.HelmClusterAddon{}). + Build() + + return NewManager(c), c +} + +func deletionOwner() *helmv1alpha1.HelmClusterAddon { + return &helmv1alpha1.HelmClusterAddon{ + ObjectMeta: metav1.ObjectMeta{Name: "addon", Generation: 3}, + } +} + +// deletingRelease is an internal object partway through its own deletion, which is +// what makes its Ready condition describe the teardown rather than whatever came +// before it. +func deletingRelease(ready *metav1.Condition) *helmv2.HelmRelease { + deleted := metav1.Now() + release := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{Name: "internal", DeletionTimestamp: &deleted, Finalizers: []string{"keep"}}, + } + if ready != nil { + release.Status.Conditions = []metav1.Condition{*ready} + } + + return release +} + +func storedConditions(t *testing.T, c client.Client, owner *helmv1alpha1.HelmClusterAddon) *helmv1alpha1.HelmClusterAddon { + t.Helper() + + stored := &helmv1alpha1.HelmClusterAddon{} + if err := c.Get(context.Background(), client.ObjectKeyFromObject(owner), stored); err != nil { + t.Fatalf("getting owner: %v", err) + } + + return stored +} + +func requireCondition(t *testing.T, owner *helmv1alpha1.HelmClusterAddon, conditionType string) metav1.Condition { + t.Helper() + + cond := apimeta.FindStatusCondition(owner.Status.Conditions, conditionType) + if cond == nil { + t.Fatalf("condition %q was not written, got %+v", conditionType, owner.Status.Conditions) + } + + return *cond +} + +// TestMarkDeletionPendingWaits pins what an owner reports while an internal +// resource it must outlive is still going away: work in flight, not a failure. +func TestMarkDeletionPendingWaits(t *testing.T) { + owner := deletionOwner() + manager, c := newDeletionManager(t, owner) + + if err := manager.MarkDeletionPending(context.Background(), owner, "internal chart", deletingRelease(nil)); err != nil { + t.Fatalf("MarkDeletionPending returned %v", err) + } + + stored := storedConditions(t, c, owner) + ready := requireCondition(t, stored, helmv1alpha1.ConditionTypeReady) + + if ready.Status != metav1.ConditionUnknown || ready.Reason != helmv1alpha1.ReasonReconciling { + t.Fatalf("Ready = %s/%s, want Unknown/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonReconciling) + } + if ready.Message != "Waiting for internal chart to be deleted" { + t.Fatalf("message = %q, want the abstract resource name", ready.Message) + } + if ready.ObservedGeneration != owner.Generation { + t.Fatalf("observedGeneration = %d, want %d", ready.ObservedGeneration, owner.Generation) + } + if stored.Status.ObservedGeneration != owner.Generation { + t.Fatalf("status observedGeneration = %d, want %d", stored.Status.ObservedGeneration, owner.Generation) + } +} + +// TestMarkDeletionPendingReportsAFailingResource pins the other half: once the +// resource's own deletion has started, its Ready=False describes the teardown and +// the owner says so. +func TestMarkDeletionPendingReportsAFailingResource(t *testing.T) { + owner := deletionOwner() + manager, c := newDeletionManager(t, owner) + + resource := deletingRelease(&metav1.Condition{ + Type: helmv1alpha1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: "Failed", + Message: "finalizer stuck", + }) + + if err := manager.MarkDeletionPending(context.Background(), owner, "internal chart", resource); err != nil { + t.Fatalf("MarkDeletionPending returned %v", err) + } + + ready := requireCondition(t, storedConditions(t, c, owner), helmv1alpha1.ConditionTypeReady) + if ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonFailed { + t.Fatalf("Ready = %s/%s, want False/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonFailed) + } + if !strings.Contains(ready.Message, "finalizer stuck") { + t.Fatalf("message = %q, want the resource's own cause carried over", ready.Message) + } +} + +// TestMarkDeletionPendingIgnoresAFailureFromBeforeTheDeletion pins the guard: a +// Ready=False left over from a failed install says nothing about the teardown, and +// reporting it as a deletion failure would be misleading. +func TestMarkDeletionPendingIgnoresAFailureFromBeforeTheDeletion(t *testing.T) { + owner := deletionOwner() + manager, c := newDeletionManager(t, owner) + + resource := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{Name: "internal"}, + Status: helmv2.HelmReleaseStatus{Conditions: []metav1.Condition{{ + Type: helmv1alpha1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: "InstallFailed", + Message: "chart values rejected", + }}}, + } + + if err := manager.MarkDeletionPending(context.Background(), owner, "internal release", resource); err != nil { + t.Fatalf("MarkDeletionPending returned %v", err) + } + + ready := requireCondition(t, storedConditions(t, c, owner), helmv1alpha1.ConditionTypeReady) + if ready.Status != metav1.ConditionUnknown { + t.Fatalf("Ready = %s, want Unknown: the deletion has not been attempted yet", ready.Status) + } +} + +// TestMarkUninstallPendingReportsAFailedUninstall pins the polarity of the two +// conditions: the abnormal-true one is raised, and Ready carries the same reason +// and message inverted. +func TestMarkUninstallPendingReportsAFailedUninstall(t *testing.T) { + owner := deletionOwner() + manager, c := newDeletionManager(t, owner) + + resource := deletingRelease(&metav1.Condition{ + Type: helmv1alpha1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: "UninstallFailed", + Message: "helm uninstall failed", + }) + + if err := manager.MarkUninstallPending(context.Background(), owner, "internal release", resource); err != nil { + t.Fatalf("MarkUninstallPending returned %v", err) + } + + stored := storedConditions(t, c, owner) + failed := requireCondition(t, stored, helmv1alpha1.ConditionTypeUninstallFailed) + ready := requireCondition(t, stored, helmv1alpha1.ConditionTypeReady) + + if failed.Status != metav1.ConditionTrue || failed.Reason != helmv1alpha1.ReasonUninstallFailed { + t.Fatalf("UninstallFailed = %s/%s, want True/%s", failed.Status, failed.Reason, helmv1alpha1.ReasonUninstallFailed) + } + if ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonUninstallFailed { + t.Fatalf("Ready = %s/%s, want False/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonUninstallFailed) + } + if ready.Message != failed.Message { + t.Fatalf("Ready message %q and UninstallFailed message %q must be the same verdict", ready.Message, failed.Message) + } +} + +// TestMarkUninstallPendingLeavesBothUnknownWhileRunning pins that an uninstall +// still in flight is not a failure on either condition. +func TestMarkUninstallPendingLeavesBothUnknownWhileRunning(t *testing.T) { + owner := deletionOwner() + manager, c := newDeletionManager(t, owner) + + if err := manager.MarkUninstallPending(context.Background(), owner, "internal release", deletingRelease(nil)); err != nil { + t.Fatalf("MarkUninstallPending returned %v", err) + } + + stored := storedConditions(t, c, owner) + for _, conditionType := range []string{helmv1alpha1.ConditionTypeUninstallFailed, helmv1alpha1.ConditionTypeReady} { + cond := requireCondition(t, stored, conditionType) + if cond.Status != metav1.ConditionUnknown || cond.Reason != helmv1alpha1.ReasonReconciling { + t.Fatalf("%s = %s/%s, want Unknown/%s", conditionType, cond.Status, cond.Reason, helmv1alpha1.ReasonReconciling) + } + } +} + +func TestMarkDeletionFailedNamesTheCause(t *testing.T) { + owner := deletionOwner() + manager, c := newDeletionManager(t, owner) + + err := manager.MarkDeletionFailed(context.Background(), owner, "auxiliary secrets", errors.New("forbidden")) + if err != nil { + t.Fatalf("MarkDeletionFailed returned %v", err) + } + + ready := requireCondition(t, storedConditions(t, c, owner), helmv1alpha1.ConditionTypeReady) + if ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonFailed { + t.Fatalf("Ready = %s/%s, want False/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonFailed) + } + if !strings.Contains(ready.Message, "auxiliary secrets") || !strings.Contains(ready.Message, "forbidden") { + t.Fatalf("message = %q, want both the resource and the cause", ready.Message) + } +} + +// TestPatchStatusSkipsAnUnchangedStatus pins the guard every status write relies +// on: repeating a verdict must not put a write on the API server. +func TestPatchStatusSkipsAnUnchangedStatus(t *testing.T) { + owner := deletionOwner() + manager, c := newDeletionManager(t, owner) + + if err := manager.MarkDeletionPending(context.Background(), owner, "internal chart", deletingRelease(nil)); err != nil { + t.Fatalf("first MarkDeletionPending returned %v", err) + } + first := storedConditions(t, c, owner).ResourceVersion + + if err := manager.MarkDeletionPending(context.Background(), owner, "internal chart", deletingRelease(nil)); err != nil { + t.Fatalf("second MarkDeletionPending returned %v", err) + } + if second := storedConditions(t, c, owner).ResourceVersion; second != first { + t.Fatalf("resourceVersion moved from %s to %s: an unchanged status must not be patched", first, second) + } +} diff --git a/images/operator-helm-controller/internal/status/manager.go b/images/operator-helm-controller/internal/status/manager.go new file mode 100644 index 00000000..1a26f65d --- /dev/null +++ b/images/operator-helm-controller/internal/status/manager.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 ( + "context" + "fmt" + "reflect" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ObjectWithConditions interface { + client.Object + GetConditions() *[]metav1.Condition + GetGeneration() int64 + GetObservedGeneration() int64 + SetObservedGeneration(int64) + GetConditionTypesForUpdate() []string +} + +type Manager struct { + client.Client +} + +func NewManager(c client.Client) *Manager { + return &Manager{ + Client: c, + } +} + +// PatchStatus applies mutate to the object and patches the status subresource +// when it actually changed. It is the thin apply path used by reconcilers that +// compute the whole desired status themselves. +// +// The whole object is compared rather than its status alone: mutate only ever +// reaches the status, so the two are the same comparison, and this one needs no +// method on the object to reach a field the interface would otherwise have to +// hand out as an any. +func (s *Manager) PatchStatus(ctx context.Context, obj ObjectWithConditions, mutate func()) error { + oldObj := obj.DeepCopyObject().(ObjectWithConditions) + + mutate() + + if reflect.DeepEqual(obj, oldObj) { + return nil + } + + if err := s.Status().Patch(ctx, obj, client.MergeFrom(oldObj)); err != nil { + return fmt.Errorf("patching status: %w", err) + } + + return nil +} diff --git a/images/operator-helm-controller/internal/utils/mapper.go b/images/operator-helm-controller/internal/utils/mapper.go index cd2e9363..1d979974 100644 --- a/images/operator-helm-controller/internal/utils/mapper.go +++ b/images/operator-helm-controller/internal/utils/mapper.go @@ -24,9 +24,6 @@ import ( "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" - "github.com/deckhouse/operator-helm/internal/index" ) func MapInternalResources(controllerName, targetNamespace, labelManagedBy, labelManagedByValue, labelSourceName string) handler.MapFunc { @@ -44,8 +41,8 @@ func MapInternalResources(controllerName, targetNamespace, labelManagedBy, label sourceName := labels[labelSourceName] if sourceName == "" { - logger.Info("resource missing source label, skipping", - "controller", controllerName, "name", obj.GetName(), "namespace", obj.GetNamespace()) + logger.V(1).Info("resource missing source label, skipping", + "controller", controllerName, "watchedObject", client.ObjectKeyFromObject(obj)) return nil } @@ -61,60 +58,43 @@ func MapInternalResources(controllerName, targetNamespace, labelManagedBy, label } } -func MapRepositoryToAddons(c client.Client) handler.MapFunc { +// MapNamespacedInternalResources is MapInternalResources for a namespaced source +// kind: the request it enqueues carries the namespace recorded in +// labelSourceNamespace next to the name. Internal objects of every family live in +// targetNamespace, so the name alone would not identify a namespaced source. An +// object carrying only one of the two labels cannot be mapped and is skipped, at +// debug verbosity, because internal objects of the other families legitimately +// match the managed-by filter. +func MapNamespacedInternalResources( + controllerName, targetNamespace, labelManagedBy, labelManagedByValue, labelSourceName, labelSourceNamespace string, +) handler.MapFunc { return func(ctx context.Context, obj client.Object) []reconcile.Request { - addonList := &helmv1alpha1.HelmClusterAddonList{} - if err := c.List(ctx, addonList, client.MatchingFields{index.AddonRepository: obj.GetName()}); err != nil { - log.FromContext(ctx).Error(err, "Failed to list HelmClusterAddons for repository mapping") - return nil - } + logger := log.FromContext(ctx) - requests := make([]reconcile.Request, 0, len(addonList.Items)) - for _, addon := range addonList.Items { - requests = append(requests, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: addon.Name}, - }) + if obj.GetNamespace() != targetNamespace { + return nil } - return requests - } -} -// MapChartToAddons enqueues the addon that claims a HelmClusterAddonChart's -// repository/chart pair whenever the chart object changes. This is the addon -// controller's only watch that can fire on a catalog write: after a terminal probe -// verdict (not a chart, or the tag no longer exists) the addon's internal HelmChart -// has already been removed and no OCIRepository was created for it, so none of the -// addon controller's other watches cover it, and without this one the addon would -// stay Ready=False until a human forces a reconcile even after the repository -// republishes something usable. -func MapChartToAddons(c client.Client) handler.MapFunc { - return func(ctx context.Context, obj client.Object) []reconcile.Request { labels := obj.GetLabels() - repoName := labels[helmv1alpha1.LabelRepositoryName] - chartName := labels[helmv1alpha1.LabelChartName] - if repoName == "" || chartName == "" { - // Same fail-open tradeoff as knownCharts: without both labels there is no - // repository/chart pair to look an addon up by, so this chart object - // cannot be mapped back to anything. - log.FromContext(ctx).Info("Chart object missing repository or chart label, cannot map to addons", "addonChartName", obj.GetName()) - + if labels[labelManagedBy] != labelManagedByValue { return nil } - var addons helmv1alpha1.HelmClusterAddonList - if err := c.List(ctx, &addons, client.MatchingFields{ - index.AddonChart: index.AddonChartValue(repoName, chartName), - }); err != nil { - log.FromContext(ctx).Error(err, "Failed to list HelmClusterAddons for chart mapping") + sourceName, sourceNamespace := labels[labelSourceName], labels[labelSourceNamespace] + if sourceName == "" || sourceNamespace == "" { + logger.V(1).Info("resource missing source labels, skipping", + "controller", controllerName, "watchedObject", client.ObjectKeyFromObject(obj)) return nil } - requests := make([]reconcile.Request, 0, len(addons.Items)) - for _, addon := range addons.Items { - requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Name: addon.Name}}) + return []reconcile.Request{ + { + NamespacedName: types.NamespacedName{ + Name: sourceName, + Namespace: sourceNamespace, + }, + }, } - - return requests } } diff --git a/images/operator-helm-controller/internal/utils/mapper_test.go b/images/operator-helm-controller/internal/utils/mapper_test.go index 93517cb2..2ee9e18f 100644 --- a/images/operator-helm-controller/internal/utils/mapper_test.go +++ b/images/operator-helm-controller/internal/utils/mapper_test.go @@ -18,115 +18,72 @@ package utils import ( "context" + "reflect" "testing" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/operator-helm/api/naming" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/index" ) -func newChartMapperClient(t *testing.T, objects ...client.Object) client.Client { - t.Helper() +func TestMapNamespacedInternalResources(t *testing.T) { + const target = "d8-operator-helm" - scheme := runtime.NewScheme() - if err := clientgoscheme.AddToScheme(scheme); err != nil { - t.Fatalf("registering client-go scheme: %v", err) - } - if err := helmv1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("registering helm scheme: %v", err) - } - - return fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithIndex(&helmv1alpha1.HelmClusterAddon{}, index.AddonChart, func(obj client.Object) []string { - addon := obj.(*helmv1alpha1.HelmClusterAddon) - - return []string{index.AddonChartValue( - addon.Spec.Chart.HelmClusterAddonRepository, - addon.Spec.Chart.HelmClusterAddonChartName, - )} - }). - Build() -} + mapper := MapNamespacedInternalResources( + "test-controller", target, + helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName, helmv1alpha1.LabelSourceNamespace, + ) -func chartObject(repoName, chartName string) *helmv1alpha1.HelmClusterAddonChart { - return &helmv1alpha1.HelmClusterAddonChart{ - ObjectMeta: metav1.ObjectMeta{ - Name: naming.HelmClusterAddonChartName(repoName, chartName), - Labels: map[string]string{ - helmv1alpha1.LabelRepositoryName: repoName, - helmv1alpha1.LabelChartName: chartName, - }, - }, + secret := func(namespace string, labels map[string]string) *corev1.Secret { + return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "internal", Namespace: namespace, Labels: labels}} } -} -func addonUsingChart(repoName, chartName, version string) *helmv1alpha1.HelmClusterAddon { - return &helmv1alpha1.HelmClusterAddon{ - ObjectMeta: metav1.ObjectMeta{Name: "consumer"}, - Spec: helmv1alpha1.HelmClusterAddonSpec{ - Namespace: "app", - Chart: helmv1alpha1.HelmClusterAddonChartRef{ - HelmClusterAddonRepository: repoName, - HelmClusterAddonChartName: chartName, - Version: version, - }, - }, + full := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: "stable", + helmv1alpha1.LabelSourceNamespace: "team-a", } -} - -// TestMapChartToAddonsEnqueuesTheClaimingAddon covers the reason this watch exists: -// after a terminal probe verdict the addon has no internal HelmChart or OCIRepository -// left for any other watch to catch, so a status change on the chart itself must be -// the thing that wakes it. -func TestMapChartToAddonsEnqueuesTheClaimingAddon(t *testing.T) { - chart := chartObject("repo-a", "podinfo") - addon := addonUsingChart("repo-a", "podinfo", "6.7.1") - - c := newChartMapperClient(t, chart, addon) - - requests := MapChartToAddons(c)(context.Background(), chart) - - if len(requests) != 1 { - t.Fatalf("requests = %+v, want exactly one", requests) + withoutNamespace := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: "stable", } - if requests[0].Name != addon.Name { - t.Fatalf("request name = %q, want %q", requests[0].Name, addon.Name) + withoutName := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.LabelSourceNamespace: "team-a", } -} - -// TestMapChartToAddonsNoAddonClaimsTheChart covers the case where nothing references -// the chart yet: no request should be produced. -func TestMapChartToAddonsNoAddonClaimsTheChart(t *testing.T) { - chart := chartObject("repo-a", "podinfo") - - c := newChartMapperClient(t, chart) - - if requests := MapChartToAddons(c)(context.Background(), chart); len(requests) != 0 { - t.Fatalf("requests = %+v, want none", requests) + foreign := map[string]string{ + helmv1alpha1.LabelManagedBy: "someone-else", + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: "stable", + helmv1alpha1.LabelSourceNamespace: "team-a", } -} -// TestMapChartToAddonsMissingLabels covers a chart object with no repository or chart -// label: the catalog synchronization treats that the same way (fail open, log and move -// on), and this map function must not panic or list every addon by an empty index -// value. -func TestMapChartToAddonsMissingLabels(t *testing.T) { - chart := &helmv1alpha1.HelmClusterAddonChart{ - ObjectMeta: metav1.ObjectMeta{Name: "orphan-chart"}, + cases := []struct { + name string + obj client.Object + want []reconcile.Request + }{ + { + name: "maps to the namespaced source", + obj: secret(target, full), + want: []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "team-a", Name: "stable"}}}, + }, + {name: "ignores objects outside the target namespace", obj: secret("team-a", full)}, + {name: "ignores objects managed by someone else", obj: secret(target, foreign)}, + {name: "skips an object without the source name", obj: secret(target, withoutName)}, + {name: "skips an object without the source namespace", obj: secret(target, withoutNamespace)}, } - addon := addonUsingChart("repo-a", "podinfo", "6.7.1") - - c := newChartMapperClient(t, chart, addon) - if requests := MapChartToAddons(c)(context.Background(), chart); len(requests) != 0 { - t.Fatalf("requests = %+v, want none for a chart with no labels", requests) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := mapper(context.Background(), tc.obj) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("requests = %v, want %v", got, tc.want) + } + }) } } diff --git a/images/operator-helm-controller/internal/utils/name.go b/images/operator-helm-controller/internal/utils/name.go index 12edbc56..31f416c7 100644 --- a/images/operator-helm-controller/internal/utils/name.go +++ b/images/operator-helm-controller/internal/utils/name.go @@ -22,11 +22,14 @@ import ( "strings" ) +// hashLength is how much of the digest every derived name carries. +const hashLength = 12 + func GetHash(s string) string { h := sha256.New() h.Write([]byte(s)) - return fmt.Sprintf("%x", h.Sum(nil))[:12] + return fmt.Sprintf("%x", h.Sum(nil))[:hashLength] } func GetInternalRepositoryAuthSecretName(internalRepoName string) string { @@ -45,7 +48,7 @@ func GetInternalRepositoryAuthSecretName(internalRepoName string) string { result += internalRepoName } - return strings.TrimRight(result, "-") + postfix + return strings.TrimRight(result, "-.") + postfix } func GetInternalRepositoryTLSSecretName(internalRepoName string) string { @@ -64,7 +67,7 @@ func GetInternalRepositoryTLSSecretName(internalRepoName string) string { result += internalRepoName } - return strings.TrimRight(result, "-") + postfix + return strings.TrimRight(result, "-.") + postfix } func GetInternalHelmReleaseName(addonName string) string { @@ -81,7 +84,7 @@ func GetInternalHelmReleaseName(addonName string) string { result += addonName } - return strings.TrimRight(result, "-") + postfix + return strings.TrimRight(result, "-.") + postfix } func GetInternalHelmChartName(addonName string) string { @@ -102,7 +105,7 @@ func GetInternalOCIRepositoryName(addonName string) string { result += addonName } - return strings.TrimRight(result, "-") + postfix + return strings.TrimRight(result, "-.") + postfix } // GetChartClaimLeaseName derives the name of the Lease that guards uniqueness of a @@ -131,5 +134,88 @@ func GetInternalHelmRepositoryName(addonRepositoryName string) string { result += addonRepositoryName } - return strings.TrimRight(result, "-") + postfix + return strings.TrimRight(result, "-.") + postfix +} + +// derivedPartLimit bounds the namespace and the name parts of a derived name. With +// the longest prefix in use ("hcapr-auth", 10 characters), two parts of this size, a +// 12-character hash and three dashes the result is 61 characters, under the +// 63-character limit shared by object names and label values. +const derivedPartLimit = 18 + +// DerivedName builds the name of an internal object derived from a source of the +// application family. Unlike the addon scheme above, the hash is always present and +// covers the kind, the namespace and the name of the source: internal objects of +// every family share one namespace, so two same-named sources in different +// namespaces, or in different kinds, must never derive the same internal name. The +// namespace part is omitted for a cluster-scoped source. +// +// The addon functions above keep their own scheme on purpose: their output names +// live objects, and changing it would re-create them. +func DerivedName(prefix, kind, namespace, name string) string { + hash := GetHash(kind + "/" + namespace + "/" + name) + + parts := []string{prefix} + if namespace != "" { + parts = append(parts, truncatePart(namespace)) + } + parts = append(parts, truncatePart(name), hash) + + return strings.Join(parts, "-") +} + +// truncatePart cuts a name part to derivedPartLimit and drops a dash or a dot the +// cut may have left at the end. A dash would double up when the parts are joined; +// a dot would put the separator at the start of a DNS label, which the API server +// rejects. Object names carry dots because a resource name is a DNS subdomain. +func truncatePart(part string) string { + if len(part) > derivedPartLimit { + part = part[:derivedPartLimit] + } + + return strings.TrimRight(part, "-.") +} + +// helmReleaseNameLimit is the longest release name Helm accepts. +const helmReleaseNameLimit = 53 + +// releaseReadableLimit is what is left for the readable part once the hash and its +// separator are taken out of helmReleaseNameLimit. +const releaseReadableLimit = helmReleaseNameLimit - len("-") - hashLength + +// HelmReleaseName bounds a release name to what Helm accepts. A name within the +// limit is used as is — that keeps every existing addon release untouched — and a +// longer one is cut and suffixed with a hash of the full name, so two long names +// that share a prefix stay distinct. +// +// The two branches are not injective between themselves: a short name spelled +// exactly like the cut and hashed form of a long one produces the same release, and +// two releases sharing a name in one namespace share one storage. Nothing can be +// done about that here without moving every addon release that exists, so the addon +// family carries it; a family whose releases are not yet installed anywhere should +// use HashedReleaseName instead. +func HelmReleaseName(name string) string { + if len(name) <= helmReleaseNameLimit { + return name + } + + return cutForHash(name) + "-" + GetHash(name) +} + +// HashedReleaseName bounds a release name the same way but always carries the hash, +// which is what makes it injective: every name goes through the one branch, so no +// two names can meet. See HelmReleaseName for what happens when they can. +func HashedReleaseName(name string) string { + return cutForHash(name) + "-" + GetHash(name) +} + +// cutForHash trims the readable part to the room the hash leaves it. A trailing dash +// would double up against the suffix, and a trailing dot would leave the suffix +// starting a DNS label, which is not a valid name. +func cutForHash(name string) string { + if len(name) > releaseReadableLimit { + name = name[:releaseReadableLimit] + } + + return strings.TrimRight(name, "-.") } diff --git a/images/operator-helm-controller/internal/utils/name_test.go b/images/operator-helm-controller/internal/utils/name_test.go new file mode 100644 index 00000000..36d453ed --- /dev/null +++ b/images/operator-helm-controller/internal/utils/name_test.go @@ -0,0 +1,261 @@ +/* +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 ( + "strings" + "testing" + + "k8s.io/apimachinery/pkg/util/validation" +) + +// TestAddonInternalNamesAreFrozen pins the exact output of every addon naming +// function. These names are the names of live internal objects: changing one is +// a re-creation of a HelmRepository or a HelmRelease in every cluster running the +// module, so the values below must never change. The long input is 61 characters, +// past every truncation threshold the functions use. +func TestAddonInternalNamesAreFrozen(t *testing.T) { + const long = "abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefg" + + cases := []struct { + name string + fn func(string) string + in string + want string + }{ + {"helm repository short", GetInternalHelmRepositoryName, "example", "hcar-example"}, + {"helm repository long", GetInternalHelmRepositoryName, long, "hcar-abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqr-ad1b0a084742"}, + {"auth secret short", GetInternalRepositoryAuthSecretName, "example", "hcar-auth-example"}, + {"auth secret long", GetInternalRepositoryAuthSecretName, long, "hcar-auth-abcdefghijklmnopqrstuvwxyz-abcdefghijklm-82ebac9f8734"}, + {"tls secret short", GetInternalRepositoryTLSSecretName, "example", "hcar-tls-example"}, + {"tls secret long", GetInternalRepositoryTLSSecretName, long, "hcar-tls-abcdefghijklmnopqrstuvwxyz-abcdefghijklmn-66c296d25f00"}, + {"helm release short", GetInternalHelmReleaseName, "example", "hca-example"}, + {"helm release long", GetInternalHelmReleaseName, long, "hca-abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrs-e5ded5e7c79d"}, + {"helm chart equals release", GetInternalHelmChartName, long, GetInternalHelmReleaseName(long)}, + {"oci repository equals release", GetInternalOCIRepositoryName, long, GetInternalHelmReleaseName(long)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.fn(tc.in); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestDerivedName(t *testing.T) { + cases := []struct { + name string + prefix string + kind string + namespace string + object string + want string + }{ + { + // Twin of TestApplicationRepositoryInternalName in + // tests/e2e/internal/naming/naming_test.go: a change on either side + // that is not mirrored on the other breaks one of the two tests. + name: "namespaced source carries namespace, name and a hash", + prefix: "hapr", + kind: "HelmApplicationRepository", + namespace: "team-a", + object: "stable", + want: "hapr-team-a-stable-42df68033b1e", + }, + { + // Twin of TestApplicationServiceAccountName's first case in + // tests/e2e/internal/naming/naming_test.go: a change on either side + // that is not mirrored on the other breaks one of the two tests. + name: "twin of the e2e ApplicationServiceAccountName fixture", + prefix: "hap", + kind: "HelmApplication", + namespace: "e2e-app-ns", + object: "e2e-test-app", + want: "hap-e2e-app-ns-e2e-test-app-26155b312741", + }, + { + name: "same name in another namespace is a different object", + prefix: "hapr", + kind: "HelmApplicationRepository", + namespace: "team-b", + object: "stable", + want: "hapr-team-b-stable-20b1eaa1620a", + }, + { + name: "cluster source has no namespace part", + prefix: "hcapr", + kind: "HelmClusterApplicationRepository", + namespace: "", + object: "stable", + want: "hcapr-stable-63c0be2c8873", + }, + { + name: "long parts are truncated to 18 characters each", + prefix: "hapr", + kind: "HelmApplicationRepository", + namespace: "very-long-namespace-name-exceeding", + object: "very-long-repository-name-exceeding", + want: "hapr-very-long-namespac-very-long-reposito-355aadb4a138", + }, + { + name: "a truncation that ends in a dash drops it", + prefix: "hapr", + kind: "HelmApplicationRepository", + namespace: "abcdefghijklmnopq-x", + object: "stable", + want: "hapr-abcdefghijklmnopq-stable-1846a7b21e01", + }, + { + // A resource name is a DNS subdomain, so it may carry dots. Keeping + // one at the cut would put the joining dash at the start of a label + // and the API server would reject every object named this way. + name: "a truncation that ends in a dot drops it", + prefix: "hap", + kind: "HelmApplication", + namespace: "team-a", + object: "abcdefghijklmnopq.x", + want: "hap-team-a-abcdefghijklmnopq-da2ee07a8439", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := DerivedName(tc.prefix, tc.kind, tc.namespace, tc.object) + if got != tc.want { + t.Fatalf("DerivedName(%q, %q, %q, %q) = %q, want %q", tc.prefix, tc.kind, tc.namespace, tc.object, got, tc.want) + } + if len(got) > 63 { + t.Fatalf("%q is %d characters, the limit is 63", got, len(got)) + } + if errs := validation.IsDNS1123Subdomain(got); len(errs) > 0 { + t.Fatalf("%q is not a valid object name: %v", got, errs) + } + }) + } +} + +// TestDerivedNameStaysWithinTheLabelLimitForTheLongestPrefix guards the arithmetic +// behind the 18-character part limit: the longest prefix any adapter uses is +// "hcapr-auth" (10 characters), and prefix + namespace + name + hash + three +// dashes must fit into 63. +func TestDerivedNameStaysWithinTheLabelLimitForTheLongestPrefix(t *testing.T) { + got := DerivedName("hcapr-auth", "Kind", strings.Repeat("n", 40), strings.Repeat("m", 40)) + if len(got) > 63 { + t.Fatalf("%q is %d characters, the limit is 63", got, len(got)) + } +} + +// TestHashedReleaseNameSeparatesTwoValidNames pins the reason the application family +// does not share HelmReleaseName. That function hashes only what exceeds the limit, +// so a short name can be spelled exactly like the cut and hashed form of a long one; +// two releases under one name in one namespace then share one storage and overwrite +// each other's history. Carrying the hash unconditionally removes the second branch +// the two names met in. +func TestHashedReleaseNameSeparatesTwoValidNames(t *testing.T) { + const ( + long = "hap-abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefg" + short = "hap-abcdefghijklmnopqrstuvwxyz-abcdefghi-dbe791f54fec" + ) + + if HelmReleaseName(long) != HelmReleaseName(short) { + t.Fatal("the fixture no longer demonstrates the collision it was chosen for") + } + + if got, other := HashedReleaseName(long), HashedReleaseName(short); got == other { + t.Fatalf("both names produce %q", got) + } + + for _, name := range []string{long, short, "hap-a"} { + got := HashedReleaseName(name) + if len(got) > helmReleaseNameLimit { + t.Fatalf("%q is %d characters, Helm accepts at most %d", got, len(got), helmReleaseNameLimit) + } + if errs := validation.IsDNS1123Subdomain(got); len(errs) > 0 { + t.Fatalf("%q is not a valid release name: %v", got, errs) + } + } + + // Twin of TestApplicationReleaseName in tests/e2e/internal/naming: a change on + // either side that is not mirrored on the other breaks one of the two tests. + if got := HashedReleaseName("hap-e2e-test-app-helm"); got != "hap-e2e-test-app-helm-eaa08759b576" { + t.Fatalf("HashedReleaseName = %q", got) + } +} + +// TestHelmReleaseName pins the release-name rule: Helm rejects names longer than 53 +// characters. A name within the limit passes through untouched — every addon that +// exists today keeps its release — and a longer one is cut and suffixed with a hash +// of the full name so two long names sharing a prefix stay distinct. +func TestHelmReleaseName(t *testing.T) { + const long = "abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefg" + + cases := []struct { + name string + in string + want string + }{ + {"short name is used as is", "podinfo", "podinfo"}, + {"a name of exactly 53 characters is used as is", strings.Repeat("a", 53), strings.Repeat("a", 53)}, + {"a longer name is cut to 40 characters and hashed", long, "abcdefghijklmnopqrstuvwxyz-abcdefghijklm-ddc3f43e8c75"}, + { + // Twin of the "a long name is cut to 40 characters and hashed" case in + // TestApplicationReleaseName + // (tests/e2e/internal/naming/naming_test.go): a change on either side + // that is not mirrored on the other breaks one of the two tests. + "hap-prefixed name over the limit is cut and hashed", + "hap-very-long-application-name-that-is-definitely-over-fifty-three-characters-long", + "hap-very-long-application-name-that-is-d-3080981cd4e1", + }, + { + // The hash is over the whole name, so two names that survive the cut + // identically still get different releases. Without it the second + // application would take over the first one's release. + "a long name is distinguished by the hash, not by the cut", + strings.Repeat("c", 50) + "-one", + strings.Repeat("c", 40) + "-0511f1bf7dbf", + }, + { + "a name sharing the first 40 characters gets a different release", + strings.Repeat("c", 50) + "-two", + strings.Repeat("c", 40) + "-bbd09a562ff3", + }, + { + // A resource name may carry dots, and a cut landing on one would + // leave the hash suffix starting a DNS label. + "a cut that lands on a dot drops it", + strings.Repeat("a", 39) + "." + strings.Repeat("b", 20), + strings.Repeat("a", 39) + "-b46d196cb11f", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := HelmReleaseName(tc.in) + if got != tc.want { + t.Fatalf("HelmReleaseName(%q) = %q, want %q", tc.in, got, tc.want) + } + if len(got) > 53 { + t.Fatalf("%q is %d characters, Helm accepts at most 53", got, len(got)) + } + if errs := validation.IsDNS1123Subdomain(got); len(errs) > 0 { + t.Fatalf("%q is not a valid release name: %v", got, errs) + } + }) + } +} diff --git a/images/operator-helm-controller/internal/utils/namespace.go b/images/operator-helm-controller/internal/utils/namespace.go index 35ca8024..58742fd4 100644 --- a/images/operator-helm-controller/internal/utils/namespace.go +++ b/images/operator-helm-controller/internal/utils/namespace.go @@ -20,18 +20,10 @@ import ( "strings" ) +// IsSystemNamespace reports whether a namespace belongs to the cluster or to +// Deckhouse rather than to a user: every kube- namespace and every d8- one. The +// default namespace is not among them — it is where a user without a namespace of +// their own works, which is exactly who this family is for. func IsSystemNamespace(namespace string) bool { - systemNamespaces := []string{"kube-system", "kube-node-lease", "kube-public"} - - for _, s := range systemNamespaces { - if namespace == s { - return true - } - } - - if strings.HasPrefix(namespace, "d8-") { - return true - } - - return false + return strings.HasPrefix(namespace, "kube-") || strings.HasPrefix(namespace, "d8-") } diff --git a/images/operator-helm-controller/internal/utils/namespace_test.go b/images/operator-helm-controller/internal/utils/namespace_test.go new file mode 100644 index 00000000..a27e4e14 --- /dev/null +++ b/images/operator-helm-controller/internal/utils/namespace_test.go @@ -0,0 +1,50 @@ +/* +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 "testing" + +// TestIsSystemNamespace pins where an application may not be installed. The rule +// matters more than it did: until this family existed, only a cluster administrator +// could create anything namespaced here, and installing an application seeds a Role +// granting everything inside its namespace. +func TestIsSystemNamespace(t *testing.T) { + cases := []struct { + namespace string + want bool + }{ + {"kube-system", true}, + {"kube-node-lease", true}, + {"kube-public", true}, + {"kube-anything", true}, + {"default", false}, + {"d8-operator-helm", true}, + {"d8-system", true}, + {"team-a", false}, + {"kubernetes-dashboard", false}, + {"defaults", false}, + {"", false}, + } + + for _, tc := range cases { + t.Run(tc.namespace, func(t *testing.T) { + if got := IsSystemNamespace(tc.namespace); got != tc.want { + t.Fatalf("IsSystemNamespace(%q) = %v, want %v", tc.namespace, got, tc.want) + } + }) + } +} diff --git a/images/operator-helm-controller/internal/utils/repository.go b/images/operator-helm-controller/internal/utils/repository.go index 009a01a5..1399ffd9 100644 --- a/images/operator-helm-controller/internal/utils/repository.go +++ b/images/operator-helm-controller/internal/utils/repository.go @@ -1,19 +1,3 @@ -/* -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 ( @@ -23,81 +7,11 @@ import ( "net/url" "strings" - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/google/go-containerregistry/pkg/name" -) - -type InternalRepositoryType string -const ( - InternalHelmRepository InternalRepositoryType = "helm" - InternalOCIRepository InternalRepositoryType = "oci" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) -// ChartSource is where one chart version is actually fetched from. It is not the -// same thing as the repository type: the repository type follows the scheme of -// spec.url and decides the catalog client, the shape of the auth secret and whether -// an internal HelmRepository exists at all, while ChartSource decides which internal -// source object one addon needs for the version it asks for. The two differ exactly -// when a helm repository's index points a version at a registry. -type ChartSource struct { - Kind InternalRepositoryType - // URL is the artifact address with the oci:// scheme and without the tag. It is - // empty for Kind == InternalHelmRepository. - URL string - // Tag is the artifact tag. It is empty for Kind == InternalHelmRepository. - Tag string -} - -// ResolveChartSource decides where one chart version comes from. A recorded OCI -// reference wins over the repository scheme: that is the hybrid case this exists for. -func ResolveChartSource( - repo *helmv1alpha1.HelmClusterAddonRepository, - version *helmv1alpha1.HelmClusterAddonChartVersion, -) (ChartSource, error) { - if version.OCIRef != "" { - // The recorded reference always carries a tag, so there is no fallback to - // offer here; a reference that cannot be split was never recorded by the - // catalog and can only come from data written by hand or by an older version. - url, tag, err := SplitOCIRef(version.OCIRef, "") - if err != nil { - return ChartSource{}, fmt.Errorf("resolving the source of version %q: %w", version.Version, err) - } - - return ChartSource{Kind: InternalOCIRepository, URL: url, Tag: tag}, nil - } - - repoType, err := GetRepositoryType(repo.Spec.URL) - if err != nil { - return ChartSource{}, fmt.Errorf("resolving the source of version %q: %w", version.Version, err) - } - - if repoType == InternalOCIRepository { - return ChartSource{Kind: InternalOCIRepository, URL: repo.Spec.URL, Tag: version.Version}, nil - } - - return ChartSource{Kind: InternalHelmRepository}, nil -} - -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) - } -} - -// GetRegistryHost extracts the registry host (with port, if any) from a repository -// URL. Registry credentials in the docker config format are keyed by host, the -// chart path inside the registry is not part of the key. func GetRegistryHost(s string) (string, error) { parsedURL, err := url.Parse(s) if err != nil { diff --git a/images/operator-helm-controller/internal/utils/repository_test.go b/images/operator-helm-controller/internal/utils/repository_test.go index d7179c53..4249e244 100644 --- a/images/operator-helm-controller/internal/utils/repository_test.go +++ b/images/operator-helm-controller/internal/utils/repository_test.go @@ -20,8 +20,6 @@ import ( "encoding/base64" "encoding/json" "testing" - - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) func TestGetRegistryHost(t *testing.T) { @@ -198,85 +196,3 @@ func TestSplitOCIRef(t *testing.T) { }) } } - -func TestResolveChartSource(t *testing.T) { - helmRepo := &helmv1alpha1.HelmClusterAddonRepository{ - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://charts.example.com/stable"}, - } - ociRepo := &helmv1alpha1.HelmClusterAddonRepository{ - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "oci://registry.example.com/charts/podinfo"}, - } - - tests := []struct { - name string - repo *helmv1alpha1.HelmClusterAddonRepository - version helmv1alpha1.HelmClusterAddonChartVersion - want ChartSource - wantErr bool - }{ - { - // The whole point of the feature: the index entry decides, not the - // repository scheme. - name: "index entry pointing at a registry wins over the repository scheme", - repo: helmRepo, - version: helmv1alpha1.HelmClusterAddonChartVersion{ - Version: "25.0.2", - OCIRef: "oci://registry-1.docker.io/bitnamicharts/airflow:25.0.2", - }, - want: ChartSource{ - Kind: InternalOCIRepository, - URL: "oci://registry-1.docker.io/bitnamicharts/airflow", - Tag: "25.0.2", - }, - }, - { - name: "helm repository without an oci reference stays on the helm path", - repo: helmRepo, - version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1"}, - want: ChartSource{Kind: InternalHelmRepository}, - }, - { - name: "oci repository addresses its own url at the version tag", - repo: ociRepo, - version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, - want: ChartSource{ - Kind: InternalOCIRepository, - URL: "oci://registry.example.com/charts/podinfo", - Tag: "6.7.1", - }, - }, - { - name: "unparsable recorded reference is an error", - repo: helmRepo, - version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "1.0.0", OCIRef: "oci://BAD_HOST//:::"}, - wantErr: true, - }, - { - name: "unsupported repository scheme is an error", - repo: &helmv1alpha1.HelmClusterAddonRepository{ - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "ftp://charts.example.com"}, - }, - version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "1.0.0"}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ResolveChartSource(tt.repo, &tt.version) - if tt.wantErr { - if err == nil { - t.Fatalf("expected an error, got %+v", got) - } - - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tt.want { - t.Fatalf("source = %+v, want %+v", got, tt.want) - } - }) - } -} diff --git a/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go b/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go new file mode 100644 index 00000000..289f1da6 --- /dev/null +++ b/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go @@ -0,0 +1,100 @@ +/* +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 helmapplication validates HelmApplication objects. Unlike the addon +// webhook it enforces no uniqueness: any number of applications may install the +// same chart. It rejects an application in a system namespace — the release +// deploys into the application's own namespace, so that is the namespace to +// check — and refuses to delete an application in maintenance mode, unless that +// namespace is itself already terminating. +package helmapplication + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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" + "github.com/deckhouse/operator-helm/internal/utils" +) + +func SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, &helmv1alpha1.HelmApplication{}). + WithValidator(&HelmApplicationWebhookValidator{Reader: mgr.GetAPIReader()}). + Complete() +} + +var _ admission.Validator[*helmv1alpha1.HelmApplication] = (*HelmApplicationWebhookValidator)(nil) + +type HelmApplicationWebhookValidator struct { + // Reader reads from the API server directly (mgr.GetAPIReader()), bypassing the + // controller cache: nothing watches Namespaces, so a cached typed Get would start + // an informer the ClusterRole has no watch permission for, and this decision — is + // the namespace terminating — must not be made against stale data anyway. + Reader client.Reader +} + +func (v *HelmApplicationWebhookValidator) ValidateCreate(_ context.Context, app *helmv1alpha1.HelmApplication) (admission.Warnings, error) { + return nil, validateNotSystemNamespace(app) +} + +func (v *HelmApplicationWebhookValidator) ValidateUpdate(_ context.Context, _, newObj *helmv1alpha1.HelmApplication) (admission.Warnings, error) { + return nil, validateNotSystemNamespace(newObj) +} + +func (v *HelmApplicationWebhookValidator) ValidateDelete(ctx context.Context, app *helmv1alpha1.HelmApplication) (admission.Warnings, error) { + if !app.MaintenanceModeActivated() { + return nil, nil + } + + // Namespace deletion deletes the namespace's objects one by one and waits for + // each of them; a DELETE webhook that denies would make that wait never end, + // leaving the namespace Terminating forever with no way out other than editing + // the application. Maintenance mode protects an application from being deleted + // by mistake, not from its namespace going away, so the refusal is dropped once + // the namespace is on its way out. + if v.namespaceTerminating(ctx, app.Namespace) { + return nil, nil + } + + return nil, fmt.Errorf("helmapplication/%s cannot be deleted while maintenance mode is active", app.Name) +} + +// namespaceTerminating reports whether the application's own namespace is already +// gone or being deleted. A read error other than NotFound is deliberately treated +// as "not terminating": the refusal is the safe answer, and a transient API error +// must not turn into a way past it. +func (v *HelmApplicationWebhookValidator) namespaceTerminating(ctx context.Context, name string) bool { + namespace := &corev1.Namespace{} + if err := v.Reader.Get(ctx, client.ObjectKey{Name: name}, namespace); err != nil { + return apierrors.IsNotFound(err) + } + + return !namespace.DeletionTimestamp.IsZero() +} + +func validateNotSystemNamespace(app *helmv1alpha1.HelmApplication) error { + if utils.IsSystemNamespace(app.Namespace) { + return fmt.Errorf("helmapplication/%s may not live in system namespace %s", app.Name, app.Namespace) + } + + return nil +} diff --git a/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go b/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go new file mode 100644 index 00000000..810a3bc1 --- /dev/null +++ b/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go @@ -0,0 +1,192 @@ +/* +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 helmapplication + +import ( + "context" + "errors" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +func maintainedApplication() *helmv1alpha1.HelmApplication { + return &helmv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "team-a"}, + Spec: helmv1alpha1.HelmApplicationSpec{Maintenance: string(helmv1alpha1.NoResourceReconciliation)}, + } +} + +func newValidator(t *testing.T, interceptors interceptor.Funcs, objects ...client.Object) *HelmApplicationWebhookValidator { + t.Helper() + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("registering client-go scheme: %v", err) + } + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithInterceptorFuncs(interceptors). + Build() + + return &HelmApplicationWebhookValidator{Reader: c} +} + +func namespaceFixture(name string, terminating bool) *corev1.Namespace { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}} + if terminating { + now := metav1.Now() + ns.DeletionTimestamp = &now + // The fake client refuses an object carrying a deletion timestamp with no + // finalizer holding it; a real terminating namespace always has one. + ns.Finalizers = []string{"kubernetes"} + } + + return ns +} + +// systemNamespaceCases pins validateNotSystemNamespace's boundary: a deckhouse +// namespace and kube-system are refused, an ordinary namespace is allowed. +func systemNamespaceCases() []struct { + name string + namespace string + wantErr bool +} { + return []struct { + name string + namespace string + wantErr bool + }{ + {name: "deckhouse namespace", namespace: "d8-monitoring", wantErr: true}, + {name: "kube-system", namespace: "kube-system", wantErr: true}, + {name: "ordinary namespace", namespace: "team-a", wantErr: false}, + } +} + +// TestValidateCreateRejectsASystemNamespace pins the fast-reject CREATE performs +// on the obvious duplicate/misplacement case; the reconciler enforces the same +// rule as a backstop (internal/reconcile/release/reconciler.go). +func TestValidateCreateRejectsASystemNamespace(t *testing.T) { + for _, tt := range systemNamespaceCases() { + t.Run(tt.name, func(t *testing.T) { + app := &helmv1alpha1.HelmApplication{ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: tt.namespace}} + v := newValidator(t, interceptor.Funcs{}) + + _, err := v.ValidateCreate(context.Background(), app) + if tt.wantErr && err == nil { + t.Fatalf("namespace %q must be rejected", tt.namespace) + } + if !tt.wantErr && err != nil { + t.Fatalf("namespace %q must be allowed, got %v", tt.namespace, err) + } + }) + } +} + +// TestValidateUpdateRejectsASystemNamespace mirrors TestValidateCreateRejectsASystemNamespace +// for UPDATE, which validates the new object's namespace. +func TestValidateUpdateRejectsASystemNamespace(t *testing.T) { + for _, tt := range systemNamespaceCases() { + t.Run(tt.name, func(t *testing.T) { + app := &helmv1alpha1.HelmApplication{ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: tt.namespace}} + v := newValidator(t, interceptor.Funcs{}) + + _, err := v.ValidateUpdate(context.Background(), app, app) + if tt.wantErr && err == nil { + t.Fatalf("namespace %q must be rejected", tt.namespace) + } + if !tt.wantErr && err != nil { + t.Fatalf("namespace %q must be allowed, got %v", tt.namespace, err) + } + }) + } +} + +// TestValidateDeleteRefusesAMaintainedApplication is the rule itself: maintenance +// mode is what keeps an application from being deleted by mistake. +func TestValidateDeleteRefusesAMaintainedApplication(t *testing.T) { + app := maintainedApplication() + v := newValidator(t, interceptor.Funcs{}, namespaceFixture("team-a", false)) + + if _, err := v.ValidateDelete(context.Background(), app); err == nil { + t.Fatal("an application in maintenance must not be deletable") + } +} + +// TestValidateDeleteAllowsAnApplicationWhoseNamespaceIsGoing pins the exception. +// Namespace deletion deletes the namespace's objects one by one and waits for each +// of them, so a DELETE this webhook denies would leave the namespace Terminating +// with no way out. +func TestValidateDeleteAllowsAnApplicationWhoseNamespaceIsGoing(t *testing.T) { + tests := []struct { + name string + objects []client.Object + }{ + {name: "namespace is terminating", objects: []client.Object{namespaceFixture("team-a", true)}}, + {name: "namespace is already gone", objects: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := newValidator(t, interceptor.Funcs{}, tt.objects...) + + if _, err := v.ValidateDelete(context.Background(), maintainedApplication()); err != nil { + t.Fatalf("the delete must be allowed, got %v", err) + } + }) + } +} + +// TestValidateDeleteFailsClosedOnANamespaceReadError pins the direction of the +// doubt: an API error is not evidence that the namespace is going away, so the +// refusal stands rather than becoming a way past it. +func TestValidateDeleteFailsClosedOnANamespaceReadError(t *testing.T) { + unreadable := interceptor.Funcs{ + Get: func(_ context.Context, _ client.WithWatch, _ client.ObjectKey, _ client.Object, _ ...client.GetOption) error { + return errors.New("apiserver is unavailable") + }, + } + v := newValidator(t, unreadable) + + if _, err := v.ValidateDelete(context.Background(), maintainedApplication()); err == nil { + t.Fatal("the refusal must stand when the namespace cannot be read") + } +} + +// TestValidateDeleteAllowsAnUnmaintainedApplication is the ordinary case: nothing +// about the namespace is even read. +func TestValidateDeleteAllowsAnUnmaintainedApplication(t *testing.T) { + app := &helmv1alpha1.HelmApplication{ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "team-a"}} + v := newValidator(t, interceptor.Funcs{}) + + if _, err := v.ValidateDelete(context.Background(), app); err != nil { + t.Fatalf("an application not in maintenance must be deletable, got %v", err) + } +} diff --git a/images/operator-helm-controller/internal/webhook/helmclusteraddon/webhook.go b/images/operator-helm-controller/internal/webhook/helmclusteraddon/webhook.go index 4bb12afd..d399c580 100644 --- a/images/operator-helm-controller/internal/webhook/helmclusteraddon/webhook.go +++ b/images/operator-helm-controller/internal/webhook/helmclusteraddon/webhook.go @@ -26,6 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" "github.com/deckhouse/operator-helm/internal/index" "github.com/deckhouse/operator-helm/internal/services" "github.com/deckhouse/operator-helm/internal/utils" @@ -114,7 +115,7 @@ func isUniquenessBypassed(ctx context.Context) bool { } func (v *HelmClusterAddonWebhookValidator) checkUniqueness(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) error { - owned, err := v.claimService.OwnedBy(ctx, addon) + owned, err := v.claimService.OwnedBy(ctx, adapter.NewAddonRelease(addon)) if err != nil { return fmt.Errorf("failed to check if helmclusteraddon/%s owns chart claim: %w", addon.Name, err) } diff --git a/images/nelm-source-controller/werf.inc.yaml b/images/source-controller/werf.inc.yaml similarity index 74% rename from images/nelm-source-controller/werf.inc.yaml rename to images/source-controller/werf.inc.yaml index 573e684f..db14967f 100644 --- a/images/nelm-source-controller/werf.inc.yaml +++ b/images/source-controller/werf.inc.yaml @@ -1,4 +1,4 @@ -{{- $nelmSourceControllerTag := "v0.1.5" }} +{{- $sourceControllerTag := "v1.9.5" }} --- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact final: false @@ -8,8 +8,8 @@ secrets: value: {{ .SOURCE_REPO }} shell: install: - - git clone --branch {{ $nelmSourceControllerTag }} --single-branch $(cat /run/secrets/SOURCE_REPO)/werf/nelm-source-controller.git /src/nelm-source-controller - - rm -rf /src/nelm-source-controller/.git + - git clone --branch {{ $sourceControllerTag }} --single-branch $(cat /run/secrets/SOURCE_REPO)/fluxcd/source-controller.git /src/source-controller + - rm -rf /src/source-controller/.git --- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact final: false @@ -26,7 +26,7 @@ import: before: install shell: install: - - cd /src/nelm-source-controller + - cd /src/source-controller - | export GOOS=linux export GOARCH=amd64 @@ -38,7 +38,7 @@ image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: base/distroless import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact - add: /src/nelm-source-controller/source-controller + add: /src/source-controller/source-controller to: /usr/bin/source-controller before: install imageSpec: diff --git a/oss.yaml b/oss.yaml index 23be831a..e1ad4d83 100644 --- a/oss.yaml +++ b/oss.yaml @@ -1,12 +1,12 @@ -- name: 3p-helm-controller - link: https://github.com/werf/3p-helm-controller +- name: helm-controller + link: https://github.com/fluxcd/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.5 - id: 3p-helm-controller -- name: nelm-source-controller - link: https://github.com/werf/nelm-source-controller + version: v1.6.4 + id: helm-controller +- name: source-controller + link: https://github.com/fluxcd/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.5 - id: nelm-source-controller + version: v1.9.5 + id: source-controller diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index 38cd5d20..0405d998 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -17,3 +17,15 @@ system-cluster-critical {{- end }} {{- $updateMode }} {{- end }} + +{{- define "operator-helm.enable_rbacv2" -}} + {{- $raw := (.Values.global).deckhouseVersion | default "dev" | toString -}} + {{- $mm := regexFind "^v?[0-9]+[.][0-9]+" $raw -}} + {{- if $mm -}} + {{- semverCompare ">= 1.78" (printf "%s.0" $mm) -}} + {{- else -}} + {{- /* "dev" or "unknown": a build off any branch says the same, so answer with the model + whose mistake only loses access. A dev stand below 1.78 flips this to false. */ -}} + true + {{- end -}} +{{- end -}} diff --git a/templates/admision-policy.yaml b/templates/admision-policy.yaml index 5b7b86a0..8947d774 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -35,14 +35,32 @@ spec: - "UPDATE" - "DELETE" resources: + # The status subresource is listed explicitly: the Role seeded in an + # application namespace grants resources: ["*"], which matches + # subresources too, so without these entries an application's service + # account could patch its namespace's catalog status. - "helmclusteraddoncharts" + - "helmclusteraddoncharts/status" + - "helmapplicationcharts" + - "helmapplicationcharts/status" + - "helmclusterapplicationcharts" + - "helmclusterapplicationcharts/status" + # Same reasoning for the kinds whose spec belongs to a user: it stays + # open, only the status the controller owns is closed. The cluster-scoped + # repositories are listed for symmetry — no role this module seeds reaches + # them, so nothing today depends on it, and an entry missing from a list + # like this reads as an oversight rather than a decision. + - "helmapplications/status" + - "helmapplicationrepositories/status" + - "helmclusterapplicationrepositories/status" + - "helmclusteraddonrepositories/status" 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:source-controller", "system:serviceaccount:d8-operator-helm:helm-controller", "system:serviceaccount:d8-operator-helm:chart-values-controller", ] diff --git a/templates/chart-values-controller/deployment.yaml b/templates/chart-values-controller/deployment.yaml index 00361d54..596a11df 100644 --- a/templates/chart-values-controller/deployment.yaml +++ b/templates/chart-values-controller/deployment.yaml @@ -5,7 +5,7 @@ cpu: 50m memory: 64Mi {{- end }} -{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} +{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler") }} --- apiVersion: autoscaling.k8s.io/v1 kind: VerticalPodAutoscaler @@ -102,7 +102,7 @@ spec: resources: requests: {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 14 }} - {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler") }} {{- include "operator_helm_chart_values_controller_resources" . | nindent 14 }} {{- end }} env: diff --git a/templates/chart-values-controller/rbac-for-us.yaml b/templates/chart-values-controller/rbac-for-us.yaml index 06f859b8..5f2b8b55 100644 --- a/templates/chart-values-controller/rbac-for-us.yaml +++ b/templates/chart-values-controller/rbac-for-us.yaml @@ -62,6 +62,14 @@ rules: - helmclusteraddonrepositories/status - helmclusteraddoncharts - helmclusteraddoncharts/status + - helmapplicationrepositories + - helmapplicationrepositories/status + - helmclusterapplicationrepositories + - helmclusterapplicationrepositories/status + - helmapplicationcharts + - helmapplicationcharts/status + - helmclusterapplicationcharts + - helmclusterapplicationcharts/status verbs: - get - list diff --git a/templates/helm-controller/deployment.yaml b/templates/helm-controller/deployment.yaml index 1a422ed8..e986e1a8 100644 --- a/templates/helm-controller/deployment.yaml +++ b/templates/helm-controller/deployment.yaml @@ -5,7 +5,7 @@ cpu: 100m memory: 64Mi {{- end }} -{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} +{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler") }} --- apiVersion: autoscaling.k8s.io/v1 kind: VerticalPodAutoscaler @@ -95,7 +95,7 @@ spec: resources: requests: {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 14 }} - {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler") }} {{- include "helm_controller_resources" . | nindent 14 }} {{- end }} env: diff --git a/templates/kube-api-rewriter/_sidecar_helpers.tpl b/templates/kube-api-rewriter/_sidecar_helpers.tpl index 0dda0bf0..e22b90cb 100644 --- a/templates/kube-api-rewriter/_sidecar_helpers.tpl +++ b/templates/kube-api-rewriter/_sidecar_helpers.tpl @@ -160,7 +160,7 @@ spec: resources: requests: {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 6 }} - {{- if not ( $ctx.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- if not ( $ctx.Values.global.enabledModules | has "vertical-pod-autoscaler") }} {{- include "kube_api_rewriter.resources" . | nindent 6 }} {{- end }} securityContext: diff --git a/templates/operator-helm-controller/deployment.yaml b/templates/operator-helm-controller/deployment.yaml index 41a92b93..08cb7c8a 100644 --- a/templates/operator-helm-controller/deployment.yaml +++ b/templates/operator-helm-controller/deployment.yaml @@ -5,7 +5,7 @@ cpu: 50m memory: 64Mi {{- end }} -{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} +{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler") }} --- apiVersion: autoscaling.k8s.io/v1 kind: VerticalPodAutoscaler @@ -100,7 +100,7 @@ spec: resources: requests: {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 14 }} - {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler") }} {{- include "operator_helm_controller_resources" . | nindent 14 }} {{- end }} env: diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index 1b8db98d..01b30f13 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -79,6 +79,63 @@ rules: - helmclusteraddoncharts/status - helmclusteraddonrepositories - helmclusteraddonrepositories/status + - helmapplicationrepositories + - helmapplicationrepositories/status + - helmclusterapplicationrepositories + - helmclusterapplicationrepositories/status + - helmapplicationcharts + - helmapplicationcharts/status + - helmclusterapplicationcharts + - helmclusterapplicationcharts/status + - helmapplicationrepositories/finalizers + - helmclusterapplicationrepositories/finalizers + - helmapplicationcharts/finalizers + - helmclusterapplicationcharts/finalizers + - helmapplications + - helmapplications/status + - helmapplications/finalizers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +# The namespace role is reconciled on every pass and watched for drift, so the +# controller reads and rewrites it besides creating it. Only the verbs that carry +# no name are granted for every role: create is authorized against the name in the +# request path, which it does not carry (RoleEscalationAuthorized passes +# requestInfo.Name, empty here — see +# k8s.io/kubernetes/pkg/registry/rbac/escalation_check.go), escalate follows it for +# that reason, and list and watch — the informer behind the drift watch — name +# nothing either. escalate is what lets the controller write a role granting more +# than it holds itself, on the create and on every patch after it. get and patch do +# carry a name, and so does bind, authorized against the name in the binding's +# roleRef: all three are scoped to the single role this module ever writes. +- apiGroups: + - rbac.authorization.k8s.io + resources: + - roles + verbs: + - create + - escalate + - list + - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - roles + resourceNames: + - operator-helm-application + verbs: + - bind + - get + - patch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings verbs: - create - delete @@ -172,6 +229,7 @@ rules: resources: - configmaps - secrets + - serviceaccounts verbs: - get - list diff --git a/templates/operator-helm-controller/validation-webhook.yaml b/templates/operator-helm-controller/validation-webhook.yaml index 460f37f8..346bfc3c 100644 --- a/templates/operator-helm-controller/validation-webhook.yaml +++ b/templates/operator-helm-controller/validation-webhook.yaml @@ -24,3 +24,20 @@ webhooks: {{ .Values.operatorHelm.internal.controller.cert.ca | b64enc }} admissionReviewVersions: ["v1"] sideEffects: None + - name: "helmapplications.operator-helm-controller.validate.d8-operator-helm" + rules: + - apiGroups: ["helm.deckhouse.io"] + apiVersions: ["v1alpha1"] + operations: ["CREATE", "UPDATE", "DELETE"] + resources: ["helmapplications"] + scope: "Namespaced" + clientConfig: + service: + namespace: d8-{{ .Chart.Name }} + name: operator-helm-controller + path: /validate-helm-deckhouse-io-v1alpha1-helmapplication + 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 index ead1ca56..fc15dd25 100644 --- a/templates/rbac-to-us.yaml +++ b/templates/rbac-to-us.yaml @@ -9,7 +9,7 @@ metadata: rules: - apiGroups: ["apps"] resources: ["deployments/prometheus-metrics"] - resourceNames: ["operator-helm-controller", "chart-values-controller", "helm-controller", "nelm-source-controller"] + resourceNames: ["operator-helm-controller", "chart-values-controller", "helm-controller", "source-controller"] verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/templates/rbacv2/manage/edit.yaml b/templates/rbacv2/manage/edit.yaml new file mode 100644 index 00000000..2056bbb6 --- /dev/null +++ b/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,36 @@ +{{- if eq (include "operator-helm.enable_rbacv2" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:system-capability:{{ .Chart.Name }}:edit + annotations: + en.meta.deckhouse.io/title: "Module {{ .Chart.Name }}: edit configuration" + ru.meta.deckhouse.io/title: "Модуль {{ .Chart.Name }}: управление конфигурацией" + en.meta.deckhouse.io/description: "Manage the {{ .Chart.Name }} module configuration." + ru.meta.deckhouse.io/description: "Управление конфигурацией модуля {{ .Chart.Name }}." + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "capability" "rbac.deckhouse.io/capability" (printf "system-capability.%s.edit" .Chart.Name) "rbac.deckhouse.io/scope" "system" "rbac.deckhouse.io/namespace" (printf "d8-%s" .Chart.Name) "rbac.deckhouse.io/aggregate-to-delivery-as" "manager")) | nindent 2 }} +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddonrepositories + - helmclusteraddons + - helmclusterapplicationrepositories + verbs: + - create + - delete + - deletecollection + - patch + - update +- apiGroups: + - deckhouse.io + resourceNames: + - {{ .Chart.Name }} + resources: + - moduleconfigs + verbs: + - create + - delete + - patch + - update +{{- end }} diff --git a/templates/rbacv2/manage/view.yaml b/templates/rbacv2/manage/view.yaml new file mode 100644 index 00000000..ae581ce2 --- /dev/null +++ b/templates/rbacv2/manage/view.yaml @@ -0,0 +1,35 @@ +{{- if eq (include "operator-helm.enable_rbacv2" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:system-capability:{{ .Chart.Name }}:view + annotations: + en.meta.deckhouse.io/title: "Module {{ .Chart.Name }}: view configuration" + ru.meta.deckhouse.io/title: "Модуль {{ .Chart.Name }}: просмотр конфигурации" + en.meta.deckhouse.io/description: "Read-only access to the {{ .Chart.Name }} module configuration." + ru.meta.deckhouse.io/description: "Доступ только на чтение к конфигурации модуля {{ .Chart.Name }}." + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "capability" "rbac.deckhouse.io/capability" (printf "system-capability.%s.view" .Chart.Name) "rbac.deckhouse.io/scope" "system" "rbac.deckhouse.io/namespace" (printf "d8-%s" .Chart.Name) "rbac.deckhouse.io/aggregate-to-delivery-as" "viewer")) | nindent 2 }} +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddoncharts + - helmclusteraddonrepositories + - helmclusteraddons + - helmclusterapplicationcharts + - helmclusterapplicationrepositories + verbs: + - get + - list + - watch +- apiGroups: + - deckhouse.io + resourceNames: + - {{ .Chart.Name }} + resources: + - moduleconfigs + verbs: + - get + - list + - watch +{{- end }} diff --git a/templates/rbacv2/use/admin.yaml b/templates/rbacv2/use/admin.yaml new file mode 100644 index 00000000..b14f029f --- /dev/null +++ b/templates/rbacv2/use/admin.yaml @@ -0,0 +1,24 @@ +{{- if eq (include "operator-helm.enable_rbacv2" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace-capability:{{ .Chart.Name }}:admin + annotations: + en.meta.deckhouse.io/title: "Module {{ .Chart.Name }}: admin" + ru.meta.deckhouse.io/title: "Модуль {{ .Chart.Name }}: администрирование" + en.meta.deckhouse.io/description: "Manage {{ .Chart.Name }} applications and repositories in a namespace." + ru.meta.deckhouse.io/description: "Управление приложениями и репозиториями модуля {{ .Chart.Name }} в пространстве имён." + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "capability" "rbac.deckhouse.io/capability" (printf "namespace-capability.%s.admin" .Chart.Name) "rbac.deckhouse.io/scope" "namespace" "rbac.deckhouse.io/aggregate-to-namespace-as" "admin")) | nindent 2 }} +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplicationrepositories + - helmapplications + verbs: + - create + - delete + - deletecollection + - patch + - update +{{- end }} diff --git a/templates/rbacv2/use/user.yaml b/templates/rbacv2/use/user.yaml new file mode 100644 index 00000000..0fc193d5 --- /dev/null +++ b/templates/rbacv2/use/user.yaml @@ -0,0 +1,23 @@ +{{- if eq (include "operator-helm.enable_rbacv2" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace-capability:{{ .Chart.Name }}:user + annotations: + en.meta.deckhouse.io/title: "Module {{ .Chart.Name }}: view" + ru.meta.deckhouse.io/title: "Модуль {{ .Chart.Name }}: просмотр" + en.meta.deckhouse.io/description: "Read-only access to {{ .Chart.Name }} applications and their chart catalog in a namespace." + ru.meta.deckhouse.io/description: "Доступ только на чтение к приложениям модуля {{ .Chart.Name }} и их каталогу чартов в пространстве имён." + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "capability" "rbac.deckhouse.io/capability" (printf "namespace-capability.%s.user" .Chart.Name) "rbac.deckhouse.io/scope" "namespace" "rbac.deckhouse.io/aggregate-to-namespace-as" "user")) | nindent 2 }} +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplicationcharts + - helmapplicationrepositories + - helmapplications + verbs: + - get + - list + - watch +{{- end }} diff --git a/templates/nelm-source-controller/_helpers.tpl b/templates/source-controller/_helpers.tpl similarity index 75% rename from templates/nelm-source-controller/_helpers.tpl rename to templates/source-controller/_helpers.tpl index e0b5dc43..175cde6d 100644 --- a/templates/nelm-source-controller/_helpers.tpl +++ b/templates/source-controller/_helpers.tpl @@ -1,4 +1,4 @@ -{{- define "nelm-source-controller.envs" -}} +{{- define "source-controller.envs" -}} - name: RUNTIME_NAMESPACE valueFrom: fieldRef: diff --git a/templates/nelm-source-controller/deployment.yaml b/templates/source-controller/deployment.yaml similarity index 75% rename from templates/nelm-source-controller/deployment.yaml rename to templates/source-controller/deployment.yaml index 593ad9d3..6e474708 100644 --- a/templates/nelm-source-controller/deployment.yaml +++ b/templates/source-controller/deployment.yaml @@ -1,32 +1,32 @@ {{- $priorityClassName := include "priorityClassName" . }} -{{- define "nelm_source_controller_resources" }} +{{- define "source_controller_resources" }} cpu: 50m memory: 64Mi {{- end }} -{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} +{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler") }} --- apiVersion: autoscaling.k8s.io/v1 kind: VerticalPodAutoscaler metadata: - name: nelm-source-controller + name: 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 }} + {{- include "helm_lib_module_labels" (list . (dict "app" "source-controller" "workload-resource-policy.deckhouse.io" "master")) | nindent 2 }} spec: targetRef: apiVersion: "apps/v1" kind: Deployment - name: nelm-source-controller + name: 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 + - containerName: source-controller minAllowed: - {{- include "nelm_source_controller_resources" . | nindent 8 }} + {{- include "source_controller_resources" . | nindent 8 }} maxAllowed: cpu: 1000m memory: 1Gi @@ -36,22 +36,22 @@ spec: apiVersion: policy/v1 kind: PodDisruptionBudget metadata: - name: nelm-source-controller + name: source-controller namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller" )) | nindent 2 }} + {{- include "helm_lib_module_labels" (list . (dict "app" "source-controller" )) | nindent 2 }} spec: minAvailable: {{ include "helm_lib_is_ha_to_value" (list . 1 0) }} selector: matchLabels: - app: nelm-source-controller + app: source-controller --- apiVersion: apps/v1 kind: Deployment metadata: - name: nelm-source-controller + name: source-controller namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller")) | nindent 2 }} + {{- include "helm_lib_module_labels" (list . (dict "app" "source-controller")) | nindent 2 }} spec: replicas: 1 strategy: @@ -59,19 +59,19 @@ spec: revisionHistoryLimit: 2 selector: matchLabels: - app: nelm-source-controller + app: source-controller template: metadata: labels: - app: nelm-source-controller + app: source-controller annotations: - kubectl.kubernetes.io/default-container: nelm-source-controller + kubectl.kubernetes.io/default-container: source-controller spec: containers: {{- include "kube_api_rewriter.sidecar_container" . | nindent 8 }} - - name: nelm-source-controller + - name: 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") }} + image: {{ include "helm_lib_module_image" (list . "sourceController") }} imagePullPolicy: IfNotPresent args: - --watch-all-namespaces=false @@ -79,7 +79,7 @@ spec: - --enable-leader-election - --storage-path=/data - --storage-addr=:9091 - - --storage-adv-addr=nelm-source-controller.$(RUNTIME_NAMESPACE).svc.{{ .Values.global.discovery.clusterDomain }} + - --storage-adv-addr=source-controller.$(RUNTIME_NAMESPACE).svc.{{ .Values.global.discovery.clusterDomain }} volumeMounts: - mountPath: /data name: data @@ -99,12 +99,12 @@ spec: 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 }} + {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler") }} + {{- include "source_controller_resources" . | nindent 14 }} {{- end }} env: {{- include "kube_api_rewriter.kubeconfig_env" . | nindent 12 }} - {{- include "nelm-source-controller.envs" . | nindent 12 }} + {{- include "source-controller.envs" . | nindent 12 }} livenessProbe: httpGet: path: /healthz @@ -123,19 +123,19 @@ spec: {{- $_ := set $kubeRbacProxySettings "listenPort" 8443 }} {{- $_ := set $kubeRbacProxySettings "portName" "rbac-proxy" }} {{- $_ := 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:8080/metrics" "path" "/metrics" "name" "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 + serviceAccountName: 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 }} + {{- include "helm_lib_pod_anti_affinity_for_ha" (list . (dict "app" "source-controller")) | nindent 6 }} volumes: - emptyDir: {} name: data diff --git a/templates/nelm-source-controller/rbac-for-us.yaml b/templates/source-controller/rbac-for-us.yaml similarity index 86% rename from templates/nelm-source-controller/rbac-for-us.yaml rename to templates/source-controller/rbac-for-us.yaml index 6c414ae9..96e9f446 100644 --- a/templates/nelm-source-controller/rbac-for-us.yaml +++ b/templates/source-controller/rbac-for-us.yaml @@ -2,9 +2,9 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: nelm-source-controller + name: source-controller namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller")) | nindent 2 }} + {{- include "helm_lib_module_labels" (list . (dict "app" "source-controller")) | nindent 2 }} imagePullSecrets: - name: operator-helm-module-registry --- @@ -12,21 +12,21 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: {{- include "helm_lib_module_labels" (list .) | nindent 2 }} - name: d8:{{ .Chart.Name }}:nelm-source-controller + name: d8:{{ .Chart.Name }}:source-controller roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: d8:{{ .Chart.Name }}:nelm-source-controller + name: d8:{{ .Chart.Name }}:source-controller subjects: - kind: ServiceAccount - name: nelm-source-controller + name: 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 + name: d8:{{ .Chart.Name }}:source-controller rules: - apiGroups: - authentication.k8s.io @@ -109,7 +109,7 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: {{- include "helm_lib_module_labels" (list .) | nindent 2 }} - name: nelm-source-controller + name: source-controller namespace: d8-{{ .Chart.Name }} rules: - apiGroups: @@ -155,12 +155,12 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: {{- include "helm_lib_module_labels" (list .) | nindent 2 }} - name: nelm-source-controller + name: source-controller namespace: d8-{{ .Chart.Name }} roleRef: apiGroup: rbac.authorization.k8s.io kind: Role - name: nelm-source-controller + name: source-controller subjects: - kind: ServiceAccount - name: nelm-source-controller + name: source-controller diff --git a/templates/nelm-source-controller/service-metrics.yaml b/templates/source-controller/service-metrics.yaml similarity index 52% rename from templates/nelm-source-controller/service-metrics.yaml rename to templates/source-controller/service-metrics.yaml index 361f572b..1bfed59f 100644 --- a/templates/nelm-source-controller/service-metrics.yaml +++ b/templates/source-controller/service-metrics.yaml @@ -2,9 +2,9 @@ apiVersion: v1 kind: Service metadata: - name: nelm-source-controller-metrics + name: source-controller-metrics namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller")) | nindent 2 }} + {{- include "helm_lib_module_labels" (list . (dict "app" "source-controller")) | nindent 2 }} spec: ports: - name: metrics @@ -12,4 +12,4 @@ spec: protocol: TCP targetPort: rbac-proxy selector: - app: nelm-source-controller + app: source-controller diff --git a/templates/nelm-source-controller/service-monitor.yaml b/templates/source-controller/service-monitor.yaml similarity index 64% rename from templates/nelm-source-controller/service-monitor.yaml rename to templates/source-controller/service-monitor.yaml index 6e8e3065..c7f239cb 100644 --- a/templates/nelm-source-controller/service-monitor.yaml +++ b/templates/source-controller/service-monitor.yaml @@ -2,9 +2,9 @@ apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: - name: {{ .Chart.Name }}-nelm-source-controller + name: {{ .Chart.Name }}-source-controller namespace: d8-monitoring - {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller" "prometheus" "main")) | nindent 2 }} + {{- include "helm_lib_module_labels" (list . (dict "app" "source-controller" "prometheus" "main")) | nindent 2 }} spec: endpoints: - bearerTokenSecret: @@ -20,4 +20,4 @@ spec: - d8-{{ .Chart.Name }} selector: matchLabels: - app: "nelm-source-controller" + app: "source-controller" diff --git a/templates/nelm-source-controller/service.yaml b/templates/source-controller/service.yaml similarity index 53% rename from templates/nelm-source-controller/service.yaml rename to templates/source-controller/service.yaml index 1d1bfa27..85df1709 100644 --- a/templates/nelm-source-controller/service.yaml +++ b/templates/source-controller/service.yaml @@ -2,9 +2,9 @@ apiVersion: v1 kind: Service metadata: - name: nelm-source-controller + name: source-controller namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list . (dict "app" "nelm-source-controller")) | nindent 2 }} + {{- include "helm_lib_module_labels" (list . (dict "app" "source-controller")) | nindent 2 }} spec: ports: - name: controller @@ -12,4 +12,4 @@ spec: targetPort: controller protocol: TCP selector: - app: nelm-source-controller + app: source-controller diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml new file mode 100644 index 00000000..751bf03f --- /dev/null +++ b/templates/user-authz-cluster-roles.yaml @@ -0,0 +1,79 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: + user-authz.deckhouse.io/access-level: PrivilegedUser + name: d8:user-authz:{{ .Chart.Name }}:privileged-user + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplicationcharts + - helmapplicationrepositories + - helmapplications + verbs: + - get + - list + - watch +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusterapplicationcharts + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: + user-authz.deckhouse.io/access-level: Admin + name: d8:user-authz:{{ .Chart.Name }}:admin + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplicationrepositories + - helmapplications + verbs: + - create + - delete + - deletecollection + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: + user-authz.deckhouse.io/access-level: ClusterAdmin + name: d8:user-authz:{{ .Chart.Name }}:cluster-admin + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddons + - helmclusteraddonrepositories + - helmclusterapplicationrepositories + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddoncharts + verbs: + - get + - list + - watch diff --git a/tests/e2e/Taskfile.dist.yaml b/tests/e2e/Taskfile.dist.yaml index 8c5d9e70..222328ae 100644 --- a/tests/e2e/Taskfile.dist.yaml +++ b/tests/e2e/Taskfile.dist.yaml @@ -11,7 +11,7 @@ includes: gciPrefix: '{{.gciPrefix | default "github.com/deckhouse/"}}' golangciConfigPath: '{{.golangciConfigPath | default "./.golangci.yaml"}}' golangciLintBinDir: '{{.golangciLintBinDir | default "../../bin"}}' - golangciLintVersion: '{{.golangciLintVersion | default "v2.8.0"}}' + golangciLintVersion: '{{.golangciLintVersion | default "v2.13.2"}}' golangciPaths: '{{.golangciPaths | default "./..."}}' paths: '{{.paths | default "."}}' @@ -20,6 +20,14 @@ vars: KIND_CLUSTER_NAME: '{{.KIND_CLUSTER_NAME | default "d8-operator-helm"}}' tasks: + # Only the packages that need no cluster. Importing internal/framework pulls in + # a package-level init that reads the cluster config and panics without it, so + # ./... cannot be used here. + test:unit: + desc: "Run the unit tests of this module." + cmds: + - go test ./internal/naming/... + kind:ci:setup: desc: Setup kind in CI cmds: diff --git a/tests/e2e/default_config.yaml b/tests/e2e/default_config.yaml index 0f7a32c9..e43b868c 100644 --- a/tests/e2e/default_config.yaml +++ b/tests/e2e/default_config.yaml @@ -16,6 +16,9 @@ controllers: # Expected in the stall scenario that points a repository at a missing # source; the reconciler logs every failed read with the repository name. - "repo-source-not-found" + # Expected in the stall scenario that puts a foreign Role under the name the + # application identity needs; the reconciler logs the refusal to adopt it. + - "is not managed by the operator" # Transient controller-runtime cache reflector reconnects (watch/list drop, # unexpected EOF); self-recovering, not a real failure. - "Unexpected error when reading response body" @@ -30,11 +33,11 @@ controllers: logFilters: exclude: [] excludeRegexp: [] - - name: "nelm-source-controller" + - name: "source-controller" namespace: "d8-operator-helm" - labelSelector: "app=nelm-source-controller" + labelSelector: "app=source-controller" containers: - - "nelm-source-controller" + - "source-controller" logFilters: exclude: [] excludeRegexp: diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index e5323d99..948251f2 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -23,6 +23,8 @@ import ( . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + _ "github.com/deckhouse/operator-helm/tests/e2e/helmapplication" + _ "github.com/deckhouse/operator-helm/tests/e2e/helmapplicationrepository" _ "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" diff --git a/tests/e2e/go.mod b/tests/e2e/go.mod index fcc81cc2..8ff19d25 100644 --- a/tests/e2e/go.mod +++ b/tests/e2e/go.mod @@ -1,6 +1,6 @@ module github.com/deckhouse/operator-helm/tests/e2e -go 1.25.0 +go 1.26.3 tool github.com/onsi/ginkgo/v2/ginkgo diff --git a/tests/e2e/helmapplication/foreign_rbac.go b/tests/e2e/helmapplication/foreign_rbac.go new file mode 100644 index 00000000..abdbd710 --- /dev/null +++ b/tests/e2e/helmapplication/foreign_rbac.go @@ -0,0 +1,168 @@ +/* +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 helmapplication + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + rbacv1 "k8s.io/api/rbac/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/framework" + "github.com/deckhouse/operator-helm/tests/e2e/internal/util" +) + +// The name of the namespace Role is fixed and computable by anyone, so a namespace +// owner can be holding it before any application is created. Widening such a Role to +// full rights and binding every application of the namespace to it is not a decision +// the controller gets to make on their behalf: it reports the collision and installs +// nothing. The verdict is terminal because the informer behind the watch on the kind +// selects on the label the object lacks — nothing observes it arriving or leaving. +// +// No AssertNoErrorsFor here on purpose: the scenario blocks the application +// deliberately, so error-level log lines from the controller are expected. Dropping +// the assertion is not enough on its own — the log watcher accumulates errors +// suite-wide and never resets, so every other scenario's assertion would fail too. +// The message is excluded in default_config.yaml; keep the two in step. +var _ = Describe("HelmApplication over a foreign namespace role", Ordered, func() { + f := framework.NewFramework("application-foreign-rbac") + + const ( + repoName = "e2e-foreign-rbac-repo" + repoURL = "https://stefanprodan.github.io/podinfo" + appName = "e2e-foreign-rbac-app" + ) + + var createdApp *apiv1alpha1.HelmApplication + + foreignRules := []rbacv1.PolicyRule{{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get"}, + }} + + BeforeAll(func() { + DeferCleanup(f.After) + f.Before() + }) + + It("should refuse to seize a role it does not own", func() { + By("Putting a role of someone else's under the name the identity needs") + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: appRoleName, Namespace: f.NamespaceName()}, + Rules: foreignRules, + } + createdRole, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Create(context.Background(), role, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + f.DeferDelete(createdRole) + + By("Creating the repository and the application over it") + repo := &apiv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: repoName, Namespace: f.NamespaceName()}, + Spec: apiv1alpha1.RepositorySpec{URL: repoURL}, + } + createdRepo, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationRepositories(f.NamespaceName()). + Create(context.Background(), repo, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + f.DeferDelete(createdRepo) + + util.UntilConditionTrue(apiv1alpha1.ConditionTypeReady, framework.LongTimeout, createdRepo) + util.UntilConditionTrue(apiv1alpha1.ConditionTypeSynced, framework.LongTimeout, createdRepo) + + app := &apiv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: appName, Namespace: f.NamespaceName()}, + Spec: apiv1alpha1.HelmApplicationSpec{ + Chart: apiv1alpha1.HelmApplicationChartRef{ + Name: chartName, + Repository: repoName, + Version: chartVer, + }, + }, + } + createdApp, err = f.OperatorClient().HelmV1alpha1(). + HelmApplications(f.NamespaceName()). + Create(context.Background(), app, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + f.DeferDelete(createdApp) + + By("The application must stall and name the collision") + util.UntilConditionTrue(apiv1alpha1.ConditionTypeStalled, framework.LongTimeout, createdApp) + util.UntilConditionReason( + apiv1alpha1.ConditionTypeStalled, + apiv1alpha1.ReasonForeignAccessObject, + framework.LongTimeout, + createdApp, + ) + util.UntilConditionStatus( + apiv1alpha1.ConditionTypeReady, + string(metav1.ConditionFalse), + framework.LongTimeout, + createdApp, + ) + + By("The role must be left exactly as it was") + stored, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Get(context.Background(), appRoleName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(stored.Rules).To(Equal(foreignRules), "a role that is not ours must not be widened") + Expect(stored.Labels).NotTo(HaveKey(apiv1alpha1.LabelManagedBy), + "a role that is not ours must not be labelled as ours") + + By("Nothing must be installed while the identity cannot be built") + _, err = f.KubeClient().RbacV1().RoleBindings(f.NamespaceName()). + Get(context.Background(), util.ApplicationServiceAccountName(f.NamespaceName(), appName), metav1.GetOptions{}) + Expect(err).To(HaveOccurred(), "no binding must be created for a stalled identity") + }) + + It("should take the role over once it is labelled as the module's", func() { + By("Handing the role to the module") + role, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Get(context.Background(), appRoleName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + if role.Labels == nil { + role.Labels = map[string]string{} + } + role.Labels[apiv1alpha1.LabelManagedBy] = apiv1alpha1.LabelManagedByValue + _, err = f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Update(context.Background(), role, metav1.UpdateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + // Labelling the role makes it enter the label-scoped informer, which is what + // wakes the application: no force request is needed for this direction. + By("The application must recover on its own") + util.UntilConditionAbsent(apiv1alpha1.ConditionTypeStalled, framework.LongTimeout, createdApp) + util.UntilConditionTrue(apiv1alpha1.ConditionTypeReady, framework.LongTimeout, createdApp) + + By("The adopted role must be reconciled to full rights") + Eventually(func(g Gomega) { + stored, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Get(context.Background(), appRoleName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(stored.Rules).To(HaveLen(1)) + g.Expect(stored.Rules[0].Verbs).To(ContainElement("*")) + g.Expect(stored.Labels).To(HaveKeyWithValue( + apiv1alpha1.LabelDeckhouseHeritage, apiv1alpha1.LabelDeckhouseHeritageValue, + )) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) +}) diff --git a/tests/e2e/helmapplication/isolation.go b/tests/e2e/helmapplication/isolation.go new file mode 100644 index 00000000..f0db775f --- /dev/null +++ b/tests/e2e/helmapplication/isolation.go @@ -0,0 +1,240 @@ +/* +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 helmapplication + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/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" +) + +var _ = Describe("HelmApplication identity and isolation", Ordered, func() { + f := framework.NewFramework("application-isolation") + + const ( + repoName = "e2e-isolation-repo" + repoURL = "https://stefanprodan.github.io/podinfo" + appName = "e2e-isolation-app" + ) + + BeforeAll(func() { + DeferCleanup(f.After) + f.Before() + }) + + AfterEach(func() { + By("Verifying no errors in operator-helm-controller logs") + controller.AssertNoErrorsFor("operator-helm-controller") + }) + + It("should install an application", func() { + repo := &apiv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: repoName, Namespace: f.NamespaceName()}, + Spec: apiv1alpha1.RepositorySpec{URL: repoURL}, + } + createdRepo, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationRepositories(f.NamespaceName()). + Create(context.Background(), repo, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + f.DeferDelete(createdRepo) + + util.UntilConditionTrue(apiv1alpha1.ConditionTypeReady, framework.LongTimeout, createdRepo) + util.UntilConditionTrue(apiv1alpha1.ConditionTypeSynced, framework.LongTimeout, createdRepo) + + app := &apiv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: appName, Namespace: f.NamespaceName()}, + Spec: apiv1alpha1.HelmApplicationSpec{ + Chart: apiv1alpha1.HelmApplicationChartRef{ + Name: chartName, + Repository: repoName, + Version: chartVer, + }, + }, + } + createdApp, err := f.OperatorClient().HelmV1alpha1(). + HelmApplications(f.NamespaceName()). + Create(context.Background(), app, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + f.DeferDelete(createdApp) + + util.UntilConditionTrue(apiv1alpha1.ConditionTypeReady, framework.LongTimeout, createdApp) + }) + + It("should apply the chart as its own service account", func() { + saName := util.ApplicationServiceAccountName(f.NamespaceName(), appName) + + By("The service account lives in the module namespace and mounts no token") + sa, err := f.KubeClient().CoreV1().ServiceAccounts(moduleNS). + Get(context.Background(), saName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(sa.AutomountServiceAccountToken).NotTo(BeNil()) + Expect(*sa.AutomountServiceAccountToken).To(BeFalse(), + "the account is only a subject name; no token must be mounted for it") + + By("The role binding names that account and the namespace role") + binding, err := f.KubeClient().RbacV1().RoleBindings(f.NamespaceName()). + Get(context.Background(), saName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(binding.RoleRef.Kind).To(Equal("Role")) + Expect(binding.RoleRef.Name).To(Equal(appRoleName)) + Expect(binding.Subjects).To(HaveLen(1)) + Expect(binding.Subjects[0].Name).To(Equal(saName)) + Expect(binding.Subjects[0].Namespace).To(Equal(moduleNS)) + + By("The internal HelmRelease actually impersonates that account and stores into the application namespace") + Eventually(func(g Gomega) { + gotServiceAccountName, gotStorageNamespace, err := util.HelmApplicationInternalReleaseSpec(saName) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(gotServiceAccountName).To(Equal(saName)) + g.Expect(gotStorageNamespace).To(Equal(f.NamespaceName())) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + + By("The Helm storage secret lives in the application namespace, not the operator's") + Eventually(func(g Gomega) { + secrets, err := f.KubeClient().CoreV1().Secrets(f.NamespaceName()). + List(context.Background(), metav1.ListOptions{FieldSelector: "type=helm.sh/release.v1"}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(secrets.Items).NotTo(BeEmpty(), "the release storage must live in the application namespace") + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) + + It("should create nothing else of its own in the namespace", func() { + saName := util.ApplicationServiceAccountName(f.NamespaceName(), appName) + + By("Every secret in the namespace is Helm's own release storage, not a projected credential") + secrets, err := f.KubeClient().CoreV1().Secrets(f.NamespaceName()). + List(context.Background(), metav1.ListOptions{}) + Expect(err).NotTo(HaveOccurred()) + for _, secret := range secrets.Items { + Expect(secret.Type).To(Equal(corev1.SecretType("helm.sh/release.v1")), + "repository credentials must never be projected into a consumer namespace") + } + + By("The operator's own objects here are exactly the role binding and the role") + bindings, err := f.KubeClient().RbacV1().RoleBindings(f.NamespaceName()). + List(context.Background(), metav1.ListOptions{ + LabelSelector: apiv1alpha1.LabelManagedBy + "=" + apiv1alpha1.LabelManagedByValue, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(bindings.Items).To(HaveLen(1)) + + roles, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + List(context.Background(), metav1.ListOptions{ + LabelSelector: apiv1alpha1.LabelManagedBy + "=" + apiv1alpha1.LabelManagedByValue, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(roles.Items).To(HaveLen(1)) + Expect(roles.Items[0].Name).To(Equal(appRoleName)) + + By("No ClusterRoleBinding escalates the application's service account cluster-wide") + clusterBindings, err := f.KubeClient().RbacV1().ClusterRoleBindings(). + List(context.Background(), metav1.ListOptions{}) + Expect(err).NotTo(HaveOccurred()) + for _, crb := range clusterBindings.Items { + for _, subject := range crb.Subjects { + Expect(subject).NotTo(Equal(rbacv1.Subject{ + Kind: rbacv1.ServiceAccountKind, + Name: saName, + Namespace: moduleNS, + }), "the application's service account must never be granted cluster-wide rights") + } + } + }) + + It("should carry the module labels on the role and the binding", func() { + saName := util.ApplicationServiceAccountName(f.NamespaceName(), appName) + + role, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Get(context.Background(), appRoleName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(role.Labels).To(HaveKeyWithValue(apiv1alpha1.LabelManagedBy, apiv1alpha1.LabelManagedByValue)) + Expect(role.Labels).To(HaveKeyWithValue(apiv1alpha1.LabelDeckhouseHeritage, apiv1alpha1.LabelDeckhouseHeritageValue)) + + binding, err := f.KubeClient().RbacV1().RoleBindings(f.NamespaceName()). + Get(context.Background(), saName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(binding.Labels).To(HaveKeyWithValue(apiv1alpha1.LabelManagedBy, apiv1alpha1.LabelManagedByValue)) + Expect(binding.Labels).To(HaveKeyWithValue(apiv1alpha1.LabelDeckhouseHeritage, apiv1alpha1.LabelDeckhouseHeritageValue)) + }) + + // Nothing here forces a reconciliation: the watches on both kinds are what has to + // turn the edit into one, and asking for a reconciliation would hide their absence. + It("should restore a narrowed role on its own", func() { + By("Narrowing the role the way a namespace owner would") + narrowed := []rbacv1.PolicyRule{{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }} + + Eventually(func(g Gomega) { + role, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Get(context.Background(), appRoleName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + + role.Rules = narrowed + _, err = f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Update(context.Background(), role, metav1.UpdateOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + + By("The full rights must come back") + Eventually(func(g Gomega) { + role, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Get(context.Background(), appRoleName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(role.Rules).To(HaveLen(1)) + g.Expect(role.Rules[0].Verbs).To(ContainElement("*")) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) + + It("should recreate a deleted role and a deleted binding on its own", func() { + saName := util.ApplicationServiceAccountName(f.NamespaceName(), appName) + + By("Deleting both objects") + Expect(f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Delete(context.Background(), appRoleName, metav1.DeleteOptions{})).To(Succeed()) + Expect(f.KubeClient().RbacV1().RoleBindings(f.NamespaceName()). + Delete(context.Background(), saName, metav1.DeleteOptions{})).To(Succeed()) + + By("Both must come back with the rights the release is applied with") + Eventually(func(g Gomega) { + role, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Get(context.Background(), appRoleName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(role.Rules).To(HaveLen(1)) + g.Expect(role.Rules[0].Verbs).To(ContainElement("*")) + + binding, err := f.KubeClient().RbacV1().RoleBindings(f.NamespaceName()). + Get(context.Background(), saName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(binding.RoleRef.Name).To(Equal(appRoleName)) + g.Expect(binding.Subjects).To(HaveLen(1)) + g.Expect(binding.Subjects[0].Name).To(Equal(saName)) + g.Expect(binding.Subjects[0].Namespace).To(Equal(moduleNS)) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) +}) diff --git a/tests/e2e/helmapplication/lifecycle.go b/tests/e2e/helmapplication/lifecycle.go new file mode 100644 index 00000000..a9634807 --- /dev/null +++ b/tests/e2e/helmapplication/lifecycle.go @@ -0,0 +1,177 @@ +/* +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 helmapplication + +import ( + "context" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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" +) + +const ( + chartName = "podinfo" + chartVer = "6.10.2" + moduleNS = "d8-operator-helm" + appRoleName = "operator-helm-application" +) + +func DefineLifecycleTests(repoType, repoURL string) { + Describe(fmt.Sprintf("Using namespaced %s repository", repoType), Ordered, func() { + f := framework.NewFramework("application-lifecycle") + + suffix := strings.ToLower(repoType) + repoName := "e2e-app-repo-" + suffix + appName := "e2e-test-app-" + suffix + + // The podinfo chart names its pods after the Helm release, not the chart: + // app.kubernetes.io/name is "-podinfo", and the release name is + // util.ApplicationReleaseName(appName) (see + // images/operator-helm-controller/internal/adapter/application_release.go + // ApplicationRelease.ReleaseName). + labelSelector := fmt.Sprintf("app.kubernetes.io/name=%s-%s", util.ApplicationReleaseName(appName), chartName) + + BeforeAll(func() { + DeferCleanup(f.After) + f.Before() + }) + + AfterEach(func() { + By("Verifying no errors in operator-helm-controller logs") + controller.AssertNoErrorsFor("operator-helm-controller") + }) + + It("should create HelmApplicationRepository and reach Ready", func() { + repo := &apiv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{Name: repoName, Namespace: f.NamespaceName()}, + Spec: apiv1alpha1.RepositorySpec{URL: repoURL}, + } + + created, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationRepositories(f.NamespaceName()). + Create(context.Background(), repo, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + f.DeferDelete(created) + + util.UntilConditionTrue(apiv1alpha1.ConditionTypeReady, framework.LongTimeout, created) + util.UntilConditionTrue(apiv1alpha1.ConditionTypeSynced, framework.LongTimeout, created) + }) + + It("should install the chart into the application's own namespace", func() { + app := &apiv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: appName, Namespace: f.NamespaceName()}, + Spec: apiv1alpha1.HelmApplicationSpec{ + Chart: apiv1alpha1.HelmApplicationChartRef{ + Name: chartName, + Repository: repoName, + Version: chartVer, + }, + }, + } + + created, err := f.OperatorClient().HelmV1alpha1(). + HelmApplications(f.NamespaceName()). + Create(context.Background(), app, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + f.DeferDelete(created) + + By("Waiting for the application to become Ready") + util.UntilConditionTrue(apiv1alpha1.ConditionTypeReady, framework.LongTimeout, created) + + By("The chart's workload must run in the application's namespace") + util.UntilPodCount(f.NamespaceName(), labelSelector, 1, framework.LongTimeout) + + By("The application must record what it applied") + Eventually(func(g Gomega) { + current, err := f.OperatorClient().HelmV1alpha1(). + HelmApplications(f.NamespaceName()). + Get(context.Background(), appName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(current.Status.LastAppliedChart).NotTo(BeNil()) + g.Expect(current.Status.LastAppliedChart.Name).To(Equal(chartName)) + g.Expect(current.Status.LastAppliedChart.Repository).To(Equal(repoName)) + g.Expect(current.Status.LastAppliedChart.ClusterRepository).To(BeEmpty(), + "a namespaced reference must not leave a cluster one behind") + g.Expect(current.Status.LastAppliedChart.Version).To(Equal(chartVer)) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) + + It("should apply values from the application spec", func() { + const values = `{"replicaCount":2}` + + util.UpdateHelmApplication(f.NamespaceName(), appName, func(app *apiv1alpha1.HelmApplication) { + app.Spec.Values = &apiextensionsv1.JSON{Raw: []byte(values)} + }) + + By("Waiting for the new replica count to take effect") + util.UntilPodCount(f.NamespaceName(), labelSelector, 2, framework.LongTimeout) + + By("The application must record the applied values") + Eventually(func(g Gomega) { + current, err := f.OperatorClient().HelmV1alpha1(). + HelmApplications(f.NamespaceName()). + Get(context.Background(), appName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(current.Status.LastAppliedValues).NotTo(BeNil()) + g.Expect(current.Status.LastAppliedValues.Raw).To(MatchJSON(values)) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) + + It("should remove the release and its identity on delete", func() { + saName := util.ApplicationServiceAccountName(f.NamespaceName(), appName) + + util.DeleteHelmApplication(f, f.NamespaceName(), appName, framework.LongTimeout) + + By("The workload must be gone") + util.UntilPodCount(f.NamespaceName(), labelSelector, 0, framework.LongTimeout) + + By("The service account and the role binding must be gone") + Eventually(func(g Gomega) { + _, err := f.KubeClient().CoreV1().ServiceAccounts(moduleNS). + Get(context.Background(), saName, metav1.GetOptions{}) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "the application's service account must be deleted") + + _, err = f.KubeClient().RbacV1().RoleBindings(f.NamespaceName()). + Get(context.Background(), saName, metav1.GetOptions{}) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "the application's role binding must be deleted") + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + + By("The namespace role must survive the application") + _, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Get(context.Background(), appRoleName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred(), + "the role is shared by every application of the namespace") + }) + }) +} + +var _ = Describe("HelmApplication lifecycle", Ordered, func() { + DefineLifecycleTests("Helm", "https://stefanprodan.github.io/podinfo") + DefineLifecycleTests("OCI", "oci://ghcr.io/stefanprodan/charts/podinfo") +}) diff --git a/tests/e2e/helmapplication/system_namespace.go b/tests/e2e/helmapplication/system_namespace.go new file mode 100644 index 00000000..f3468682 --- /dev/null +++ b/tests/e2e/helmapplication/system_namespace.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 helmapplication + +import ( + "context" + + . "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" +) + +var _ = Describe("HelmApplication system namespace restriction", Ordered, func() { + f := framework.NewFramework("") + + newApplication := func(namespace string) *apiv1alpha1.HelmApplication { + return &apiv1alpha1.HelmApplication{ + ObjectMeta: metav1.ObjectMeta{Name: "e2e-system-ns-app", Namespace: namespace}, + Spec: apiv1alpha1.HelmApplicationSpec{ + Chart: apiv1alpha1.HelmApplicationChartRef{ + Name: chartName, + Repository: "any-repo", + Version: chartVer, + }, + }, + } + } + + BeforeAll(func() { + DeferCleanup(f.After) + f.Before() + }) + + DescribeTable( + "should reject an application in a system namespace", + func(namespace string) { + created, err := f.OperatorClient().HelmV1alpha1(). + HelmApplications(namespace). + Create(context.Background(), newApplication(namespace), metav1.CreateOptions{}) + if err == nil { + f.DeferDelete(created) + } + + Expect(err).To(HaveOccurred(), "an application in %q must be rejected", namespace) + Expect(err.Error()).To(ContainSubstring("system namespace")) + }, + Entry("kube-system", "kube-system"), + Entry("kube-public", "kube-public"), + Entry("kube-node-lease", "kube-node-lease"), + Entry("the module's own namespace", moduleNS), + ) +}) diff --git a/tests/e2e/helmapplicationrepository/lifecycle.go b/tests/e2e/helmapplicationrepository/lifecycle.go new file mode 100644 index 00000000..6f2190eb --- /dev/null +++ b/tests/e2e/helmapplicationrepository/lifecycle.go @@ -0,0 +1,192 @@ +/* +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 helmapplicationrepository + +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 namespaced %s repository", repoType), Ordered, func() { + f := framework.NewFramework("app-repository-lifecycle") + + repoName := "e2e-app-repo-" + strings.ToLower(repoType) + + BeforeAll(func() { + DeferCleanup(f.After) + f.Before() + }) + + AfterEach(func() { + By("Verifying no errors in operator-helm-controller logs") + controller.AssertNoErrorsFor("operator-helm-controller") + }) + + It("should create HelmApplicationRepository and fill its catalog", func() { + repo := &apiv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: repoName, + Namespace: f.NamespaceName(), + }, + Spec: apiv1alpha1.RepositorySpec{ + URL: repoURL, + InsecureSkipVerify: false, + }, + } + + created, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationRepositories(f.NamespaceName()). + Create(context.Background(), repo, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + f.DeferDelete(created) + + By("Waiting for repository to become Ready") + util.UntilConditionTrue(apiv1alpha1.ConditionTypeReady, framework.LongTimeout, created) + + By("Waiting for repository to become Synced") + util.UntilConditionTrue(apiv1alpha1.ConditionTypeSynced, framework.LongTimeout, created) + + By("Healthy repository must carry no abnormal-true conditions") + util.UntilConditionAbsent(apiv1alpha1.ConditionTypeReconciling, framework.LongTimeout, created) + util.UntilConditionAbsent(apiv1alpha1.ConditionTypeStalled, framework.LongTimeout, created) + + By("Repository must report its synchronization schedule") + Eventually(func(g Gomega) { + current, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationRepositories(f.NamespaceName()). + Get(context.Background(), repoName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(current.Status.LastSuccessfulSyncTime).NotTo(BeNil()) + g.Expect(current.Status.NextSyncTime).NotTo(BeNil()) + g.Expect(current.Status.ConsecutiveFetchFailures).To(BeZero()) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + + By("The catalog must appear in the repository's own namespace") + labelSelector := fmt.Sprintf("repository=%s", repoName) + Eventually(func(g Gomega) { + charts, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationCharts(f.NamespaceName()). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(charts.Items).NotTo(BeEmpty(), "the repository must publish at least one chart") + + usable := 0 + for _, chart := range charts.Items { + for _, version := range chart.Status.Versions { + if version.UnavailableReason == "" { + usable++ + } + } + } + g.Expect(usable).To(BeNumerically(">=", 1), "the catalog must carry at least one usable version") + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + + By("No catalog object of this repository may appear cluster-wide") + Consistently(func(g Gomega) { + clusterCharts, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterApplicationCharts(). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(clusterCharts.Items).To(BeEmpty(), + "a namespaced repository must not publish into the cluster-wide catalog") + }).WithTimeout(framework.ShortTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) + + It("should keep a same-named repository in another namespace apart", func() { + other := util.EnsureNamespace(f.NamespaceName()+"-other", map[string]string{framework.E2ELabel: "true"}) + DeferCleanup(func() { + if framework.IsCleanUpNeeded() { + util.DeleteNamespace(other.Name, true, framework.LongTimeout) + } + }) + + twin := &apiv1alpha1.HelmApplicationRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: repoName, + Namespace: other.Name, + }, + Spec: apiv1alpha1.RepositorySpec{URL: repoURL}, + } + + createdTwin, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationRepositories(other.Name). + Create(context.Background(), twin, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + By("Both repositories must reach Ready independently") + util.UntilConditionTrue(apiv1alpha1.ConditionTypeReady, framework.LongTimeout, createdTwin) + + labelSelector := fmt.Sprintf("repository=%s", repoName) + By("The twin must publish its own catalog into its own namespace") + Eventually(func(g Gomega) { + charts, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationCharts(other.Name). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(charts.Items).NotTo(BeEmpty(), "the twin must publish its own catalog") + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + + By("Deleting the twin must leave the original healthy") + util.DeleteHelmApplicationRepository(f, other.Name, repoName, framework.LongTimeout) + + Consistently(func(g Gomega) { + current, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationRepositories(f.NamespaceName()). + Get(context.Background(), repoName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(current.Status.Conditions).To(ContainElement(And( + HaveField("Type", apiv1alpha1.ConditionTypeReady), + HaveField("Status", metav1.ConditionTrue), + ))) + + charts, err := f.OperatorClient().HelmV1alpha1(). + HelmApplicationCharts(f.NamespaceName()). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(charts.Items).NotTo(BeEmpty(), "the original's catalog must survive the twin's deletion") + }).WithTimeout(framework.ShortTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + + // Only a helm repository owns an internal HelmRepository; an oci:// one + // has none of its own, its artifacts come from the consumers' own + // OCIRepository objects. + if strings.EqualFold(repoType, "helm") { + By("The original's internal HelmRepository must survive the twin's deletion") + _, err = util.GetHelmApplicationRepositoryInternalHelmRepository( + util.HelmApplicationRepositoryInternalName(f.NamespaceName(), repoName), + ) + Expect(err).NotTo(HaveOccurred()) + } + }) + }) +} + +var _ = Describe("HelmApplicationRepository lifecycle", Ordered, func() { + DefineLifecycleTests("Helm", "https://stefanprodan.github.io/podinfo") + DefineLifecycleTests("OCI", "oci://ghcr.io/stefanprodan/charts/podinfo") +}) diff --git a/tests/e2e/helmclusteraddon/chartclaim.go b/tests/e2e/helmclusteraddon/chartclaim.go index ce5150ba..712125d7 100644 --- a/tests/e2e/helmclusteraddon/chartclaim.go +++ b/tests/e2e/helmclusteraddon/chartclaim.go @@ -100,7 +100,7 @@ var _ = Describe("HelmClusterAddon chart claim", Ordered, func() { for _, name := range []string{repoAName, repoBName} { repo := &apiv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: name}, - Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: apiv1alpha1.RepositorySpec{ URL: repoURL, InsecureSkipVerify: false, }, diff --git a/tests/e2e/helmclusteraddon/hybrid.go b/tests/e2e/helmclusteraddon/hybrid.go index f4e9706c..d93ba1e4 100644 --- a/tests/e2e/helmclusteraddon/hybrid.go +++ b/tests/e2e/helmclusteraddon/hybrid.go @@ -151,7 +151,7 @@ var _ = Describe("Using a helm repository whose index publishes a version in a r It("should create HelmClusterAddonRepository and reach Ready and Synced", func() { repo := &apiv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: repoName}, - Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: apiv1alpha1.RepositorySpec{ URL: fmt.Sprintf("http://%s.%s.svc", indexName, f.NamespaceName()), }, } diff --git a/tests/e2e/helmclusteraddon/lifecycle.go b/tests/e2e/helmclusteraddon/lifecycle.go index 60955060..e033252c 100644 --- a/tests/e2e/helmclusteraddon/lifecycle.go +++ b/tests/e2e/helmclusteraddon/lifecycle.go @@ -57,7 +57,7 @@ func DefineLifecycleTests(repoType, repoURL string) { ObjectMeta: metav1.ObjectMeta{ Name: repoName, }, - Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: apiv1alpha1.RepositorySpec{ URL: repoURL, InsecureSkipVerify: false, }, diff --git a/tests/e2e/helmclusteraddon/system_namespace.go b/tests/e2e/helmclusteraddon/system_namespace.go index 6eb476a8..b557f12f 100644 --- a/tests/e2e/helmclusteraddon/system_namespace.go +++ b/tests/e2e/helmclusteraddon/system_namespace.go @@ -89,7 +89,7 @@ var _ = Describe("HelmClusterAddon system namespace restriction", Ordered, func( ObjectMeta: metav1.ObjectMeta{ Name: repoName, }, - Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: apiv1alpha1.RepositorySpec{ URL: repoURL, InsecureSkipVerify: false, }, diff --git a/tests/e2e/helmclusteraddonrepository/lifecycle.go b/tests/e2e/helmclusteraddonrepository/lifecycle.go index 588e8d45..1dd7f5e1 100644 --- a/tests/e2e/helmclusteraddonrepository/lifecycle.go +++ b/tests/e2e/helmclusteraddonrepository/lifecycle.go @@ -52,7 +52,7 @@ func DefineLifecycleTests(repoType, repoURL string) { ObjectMeta: metav1.ObjectMeta{ Name: repoName, }, - Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: apiv1alpha1.RepositorySpec{ URL: repoURL, InsecureSkipVerify: false, }, @@ -151,7 +151,7 @@ var _ = Describe("Create HelmClusterAddonRepository with invalid url", Ordered, ObjectMeta: metav1.ObjectMeta{ Name: repoName, }, - Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: apiv1alpha1.RepositorySpec{ URL: "invalid-url", }, } @@ -183,7 +183,7 @@ var _ = Describe("HelmClusterAddonRepository with an unreachable source", Ordere It("should stall on a missing source and recover after the url is fixed", func() { repo := &apiv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: repoName}, - Spec: apiv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: apiv1alpha1.RepositorySpec{ URL: "https://stefanprodan.github.io/podinfo-does-not-exist", }, } diff --git a/tests/e2e/internal/framework/framework.go b/tests/e2e/internal/framework/framework.go index b404dfbb..deed953d 100644 --- a/tests/e2e/internal/framework/framework.go +++ b/tests/e2e/internal/framework/framework.go @@ -77,6 +77,11 @@ func (f *Framework) Before() { Expect(err).NotTo(HaveOccurred()) By(fmt.Sprintf("Namespace %q has been created", ns.Name)) f.namespace = ns + + // Registered last so the reversal in After deletes it after everything created + // inside it. Without this a run against a live cluster leaves the namespace and + // whatever the module seeded in it behind; in CI the kind cluster hides that. + f.objectsToDelete = append(f.objectsToDelete, ns) } // After handles cleanup and dump on failure. diff --git a/tests/e2e/internal/naming/naming.go b/tests/e2e/internal/naming/naming.go new file mode 100644 index 00000000..440d4958 --- /dev/null +++ b/tests/e2e/internal/naming/naming.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 naming reproduces the names operator-helm-controller derives for the +// internal objects of a source in the application family. It imports nothing from +// internal/framework, so it stays testable with no cluster and no config file +// present; internal/util calls into it for anything cluster-facing. +package naming + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +// applicationDerivedPartLimit bounds the namespace and name parts of a derived +// name. It must match derivedPartLimit in +// images/operator-helm-controller/internal/utils/name.go. +const applicationDerivedPartLimit = 18 + +// ApplicationServiceAccountName reproduces the name operator-helm-controller +// derives for an application's internal objects — the ServiceAccount it is applied +// as, its RoleBinding, its HelmRelease. The scheme is +// "hap---" over (kind, namespace, name). +// +// This is the twin of DerivedName("hap", "HelmApplication", ...) in TestDerivedName +// (images/operator-helm-controller/internal/utils/name_test.go): a change on either +// side that is not mirrored on the other breaks that test or +// TestApplicationServiceAccountName in this package. +func ApplicationServiceAccountName(namespace, name string) string { + sum := sha256.Sum256([]byte("HelmApplication/" + namespace + "/" + name)) + + return strings.Join([]string{ + "hap", + truncateNamePart(namespace), + truncateNamePart(name), + fmt.Sprintf("%x", sum[:])[:12], + }, "-") +} + +// truncateNamePart cuts a name part to applicationDerivedPartLimit and drops a dash +// or a dot the cut may have left at the end, mirroring utils.truncatePart. +func truncateNamePart(part string) string { + if len(part) > applicationDerivedPartLimit { + part = part[:applicationDerivedPartLimit] + } + + return strings.TrimRight(part, "-.") +} + +// helmReleaseNameLimit is the longest release name Helm accepts. It must match +// helmReleaseNameLimit in +// images/operator-helm-controller/internal/utils/name.go. +const helmReleaseNameLimit = 53 + +// ApplicationReleaseName reproduces the Helm release name operator-helm-controller +// installs a HelmApplication's chart under: the twin of +// utils.HashedReleaseName("hap-"+name) in +// images/operator-helm-controller/internal/adapter/application_release.go +// (ApplicationRelease.ReleaseName). The readable part is cut to what the hash +// leaves it, and the hash of the full name is always appended: without it a short +// name could be spelled exactly like the cut form of a long one, and the two +// applications would share one release storage. +// +// This is the twin of TestHashedReleaseNameSeparatesTwoValidNames +// (images/operator-helm-controller/internal/utils/name_test.go): a change on +// either side that is not mirrored on the other breaks one of the two tests. +func ApplicationReleaseName(name string) string { + full := "hap-" + name + + readable := full + if len(readable) > helmReleaseNameLimit-13 { + readable = readable[:helmReleaseNameLimit-13] + } + + sum := sha256.Sum256([]byte(full)) + + return strings.TrimRight(readable, "-.") + "-" + fmt.Sprintf("%x", sum[:])[:12] +} + +// ApplicationRepositoryInternalName reproduces the name operator-helm-controller +// derives for a HelmApplicationRepository's internal HelmRepository. The scheme is +// "hapr---" over (kind, namespace, name). +// +// This is the twin of DerivedName("hapr", "HelmApplicationRepository", ...) in +// TestDerivedName (images/operator-helm-controller/internal/utils/name_test.go): a +// change on either side that is not mirrored on the other breaks that test or +// TestApplicationRepositoryInternalName in this package. +func ApplicationRepositoryInternalName(namespace, name string) string { + sum := sha256.Sum256([]byte("HelmApplicationRepository/" + namespace + "/" + name)) + + return strings.Join([]string{ + "hapr", + truncateNamePart(namespace), + truncateNamePart(name), + fmt.Sprintf("%x", sum[:])[:12], + }, "-") +} diff --git a/tests/e2e/internal/naming/naming_test.go b/tests/e2e/internal/naming/naming_test.go new file mode 100644 index 00000000..56a3857c --- /dev/null +++ b/tests/e2e/internal/naming/naming_test.go @@ -0,0 +1,142 @@ +/* +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 naming + +import "testing" + +// TestApplicationServiceAccountName pins ApplicationServiceAccountName's output, +// including against the value operator-helm-controller's DerivedName produces for +// the same inputs. +// +// The first case is the twin of TestDerivedName's +// `DerivedName("hap", "HelmApplication", "e2e-app-ns", "e2e-test-app")` case in +// images/operator-helm-controller/internal/utils/name_test.go: a change on either +// side that is not mirrored on the other breaks one of the two tests. +func TestApplicationServiceAccountName(t *testing.T) { + cases := []struct { + name string + namespace string + object string + want string + }{ + { + name: "twin of DerivedName(hap, HelmApplication, e2e-app-ns, e2e-test-app)", + namespace: "e2e-app-ns", + object: "e2e-test-app", + want: "hap-e2e-app-ns-e2e-test-app-26155b312741", + }, + { + name: "long parts are truncated to 18 characters each", + namespace: "very-long-namespace-name-exceeding", + object: "very-long-application-name-exceeding", + want: "hap-very-long-namespac-very-long-applicat-35f137b7281e", + }, + { + name: "a truncation that ends in a dash drops it", + namespace: "abcdefghijklmnopq-x", + object: "stable", + want: "hap-abcdefghijklmnopq-stable-2b3b33a25854", + }, + { + // Twin of the "a truncation that ends in a dot drops it" case in + // TestDerivedName + // (images/operator-helm-controller/internal/utils/name_test.go). + name: "a truncation that ends in a dot drops it", + namespace: "team-a", + object: "abcdefghijklmnopq.x", + want: "hap-team-a-abcdefghijklmnopq-da2ee07a8439", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ApplicationServiceAccountName(tc.namespace, tc.object) + if got != tc.want { + t.Fatalf("ApplicationServiceAccountName(%q, %q) = %q, want %q", tc.namespace, tc.object, got, tc.want) + } + }) + } +} + +// TestApplicationReleaseName pins ApplicationReleaseName's output, including +// against the value operator-helm-controller's HelmReleaseName produces for the +// same "hap-"-prefixed input. +func TestApplicationReleaseName(t *testing.T) { + cases := []struct { + name string + object string + want string + }{ + { + name: "a short name still carries the hash", + object: "e2e-test-app-helm", + want: "hap-e2e-test-app-helm-eaa08759b576", + }, + { + // Twin of the "hap-prefixed name over the limit is cut and hashed" case + // in TestHelmReleaseName + // (images/operator-helm-controller/internal/utils/name_test.go): a + // change on either side that is not mirrored on the other breaks one of + // the two tests. + name: "a long name is cut to 40 characters and hashed", + object: "very-long-application-name-that-is-definitely-over-fifty-three-characters-long", + want: "hap-very-long-application-name-that-is-d-3080981cd4e1", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ApplicationReleaseName(tc.object) + if got != tc.want { + t.Fatalf("ApplicationReleaseName(%q) = %q, want %q", tc.object, got, tc.want) + } + }) + } +} + +// TestApplicationRepositoryInternalName pins ApplicationRepositoryInternalName's +// output, including against the value operator-helm-controller's DerivedName +// produces for the same inputs. +func TestApplicationRepositoryInternalName(t *testing.T) { + cases := []struct { + name string + namespace string + object string + want string + }{ + { + // Twin of TestDerivedName's "namespaced source carries namespace, name + // and a hash" case in + // images/operator-helm-controller/internal/utils/name_test.go: a + // change on either side that is not mirrored on the other breaks one + // of the two tests. + name: "twin of DerivedName(hapr, HelmApplicationRepository, team-a, stable)", + namespace: "team-a", + object: "stable", + want: "hapr-team-a-stable-42df68033b1e", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ApplicationRepositoryInternalName(tc.namespace, tc.object) + if got != tc.want { + t.Fatalf("ApplicationRepositoryInternalName(%q, %q) = %q, want %q", tc.namespace, tc.object, got, tc.want) + } + }) + } +} diff --git a/tests/e2e/internal/util/helmapplication.go b/tests/e2e/internal/util/helmapplication.go new file mode 100644 index 00000000..4cf85a85 --- /dev/null +++ b/tests/e2e/internal/util/helmapplication.go @@ -0,0 +1,135 @@ +/* +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" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" + "github.com/deckhouse/operator-helm/tests/e2e/internal/naming" +) + +// ApplicationServiceAccountName reproduces the name operator-helm-controller +// derives for an application's internal objects — the ServiceAccount it is applied +// as, its RoleBinding, its HelmRelease. The tests derive it rather than +// hard-coding one so a rename of an application under test does not silently stop +// checking anything. See internal/naming for the derivation itself. +func ApplicationServiceAccountName(namespace, name string) string { + return naming.ApplicationServiceAccountName(namespace, name) +} + +// ApplicationReleaseName reproduces the Helm release name operator-helm-controller +// installs an application's chart under. See internal/naming for the derivation +// itself. +func ApplicationReleaseName(name string) string { + return naming.ApplicationReleaseName(name) +} + +// HelmApplicationInternalReleaseSpec returns the serviceAccountName and +// storageNamespace recorded on an application's internal HelmRelease, identified +// by its derived internal name (see ApplicationServiceAccountName) in the module +// namespace. +func HelmApplicationInternalReleaseSpec(internalName string) (serviceAccountName, storageNamespace string, err error) { + release, err := framework.GetClients().DynamicClient().Resource(operatorHelmInternalHelmReleaseGVR). + Namespace(moduleNamespace).Get(context.Background(), internalName, metav1.GetOptions{}) + if err != nil { + return "", "", err + } + + serviceAccountName, _, err = unstructured.NestedString(release.Object, "spec", "serviceAccountName") + if err != nil { + return "", "", err + } + + storageNamespace, _, err = unstructured.NestedString(release.Object, "spec", "storageNamespace") + if err != nil { + return "", "", err + } + + return serviceAccountName, storageNamespace, nil +} + +// DeleteHelmApplication removes the application and waits until its internal helm +// release is gone, which is what says the uninstall actually finished. +func DeleteHelmApplication(f *framework.Framework, namespace, name string, timeout time.Duration) { + GinkgoHelper() + + err := f.OperatorClient().HelmV1alpha1().HelmApplications(namespace). + Delete(context.Background(), name, metav1.DeleteOptions{}) + Expect(client.IgnoreNotFound(err)).NotTo(HaveOccurred(), "failed to remove HelmApplication") + + UntilHelmApplicationDeleted(namespace, name, timeout) +} + +// UntilHelmApplicationDeleted waits until both the application and the internal helm +// release it owns are gone. The application alone would be a weaker signal than the +// addon family's own helper gives: that one waits for the internal release, and the +// uninstall is what the release's disappearance reports. +func UntilHelmApplicationDeleted(namespace, name string, timeout time.Duration) { + GinkgoHelper() + + internalName := ApplicationServiceAccountName(namespace, name) + + Eventually(func(g Gomega) { + _, err := framework.GetClients().OperatorClient().HelmV1alpha1().HelmApplications(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "HelmApplication %s/%s still exists", namespace, name) + + _, err = framework.GetClients().DynamicClient(). + Resource(operatorHelmInternalHelmReleaseGVR).Namespace(moduleNamespace). + Get(context.Background(), internalName, metav1.GetOptions{}) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "internal helm release %s still exists", internalName) + }).WithTimeout(timeout).WithPolling(framework.PollingInterval).Should(Succeed()) +} + +// DeleteHelmApplicationRepository removes the repository and waits until it is gone. +func DeleteHelmApplicationRepository(f *framework.Framework, namespace, name string, timeout time.Duration) { + GinkgoHelper() + + err := f.OperatorClient().HelmV1alpha1().HelmApplicationRepositories(namespace). + Delete(context.Background(), name, metav1.DeleteOptions{}) + Expect(client.IgnoreNotFound(err)).NotTo(HaveOccurred(), "failed to remove HelmApplicationRepository") + + Eventually(func(g Gomega) { + _, err := framework.GetClients().OperatorClient().HelmV1alpha1().HelmApplicationRepositories(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "HelmApplicationRepository %s/%s still exists", namespace, name) + }).WithTimeout(timeout).WithPolling(framework.PollingInterval).Should(Succeed()) +} + +// HelmApplicationRepositoryInternalName reproduces the name operator-helm-controller +// derives for a HelmApplicationRepository's internal HelmRepository. See +// internal/naming for the derivation itself. +func HelmApplicationRepositoryInternalName(namespace, name string) string { + return naming.ApplicationRepositoryInternalName(namespace, name) +} + +// GetHelmApplicationRepositoryInternalHelmRepository fetches an application +// repository's internal HelmRepository, identified by its derived internal name +// (see HelmApplicationRepositoryInternalName), in the module namespace. +func GetHelmApplicationRepositoryInternalHelmRepository(internalName string) (*unstructured.Unstructured, error) { + return framework.GetClients().DynamicClient().Resource(operatorHelmInternalHelmRepositoryGVR). + Namespace(moduleNamespace).Get(context.Background(), internalName, metav1.GetOptions{}) +} diff --git a/tests/e2e/internal/util/update.go b/tests/e2e/internal/util/update.go index 9c64554a..dc7655c8 100644 --- a/tests/e2e/internal/util/update.go +++ b/tests/e2e/internal/util/update.go @@ -72,3 +72,26 @@ func UpdateHelmClusterAddonRepository(name string, mutate func(*apiv1alpha1.Helm return updated } + +// UpdateHelmApplication performs a read-modify-write cycle on a HelmApplication +// with automatic retry on conflict. +func UpdateHelmApplication(namespace, name string, mutate func(*apiv1alpha1.HelmApplication)) *apiv1alpha1.HelmApplication { + GinkgoHelper() + + var updated *apiv1alpha1.HelmApplication + Eventually(func(g Gomega) { + current, err := framework.GetClients().OperatorClient().HelmV1alpha1(). + HelmApplications(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + + mutate(current) + + updated, err = framework.GetClients().OperatorClient().HelmV1alpha1(). + HelmApplications(namespace). + Update(context.Background(), current, metav1.UpdateOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + + return updated +} diff --git a/tools/internalcrds/.golangci.yaml b/tools/internalcrds/.golangci.yaml new file mode 100644 index 00000000..9e052264 --- /dev/null +++ b/tools/internalcrds/.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 diff --git a/tools/internalcrds/Taskfile.dist.yaml b/tools/internalcrds/Taskfile.dist.yaml new file mode 100644 index 00000000..7bf197ea --- /dev/null +++ b/tools/internalcrds/Taskfile.dist.yaml @@ -0,0 +1,21 @@ +version: "3" + +silent: true + +includes: + artifact: + taskfile: https://raw.githubusercontent.com/werf/common-ci/refs/heads/main/Taskfile.format_lint.yml + flatten: true + vars: + gciPrefix: '{{.gciPrefix | default "github.com/deckhouse/"}}' + golangciConfigPath: '{{.golangciConfigPath | default "./.golangci.yaml"}}' + golangciLintBinDir: '{{.golangciLintBinDir | default "../../bin"}}' + golangciLintVersion: '{{.golangciLintVersion | default "v2.13.2"}}' + golangciPaths: '{{.golangciPaths | default "./..."}}' + paths: '{{.paths | default "."}}' + +tasks: + test:unit: + desc: "Run the unit tests of this module." + cmds: + - go test ./... diff --git a/tools/internalcrds/go.mod b/tools/internalcrds/go.mod new file mode 100644 index 00000000..d7154b01 --- /dev/null +++ b/tools/internalcrds/go.mod @@ -0,0 +1,5 @@ +module github.com/deckhouse/operator-helm/tools/internalcrds + +go 1.26.3 + +require go.yaml.in/yaml/v3 v3.0.4 diff --git a/tools/internalcrds/go.sum b/tools/internalcrds/go.sum new file mode 100644 index 00000000..56a75c76 --- /dev/null +++ b/tools/internalcrds/go.sum @@ -0,0 +1,4 @@ +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/tools/internalcrds/main.go b/tools/internalcrds/main.go new file mode 100644 index 00000000..fed07c32 --- /dev/null +++ b/tools/internalcrds/main.go @@ -0,0 +1,173 @@ +/* +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. +*/ + +// Command internalcrds turns the upstream flux CustomResourceDefinitions into +// the internal ones this module ships. It reads every yaml document from the +// input directories, renames it, and writes one file per controller. +// +// Usage: +// +// internalcrds -out crds/embedded/helm-controller.yaml ... +package main + +import ( + "bytes" + "flag" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "go.yaml.in/yaml/v3" +) + +// forbiddenGroup must not survive into the rendered output. Its presence means +// a group slipped past Rename: one nested somewhere the walk does not visit, +// which a future flux version could introduce without the rename table +// noticing. +const forbiddenGroup = "toolkit.fluxcd.io" + +func main() { + out := flag.String("out", "", "file to write the renamed definitions to") + flag.Parse() + + if *out == "" || flag.NArg() == 0 { + fmt.Fprintln(os.Stderr, "usage: internalcrds -out ...") + os.Exit(2) + } + + if err := run(*out, flag.Args()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(out string, dirs []string) error { + var docs []map[string]any + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("reading %s: %w", dir, err) + } + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { + continue + } + + raw, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return fmt.Errorf("reading %s: %w", entry.Name(), err) + } + + doc := map[string]any{} + if err := yaml.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("parsing %s: %w", entry.Name(), err) + } + if err := checkKnownKind(entry.Name(), doc); err != nil { + return err + } + if err := Rename(doc); err != nil { + return fmt.Errorf("renaming %s: %w", entry.Name(), err) + } + + docs = append(docs, doc) + } + } + + // A stable order, or every run produces a different file. + slices.SortFunc(docs, func(a, b map[string]any) int { + return strings.Compare(name(a), name(b)) + }) + + var buf bytes.Buffer + for _, doc := range docs { + encoded, err := marshal(doc) + if err != nil { + return fmt.Errorf("encoding %s: %w", name(doc), err) + } + + buf.WriteString("---\n") + buf.Write(encoded) + } + + if err := checkNoLeftoverUpstream(out, buf.Bytes()); err != nil { + return err + } + + return os.WriteFile(out, buf.Bytes(), 0o644) +} + +// marshal renders a document with the indent kubectl and controller-gen use, +// rather than the library's own default, so the committed files carry no +// unrelated whitespace diff on top of the actual rename. +func marshal(doc map[string]any) ([]byte, error) { + var buf bytes.Buffer + + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + + if err := enc.Encode(doc); err != nil { + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func name(doc map[string]any) string { + metadata, _ := doc["metadata"].(map[string]any) + value, _ := metadata["name"].(string) + + return value +} + +// checkKnownKind fails when a document declares a kind Rename does not know +// about. A new upstream kind arrives as a new definition file, so this is +// where such an addition is caught, before Rename has a chance to leave a +// reference to it unrenamed elsewhere in the schema. +func checkKnownKind(entryName string, doc map[string]any) error { + spec, _ := doc["spec"].(map[string]any) + names, _ := spec["names"].(map[string]any) + kind, _ := names["kind"].(string) + + if !slices.Contains(upstreamKinds, kind) { + return fmt.Errorf("%s: unknown upstream kind %q", entryName, kind) + } + + return nil +} + +// checkNoLeftoverUpstream fails if the rendered output still names an upstream +// flux group or kind: Rename addresses both by path, so one nested somewhere +// the walk does not visit would otherwise slip through and ship silently +// unrenamed. A kind that survives in a validation expression is not merely +// untidy — the rule can no longer hold against the renamed enum beside it. +func checkNoLeftoverUpstream(path string, rendered []byte) error { + for i, line := range strings.Split(string(rendered), "\n") { + if !strings.Contains(line, forbiddenGroup) && !kindWord.MatchString(line) { + continue + } + + return fmt.Errorf("%s:%d: leftover upstream identity: %s", path, i+1, strings.TrimSpace(line)) + } + + return nil +} diff --git a/tools/internalcrds/main_test.go b/tools/internalcrds/main_test.go new file mode 100644 index 00000000..da682437 --- /dev/null +++ b/tools/internalcrds/main_test.go @@ -0,0 +1,74 @@ +/* +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 ( + "os" + "path/filepath" + "testing" +) + +func TestRunFailsOnUnknownKind(t *testing.T) { + dir := t.TempDir() + doc := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: artifactgenerators.source.toolkit.fluxcd.io +spec: + group: source.toolkit.fluxcd.io + names: + kind: ArtifactGenerator + listKind: ArtifactGeneratorList + plural: artifactgenerators + singular: artifactgenerator +` + if err := os.WriteFile(filepath.Join(dir, "artifactgenerators.yaml"), []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + + out := filepath.Join(t.TempDir(), "out.yaml") + if err := run(out, []string{dir}); err == nil { + t.Fatal("expected an error, got nil") + } + + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatal("run must not write the output file when a document declares an unknown kind") + } +} + +func TestCheckNoLeftoverUpstreamFailsOnLeftoverGroup(t *testing.T) { + rendered := "spec:\n group: source.toolkit.fluxcd.io\n" + + err := checkNoLeftoverUpstream("out.yaml", []byte(rendered)) + if err == nil { + t.Fatal("expected an error, got nil") + } + + const want = `out.yaml:2: leftover upstream identity: group: source.toolkit.fluxcd.io` + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestCheckNoLeftoverUpstreamRejectsSubstituteAnnotation(t *testing.T) { + rendered := "metadata:\n annotations:\n kustomize.toolkit.fluxcd.io/substitute: \"true\"\n" + + if err := checkNoLeftoverUpstream("out.yaml", []byte(rendered)); err == nil { + t.Fatal("expected an error, got nil") + } +} diff --git a/tools/internalcrds/rename.go b/tools/internalcrds/rename.go new file mode 100644 index 00000000..56076cf0 --- /dev/null +++ b/tools/internalcrds/rename.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 main + +import ( + "errors" + "fmt" + "regexp" + "slices" + "strings" +) + +// internalPrefix is where every upstream flux group is mapped to. It matches the +// prefix the api rewriter uses, and the two must never diverge: a resource the +// proxy renames to a group no CustomResourceDefinition declares is simply not +// found, and that only shows up against a live cluster. +const internalPrefix = "internal.operator-helm.deckhouse.io" + +// InternalGroup maps an upstream flux API group to the internal one this module +// serves. Anything but the two flux groups is a programming error: the generator +// is fed a fixed list of upstream CustomResourceDefinitions. +func InternalGroup(group string) string { + switch group { + case "source.toolkit.fluxcd.io": + return "source." + internalPrefix + case "helm.toolkit.fluxcd.io": + return "helm." + internalPrefix + default: + return "" + } +} + +// upstreamKinds lists every kind the two upstream controllers declare, so a +// reference inside a schema can be recognised and renamed. A kind missing here +// silently stays unrenamed, which is why Rename fails on an unknown group +// rather than guessing. +var upstreamKinds = []string{ + "Bucket", + "ExternalArtifact", + "GitRepository", + "HelmChart", + "HelmRelease", + "HelmRepository", + "OCIRepository", +} + +// kindPrefix is prepended to every upstream kind. It carries the word "Nelm" +// for historical reasons: these are the names of live objects in every cluster +// running the module, and moving them would be a migration of its own. +const kindPrefix = "InternalNelmOperator" + +// kindWord matches an upstream kind standing on its own as a word, which is +// how the schemas refer to one in prose and in validation expressions. The +// word boundaries keep it off the type names built from a kind — a +// HelmChartStatus is upstream's own type and is not served under any name +// here — and off a renamed kind, whose prefix leaves no boundary in front. +var kindWord = regexp.MustCompile(`\b(` + strings.Join(upstreamKinds, "|") + `)\b`) + +// Rename turns one upstream CustomResourceDefinition into the internal one this +// module serves: the group, the names block, the object name, and every +// reference to a kind inside the schemas, whether it is a value the API server +// reads or text a reader does. +func Rename(doc map[string]any) error { + spec, ok := doc["spec"].(map[string]any) + if !ok { + return errors.New("the document has no spec") + } + + group, _ := spec["group"].(string) + internal := InternalGroup(group) + if internal == "" { + return fmt.Errorf("unexpected api group %q", group) + } + spec["group"] = internal + + names, ok := spec["names"].(map[string]any) + if !ok { + return errors.New("the document has no spec.names") + } + + kind, ok := names["kind"].(string) + if !ok { + return errors.New("the document has no spec.names.kind") + } + names["kind"] = kindPrefix + kind + if listKind, ok := names["listKind"].(string); ok { + names["listKind"] = kindPrefix + listKind + } + + plural, ok := names["plural"].(string) + if !ok { + return errors.New("the document has no spec.names.plural") + } + names["plural"] = strings.ToLower(kindPrefix) + plural + if singular, ok := names["singular"].(string); ok { + names["singular"] = strings.ToLower(kindPrefix) + singular + } + + // Service objects: nobody types them, and the upstream values would take + // "hr", "hc" and the "all" category away from a real flux in the cluster. + delete(names, "shortNames") + delete(names, "categories") + + metadata, ok := doc["metadata"].(map[string]any) + if !ok { + return errors.New("the document has no metadata") + } + // Upstream tells a real flux kustomize-controller not to substitute + // variables into these definitions. Nothing applies them through one — + // Deckhouse installs them with the module — so the instruction has no + // reader here and only leaves an upstream identity on the object. + if annotations, ok := metadata["annotations"].(map[string]any); ok { + delete(annotations, "kustomize.toolkit.fluxcd.io/substitute") + + if len(annotations) == 0 { + delete(metadata, "annotations") + } + } + + metadata["name"] = names["plural"].(string) + "." + internal + metadata["labels"] = map[string]any{ + "backup.deckhouse.io/cluster-config": "true", + "heritage": "deckhouse", + "module": "operator-helm", + } + + renameKindReferences(spec) + + return nil +} + +// renameKindReferences walks the schemas and renames every upstream kind it +// finds: under a "kind" key in a map; with no key to scope the match, any +// plain string list element equal to a kind; and inside the three free-text +// fields that name kinds — the documentation a reader gets from kubectl +// explain, and the message and expression of a validation rule. Today the +// only lists it reaches are the sourceRef and chartRef kind enums, so +// matching a list element by value alone is safe; a future field whose string +// entries happened to equal an upstream kind's name would be renamed too. +// +// Renaming the expression of a rule is not cosmetic: a rule comparing against +// an upstream kind can never hold once the enum beside it is renamed. +func renameKindReferences(node any) { + switch typed := node.(type) { + case map[string]any: + for key, value := range typed { + str, ok := value.(string) + if !ok { + renameKindReferences(value) + + continue + } + + switch { + case key == "kind" && slices.Contains(upstreamKinds, str): + typed[key] = kindPrefix + str + case key == "description", key == "message", key == "rule": + typed[key] = kindWord.ReplaceAllString(str, kindPrefix+"${1}") + } + } + case []any: + for i, value := range typed { + if str, ok := value.(string); ok && slices.Contains(upstreamKinds, str) { + typed[i] = kindPrefix + str + + continue + } + + renameKindReferences(value) + } + } +} diff --git a/tools/internalcrds/rename_test.go b/tools/internalcrds/rename_test.go new file mode 100644 index 00000000..cb3bbe62 --- /dev/null +++ b/tools/internalcrds/rename_test.go @@ -0,0 +1,277 @@ +/* +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 ( + "reflect" + "testing" +) + +func TestInternalGroup(t *testing.T) { + cases := map[string]string{ + "source.toolkit.fluxcd.io": "source.internal.operator-helm.deckhouse.io", + "helm.toolkit.fluxcd.io": "helm.internal.operator-helm.deckhouse.io", + } + + for in, want := range cases { + if got := InternalGroup(in); got != want { + t.Fatalf("InternalGroup(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRenameRewritesEveryIdentity(t *testing.T) { + doc := map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]any{ + "name": "helmreleases.helm.toolkit.fluxcd.io", + }, + "spec": map[string]any{ + "group": "helm.toolkit.fluxcd.io", + "names": map[string]any{ + "kind": "HelmRelease", + "listKind": "HelmReleaseList", + "plural": "helmreleases", + "singular": "helmrelease", + "shortNames": []any{"hr"}, + "categories": []any{"all", "fluxcd"}, + }, + }, + } + + if err := Rename(doc); err != nil { + t.Fatalf("Rename returned %v", err) + } + + spec := doc["spec"].(map[string]any) + names := spec["names"].(map[string]any) + meta := doc["metadata"].(map[string]any) + + if got := spec["group"]; got != "helm.internal.operator-helm.deckhouse.io" { + t.Fatalf("group = %v", got) + } + if got := names["kind"]; got != "InternalNelmOperatorHelmRelease" { + t.Fatalf("kind = %v", got) + } + if got := names["listKind"]; got != "InternalNelmOperatorHelmReleaseList" { + t.Fatalf("listKind = %v", got) + } + if got := names["plural"]; got != "internalnelmoperatorhelmreleases" { + t.Fatalf("plural = %v", got) + } + if got := names["singular"]; got != "internalnelmoperatorhelmrelease" { + t.Fatalf("singular = %v", got) + } + if got := meta["name"]; got != "internalnelmoperatorhelmreleases.helm.internal.operator-helm.deckhouse.io" { + t.Fatalf("metadata.name = %v", got) + } + + // Short names and categories are dropped rather than renamed: these are + // service objects, and the upstream values would collide with a real flux. + if _, ok := names["shortNames"]; ok { + t.Fatal("shortNames must be gone") + } + if _, ok := names["categories"]; ok { + t.Fatal("categories must be gone") + } +} + +func TestRenameRewritesNestedReferences(t *testing.T) { + doc := map[string]any{ + "spec": map[string]any{ + "group": "helm.toolkit.fluxcd.io", + "names": map[string]any{"kind": "HelmRelease", "plural": "helmreleases", "singular": "helmrelease"}, + "versions": []any{map[string]any{ + "schema": map[string]any{"openAPIV3Schema": map[string]any{ + "properties": map[string]any{"spec": map[string]any{ + "properties": map[string]any{"chartRef": map[string]any{ + "properties": map[string]any{ + "kind": map[string]any{ + "enum": []any{"OCIRepository", "HelmChart"}, + }, + }, + }}, + }}, + }}, + }}, + }, + "metadata": map[string]any{"name": "helmreleases.helm.toolkit.fluxcd.io"}, + } + + if err := Rename(doc); err != nil { + t.Fatalf("Rename returned %v", err) + } + + enum := doc["spec"].(map[string]any)["versions"].([]any)[0].(map[string]any)["schema"].(map[string]any)["openAPIV3Schema"].(map[string]any)["properties"].(map[string]any)["spec"].(map[string]any)["properties"].(map[string]any)["chartRef"].(map[string]any)["properties"].(map[string]any)["kind"].(map[string]any)["enum"].([]any) + + want := []any{"InternalNelmOperatorOCIRepository", "InternalNelmOperatorHelmChart"} + if !reflect.DeepEqual(enum, want) { + t.Fatalf("chartRef kind enum = %v, want %v", enum, want) + } +} + +func TestRenameFailsOnMissingNames(t *testing.T) { + cases := map[string]struct { + names map[string]any + want string + }{ + "kind missing": {map[string]any{"plural": "helmreleases", "singular": "helmrelease"}, "the document has no spec.names.kind"}, + "kind not a string": {map[string]any{"kind": 1, "plural": "helmreleases", "singular": "helmrelease"}, "the document has no spec.names.kind"}, + "plural missing": {map[string]any{"kind": "HelmRelease", "singular": "helmrelease"}, "the document has no spec.names.plural"}, + } + + for name, tc := range cases { + doc := map[string]any{ + "spec": map[string]any{ + "group": "helm.toolkit.fluxcd.io", + "names": tc.names, + }, + "metadata": map[string]any{"name": "helmreleases.helm.toolkit.fluxcd.io"}, + } + + err := Rename(doc) + if err == nil { + t.Fatalf("%s: expected an error, got nil", name) + } + + if err.Error() != tc.want { + t.Fatalf("%s: error = %q, want %q", name, err.Error(), tc.want) + } + } +} + +func TestRenameRewritesKindNamesInProse(t *testing.T) { + doc := map[string]any{ + "spec": map[string]any{ + "group": "source.toolkit.fluxcd.io", + "names": map[string]any{"kind": "HelmChart", "plural": "helmcharts", "singular": "helmchart"}, + "versions": []any{map[string]any{ + "schema": map[string]any{"openAPIV3Schema": map[string]any{ + "description": "HelmChart is the Schema for the helmcharts API.", + "properties": map[string]any{"spec": map[string]any{ + "properties": map[string]any{"sourceRef": map[string]any{ + "properties": map[string]any{"kind": map[string]any{ + "description": "Kind of the referent, valid values are ('HelmRepository', 'GitRepository', 'Bucket').", + }}, + }}, + "x-kubernetes-validations": []any{map[string]any{ + "message": "spec.verify is only supported when spec.sourceRef.kind is 'HelmRepository'", + "rule": "!has(self.verify) || self.sourceRef.kind == 'HelmRepository'", + }}, + }}, + "status": map[string]any{ + "description": "HelmChartStatus records the observed state of the HelmChart.", + }, + }}, + }}, + }, + "metadata": map[string]any{"name": "helmcharts.source.toolkit.fluxcd.io"}, + } + + if err := Rename(doc); err != nil { + t.Fatalf("Rename returned %v", err) + } + + schema := doc["spec"].(map[string]any)["versions"].([]any)[0].(map[string]any)["schema"].(map[string]any)["openAPIV3Schema"].(map[string]any) + specSchema := schema["properties"].(map[string]any)["spec"].(map[string]any) + validation := specSchema["x-kubernetes-validations"].([]any)[0].(map[string]any) + + cases := map[string]struct { + got any + want string + }{ + "schema description": { + schema["description"], + "InternalNelmOperatorHelmChart is the Schema for the helmcharts API.", + }, + "referent description": { + specSchema["properties"].(map[string]any)["sourceRef"].(map[string]any)["properties"].(map[string]any)["kind"].(map[string]any)["description"], + "Kind of the referent, valid values are ('InternalNelmOperatorHelmRepository', 'InternalNelmOperatorGitRepository', 'InternalNelmOperatorBucket').", + }, + "validation message": { + validation["message"], + "spec.verify is only supported when spec.sourceRef.kind is 'InternalNelmOperatorHelmRepository'", + }, + "validation rule": { + validation["rule"], + "!has(self.verify) || self.sourceRef.kind == 'InternalNelmOperatorHelmRepository'", + }, + // A composite upstream type name is not a kind and stays as upstream + // wrote it; only the kind it is named after moves. + "composite type name": { + schema["status"].(map[string]any)["description"], + "HelmChartStatus records the observed state of the InternalNelmOperatorHelmChart.", + }, + } + + for name, tc := range cases { + if tc.got != tc.want { + t.Errorf("%s = %v, want %q", name, tc.got, tc.want) + } + } +} + +func TestRenameDropsTheSubstituteAnnotation(t *testing.T) { + doc := map[string]any{ + "spec": map[string]any{ + "group": "helm.toolkit.fluxcd.io", + "names": map[string]any{"kind": "HelmRelease", "plural": "helmreleases", "singular": "helmrelease"}, + }, + "metadata": map[string]any{ + "name": "helmreleases.helm.toolkit.fluxcd.io", + "annotations": map[string]any{ + "controller-gen.kubebuilder.io/version": "v0.21.0", + "kustomize.toolkit.fluxcd.io/substitute": "disabled", + }, + }, + } + + if err := Rename(doc); err != nil { + t.Fatalf("Rename returned %v", err) + } + + annotations := doc["metadata"].(map[string]any)["annotations"].(map[string]any) + if _, ok := annotations["kustomize.toolkit.fluxcd.io/substitute"]; ok { + t.Error("the substitute annotation survived") + } + + if annotations["controller-gen.kubebuilder.io/version"] != "v0.21.0" { + t.Errorf("unrelated annotations = %v", annotations) + } +} + +func TestRenameDropsAnEmptiedAnnotationsBlock(t *testing.T) { + doc := map[string]any{ + "spec": map[string]any{ + "group": "helm.toolkit.fluxcd.io", + "names": map[string]any{"kind": "HelmRelease", "plural": "helmreleases", "singular": "helmrelease"}, + }, + "metadata": map[string]any{ + "name": "helmreleases.helm.toolkit.fluxcd.io", + "annotations": map[string]any{"kustomize.toolkit.fluxcd.io/substitute": "disabled"}, + }, + } + + if err := Rename(doc); err != nil { + t.Fatalf("Rename returned %v", err) + } + + if _, ok := doc["metadata"].(map[string]any)["annotations"]; ok { + t.Error("an empty annotations block was left behind") + } +}