From fee4e24a22b5f58ef48a60c12dd33737fb610121 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 11:49:01 +0300 Subject: [PATCH 001/145] refactor(api): extract the shared chart catalog naming scheme Signed-off-by: Ilya Drey --- api/naming/naming.go | 27 ++++++++++-- api/naming/naming_test.go | 88 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/api/naming/naming.go b/api/naming/naming.go index b6c82865..f6f61aba 100644 --- a/api/naming/naming.go +++ b/api/naming/naming.go @@ -23,10 +23,31 @@ 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 { + 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 single 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: the name is a truncated hash, so both +// must derive it identically. Names coincide across families on purpose — the +// objects differ in kind, and the namespaced and cluster variants live in +// different scopes, so a shared name cannot collide. +func chartObjectName(repoName, chartName string) string { hash := hash(fmt.Sprintf("%s-chart-%s", repoName, chartName)) var result, postfix string diff --git a/api/naming/naming_test.go b/api/naming/naming_test.go index 2b5f8df9..ca1483f3 100644 --- a/api/naming/naming_test.go +++ b/api/naming/naming_test.go @@ -53,3 +53,91 @@ func TestHelmClusterAddonChartName(t *testing.T) { }) } } + +func TestApplicationChartName(t *testing.T) { + cases := []struct { + name string + repo string + chart string + want string + }{ + { + name: "short names are joined verbatim", + repo: "example", + chart: "podinfo", + want: "example-chart-podinfo", + }, + { + 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", + }, + { + name: "an empty chart name leaves no trailing dash", + repo: "repo", + chart: "", + want: "repo-chart", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ApplicationChartName(tc.repo, tc.chart); got != tc.want { + t.Fatalf("ApplicationChartName(%q, %q) = %q, want %q", tc.repo, tc.chart, got, tc.want) + } + }) + } +} + +func TestClusterApplicationChartName(t *testing.T) { + cases := []struct { + name string + repo string + chart string + want string + }{ + { + name: "short names are joined verbatim", + repo: "shared", + chart: "nginx", + want: "shared-chart-nginx", + }, + { + 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", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ClusterApplicationChartName(tc.repo, tc.chart); got != tc.want { + t.Fatalf("ClusterApplicationChartName(%q, %q) = %q, want %q", tc.repo, tc.chart, got, tc.want) + } + }) + } +} + +// 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) + } +} From 941c23e5f96c3aba0848a856fa59c6714f7e9497 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 12:03:25 +0300 Subject: [PATCH 002/145] feat(api): add the HelmApplicationRepository custom resource Signed-off-by: Ilya Drey --- .../typed/api/v1alpha1/api_client.go | 5 + .../api/v1alpha1/fake/fake_api_client.go | 4 + .../fake/fake_helmapplicationrepository.go | 52 +++++ .../typed/api/v1alpha1/generated_expansion.go | 2 + .../api/v1alpha1/helmapplicationrepository.go | 70 ++++++ .../api/v1alpha1/helmapplicationrepository.go | 102 +++++++++ .../api/v1alpha1/interface.go | 7 + .../informers/externalversions/generic.go | 2 + .../api/v1alpha1/expansion_generated.go | 8 + .../api/v1alpha1/helmapplicationrepository.go | 70 ++++++ api/v1alpha1/helm_application_repository.go | 179 +++++++++++++++ .../helm_application_repository_test.go | 96 ++++++++ api/v1alpha1/register.go | 3 + api/v1alpha1/zz_generated.deepcopy.go | 133 +++++++++++ crds/doc-ru-helmapplicationrepositories.yaml | 52 +++++ crds/helmapplicationrepositories.yaml | 215 ++++++++++++++++++ 16 files changed, 1000 insertions(+) create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplicationrepository.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplicationrepository.go create mode 100644 api/client/generated/informers/externalversions/api/v1alpha1/helmapplicationrepository.go create mode 100644 api/client/generated/listers/api/v1alpha1/helmapplicationrepository.go create mode 100644 api/v1alpha1/helm_application_repository.go create mode 100644 api/v1alpha1/helm_application_repository_test.go create mode 100644 crds/doc-ru-helmapplicationrepositories.yaml create mode 100644 crds/helmapplicationrepositories.yaml 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..a3748c33 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,6 +28,7 @@ import ( type HelmV1alpha1Interface interface { RESTClient() rest.Interface + HelmApplicationRepositoriesGetter HelmClusterAddonsGetter HelmClusterAddonChartsGetter HelmClusterAddonRepositoriesGetter @@ -38,6 +39,10 @@ type HelmV1alpha1Client struct { restClient rest.Interface } +func (c *HelmV1alpha1Client) HelmApplicationRepositories(namespace string) HelmApplicationRepositoryInterface { + return newHelmApplicationRepositories(c, namespace) +} + func (c *HelmV1alpha1Client) HelmClusterAddons() HelmClusterAddonInterface { return newHelmClusterAddons(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..85c7c270 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,10 @@ type FakeHelmV1alpha1 struct { *testing.Fake } +func (c *FakeHelmV1alpha1) HelmApplicationRepositories(namespace string) v1alpha1.HelmApplicationRepositoryInterface { + return newFakeHelmApplicationRepositories(c, namespace) +} + func (c *FakeHelmV1alpha1) HelmClusterAddons() v1alpha1.HelmClusterAddonInterface { return newFakeHelmClusterAddons(c) } 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/generated_expansion.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go index 911c8ea4..ecf064d3 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,6 +18,8 @@ limitations under the License. package v1alpha1 +type HelmApplicationRepositoryExpansion interface{} + type HelmClusterAddonExpansion interface{} type HelmClusterAddonChartExpansion interface{} 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/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/interface.go b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go index e8deecc9..7d8bed25 100644 --- a/api/client/generated/informers/externalversions/api/v1alpha1/interface.go +++ b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go @@ -24,6 +24,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // HelmApplicationRepositories returns a HelmApplicationRepositoryInformer. + HelmApplicationRepositories() HelmApplicationRepositoryInformer // HelmClusterAddons returns a HelmClusterAddonInformer. HelmClusterAddons() HelmClusterAddonInformer // HelmClusterAddonCharts returns a HelmClusterAddonChartInformer. @@ -43,6 +45,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: 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} diff --git a/api/client/generated/informers/externalversions/generic.go b/api/client/generated/informers/externalversions/generic.go index aca9bbbb..0f6af379 100644 --- a/api/client/generated/informers/externalversions/generic.go +++ b/api/client/generated/informers/externalversions/generic.go @@ -53,6 +53,8 @@ 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("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"): diff --git a/api/client/generated/listers/api/v1alpha1/expansion_generated.go b/api/client/generated/listers/api/v1alpha1/expansion_generated.go index 8e4f30ff..a39267d8 100644 --- a/api/client/generated/listers/api/v1alpha1/expansion_generated.go +++ b/api/client/generated/listers/api/v1alpha1/expansion_generated.go @@ -18,6 +18,14 @@ limitations under the License. package v1alpha1 +// 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{} 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/v1alpha1/helm_application_repository.go b/api/v1alpha1/helm_application_repository.go new file mode 100644 index 00000000..2c3dfec1 --- /dev/null +++ b/api/v1alpha1/helm_application_repository.go @@ -0,0 +1,179 @@ +/* +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" +) + +// 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. +// +// The name length is guarded by a CEL rule rather than by the schema because +// metadata.name has no schema of its own. The upper bound is not decorative: the +// repository name is stored as the value of the "repository" label on the objects +// of its chart catalog, and a label value cannot exceed 63 characters. +// +// Both notes are deliberately outside the doc comment below — controller-gen folds +// every non-marker line of that block into the resource's API description. + +// 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 ApplicationRepositorySpec `json:"spec"` + Status ApplicationRepositoryStatus `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) GetStatus() any { + return r.Status +} + +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 +} + +// ApplicationRepositorySpec and ApplicationRepositoryStatus below are shared by +// HelmApplicationRepository and HelmClusterApplicationRepository: the two kinds +// differ only in scope. Declaring them once makes a divergence between the two +// schemas impossible by construction, and keeps a single translation for both in +// crds/doc-ru-*.yaml. +// +// 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 ApplicationRepositorySpec 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 *ApplicationRepositoryAuth `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 ApplicationRepositoryAuth struct { + // Repository authentication username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + // Repository authentication password. + // +kubebuilder:validation:MinLength=1 + Password string `json:"password"` +} + +type ApplicationRepositoryStatus 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"` +} + +// 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/register.go b/api/v1alpha1/register.go index 1e7fd460..cca316ca 100644 --- a/api/v1alpha1/register.go +++ b/api/v1alpha1/register.go @@ -33,6 +33,7 @@ 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} + HelmApplicationRepositoryGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmApplicationRepositoryKind} ) func Kind(kind string) schema.GroupKind { @@ -60,6 +61,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &HelmClusterAddonRepositoryList{}, &HelmClusterAddonChart{}, &HelmClusterAddonChartList{}, + &HelmApplicationRepository{}, + &HelmApplicationRepositoryList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index e02b8b17..27bd40d4 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -27,6 +27,139 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApplicationRepositoryAuth) DeepCopyInto(out *ApplicationRepositoryAuth) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationRepositoryAuth. +func (in *ApplicationRepositoryAuth) DeepCopy() *ApplicationRepositoryAuth { + if in == nil { + return nil + } + out := new(ApplicationRepositoryAuth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApplicationRepositorySpec) DeepCopyInto(out *ApplicationRepositorySpec) { + *out = *in + if in.Auth != nil { + in, out := &in.Auth, &out.Auth + *out = new(ApplicationRepositoryAuth) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationRepositorySpec. +func (in *ApplicationRepositorySpec) DeepCopy() *ApplicationRepositorySpec { + if in == nil { + return nil + } + out := new(ApplicationRepositorySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApplicationRepositoryStatus) DeepCopyInto(out *ApplicationRepositoryStatus) { + *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.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() + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationRepositoryStatus. +func (in *ApplicationRepositoryStatus) DeepCopy() *ApplicationRepositoryStatus { + if in == nil { + return nil + } + out := new(ApplicationRepositoryStatus) + in.DeepCopyInto(out) + return out +} + +// 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 *HelmClusterAddon) DeepCopyInto(out *HelmClusterAddon) { *out = *in diff --git a/crds/doc-ru-helmapplicationrepositories.yaml b/crds/doc-ru-helmapplicationrepositories.yaml new file mode 100644 index 00000000..e7c8797b --- /dev/null +++ b/crds/doc-ru-helmapplicationrepositories.yaml @@ -0,0 +1,52 @@ +--- +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: | + Условия отражают последние наблюдения за состоянием репозитория. + + `Ready` сообщает, пригоден ли репозиторий: вспомогательные ресурсы на месте, внутренний объект источника исправен, и репозиторий ответил на чтение каталога на текущей спецификации. Транзиентная ошибка чтения не переводит `Ready` в `False`. + + `Synced` сообщает, актуален ли каталог чартов. + + `Reconciling` и `Stalled` следуют соглашению kstatus: они присутствуют, только когда применимы. `Reconciling` означает, что работа выполняется или запланирован повтор; `Stalled` — что репозиторий не восстановится без вмешательства. Пока выполняется синхронизация, `Reconciling` имеет причину `Synchronization`, либо `ForceReconcile`, если проход был запрошен аннотацией принудительной реконсиляции. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. + lastSuccessfulSyncTime: + description: Время последнего успешного приведения каталога чартов в актуальное состояние. + nextSyncTime: + description: Запланированное время следующей попытки синхронизации. + lastForceReconcileTime: + description: | + Время обработки последнего запроса принудительной реконсиляции. Фиксирует, что запрос был обработан, а не что он завершился успешно — результат отражают `Ready` и `Synced`. + consecutiveFetchFailures: + description: Число подряд идущих неудачных обращений к репозиторию. Определяет задержку повтора и обнуляется при первом успехе. diff --git a/crds/helmapplicationrepositories.yaml b/crds/helmapplicationrepositories.yaml new file mode 100644 index 00000000..ac54c011 --- /dev/null +++ b/crds/helmapplicationrepositories.yaml @@ -0,0 +1,215 @@ +--- +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: + conditions: + 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. + 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: {} From f64f5bf9256bc6d509ad86e745ff47d5a1c9d92c Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 12:13:31 +0300 Subject: [PATCH 003/145] feat(api): add the HelmClusterApplicationRepository custom resource Signed-off-by: Ilya Drey --- .../typed/api/v1alpha1/api_client.go | 5 + .../api/v1alpha1/fake/fake_api_client.go | 4 + .../fake_helmclusterapplicationrepository.go | 54 +++++ .../typed/api/v1alpha1/generated_expansion.go | 2 + .../helmclusterapplicationrepository.go | 74 ++++++ .../helmclusterapplicationrepository.go | 101 ++++++++ .../api/v1alpha1/interface.go | 7 + .../informers/externalversions/generic.go | 2 + .../api/v1alpha1/expansion_generated.go | 4 + .../helmclusterapplicationrepository.go | 48 ++++ .../helm_cluster_application_repository.go | 111 +++++++++ ...elm_cluster_application_repository_test.go | 109 +++++++++ api/v1alpha1/register.go | 11 +- api/v1alpha1/zz_generated.deepcopy.go | 61 +++++ ...ru-helmclusterapplicationrepositories.yaml | 52 +++++ crds/helmclusterapplicationrepositories.yaml | 215 ++++++++++++++++++ 16 files changed, 856 insertions(+), 4 deletions(-) create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusterapplicationrepository.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusterapplicationrepository.go create mode 100644 api/client/generated/informers/externalversions/api/v1alpha1/helmclusterapplicationrepository.go create mode 100644 api/client/generated/listers/api/v1alpha1/helmclusterapplicationrepository.go create mode 100644 api/v1alpha1/helm_cluster_application_repository.go create mode 100644 api/v1alpha1/helm_cluster_application_repository_test.go create mode 100644 crds/doc-ru-helmclusterapplicationrepositories.yaml create mode 100644 crds/helmclusterapplicationrepositories.yaml 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 a3748c33..ca446f88 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 @@ -32,6 +32,7 @@ type HelmV1alpha1Interface interface { HelmClusterAddonsGetter HelmClusterAddonChartsGetter HelmClusterAddonRepositoriesGetter + HelmClusterApplicationRepositoriesGetter } // HelmV1alpha1Client is used to interact with features provided by the helm.deckhouse.io group. @@ -55,6 +56,10 @@ func (c *HelmV1alpha1Client) HelmClusterAddonRepositories() HelmClusterAddonRepo return newHelmClusterAddonRepositories(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 85c7c270..0b7d5865 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 @@ -44,6 +44,10 @@ func (c *FakeHelmV1alpha1) HelmClusterAddonRepositories() v1alpha1.HelmClusterAd return newFakeHelmClusterAddonRepositories(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_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 ecf064d3..97cf2c99 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 @@ -25,3 +25,5 @@ type HelmClusterAddonExpansion interface{} type HelmClusterAddonChartExpansion interface{} type HelmClusterAddonRepositoryExpansion interface{} + +type HelmClusterApplicationRepositoryExpansion interface{} 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/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 7d8bed25..9fc62750 100644 --- a/api/client/generated/informers/externalversions/api/v1alpha1/interface.go +++ b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go @@ -32,6 +32,8 @@ type Interface interface { HelmClusterAddonCharts() HelmClusterAddonChartInformer // HelmClusterAddonRepositories returns a HelmClusterAddonRepositoryInformer. HelmClusterAddonRepositories() HelmClusterAddonRepositoryInformer + // HelmClusterApplicationRepositories returns a HelmClusterApplicationRepositoryInformer. + HelmClusterApplicationRepositories() HelmClusterApplicationRepositoryInformer } type version struct { @@ -64,3 +66,8 @@ func (v *version) HelmClusterAddonCharts() HelmClusterAddonChartInformer { func (v *version) HelmClusterAddonRepositories() HelmClusterAddonRepositoryInformer { return &helmClusterAddonRepositoryInformer{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 0f6af379..7048a327 100644 --- a/api/client/generated/informers/externalversions/generic.go +++ b/api/client/generated/informers/externalversions/generic.go @@ -61,6 +61,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource 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("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 a39267d8..75e31423 100644 --- a/api/client/generated/listers/api/v1alpha1/expansion_generated.go +++ b/api/client/generated/listers/api/v1alpha1/expansion_generated.go @@ -37,3 +37,7 @@ type HelmClusterAddonChartListerExpansion interface{} // HelmClusterAddonRepositoryListerExpansion allows custom methods to be added to // HelmClusterAddonRepositoryLister. type HelmClusterAddonRepositoryListerExpansion interface{} + +// HelmClusterApplicationRepositoryListerExpansion allows custom methods to be added to +// HelmClusterApplicationRepositoryLister. +type HelmClusterApplicationRepositoryListerExpansion interface{} 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/v1alpha1/helm_cluster_application_repository.go b/api/v1alpha1/helm_cluster_application_repository.go new file mode 100644 index 00000000..1f1d10bd --- /dev/null +++ b/api/v1alpha1/helm_cluster_application_repository.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 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" +) + +// The spec and status of this kind are the shared ApplicationRepositorySpec and +// ApplicationRepositoryStatus declared next to HelmApplicationRepository: the two +// kinds differ only in scope. +// +// 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. +// +// The name length is guarded by a CEL rule rather than by the schema because +// metadata.name has no schema of its own. The upper bound is not decorative: the +// repository name is stored as the value of the "repository" label on the objects +// of its chart catalog, and a label value cannot exceed 63 characters. +// +// These notes are deliberately outside the doc comment below — controller-gen folds +// every non-marker line of that block into the resource's API description. + +// 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 ApplicationRepositorySpec `json:"spec"` + Status ApplicationRepositoryStatus `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) GetStatus() any { + return r.Status +} + +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 cca316ca..18aa4bc2 100644 --- a/api/v1alpha1/register.go +++ b/api/v1alpha1/register.go @@ -30,10 +30,11 @@ 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} - HelmApplicationRepositoryGVK = schema.GroupVersionKind{Group: SchemeGroupVersion.Group, Version: SchemeGroupVersion.Version, Kind: HelmApplicationRepositoryKind} + 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} ) func Kind(kind string) schema.GroupKind { @@ -63,6 +64,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &HelmClusterAddonChartList{}, &HelmApplicationRepository{}, &HelmApplicationRepositoryList{}, + &HelmClusterApplicationRepository{}, + &HelmClusterApplicationRepositoryList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 27bd40d4..57a9ce38 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -548,3 +548,64 @@ func (in *HelmClusterAddonStatus) DeepCopy() *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 *HelmClusterApplicationRepository) DeepCopyInto(out *HelmClusterApplicationRepository) { + *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 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 +} diff --git a/crds/doc-ru-helmclusterapplicationrepositories.yaml b/crds/doc-ru-helmclusterapplicationrepositories.yaml new file mode 100644 index 00000000..b4437c22 --- /dev/null +++ b/crds/doc-ru-helmclusterapplicationrepositories.yaml @@ -0,0 +1,52 @@ +--- +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: | + Условия отражают последние наблюдения за состоянием репозитория. + + `Ready` сообщает, пригоден ли репозиторий: вспомогательные ресурсы на месте, внутренний объект источника исправен, и репозиторий ответил на чтение каталога на текущей спецификации. Транзиентная ошибка чтения не переводит `Ready` в `False`. + + `Synced` сообщает, актуален ли каталог чартов. + + `Reconciling` и `Stalled` следуют соглашению kstatus: они присутствуют, только когда применимы. `Reconciling` означает, что работа выполняется или запланирован повтор; `Stalled` — что репозиторий не восстановится без вмешательства. Пока выполняется синхронизация, `Reconciling` имеет причину `Synchronization`, либо `ForceReconcile`, если проход был запрошен аннотацией принудительной реконсиляции. + observedGeneration: + description: Поколение ресурса, обработанное контроллером последним. + lastSuccessfulSyncTime: + description: Время последнего успешного приведения каталога чартов в актуальное состояние. + nextSyncTime: + description: Запланированное время следующей попытки синхронизации. + lastForceReconcileTime: + description: | + Время обработки последнего запроса принудительной реконсиляции. Фиксирует, что запрос был обработан, а не что он завершился успешно — результат отражают `Ready` и `Synced`. + consecutiveFetchFailures: + description: Число подряд идущих неудачных обращений к репозиторию. Определяет задержку повтора и обнуляется при первом успехе. diff --git a/crds/helmclusterapplicationrepositories.yaml b/crds/helmclusterapplicationrepositories.yaml new file mode 100644 index 00000000..b5d6ab05 --- /dev/null +++ b/crds/helmclusterapplicationrepositories.yaml @@ -0,0 +1,215 @@ +--- +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: + conditions: + 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. + 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: {} From bdf0800cf438d87287b78e8740bda4dcd7049f50 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 12:23:27 +0300 Subject: [PATCH 004/145] feat(api): add the HelmApplicationChart custom resource Move LabelRepositoryName, LabelChartName and the UnavailableReason* constants out of helm_cluster_addon_chart.go into constants.go, since they are now shared by three chart-catalog families instead of one. Signed-off-by: Ilya Drey --- .../typed/api/v1alpha1/api_client.go | 5 + .../api/v1alpha1/fake/fake_api_client.go | 4 + .../fake/fake_helmapplicationchart.go | 52 ++++++ .../typed/api/v1alpha1/generated_expansion.go | 2 + .../api/v1alpha1/helmapplicationchart.go | 70 ++++++++ .../api/v1alpha1/helmapplicationchart.go | 102 +++++++++++ .../api/v1alpha1/interface.go | 7 + .../informers/externalversions/generic.go | 2 + .../api/v1alpha1/expansion_generated.go | 8 + .../api/v1alpha1/helmapplicationchart.go | 70 ++++++++ api/v1alpha1/constants.go | 31 ++++ api/v1alpha1/helm_application_chart.go | 135 ++++++++++++++ api/v1alpha1/helm_application_chart_test.go | 54 ++++++ api/v1alpha1/helm_cluster_addon_chart.go | 29 --- api/v1alpha1/register.go | 3 + api/v1alpha1/zz_generated.deepcopy.go | 104 +++++++++++ crds/doc-ru-helmapplicationcharts.yaml | 34 ++++ crds/helmapplicationcharts.yaml | 165 ++++++++++++++++++ templates/admision-policy.yaml | 1 + 19 files changed, 849 insertions(+), 29 deletions(-) create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplicationchart.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplicationchart.go create mode 100644 api/client/generated/informers/externalversions/api/v1alpha1/helmapplicationchart.go create mode 100644 api/client/generated/listers/api/v1alpha1/helmapplicationchart.go create mode 100644 api/v1alpha1/helm_application_chart.go create mode 100644 api/v1alpha1/helm_application_chart_test.go create mode 100644 crds/doc-ru-helmapplicationcharts.yaml create mode 100644 crds/helmapplicationcharts.yaml 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 ca446f88..e3043769 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,6 +28,7 @@ import ( type HelmV1alpha1Interface interface { RESTClient() rest.Interface + HelmApplicationChartsGetter HelmApplicationRepositoriesGetter HelmClusterAddonsGetter HelmClusterAddonChartsGetter @@ -40,6 +41,10 @@ type HelmV1alpha1Client struct { restClient rest.Interface } +func (c *HelmV1alpha1Client) HelmApplicationCharts(namespace string) HelmApplicationChartInterface { + return newHelmApplicationCharts(c, namespace) +} + func (c *HelmV1alpha1Client) HelmApplicationRepositories(namespace string) HelmApplicationRepositoryInterface { return newHelmApplicationRepositories(c, namespace) } 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 0b7d5865..3453fff5 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,10 @@ type FakeHelmV1alpha1 struct { *testing.Fake } +func (c *FakeHelmV1alpha1) HelmApplicationCharts(namespace string) v1alpha1.HelmApplicationChartInterface { + return newFakeHelmApplicationCharts(c, namespace) +} + func (c *FakeHelmV1alpha1) HelmApplicationRepositories(namespace string) v1alpha1.HelmApplicationRepositoryInterface { return newFakeHelmApplicationRepositories(c, namespace) } 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/generated_expansion.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go index 97cf2c99..2d16afe8 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,6 +18,8 @@ limitations under the License. package v1alpha1 +type HelmApplicationChartExpansion interface{} + type HelmApplicationRepositoryExpansion interface{} type HelmClusterAddonExpansion interface{} 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/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/interface.go b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go index 9fc62750..d847810f 100644 --- a/api/client/generated/informers/externalversions/api/v1alpha1/interface.go +++ b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go @@ -24,6 +24,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // HelmApplicationCharts returns a HelmApplicationChartInformer. + HelmApplicationCharts() HelmApplicationChartInformer // HelmApplicationRepositories returns a HelmApplicationRepositoryInformer. HelmApplicationRepositories() HelmApplicationRepositoryInformer // HelmClusterAddons returns a HelmClusterAddonInformer. @@ -47,6 +49,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: 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} diff --git a/api/client/generated/informers/externalversions/generic.go b/api/client/generated/informers/externalversions/generic.go index 7048a327..7b8de6e5 100644 --- a/api/client/generated/informers/externalversions/generic.go +++ b/api/client/generated/informers/externalversions/generic.go @@ -53,6 +53,8 @@ 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("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"): diff --git a/api/client/generated/listers/api/v1alpha1/expansion_generated.go b/api/client/generated/listers/api/v1alpha1/expansion_generated.go index 75e31423..29a8a49c 100644 --- a/api/client/generated/listers/api/v1alpha1/expansion_generated.go +++ b/api/client/generated/listers/api/v1alpha1/expansion_generated.go @@ -18,6 +18,14 @@ limitations under the License. package v1alpha1 +// 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{} 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/v1alpha1/constants.go b/api/v1alpha1/constants.go index 2b836330..59e9236e 100644 --- a/api/v1alpha1/constants.go +++ b/api/v1alpha1/constants.go @@ -33,4 +33,35 @@ const ( 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_chart.go b/api/v1alpha1/helm_application_chart.go new file mode 100644 index 00000000..1de0d22f --- /dev/null +++ b/api/v1alpha1/helm_application_chart.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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + HelmApplicationChartKind = "HelmApplicationChart" + HelmApplicationChartResource = "helmapplicationcharts" + + HelmApplicationChartLabelSourceName = "helm.deckhouse.io/application-chart" +) + +// The object carries no spec on purpose: it is a projection of a repository catalog, +// not user input. Writes by anyone other than the module's service accounts are +// refused by the ValidatingAdmissionPolicy in templates/admision-policy.yaml. +// +// 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. + +// 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 ApplicationChartStatus `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) GetStatus() any { + return r.Status +} + +func (r *HelmApplicationChart) GetConditionTypesForUpdate() []string { + return []string{ConditionTypeReady} +} + +// ApplicationChartStatus and ApplicationChartVersion below are shared by +// HelmApplicationChart and HelmClusterApplicationChart: the two kinds differ only +// in scope. Declaring them once makes a divergence between the two schemas +// impossible by construction, and keeps a single translation for both in +// crds/doc-ru-*.yaml. +// +// 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 ApplicationChartStatus 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 []ApplicationChartVersion `json:"versions"` +} + +type ApplicationChartVersion 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"` +} + +// 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_cluster_addon_chart.go b/api/v1alpha1/helm_cluster_addon_chart.go index 3c042c1d..5bfa1c04 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. diff --git a/api/v1alpha1/register.go b/api/v1alpha1/register.go index 18aa4bc2..2826a6c9 100644 --- a/api/v1alpha1/register.go +++ b/api/v1alpha1/register.go @@ -35,6 +35,7 @@ var ( 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} ) func Kind(kind string) schema.GroupKind { @@ -66,6 +67,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &HelmApplicationRepositoryList{}, &HelmClusterApplicationRepository{}, &HelmClusterApplicationRepositoryList{}, + &HelmApplicationChart{}, + &HelmApplicationChartList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 57a9ce38..894f4dde 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -27,6 +27,50 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApplicationChartStatus) DeepCopyInto(out *ApplicationChartStatus) { + *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([]ApplicationChartVersion, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationChartStatus. +func (in *ApplicationChartStatus) DeepCopy() *ApplicationChartStatus { + if in == nil { + return nil + } + out := new(ApplicationChartStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApplicationChartVersion) DeepCopyInto(out *ApplicationChartVersion) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationChartVersion. +func (in *ApplicationChartVersion) DeepCopy() *ApplicationChartVersion { + if in == nil { + return nil + } + out := new(ApplicationChartVersion) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApplicationRepositoryAuth) DeepCopyInto(out *ApplicationRepositoryAuth) { *out = *in @@ -99,6 +143,66 @@ func (in *ApplicationRepositoryStatus) DeepCopy() *ApplicationRepositoryStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmApplicationChart) DeepCopyInto(out *HelmApplicationChart) { + *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 HelmApplicationChart. +func (in *HelmApplicationChart) DeepCopy() *HelmApplicationChart { + if in == nil { + return nil + } + out := new(HelmApplicationChart) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmApplicationChart) 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 *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([]HelmApplicationChart, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// 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(HelmApplicationChartList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmApplicationChartList) 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 diff --git a/crds/doc-ru-helmapplicationcharts.yaml b/crds/doc-ru-helmapplicationcharts.yaml new file mode 100644 index 00000000..f34f135e --- /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://-репозитория и только когда слой поддерживается: пустое значение там означает, что версию нельзя задеплоить. Для версии с ociRef остаётся пустым — слой такого артефакта проверяется в момент раскатки и здесь не записывается." + unavailableReason: + description: Причина, по которой версию нельзя задеплоить. Отсутствие поля означает, что версия пригодна. + unavailableMessage: + description: Человекочитаемые подробности к unavailableReason. diff --git a/crds/helmapplicationcharts.yaml b/crds/helmapplicationcharts.yaml new file mode 100644 index 00000000..6083cfa7 --- /dev/null +++ b/crds/helmapplicationcharts.yaml @@ -0,0 +1,165 @@ +--- +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. It stays + empty for a version carrying OCIRef — the layer of such an artifact is examined + at deploy time and is not recorded here. + 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. 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. + 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/templates/admision-policy.yaml b/templates/admision-policy.yaml index 5b7b86a0..40b57739 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -36,6 +36,7 @@ spec: - "DELETE" resources: - "helmclusteraddoncharts" + - "helmapplicationcharts" validations: - expression: | request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || From 7cffbcdd129ed7415f16a75488754fe95b1db313 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 12:35:05 +0300 Subject: [PATCH 005/145] feat(api): add the HelmClusterApplicationChart custom resource Signed-off-by: Ilya Drey --- .../typed/api/v1alpha1/api_client.go | 5 + .../api/v1alpha1/fake/fake_api_client.go | 4 + .../fake/fake_helmclusterapplicationchart.go | 52 ++++++ .../typed/api/v1alpha1/generated_expansion.go | 2 + .../v1alpha1/helmclusterapplicationchart.go | 72 ++++++++ .../v1alpha1/helmclusterapplicationchart.go | 101 +++++++++++ .../api/v1alpha1/interface.go | 7 + .../informers/externalversions/generic.go | 2 + .../api/v1alpha1/expansion_generated.go | 4 + .../v1alpha1/helmclusterapplicationchart.go | 48 +++++ .../helm_cluster_application_chart.go | 85 +++++++++ .../helm_cluster_application_chart_test.go | 68 +++++++ api/v1alpha1/register.go | 3 + api/v1alpha1/zz_generated.deepcopy.go | 60 +++++++ crds/doc-ru-helmclusterapplicationcharts.yaml | 34 ++++ crds/helmclusterapplicationcharts.yaml | 166 ++++++++++++++++++ templates/admision-policy.yaml | 1 + 17 files changed, 714 insertions(+) create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmclusterapplicationchart.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/helmclusterapplicationchart.go create mode 100644 api/client/generated/informers/externalversions/api/v1alpha1/helmclusterapplicationchart.go create mode 100644 api/client/generated/listers/api/v1alpha1/helmclusterapplicationchart.go create mode 100644 api/v1alpha1/helm_cluster_application_chart.go create mode 100644 api/v1alpha1/helm_cluster_application_chart_test.go create mode 100644 crds/doc-ru-helmclusterapplicationcharts.yaml create mode 100644 crds/helmclusterapplicationcharts.yaml 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 e3043769..e8987601 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 @@ -33,6 +33,7 @@ type HelmV1alpha1Interface interface { HelmClusterAddonsGetter HelmClusterAddonChartsGetter HelmClusterAddonRepositoriesGetter + HelmClusterApplicationChartsGetter HelmClusterApplicationRepositoriesGetter } @@ -61,6 +62,10 @@ func (c *HelmV1alpha1Client) HelmClusterAddonRepositories() HelmClusterAddonRepo return newHelmClusterAddonRepositories(c) } +func (c *HelmV1alpha1Client) HelmClusterApplicationCharts() HelmClusterApplicationChartInterface { + return newHelmClusterApplicationCharts(c) +} + func (c *HelmV1alpha1Client) HelmClusterApplicationRepositories() HelmClusterApplicationRepositoryInterface { return newHelmClusterApplicationRepositories(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 3453fff5..5b060771 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 @@ -48,6 +48,10 @@ 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) } 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/generated_expansion.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go index 2d16afe8..18f92d89 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 @@ -28,4 +28,6 @@ type HelmClusterAddonChartExpansion interface{} type HelmClusterAddonRepositoryExpansion interface{} +type HelmClusterApplicationChartExpansion interface{} + type HelmClusterApplicationRepositoryExpansion interface{} 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/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/interface.go b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go index d847810f..6b4bfc4c 100644 --- a/api/client/generated/informers/externalversions/api/v1alpha1/interface.go +++ b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go @@ -34,6 +34,8 @@ type Interface interface { HelmClusterAddonCharts() HelmClusterAddonChartInformer // HelmClusterAddonRepositories returns a HelmClusterAddonRepositoryInformer. HelmClusterAddonRepositories() HelmClusterAddonRepositoryInformer + // HelmClusterApplicationCharts returns a HelmClusterApplicationChartInformer. + HelmClusterApplicationCharts() HelmClusterApplicationChartInformer // HelmClusterApplicationRepositories returns a HelmClusterApplicationRepositoryInformer. HelmClusterApplicationRepositories() HelmClusterApplicationRepositoryInformer } @@ -74,6 +76,11 @@ func (v *version) HelmClusterAddonRepositories() HelmClusterAddonRepositoryInfor 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 7b8de6e5..d91417d6 100644 --- a/api/client/generated/informers/externalversions/generic.go +++ b/api/client/generated/informers/externalversions/generic.go @@ -63,6 +63,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource 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 29a8a49c..85fc597d 100644 --- a/api/client/generated/listers/api/v1alpha1/expansion_generated.go +++ b/api/client/generated/listers/api/v1alpha1/expansion_generated.go @@ -46,6 +46,10 @@ type HelmClusterAddonChartListerExpansion interface{} // 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/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/v1alpha1/helm_cluster_application_chart.go b/api/v1alpha1/helm_cluster_application_chart.go new file mode 100644 index 00000000..290e6f4c --- /dev/null +++ b/api/v1alpha1/helm_cluster_application_chart.go @@ -0,0 +1,85 @@ +/* +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" +) + +// The status of this kind is the shared ApplicationChartStatus declared next to +// HelmApplicationChart: the two kinds differ only in scope. +// +// The object carries no spec on purpose: it is a projection of a repository catalog, +// not user input. Writes by anyone other than the module's service accounts are +// refused by the ValidatingAdmissionPolicy in templates/admision-policy.yaml. +// +// These notes are deliberately outside the doc comment below — controller-gen folds +// every non-marker line of that block into the resource's API 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. +// +// +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 ApplicationChartStatus `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) GetStatus() any { + return r.Status +} + +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..645e8831 --- /dev/null +++ b/api/v1alpha1/helm_cluster_application_chart_test.go @@ -0,0 +1,68 @@ +/* +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) + } +} + +// TestApplicationChartStatusIsShared pins that both chart catalog kinds are built on +// one status type. If someone later splits them into per-kind copies, the two +// schemas start drifting apart silently; this assignment stops compiling instead. +func TestApplicationChartStatusIsShared(t *testing.T) { + namespaced := &HelmApplicationChart{} + cluster := &HelmClusterApplicationChart{} + + namespaced.Status = cluster.Status + + if namespaced.Status.IconURL != "" { + t.Fatalf("unexpected iconURL after the assignment: %q", namespaced.Status.IconURL) + } +} diff --git a/api/v1alpha1/register.go b/api/v1alpha1/register.go index 2826a6c9..e794ce55 100644 --- a/api/v1alpha1/register.go +++ b/api/v1alpha1/register.go @@ -36,6 +36,7 @@ var ( 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} ) func Kind(kind string) schema.GroupKind { @@ -69,6 +70,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &HelmClusterApplicationRepositoryList{}, &HelmApplicationChart{}, &HelmApplicationChartList{}, + &HelmClusterApplicationChart{}, + &HelmClusterApplicationChartList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 894f4dde..d5ae6039 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -653,6 +653,66 @@ func (in *HelmClusterAddonStatus) DeepCopy() *HelmClusterAddonStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmClusterApplicationChart) DeepCopyInto(out *HelmClusterApplicationChart) { + *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 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 HelmClusterApplicationChartList. +func (in *HelmClusterApplicationChartList) DeepCopy() *HelmClusterApplicationChartList { + if in == nil { + return nil + } + 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 *HelmClusterApplicationRepository) DeepCopyInto(out *HelmClusterApplicationRepository) { *out = *in diff --git a/crds/doc-ru-helmclusterapplicationcharts.yaml b/crds/doc-ru-helmclusterapplicationcharts.yaml new file mode 100644 index 00000000..4c255780 --- /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://-репозитория и только когда слой поддерживается: пустое значение там означает, что версию нельзя задеплоить. Для версии с ociRef остаётся пустым — слой такого артефакта проверяется в момент раскатки и здесь не записывается." + unavailableReason: + description: Причина, по которой версию нельзя задеплоить. Отсутствие поля означает, что версия пригодна. + unavailableMessage: + description: Человекочитаемые подробности к unavailableReason. diff --git a/crds/helmclusterapplicationcharts.yaml b/crds/helmclusterapplicationcharts.yaml new file mode 100644 index 00000000..8a9002e7 --- /dev/null +++ b/crds/helmclusterapplicationcharts.yaml @@ -0,0 +1,166 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + heritage: deckhouse + module: operator-helm + name: 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. It stays + empty for a version carrying OCIRef — the layer of such an artifact is examined + at deploy time and is not recorded here. + 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. 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. + 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/templates/admision-policy.yaml b/templates/admision-policy.yaml index 40b57739..f54f4322 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -37,6 +37,7 @@ spec: resources: - "helmclusteraddoncharts" - "helmapplicationcharts" + - "helmclusterapplicationcharts" validations: - expression: | request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || From 5e3d6bd9ec52831ca764e24b75b5ce934e6e5532 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 12:42:55 +0300 Subject: [PATCH 006/145] feat(api): add the HelmApplication custom resource Signed-off-by: Ilya Drey --- .../typed/api/v1alpha1/api_client.go | 5 + .../api/v1alpha1/fake/fake_api_client.go | 4 + .../api/v1alpha1/fake/fake_helmapplication.go | 52 +++ .../typed/api/v1alpha1/generated_expansion.go | 2 + .../typed/api/v1alpha1/helmapplication.go | 70 ++++ .../api/v1alpha1/helmapplication.go | 102 ++++++ .../api/v1alpha1/interface.go | 7 + .../informers/externalversions/generic.go | 2 + .../api/v1alpha1/expansion_generated.go | 8 + .../listers/api/v1alpha1/helmapplication.go | 70 ++++ api/v1alpha1/helm_application.go | 285 +++++++++++++++ api/v1alpha1/helm_application_test.go | 338 ++++++++++++++++++ api/v1alpha1/register.go | 3 + api/v1alpha1/zz_generated.deepcopy.go | 152 ++++++++ crds/doc-ru-helmapplications.yaml | 60 ++++ crds/helmapplications.yaml | 235 ++++++++++++ 16 files changed, 1395 insertions(+) create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_helmapplication.go create mode 100644 api/client/generated/clientset/versioned/typed/api/v1alpha1/helmapplication.go create mode 100644 api/client/generated/informers/externalversions/api/v1alpha1/helmapplication.go create mode 100644 api/client/generated/listers/api/v1alpha1/helmapplication.go create mode 100644 api/v1alpha1/helm_application.go create mode 100644 api/v1alpha1/helm_application_test.go create mode 100644 crds/doc-ru-helmapplications.yaml create mode 100644 crds/helmapplications.yaml 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 e8987601..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,6 +28,7 @@ import ( type HelmV1alpha1Interface interface { RESTClient() rest.Interface + HelmApplicationsGetter HelmApplicationChartsGetter HelmApplicationRepositoriesGetter HelmClusterAddonsGetter @@ -42,6 +43,10 @@ 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) } 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 5b060771..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,10 @@ 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) } 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/generated_expansion.go b/api/client/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go index 18f92d89..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,6 +18,8 @@ limitations under the License. package v1alpha1 +type HelmApplicationExpansion interface{} + type HelmApplicationChartExpansion interface{} type HelmApplicationRepositoryExpansion 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/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/interface.go b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go index 6b4bfc4c..09e4800b 100644 --- a/api/client/generated/informers/externalversions/api/v1alpha1/interface.go +++ b/api/client/generated/informers/externalversions/api/v1alpha1/interface.go @@ -24,6 +24,8 @@ 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. @@ -51,6 +53,11 @@ 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} diff --git a/api/client/generated/informers/externalversions/generic.go b/api/client/generated/informers/externalversions/generic.go index d91417d6..45ec4194 100644 --- a/api/client/generated/informers/externalversions/generic.go +++ b/api/client/generated/informers/externalversions/generic.go @@ -53,6 +53,8 @@ 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"): diff --git a/api/client/generated/listers/api/v1alpha1/expansion_generated.go b/api/client/generated/listers/api/v1alpha1/expansion_generated.go index 85fc597d..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,14 @@ 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{} 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/v1alpha1/helm_application.go b/api/v1alpha1/helm_application.go new file mode 100644 index 00000000..4405d498 --- /dev/null +++ b/api/v1alpha1/helm_application.go @@ -0,0 +1,285 @@ +/* +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" +) + +// The release is always deployed into the namespace of the resource itself: there +// is deliberately no field naming a target namespace, because it would turn a +// namespaced resource into a way of writing outside its own namespace. +// +// The repository is printed by two columns instead of one reading +// "kind/": a printer column's jsonPath is a simple JSON path with no +// concatenation and no "first non-empty" choice, so a single cell would require the +// object to already store a composite value. Both columns carry priority=1, so the +// default output shows neither and -o wide shows both, exactly one of them filled. +// +// These notes are deliberately outside the doc comment below — controller-gen folds +// every non-marker line of that block into the resource's API description. + +// HelmApplication represents an installation of a Helm chart inside a single namespace. The release is deployed into the namespace of the resource itself, so it requires no cluster-wide permissions. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:labels={heritage=deckhouse,module=operator-helm} +// +kubebuilder:resource:singular=helmapplication,scope=Namespaced +// +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 +} + +func (r *HelmApplication) GetStatus() any { + return r.Status +} + +// 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. + // + // 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. + 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. + +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"` +} + +// 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_test.go b/api/v1alpha1/helm_application_test.go new file mode 100644 index 00000000..b273baca --- /dev/null +++ b/api/v1alpha1/helm_application_test.go @@ -0,0 +1,338 @@ +/* +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 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 only about Installed", 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 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/register.go b/api/v1alpha1/register.go index e794ce55..79b0d258 100644 --- a/api/v1alpha1/register.go +++ b/api/v1alpha1/register.go @@ -37,6 +37,7 @@ var ( 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 { @@ -72,6 +73,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &HelmApplicationChartList{}, &HelmClusterApplicationChart{}, &HelmClusterApplicationChartList{}, + &HelmApplication{}, + &HelmApplicationList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d5ae6039..a0a9986c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -143,6 +143,34 @@ func (in *ApplicationRepositoryStatus) DeepCopy() *ApplicationRepositoryStatus { 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) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// 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(HelmApplication) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HelmApplication) 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 *HelmApplicationChart) DeepCopyInto(out *HelmApplicationChart) { *out = *in @@ -203,6 +231,71 @@ func (in *HelmApplicationChartList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmApplicationChartRef) DeepCopyInto(out *HelmApplicationChartRef) { + *out = *in + return +} + +// 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(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 @@ -264,6 +357,65 @@ func (in *HelmApplicationRepositoryList) DeepCopyObject() runtime.Object { 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 *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)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + 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 diff --git a/crds/doc-ru-helmapplications.yaml b/crds/doc-ru-helmapplications.yaml new file mode 100644 index 00000000..b5871ee1 --- /dev/null +++ b/crds/doc-ru-helmapplications.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: helmapplications.helm.deckhouse.io +spec: + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: HelmApplication представляет собой установку Helm-чарта в пределах одного пространства имён. Релиз развёртывается в том же пространстве имён, где создан ресурс, поэтому кластерных прав не требует. + 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: | + Условия отражают последние наблюдения за состоянием ресурса. + + `Reconciling` присутствует, только когда применимо, следуя соглашению kstatus. Пока выполняется реконсиляция, запрошенная аннотацией принудительной реконсиляции, это условие имеет причину `ForceReconcile`. + 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/helmapplications.yaml b/crds/helmapplications.yaml new file mode 100644 index 00000000..63bf400f --- /dev/null +++ b/crds/helmapplications.yaml @@ -0,0 +1,235 @@ +--- +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, so it requires no cluster-wide permissions. + 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. + + 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. + 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 + served: true + storage: true + subresources: + status: {} From 09542a2fe4ca61b377d9cb38c84a531161536a44 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 13:18:12 +0300 Subject: [PATCH 007/145] feat(api): bound the HelmApplication name and add status repository helpers Bound spec.metadata.name to 63 characters via a CEL rule: the controller writes the object's name into a source label value on every internal resource it creates (chart_service.go, release_service.go, oci_repo_service.go, chart_claim_service.go), and Kubernetes caps label values at 63 characters. The kind is unreleased, so tightening it now is free; closing it after release would be a breaking change. No lower bound is added, since nothing references a HelmApplication by name. Added RepositoryName() and RepositoryKind() on *HelmApplicationLastAppliedChartRef, mirroring the existing helpers on HelmApplication's spec, so callers that need "which repository did we last deploy from" (uninstall, and the namespaced-to-cluster migration IsChartStatusInfoOutdated detects) don't have to re-implement the repository/clusterRepository XOR by hand. Also covers ConfigurationApplyInProgress/UpdateInstallInProgress with tests that reach their Unknown+ReasonReconciling branch, and clarifies a subtest name that overstated its assertion. Signed-off-by: Ilya Drey --- api/v1alpha1/helm_application.go | 36 +++++++ api/v1alpha1/helm_application_test.go | 138 +++++++++++++++++++++++++- crds/helmapplications.yaml | 3 + 3 files changed, 176 insertions(+), 1 deletion(-) diff --git a/api/v1alpha1/helm_application.go b/api/v1alpha1/helm_application.go index 4405d498..5812c867 100644 --- a/api/v1alpha1/helm_application.go +++ b/api/v1alpha1/helm_application.go @@ -42,6 +42,13 @@ const ( // object to already store a composite value. Both columns carry priority=1, so the // default output shows neither and -o wide shows both, exactly one of them filled. // +// The name is bounded because the controller stores it as the value of a source +// label on the internal resources it creates, and a label value cannot exceed 63 +// characters. There is no lower bound: nothing references a HelmApplication by +// name. The Helm release name, which Helm caps at 53 characters, is not what this +// rule guards — the controller derives that by truncation plus hash, the way +// utils/name.go already derives internal object names. +// // These notes are deliberately outside the doc comment below — controller-gen folds // every non-marker line of that block into the resource's API description. @@ -51,6 +58,7 @@ const ( // +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" @@ -255,6 +263,10 @@ type HelmApplicationStatus struct { // 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. +// +// The controller must replace this struct wholesale rather than merge into it: a +// merge would leave a stale repository alongside a new clusterRepository, both +// fields would be set, and IsChartStatusInfoOutdated would pin to true forever. type HelmApplicationLastAppliedChartRef struct { // Specifies the name of the Helm chart the release was last deployed from. @@ -273,6 +285,30 @@ type HelmApplicationLastAppliedChartRef struct { 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 diff --git a/api/v1alpha1/helm_application_test.go b/api/v1alpha1/helm_application_test.go index b273baca..f65080ae 100644 --- a/api/v1alpha1/helm_application_test.go +++ b/api/v1alpha1/helm_application_test.go @@ -92,6 +92,74 @@ func TestHelmApplicationRepositoryKind(t *testing.T) { } } +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", @@ -180,7 +248,7 @@ func TestHelmApplicationGetConditionTypesForUpdate(t *testing.T) { 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 only about Installed", func(t *testing.T) { + 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() @@ -239,6 +307,74 @@ func TestHelmApplicationGetConditionTypesForUpdate(t *testing.T) { }) } +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 diff --git a/crds/helmapplications.yaml b/crds/helmapplications.yaml index 63bf400f..61fa3c03 100644 --- a/crds/helmapplications.yaml +++ b/crds/helmapplications.yaml @@ -229,6 +229,9 @@ spec: 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: From b1d1072fee4b21b5a2add2015510a370e908ca87 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 15:14:19 +0300 Subject: [PATCH 008/145] internal: drop the permissions claim from the HelmApplication description The description asserted that the resource "requires no cluster-wide permissions". Whether that holds is decided by the RBAC that ships with the controllers, not by the schema, so the CRD is the wrong place to promise it. The namespace statement it followed already carries the part that is true of the API itself. Signed-off-by: Ilya Drey --- api/v1alpha1/helm_application.go | 2 +- crds/doc-ru-helmapplications.yaml | 2 +- crds/helmapplications.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/v1alpha1/helm_application.go b/api/v1alpha1/helm_application.go index 5812c867..41540b59 100644 --- a/api/v1alpha1/helm_application.go +++ b/api/v1alpha1/helm_application.go @@ -52,7 +52,7 @@ const ( // These notes are deliberately outside the doc comment below — controller-gen folds // every non-marker line of that block into the resource's API description. -// HelmApplication represents an installation of a Helm chart inside a single namespace. The release is deployed into the namespace of the resource itself, so it requires no cluster-wide permissions. +// HelmApplication represents an installation of a Helm chart inside a single namespace. The release is deployed into the namespace of the resource itself. // // +kubebuilder:object:root=true // +kubebuilder:subresource:status diff --git a/crds/doc-ru-helmapplications.yaml b/crds/doc-ru-helmapplications.yaml index b5871ee1..76904f5f 100644 --- a/crds/doc-ru-helmapplications.yaml +++ b/crds/doc-ru-helmapplications.yaml @@ -8,7 +8,7 @@ spec: - name: v1alpha1 schema: openAPIV3Schema: - description: HelmApplication представляет собой установку Helm-чарта в пределах одного пространства имён. Релиз развёртывается в том же пространстве имён, где создан ресурс, поэтому кластерных прав не требует. + description: HelmApplication представляет собой установку Helm-чарта в пределах одного пространства имён. Релиз развёртывается в том же пространстве имён, где создан ресурс. properties: spec: properties: diff --git a/crds/helmapplications.yaml b/crds/helmapplications.yaml index 61fa3c03..d522b1e5 100644 --- a/crds/helmapplications.yaml +++ b/crds/helmapplications.yaml @@ -48,7 +48,7 @@ spec: 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, so it requires no cluster-wide permissions. + itself. properties: apiVersion: description: |- From c66105b61a6d1dbaa4104f7e9d4a5a77edf844c6 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 18:39:53 +0300 Subject: [PATCH 009/145] refactor(api): share one spec and status across the repository kinds The three repository kinds differ only in scope; their spec and status were already identical field for field, description for description, so the generated CRDs are unchanged by this commit. One declaration lets the controller reconcile all of them through a single code path. Signed-off-by: Ilya Drey --- api/v1alpha1/helm_application_repository.go | 77 +------ api/v1alpha1/helm_cluster_addon_repository.go | 68 +----- .../helm_cluster_application_repository.go | 10 +- api/v1alpha1/repository_types.go | 96 ++++++++ api/v1alpha1/zz_generated.deepcopy.go | 216 ++++++------------ .../internal/resolver/resolver_test.go | 4 +- .../helmclusteraddon/reconciler_test.go | 4 +- .../helmclusteraddonrepository/evaluate.go | 14 +- .../evaluate_test.go | 22 +- .../reconciler_test.go | 6 +- .../schedule_test.go | 30 +-- .../internal/services/base.go | 6 +- .../internal/services/base_test.go | 10 +- .../services/helm_repo_service_test.go | 2 +- .../services/oci_repo_service_test.go | 8 +- .../internal/utils/repository_test.go | 6 +- tests/e2e/helmclusteraddon/chartclaim.go | 2 +- tests/e2e/helmclusteraddon/hybrid.go | 2 +- tests/e2e/helmclusteraddon/lifecycle.go | 2 +- .../e2e/helmclusteraddon/system_namespace.go | 2 +- .../helmclusteraddonrepository/lifecycle.go | 6 +- 21 files changed, 240 insertions(+), 353 deletions(-) create mode 100644 api/v1alpha1/repository_types.go diff --git a/api/v1alpha1/helm_application_repository.go b/api/v1alpha1/helm_application_repository.go index 2c3dfec1..b567e185 100644 --- a/api/v1alpha1/helm_application_repository.go +++ b/api/v1alpha1/helm_application_repository.go @@ -59,8 +59,8 @@ type HelmApplicationRepository struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec ApplicationRepositorySpec `json:"spec"` - Status ApplicationRepositoryStatus `json:"status,omitempty"` + Spec RepositorySpec `json:"spec"` + Status RepositoryStatus `json:"status,omitempty"` } func (r *HelmApplicationRepository) GetConditions() *[]metav1.Condition { @@ -94,79 +94,6 @@ func (r *HelmApplicationRepository) ForceReconcileRequired() bool { return found } -// ApplicationRepositorySpec and ApplicationRepositoryStatus below are shared by -// HelmApplicationRepository and HelmClusterApplicationRepository: the two kinds -// differ only in scope. Declaring them once makes a divergence between the two -// schemas impossible by construction, and keeps a single translation for both in -// crds/doc-ru-*.yaml. -// -// 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 ApplicationRepositorySpec 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 *ApplicationRepositoryAuth `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 ApplicationRepositoryAuth struct { - // Repository authentication username. - // +kubebuilder:validation:MinLength=1 - Username string `json:"username"` - // Repository authentication password. - // +kubebuilder:validation:MinLength=1 - Password string `json:"password"` -} - -type ApplicationRepositoryStatus 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"` -} - // HelmApplicationRepositoryList contains a list of HelmApplicationRepositories. // +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..56713b21 100644 --- a/api/v1alpha1/helm_cluster_addon_repository.go +++ b/api/v1alpha1/helm_cluster_addon_repository.go @@ -54,8 +54,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 { @@ -89,70 +89,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_repository.go b/api/v1alpha1/helm_cluster_application_repository.go index 1f1d10bd..800ecc60 100644 --- a/api/v1alpha1/helm_cluster_application_repository.go +++ b/api/v1alpha1/helm_cluster_application_repository.go @@ -28,9 +28,9 @@ const ( HelmClusterApplicationRepositoryLabelSourceName = "helm.deckhouse.io/cluster-application-repository" ) -// The spec and status of this kind are the shared ApplicationRepositorySpec and -// ApplicationRepositoryStatus declared next to HelmApplicationRepository: the two -// kinds differ only in scope. +// The spec and status of this kind are the shared RepositorySpec and +// RepositoryStatus declared in repository_types.go: every repository kind of the +// module has the same shape. // // 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 @@ -64,8 +64,8 @@ type HelmClusterApplicationRepository struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec ApplicationRepositorySpec `json:"spec"` - Status ApplicationRepositoryStatus `json:"status,omitempty"` + Spec RepositorySpec `json:"spec"` + Status RepositoryStatus `json:"status,omitempty"` } func (r *HelmClusterApplicationRepository) GetConditions() *[]metav1.Condition { diff --git a/api/v1alpha1/repository_types.go b/api/v1alpha1/repository_types.go new file mode 100644 index 00000000..e9961384 --- /dev/null +++ b/api/v1alpha1/repository_types.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 ( + 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. + // + // 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"` +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a0a9986c..bcb06dd6 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -71,78 +71,6 @@ func (in *ApplicationChartVersion) DeepCopy() *ApplicationChartVersion { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ApplicationRepositoryAuth) DeepCopyInto(out *ApplicationRepositoryAuth) { - *out = *in - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationRepositoryAuth. -func (in *ApplicationRepositoryAuth) DeepCopy() *ApplicationRepositoryAuth { - if in == nil { - return nil - } - out := new(ApplicationRepositoryAuth) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ApplicationRepositorySpec) DeepCopyInto(out *ApplicationRepositorySpec) { - *out = *in - if in.Auth != nil { - in, out := &in.Auth, &out.Auth - *out = new(ApplicationRepositoryAuth) - **out = **in - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationRepositorySpec. -func (in *ApplicationRepositorySpec) DeepCopy() *ApplicationRepositorySpec { - if in == nil { - return nil - } - out := new(ApplicationRepositorySpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ApplicationRepositoryStatus) DeepCopyInto(out *ApplicationRepositoryStatus) { - *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.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() - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationRepositoryStatus. -func (in *ApplicationRepositoryStatus) DeepCopy() *ApplicationRepositoryStatus { - if in == nil { - return nil - } - out := new(ApplicationRepositoryStatus) - 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 @@ -641,22 +569,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 @@ -690,62 +602,6 @@ func (in *HelmClusterAddonRepositoryList) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonRepositorySpec) DeepCopyInto(out *HelmClusterAddonRepositorySpec) { - *out = *in - if in.Auth != nil { - in, out := &in.Auth, &out.Auth - *out = new(HelmClusterAddonRepositoryAuth) - **out = **in - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonRepositorySpec. -func (in *HelmClusterAddonRepositorySpec) DeepCopy() *HelmClusterAddonRepositorySpec { - if in == nil { - return nil - } - out := new(HelmClusterAddonRepositorySpec) - 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) { - *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.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() - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonRepositoryStatus. -func (in *HelmClusterAddonRepositoryStatus) DeepCopy() *HelmClusterAddonRepositoryStatus { - if in == nil { - return nil - } - out := new(HelmClusterAddonRepositoryStatus) - 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) { *out = *in @@ -925,3 +781,75 @@ func (in *HelmClusterApplicationRepositoryList) DeepCopyObject() runtime.Object } 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 + } + 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)) + for i := range *in { + (*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() + } + return +} + +// 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(RepositoryStatus) + in.DeepCopyInto(out) + return out +} diff --git a/images/chart-values-controller/internal/resolver/resolver_test.go b/images/chart-values-controller/internal/resolver/resolver_test.go index 38514e31..f21655fb 100644 --- a/images/chart-values-controller/internal/resolver/resolver_test.go +++ b/images/chart-values-controller/internal/resolver/resolver_test.go @@ -268,7 +268,7 @@ 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{ Version: "0.1.0", @@ -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{ diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go index bebc8f79..9cd531b1 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go @@ -285,7 +285,7 @@ func (r *stubChartResolver) ResolveChartArtifact(_ context.Context, _ string, _ func helmRepositoryFixture() *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"}, } } @@ -344,7 +344,7 @@ func newFullReconciler( func ociRepositoryFixture() *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"}, } } diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate.go index 936aee5b..b83ea924 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate.go @@ -35,7 +35,7 @@ type Inputs struct { Generation int64 Now time.Time Jitter float64 - Current helmv1alpha1.HelmClusterAddonRepositoryStatus + Current helmv1alpha1.RepositoryStatus SecretsErr error InternalRepositoryErr error @@ -56,7 +56,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 +71,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 @@ -310,7 +310,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 +328,7 @@ func hasEvidence(current helmv1alpha1.HelmClusterAddonRepositoryStatus, generati } func setCondition( - status *helmv1alpha1.HelmClusterAddonRepositoryStatus, + status *helmv1alpha1.RepositoryStatus, in Inputs, conditionType string, conditionStatus metav1.ConditionStatus, @@ -345,7 +345,7 @@ func setCondition( } func applyAbnormal( - status *helmv1alpha1.HelmClusterAddonRepositoryStatus, + status *helmv1alpha1.RepositoryStatus, in Inputs, conditionType string, cond abnormalCondition, @@ -399,7 +399,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/helmclusteraddonrepository/evaluate_test.go index 42e35ea8..b940f785 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate_test.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate_test.go @@ -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,7 @@ func TestEvaluateFullSyncAdvancesLastSuccessfulSyncTime(t *testing.T) { } } -func assertAbnormal(t *testing.T, status helmv1alpha1.HelmClusterAddonRepositoryStatus, conditionType, wantReason string) { +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/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go index 21d8f404..e2ed01c2 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go @@ -116,14 +116,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"}, } } @@ -322,7 +322,7 @@ 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 { diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/schedule_test.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/schedule_test.go index 68913615..d9a85f77 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/schedule_test.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/schedule_test.go @@ -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/base.go b/images/operator-helm-controller/internal/services/base.go index 87f51ff9..708577ac 100644 --- a/images/operator-helm-controller/internal/services/base.go +++ b/images/operator-helm-controller/internal/services/base.go @@ -107,7 +107,7 @@ func (s *BaseRepoService) EnsureSecrets( // 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) { + func(auth *helmv1alpha1.RepositoryAuth) (map[string]string, error) { return map[string]string{ "username": auth.Username, "password": auth.Password, @@ -121,7 +121,7 @@ func (s *BaseRepoService) reconcileBasicAuthSecret(ctx context.Context, repo *he // 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) { + func(auth *helmv1alpha1.RepositoryAuth) (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) @@ -136,7 +136,7 @@ func (s *BaseRepoService) reconcileAuthSecret( ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, secretType corev1.SecretType, - buildData func(auth *helmv1alpha1.HelmClusterAddonRepositoryAuth) (map[string]string, error), + buildData func(auth *helmv1alpha1.RepositoryAuth) (map[string]string, error), ) error { secretName := utils.GetInternalRepositoryAuthSecretName(repo.Name) nn := types.NamespacedName{Name: secretName, Namespace: s.TargetNamespace} diff --git a/images/operator-helm-controller/internal/services/base_test.go b/images/operator-helm-controller/internal/services/base_test.go index 316f6a1c..973c1153 100644 --- a/images/operator-helm-controller/internal/services/base_test.go +++ b/images/operator-helm-controller/internal/services/base_test.go @@ -65,9 +65,9 @@ func newBaseRepoService(t *testing.T, objects ...client.Object) (*BaseRepoServic func TestEnsureSecretsCreatesAuthAndTLS(t *testing.T) { repo := &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example"}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: helmv1alpha1.RepositorySpec{ URL: "https://example.invalid/charts", - Auth: &helmv1alpha1.HelmClusterAddonRepositoryAuth{Username: "user", Password: "secret"}, + Auth: &helmv1alpha1.RepositoryAuth{Username: "user", Password: "secret"}, CACertificate: "-----BEGIN CERTIFICATE-----", }, } @@ -99,7 +99,7 @@ func TestEnsureSecretsCreatesAuthAndTLS(t *testing.T) { func TestEnsureSecretsRemovesObsoleteSecrets(t *testing.T) { repo := &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example"}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://example.invalid/charts"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://example.invalid/charts"}, } obsolete := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -123,9 +123,9 @@ func TestEnsureSecretsRemovesObsoleteSecrets(t *testing.T) { func TestEnsureSecretsUsesDockerConfigForOCIRepositories(t *testing.T) { repo := &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example"}, - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{ + Spec: helmv1alpha1.RepositorySpec{ URL: "oci://ghcr.io/example/podinfo", - Auth: &helmv1alpha1.HelmClusterAddonRepositoryAuth{Username: "user", Password: "secret"}, + Auth: &helmv1alpha1.RepositoryAuth{Username: "user", Password: "secret"}, }, } 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..84b4c7ff 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 @@ -62,7 +62,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"}, } } 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..2f3786e2 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 @@ -109,7 +109,7 @@ 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"}, } } @@ -131,9 +131,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, }, @@ -452,7 +452,7 @@ 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) diff --git a/images/operator-helm-controller/internal/utils/repository_test.go b/images/operator-helm-controller/internal/utils/repository_test.go index d7179c53..b60df68f 100644 --- a/images/operator-helm-controller/internal/utils/repository_test.go +++ b/images/operator-helm-controller/internal/utils/repository_test.go @@ -201,10 +201,10 @@ func TestSplitOCIRef(t *testing.T) { func TestResolveChartSource(t *testing.T) { helmRepo := &helmv1alpha1.HelmClusterAddonRepository{ - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "https://charts.example.com/stable"}, + Spec: helmv1alpha1.RepositorySpec{URL: "https://charts.example.com/stable"}, } ociRepo := &helmv1alpha1.HelmClusterAddonRepository{ - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "oci://registry.example.com/charts/podinfo"}, + Spec: helmv1alpha1.RepositorySpec{URL: "oci://registry.example.com/charts/podinfo"}, } tests := []struct { @@ -254,7 +254,7 @@ func TestResolveChartSource(t *testing.T) { { name: "unsupported repository scheme is an error", repo: &helmv1alpha1.HelmClusterAddonRepository{ - Spec: helmv1alpha1.HelmClusterAddonRepositorySpec{URL: "ftp://charts.example.com"}, + Spec: helmv1alpha1.RepositorySpec{URL: "ftp://charts.example.com"}, }, version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "1.0.0"}, wantErr: true, 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", }, } From 406427189e7b53de4893e2d8af91f2491f55739f Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 18:48:01 +0300 Subject: [PATCH 010/145] refactor(api): share one status across the chart catalog kinds The three catalog kinds are projections of a repository index and were already identical in shape. Sharing the status type changes one word in the released HelmClusterAddonChart description ("addon chart state" -> "chart state") and its translation; nothing else in the schema moves. Signed-off-by: Ilya Drey --- api/v1alpha1/chart_artifact.go | 4 +- api/v1alpha1/chart_catalog_types.go | 75 +++++++++++++++++++ api/v1alpha1/helm_application_chart.go | 56 +------------- api/v1alpha1/helm_cluster_addon_chart.go | 47 +----------- .../helm_cluster_application_chart.go | 6 +- .../helm_cluster_application_chart_test.go | 4 +- api/v1alpha1/zz_generated.deepcopy.go | 62 +++------------ crds/doc-ru-helmclusteraddoncharts.yaml | 2 +- crds/helmclusteraddoncharts.yaml | 2 +- .../internal/resolver/resolver.go | 8 +- .../internal/resolver/resolver_test.go | 26 +++---- .../reconcile/helmclusteraddon/reconciler.go | 4 +- .../helmclusteraddon/reconciler_test.go | 36 ++++----- .../internal/services/oci_repo_service.go | 4 +- .../services/oci_repo_service_test.go | 34 ++++----- .../internal/services/repo_sync_service.go | 12 +-- .../services/repo_sync_service_test.go | 26 +++---- .../internal/utils/repository.go | 2 +- .../internal/utils/repository_test.go | 12 +-- 19 files changed, 177 insertions(+), 245 deletions(-) create mode 100644 api/v1alpha1/chart_catalog_types.go 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..b0fc040a --- /dev/null +++ b/api/v1alpha1/chart_catalog_types.go @@ -0,0 +1,75 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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. 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"` +} diff --git a/api/v1alpha1/helm_application_chart.go b/api/v1alpha1/helm_application_chart.go index 1de0d22f..aa8dd42a 100644 --- a/api/v1alpha1/helm_application_chart.go +++ b/api/v1alpha1/helm_application_chart.go @@ -46,7 +46,7 @@ type HelmApplicationChart struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Status ApplicationChartStatus `json:"status,omitempty"` + Status ChartCatalogStatus `json:"status,omitempty"` } func (r *HelmApplicationChart) GetConditions() *[]metav1.Condition { @@ -69,60 +69,6 @@ func (r *HelmApplicationChart) GetConditionTypesForUpdate() []string { return []string{ConditionTypeReady} } -// ApplicationChartStatus and ApplicationChartVersion below are shared by -// HelmApplicationChart and HelmClusterApplicationChart: the two kinds differ only -// in scope. Declaring them once makes a divergence between the two schemas -// impossible by construction, and keeps a single translation for both in -// crds/doc-ru-*.yaml. -// -// 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 ApplicationChartStatus 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 []ApplicationChartVersion `json:"versions"` -} - -type ApplicationChartVersion 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"` -} - // HelmApplicationChartList contains a list of HelmApplicationCharts. // +kubebuilder:object:root=true // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/api/v1alpha1/helm_cluster_addon_chart.go b/api/v1alpha1/helm_cluster_addon_chart.go index 5bfa1c04..d41867b8 100644 --- a/api/v1alpha1/helm_cluster_addon_chart.go +++ b/api/v1alpha1/helm_cluster_addon_chart.go @@ -40,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 { @@ -63,51 +63,6 @@ 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_application_chart.go b/api/v1alpha1/helm_cluster_application_chart.go index 290e6f4c..7536a86e 100644 --- a/api/v1alpha1/helm_cluster_application_chart.go +++ b/api/v1alpha1/helm_cluster_application_chart.go @@ -27,8 +27,8 @@ const ( HelmClusterApplicationChartLabelSourceName = "helm.deckhouse.io/cluster-application-chart" ) -// The status of this kind is the shared ApplicationChartStatus declared next to -// HelmApplicationChart: the two kinds differ only in scope. +// The status of this kind is the shared ChartCatalogStatus declared in +// chart_catalog_types.go: every chart catalog kind of the module has the same shape. // // The object carries no spec on purpose: it is a projection of a repository catalog, // not user input. Writes by anyone other than the module's service accounts are @@ -50,7 +50,7 @@ type HelmClusterApplicationChart struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Status ApplicationChartStatus `json:"status,omitempty"` + Status ChartCatalogStatus `json:"status,omitempty"` } func (r *HelmClusterApplicationChart) GetConditions() *[]metav1.Condition { diff --git a/api/v1alpha1/helm_cluster_application_chart_test.go b/api/v1alpha1/helm_cluster_application_chart_test.go index 645e8831..26895c65 100644 --- a/api/v1alpha1/helm_cluster_application_chart_test.go +++ b/api/v1alpha1/helm_cluster_application_chart_test.go @@ -53,10 +53,10 @@ func TestHelmClusterApplicationChartGetConditionTypesForUpdate(t *testing.T) { } } -// TestApplicationChartStatusIsShared pins that both chart catalog kinds are built on +// TestChartCatalogStatusIsShared pins that both chart catalog kinds are built on // one status type. If someone later splits them into per-kind copies, the two // schemas start drifting apart silently; this assignment stops compiling instead. -func TestApplicationChartStatusIsShared(t *testing.T) { +func TestChartCatalogStatusIsShared(t *testing.T) { namespaced := &HelmApplicationChart{} cluster := &HelmClusterApplicationChart{} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index bcb06dd6..4ea43776 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -28,7 +28,7 @@ import ( ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ApplicationChartStatus) DeepCopyInto(out *ApplicationChartStatus) { +func (in *ChartCatalogStatus) DeepCopyInto(out *ChartCatalogStatus) { *out = *in if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions @@ -39,34 +39,34 @@ func (in *ApplicationChartStatus) DeepCopyInto(out *ApplicationChartStatus) { } if in.Versions != nil { in, out := &in.Versions, &out.Versions - *out = make([]ApplicationChartVersion, len(*in)) + *out = make([]ChartVersion, len(*in)) copy(*out, *in) } return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationChartStatus. -func (in *ApplicationChartStatus) DeepCopy() *ApplicationChartStatus { +// 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(ApplicationChartStatus) + 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 *ApplicationChartVersion) DeepCopyInto(out *ApplicationChartVersion) { +func (in *ChartVersion) DeepCopyInto(out *ChartVersion) { *out = *in return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationChartVersion. -func (in *ApplicationChartVersion) DeepCopy() *ApplicationChartVersion { +// 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(ApplicationChartVersion) + out := new(ChartVersion) in.DeepCopyInto(out) return out } @@ -448,50 +448,6 @@ func (in *HelmClusterAddonChartRef) DeepCopy() *HelmClusterAddonChartRef { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonChartStatus) DeepCopyInto(out *HelmClusterAddonChartStatus) { - *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([]HelmClusterAddonChartVersion, len(*in)) - copy(*out, *in) - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChartStatus. -func (in *HelmClusterAddonChartStatus) DeepCopy() *HelmClusterAddonChartStatus { - if in == nil { - return nil - } - out := new(HelmClusterAddonChartStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmClusterAddonChartVersion) DeepCopyInto(out *HelmClusterAddonChartVersion) { - *out = *in - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmClusterAddonChartVersion. -func (in *HelmClusterAddonChartVersion) DeepCopy() *HelmClusterAddonChartVersion { - if in == nil { - return nil - } - out := new(HelmClusterAddonChartVersion) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HelmClusterAddonLastAppliedChartRef) DeepCopyInto(out *HelmClusterAddonLastAppliedChartRef) { *out = *in diff --git a/crds/doc-ru-helmclusteraddoncharts.yaml b/crds/doc-ru-helmclusteraddoncharts.yaml index 34da4420..9e8e5563 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: diff --git a/crds/helmclusteraddoncharts.yaml b/crds/helmclusteraddoncharts.yaml index 11302f5f..63381361 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. diff --git a/images/chart-values-controller/internal/resolver/resolver.go b/images/chart-values-controller/internal/resolver/resolver.go index b780c368..9816d952 100644 --- a/images/chart-values-controller/internal/resolver/resolver.go +++ b/images/chart-values-controller/internal/resolver/resolver.go @@ -137,7 +137,7 @@ 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) { +func (r *Resolver) chartVersion(ctx context.Context, req Request) (*helmv1alpha1.ChartVersion, *Result, error) { chart := &helmv1alpha1.HelmClusterAddonChart{} key := types.NamespacedName{Name: apinaming.HelmClusterAddonChartName(req.RepositoryName, req.Chart)} @@ -179,7 +179,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 +204,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 @@ -409,7 +409,7 @@ func (r *Resolver) ensureHybridOCIRepository( repo *helmv1alpha1.HelmClusterAddonRepository, 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 { diff --git a/images/chart-values-controller/internal/resolver/resolver_test.go b/images/chart-values-controller/internal/resolver/resolver_test.go index f21655fb..c268fe20 100644 --- a/images/chart-values-controller/internal/resolver/resolver_test.go +++ b/images/chart-values-controller/internal/resolver/resolver_test.go @@ -53,10 +53,10 @@ func newTestResolver(t *testing.T, objects ...client.Object) *Resolver { return &Resolver{client: c} } -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,7 +135,7 @@ 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) @@ -155,7 +155,7 @@ 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) @@ -172,7 +172,7 @@ 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", @@ -202,7 +202,7 @@ 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) @@ -270,7 +270,7 @@ func TestResolveHybridVersionUsesOCIRepository(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "example"}, 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", }) @@ -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, diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go index 16091604..206134a5 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go @@ -517,7 +517,7 @@ func (r *Reconciler) getHelmClusterAddonChart( ctx context.Context, addon *helmv1alpha1.HelmClusterAddon, repoType utils.InternalRepositoryType, -) (*helmv1alpha1.HelmClusterAddonChart, *helmv1alpha1.HelmClusterAddonChartVersion, error) { +) (*helmv1alpha1.HelmClusterAddonChart, *helmv1alpha1.ChartVersion, error) { addonChartName := naming.HelmClusterAddonChartName( addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName, ) @@ -558,7 +558,7 @@ func (r *Reconciler) getHelmClusterAddonChart( } // versionUnavailableDetail explains why a catalog entry is not deployable. -func versionUnavailableDetail(version helmv1alpha1.HelmClusterAddonChartVersion) string { +func versionUnavailableDetail(version helmv1alpha1.ChartVersion) string { switch { case version.UnavailableReason == "": return "the repository catalog has not resolved it yet" diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go index 9cd531b1..a586e08a 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go @@ -81,12 +81,12 @@ func testAddon() *helmv1alpha1.HelmClusterAddon { } } -func addonChartFixture(repoName, chartName string, versions ...helmv1alpha1.HelmClusterAddonChartVersion) *helmv1alpha1.HelmClusterAddonChart { +func addonChartFixture(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}, } } @@ -102,14 +102,14 @@ func TestGetHelmClusterAddonChart(t *testing.T) { // 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 + version helmv1alpha1.ChartVersion repoType utils.InternalRepositoryType wantErr bool wantErrContain string }{ { name: "oci version with a media type passes", - version: helmv1alpha1.HelmClusterAddonChartVersion{ + version: helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", }, @@ -120,7 +120,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { // 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: helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", UnavailableReason: helmv1alpha1.UnavailableReasonRemovedFromRepository, @@ -129,7 +129,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { }, { name: "oci version stuck resolving is rejected with reason and message", - version: helmv1alpha1.HelmClusterAddonChartVersion{ + version: helmv1alpha1.ChartVersion{ Version: "6.7.1", UnavailableReason: helmv1alpha1.UnavailableReasonResolvePending, UnavailableMessage: "manifest request failed", @@ -140,7 +140,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { }, { name: "oci version with unsupported media type and no message is rejected with reason alone", - version: helmv1alpha1.HelmClusterAddonChartVersion{ + version: helmv1alpha1.ChartVersion{ Version: "6.7.1", UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, }, @@ -153,7 +153,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { // 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: helmv1alpha1.ChartVersion{ Version: "6.7.1", UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, }, @@ -164,7 +164,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { // 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: helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", }, @@ -176,7 +176,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { // 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: helmv1alpha1.ChartVersion{ Version: "6.7.1", }, repoType: utils.InternalOCIRepository, @@ -185,7 +185,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { }, { name: "a version the addon does not reference is rejected", - version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "9.9.9"}, + version: helmv1alpha1.ChartVersion{Version: "9.9.9"}, repoType: utils.InternalOCIRepository, wantErr: true, wantErrContain: `does not have version "6.7.1"`, @@ -195,7 +195,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { // 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: helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", }, @@ -205,7 +205,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { // 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: helmv1alpha1.ChartVersion{ Version: "6.7.1", UnavailableReason: helmv1alpha1.UnavailableReasonInvalidChartReference, UnavailableMessage: "oci reference \"oci://BAD_HOST//:::\" is not a valid tagged reference", @@ -351,7 +351,7 @@ func ociRepositoryFixture() *helmv1alpha1.HelmClusterAddonRepository { func forceTestFixtures() []client.Object { return []client.Object{ ociRepositoryFixture(), - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", }), @@ -379,7 +379,7 @@ func TestReconcileHybridVersionUsesInternalOCIRepository(t *testing.T) { r, c := newFullReconciler(t, resolver, interceptor.Funcs{}, addon, helmRepositoryFixture(), - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", }), @@ -424,7 +424,7 @@ func TestReconcileArchiveVersionOfHelmRepositoryStaysOnTheHelmPath(t *testing.T) r, c := newFullReconciler(t, &stubChartResolver{}, interceptor.Funcs{}, addon, helmRepositoryFixture(), - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ Version: "6.7.1", }), ) @@ -477,7 +477,7 @@ func TestReconcileVersionMovedOutOfRegistrySupersedesTheOCIRepository(t *testing addon, helmRepositoryFixture(), supersededOCIRepo, - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ Version: "6.7.1", }), ) @@ -525,7 +525,7 @@ func TestReconcileVersionMovedIntoRegistrySupersedesTheHelmChart(t *testing.T) { addon, helmRepositoryFixture(), supersededChart, - addonChartFixture("example", "podinfo", helmv1alpha1.HelmClusterAddonChartVersion{ + addonChartFixture("example", "podinfo", helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", }), 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..330a4617 100644 --- a/images/operator-helm-controller/internal/services/oci_repo_service.go +++ b/images/operator-helm-controller/internal/services/oci_repo_service.go @@ -115,7 +115,7 @@ func (s *OCIRepoService) EnsureInternalOCIRepository( addon *helmv1alpha1.HelmClusterAddon, repo *helmv1alpha1.HelmClusterAddonRepository, source utils.ChartSource, - version *helmv1alpha1.HelmClusterAddonChartVersion, + version *helmv1alpha1.ChartVersion, ) OCIRepoResult { logger := log.FromContext(ctx) @@ -188,7 +188,7 @@ func (s *OCIRepoService) resolveMediaType( addon *helmv1alpha1.HelmClusterAddon, repo *helmv1alpha1.HelmClusterAddonRepository, source utils.ChartSource, - version *helmv1alpha1.HelmClusterAddonChartVersion, + version *helmv1alpha1.ChartVersion, ) (string, *OCIRepoResult) { if version.MediaType != "" { return version.MediaType, nil 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 2f3786e2..19b8a3fe 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 @@ -115,7 +115,7 @@ func ociTestRepository() *helmv1alpha1.HelmClusterAddonRepository { // 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) utils.ChartSource { t.Helper() source, err := utils.ResolveChartSource(repo, version) @@ -144,7 +144,7 @@ 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", } @@ -169,7 +169,7 @@ 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, @@ -200,7 +200,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, @@ -266,7 +266,7 @@ 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", } @@ -294,7 +294,7 @@ 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", } @@ -376,7 +376,7 @@ 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", } @@ -421,7 +421,7 @@ 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", } @@ -457,7 +457,7 @@ func TestEnsureInternalOCIRepositoryKeepsOCIRepositoryCredentials(t *testing.T) service, c := newOCIRepoService(t, addon, repo) - version := &helmv1alpha1.HelmClusterAddonChartVersion{ + version := &helmv1alpha1.ChartVersion{ Version: "6.7.1", MediaType: "application/tar+gzip", } @@ -488,7 +488,7 @@ 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", } @@ -522,7 +522,7 @@ 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", } @@ -544,13 +544,13 @@ 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) - second := &helmv1alpha1.HelmClusterAddonChartVersion{ + second := &helmv1alpha1.ChartVersion{ Version: "6.7.1", OCIRef: "oci://mirror.example.com/charts/podinfo:6.7.1", } @@ -568,7 +568,7 @@ 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", } @@ -591,7 +591,7 @@ 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", } @@ -613,7 +613,7 @@ 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", } @@ -644,7 +644,7 @@ 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", } 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..b7968a9b 100644 --- a/images/operator-helm-controller/internal/services/repo_sync_service.go +++ b/images/operator-helm-controller/internal/services/repo_sync_service.go @@ -356,13 +356,13 @@ func (s *RepoSyncService) inUseVersions(ctx context.Context, repoName, chartName // the source controller instead. func mergeChartVersions( fetched []repoclient.ChartVersion, - current []helmv1alpha1.HelmClusterAddonChartVersion, + current []helmv1alpha1.ChartVersion, inUse map[string]struct{}, -) []helmv1alpha1.HelmClusterAddonChartVersion { - merged := make([]helmv1alpha1.HelmClusterAddonChartVersion, 0, len(fetched)+len(current)) +) []helmv1alpha1.ChartVersion { + merged := make([]helmv1alpha1.ChartVersion, 0, len(fetched)+len(current)) listed := make(map[string]struct{}, len(fetched)) - currentByVersion := make(map[string]helmv1alpha1.HelmClusterAddonChartVersion, len(current)) + currentByVersion := make(map[string]helmv1alpha1.ChartVersion, len(current)) for _, version := range current { currentByVersion[version.Version] = version } @@ -385,7 +385,7 @@ func mergeChartVersions( } } - merged = append(merged, helmv1alpha1.HelmClusterAddonChartVersion{ + merged = append(merged, helmv1alpha1.ChartVersion{ Version: name, OCIRef: version.OCIRef, MediaType: mediaType, @@ -423,7 +423,7 @@ func mergeChartVersions( // 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) { +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) 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..0e39f847 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 @@ -83,13 +83,13 @@ func ociVersion(version, mediaType string) repoclient.ChartVersion { 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 { 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 +107,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 { t.Helper() chart := &helmv1alpha1.HelmClusterAddonChart{} @@ -210,8 +210,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", @@ -271,8 +271,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") @@ -321,8 +321,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") @@ -435,7 +435,7 @@ 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") @@ -508,7 +508,7 @@ func TestMergeChartVersionsCarriesOCIRef(t *testing.T) { {Version: semver.MustParse("2.0.0")}, } - current := []helmv1alpha1.HelmClusterAddonChartVersion{ + 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"}, } @@ -517,7 +517,7 @@ func TestMergeChartVersionsCarriesOCIRef(t *testing.T) { merged := mergeChartVersions(fetched, current, inUse) - byVersion := map[string]helmv1alpha1.HelmClusterAddonChartVersion{} + byVersion := map[string]helmv1alpha1.ChartVersion{} for _, version := range merged { byVersion[version.Version] = version } @@ -554,7 +554,7 @@ func TestMergeChartVersionsDoesNotCarryMediaTypeOntoOCIRef(t *testing.T) { {Version: semver.MustParse("6.7.1"), OCIRef: "oci://other-registry.example.com/x/podinfo:6.7.1"}, } - current := []helmv1alpha1.HelmClusterAddonChartVersion{ + current := []helmv1alpha1.ChartVersion{ {Version: "6.7.1", MediaType: "application/tar+gzip"}, } diff --git a/images/operator-helm-controller/internal/utils/repository.go b/images/operator-helm-controller/internal/utils/repository.go index 009a01a5..ba4d8ae4 100644 --- a/images/operator-helm-controller/internal/utils/repository.go +++ b/images/operator-helm-controller/internal/utils/repository.go @@ -53,7 +53,7 @@ type ChartSource struct { // reference wins over the repository scheme: that is the hybrid case this exists for. func ResolveChartSource( repo *helmv1alpha1.HelmClusterAddonRepository, - version *helmv1alpha1.HelmClusterAddonChartVersion, + version *helmv1alpha1.ChartVersion, ) (ChartSource, error) { if version.OCIRef != "" { // The recorded reference always carries a tag, so there is no fallback to diff --git a/images/operator-helm-controller/internal/utils/repository_test.go b/images/operator-helm-controller/internal/utils/repository_test.go index b60df68f..4280a3b0 100644 --- a/images/operator-helm-controller/internal/utils/repository_test.go +++ b/images/operator-helm-controller/internal/utils/repository_test.go @@ -210,7 +210,7 @@ func TestResolveChartSource(t *testing.T) { tests := []struct { name string repo *helmv1alpha1.HelmClusterAddonRepository - version helmv1alpha1.HelmClusterAddonChartVersion + version helmv1alpha1.ChartVersion want ChartSource wantErr bool }{ @@ -219,7 +219,7 @@ func TestResolveChartSource(t *testing.T) { // repository scheme. name: "index entry pointing at a registry wins over the repository scheme", repo: helmRepo, - version: helmv1alpha1.HelmClusterAddonChartVersion{ + version: helmv1alpha1.ChartVersion{ Version: "25.0.2", OCIRef: "oci://registry-1.docker.io/bitnamicharts/airflow:25.0.2", }, @@ -232,13 +232,13 @@ func TestResolveChartSource(t *testing.T) { { name: "helm repository without an oci reference stays on the helm path", repo: helmRepo, - version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "6.7.1"}, + version: helmv1alpha1.ChartVersion{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"}, + version: helmv1alpha1.ChartVersion{Version: "6.7.1", MediaType: "application/tar+gzip"}, want: ChartSource{ Kind: InternalOCIRepository, URL: "oci://registry.example.com/charts/podinfo", @@ -248,7 +248,7 @@ func TestResolveChartSource(t *testing.T) { { name: "unparsable recorded reference is an error", repo: helmRepo, - version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "1.0.0", OCIRef: "oci://BAD_HOST//:::"}, + version: helmv1alpha1.ChartVersion{Version: "1.0.0", OCIRef: "oci://BAD_HOST//:::"}, wantErr: true, }, { @@ -256,7 +256,7 @@ func TestResolveChartSource(t *testing.T) { repo: &helmv1alpha1.HelmClusterAddonRepository{ Spec: helmv1alpha1.RepositorySpec{URL: "ftp://charts.example.com"}, }, - version: helmv1alpha1.HelmClusterAddonChartVersion{Version: "1.0.0"}, + version: helmv1alpha1.ChartVersion{Version: "1.0.0"}, wantErr: true, }, } From 26f60e1d5eb7f34d9b9a31bde72239cc4d3c3f0f Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 18:55:51 +0300 Subject: [PATCH 011/145] feat(controller): derive internal names for namespaced sources Internal objects of every family share d8-operator-helm, so a name derived from the source name alone lets two same-named namespaced sources overwrite each other's HelmRepository and auth secret. DerivedName always includes a hash over kind, namespace and name. The addon naming functions are left untouched and their exact output is now pinned by a test: those names are live objects. Signed-off-by: Ilya Drey --- api/v1alpha1/constants.go | 7 + .../internal/utils/name.go | 37 +++++ .../internal/utils/name_test.go | 132 ++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 images/operator-helm-controller/internal/utils/name_test.go diff --git a/api/v1alpha1/constants.go b/api/v1alpha1/constants.go index 59e9236e..3cab49c2 100644 --- a/api/v1alpha1/constants.go +++ b/api/v1alpha1/constants.go @@ -29,6 +29,13 @@ 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" diff --git a/images/operator-helm-controller/internal/utils/name.go b/images/operator-helm-controller/internal/utils/name.go index 12edbc56..2bbe002d 100644 --- a/images/operator-helm-controller/internal/utils/name.go +++ b/images/operator-helm-controller/internal/utils/name.go @@ -133,3 +133,40 @@ func GetInternalHelmRepositoryName(addonRepositoryName string) string { 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 the cut may +// have left at the end, so the joined name never carries a double dash. +func truncatePart(part string) string { + if len(part) > derivedPartLimit { + part = part[:derivedPartLimit] + } + + return strings.TrimRight(part, "-") +} 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..44559218 --- /dev/null +++ b/images/operator-helm-controller/internal/utils/name_test.go @@ -0,0 +1,132 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "strings" + "testing" +) + +// 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 + }{ + { + name: "namespaced source carries namespace, name and a hash", + prefix: "hapr", + kind: "HelmApplicationRepository", + namespace: "team-a", + object: "stable", + want: "hapr-team-a-stable-42df68033b1e", + }, + { + 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", + }, + } + + 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)) + } + }) + } +} + +// 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)) + } +} From 403e83d511d3ab8c12392b46c8e89d1301e33ac3 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 19:02:33 +0300 Subject: [PATCH 012/145] feat(controller): add the repository source contract and its adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit source.Repository is how the services will see any repository kind; the three adapters hold everything that differs between kinds — labels, derived names, owner GVK. The addon adapter reproduces the released names and labels exactly. Nothing consumes the contract yet. Signed-off-by: Ilya Drey --- .../internal/adapter/addon_repository.go | 76 +++++++ .../adapter/application_repository.go | 86 ++++++++ .../adapter/cluster_application_repository.go | 89 ++++++++ .../internal/adapter/doc.go | 25 +++ .../internal/adapter/repository_test.go | 200 ++++++++++++++++++ .../internal/source/catalog.go | 39 ++++ .../internal/source/doc.go | 27 +++ .../internal/source/repository.go | 81 +++++++ 8 files changed, 623 insertions(+) create mode 100644 images/operator-helm-controller/internal/adapter/addon_repository.go create mode 100644 images/operator-helm-controller/internal/adapter/application_repository.go create mode 100644 images/operator-helm-controller/internal/adapter/cluster_application_repository.go create mode 100644 images/operator-helm-controller/internal/adapter/doc.go create mode 100644 images/operator-helm-controller/internal/adapter/repository_test.go create mode 100644 images/operator-helm-controller/internal/source/catalog.go create mode 100644 images/operator-helm-controller/internal/source/doc.go create mode 100644 images/operator-helm-controller/internal/source/repository.go 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..8e98ecc2 --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/addon_repository.go @@ -0,0 +1,76 @@ +/* +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/manager/status" + "github.com/deckhouse/operator-helm/internal/source" + "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_repository.go b/images/operator-helm-controller/internal/adapter/application_repository.go new file mode 100644 index 00000000..2f8ec261 --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/application_repository.go @@ -0,0 +1,86 @@ +/* +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/manager/status" + "github.com/deckhouse/operator-helm/internal/source" + "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/cluster_application_repository.go b/images/operator-helm-controller/internal/adapter/cluster_application_repository.go new file mode 100644 index 00000000..d65396c7 --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/cluster_application_repository.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 adapter + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/manager/status" + "github.com/deckhouse/operator-helm/internal/source" + "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/source/catalog.go b/images/operator-helm-controller/internal/source/catalog.go new file mode 100644 index 00000000..ed5982ea --- /dev/null +++ b/images/operator-helm-controller/internal/source/catalog.go @@ -0,0 +1,39 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + + 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) + // 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) +} 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/repository.go b/images/operator-helm-controller/internal/source/repository.go new file mode 100644 index 00000000..e0f2f358 --- /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/manager/status" +) + +// InternalNames are the names of the internal objects derived from one +// repository. They are computed once 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 +} From 0acc3215b7b3d0c963109984ec731075a6aea578 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 19:11:18 +0300 Subject: [PATCH 013/145] feat(controller): mirror repository catalogs through a kind-agnostic catalog The catalog writer is the one place generic over API types: catalog objects must be created, listed and status-patched with their concrete kind. A namespaced catalog lists by the repository namespace, which is what keeps two same-named repositories in different namespaces apart. The merge and sort rules move here unchanged with their tests; the application catalogs have no consumers until HelmApplication is reconciled. Signed-off-by: Ilya Drey --- .../internal/adapter/catalogs.go | 122 ++++++++++ .../internal/adapter/catalogs_test.go | 98 ++++++++ .../internal/catalog/catalog.go | 221 ++++++++++++++++++ .../internal/catalog/catalog_test.go | 195 ++++++++++++++++ .../internal/catalog/doc.go | 24 ++ .../internal/catalog/merge.go | 128 ++++++++++ .../internal/catalog/merge_test.go | 103 ++++++++ .../services/repo_sync_service_test.go | 77 ------ 8 files changed, 891 insertions(+), 77 deletions(-) create mode 100644 images/operator-helm-controller/internal/adapter/catalogs.go create mode 100644 images/operator-helm-controller/internal/adapter/catalogs_test.go create mode 100644 images/operator-helm-controller/internal/catalog/catalog.go create mode 100644 images/operator-helm-controller/internal/catalog/catalog_test.go create mode 100644 images/operator-helm-controller/internal/catalog/doc.go create mode 100644 images/operator-helm-controller/internal/catalog/merge.go create mode 100644 images/operator-helm-controller/internal/catalog/merge_test.go 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..4764c28c --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/catalogs.go @@ -0,0 +1,122 @@ +/* +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" + + "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/index" + "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: addonChartConsumers(c), + }) +} + +// NewApplicationCatalog builds the HelmApplicationChart catalog. Consumers is nil +// until the HelmApplication controller exists: nothing can reference a chart yet. +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, + }) +} + +// NewClusterApplicationCatalog builds the HelmClusterApplicationChart catalog. +// Consumers is nil for the same reason as in NewApplicationCatalog. +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, + }) +} + +// 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 +} + +// addonChartConsumers returns the chart versions referenced by the addon that uses +// a 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 addonChartConsumers(c client.Client) func(context.Context, source.Repository, string) (map[string]struct{}, error) { + return func(ctx context.Context, repo source.Repository, chartName string) (map[string]struct{}, error) { + var addons helmv1alpha1.HelmClusterAddonList + if err := c.List(ctx, &addons, client.MatchingFields{ + index.AddonChart: index.AddonChartValue(repo.Name(), 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 == repo.Name() { + 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..7adc5002 --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/catalogs_test.go @@ -0,0 +1,98 @@ +/* +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)} + }). + 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) + } +} 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..9a62b72f --- /dev/null +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -0,0 +1,221 @@ +/* +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 %q: %w", t.cfg.Kind, 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, "name", chart.GetName()) + + 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 %q: %w", name, err) + } + + if op != controllerutil.OperationResultNone { + logger.Info("Reconciled chart catalog object", "kind", t.cfg.Kind, "operation", op, "name", name) + } + + 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 %q: %w", name, 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, "name", chart.GetName()) + } + + 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, "name", chart.GetName()) + + continue + } + + if err := client.IgnoreNotFound(t.client.Delete(ctx, chart)); err != nil { + return fmt.Errorf("deleting stale charts: %w", 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) +} 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..1a2ecf4e --- /dev/null +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -0,0 +1,195 @@ +/* +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" + "testing" + + "github.com/Masterminds/semver/v3" + 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" + + "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/source" +) + +func newClient(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...). + WithStatusSubresource(&helmv1alpha1.HelmApplicationChart{}, &helmv1alpha1.HelmClusterApplicationChart{}). + Build() +} + +func applicationRepo(namespace, name string) source.Repository { + 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}, + }) +} + +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) + } +} + +// TestWithoutConsumersEveryUnlistedVersionIsPruned pins the behaviour of the +// application catalogs until HelmApplication is reconciled: nothing can reference +// a chart, so nothing is protected from pruning. +func TestWithoutConsumersEveryUnlistedVersionIsPruned(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 := cat.Reconcile(context.Background(), repo, []repoclient.Chart{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) + } +} 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/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/services/repo_sync_service_test.go b/images/operator-helm-controller/internal/services/repo_sync_service_test.go index 0e39f847..40375839 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 @@ -495,80 +495,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.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) - } -} From 47c1bb33dd124d2e2f9960530828d1d5ed0dca16 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 19:23:07 +0300 Subject: [PATCH 014/145] refactor(controller): reconcile repository secrets and sources through the contract EnsureSecrets, EnsureInternalHelmRepository and the cleanup paths take a source.Repository instead of the addon type; names and labels come from the adapter. The addon reconciler wraps its object at the boundary, so nothing observable changes for the addon family. Signed-off-by: Ilya Drey --- .../helmclusteraddonrepository/reconciler.go | 12 ++++-- .../internal/services/base.go | 35 +++++++-------- .../internal/services/base_test.go | 7 +-- .../internal/services/helm_repo_service.go | 43 ++++++++----------- .../services/helm_repo_service_test.go | 11 ++--- 5 files changed, 51 insertions(+), 57 deletions(-) diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go index 94b3ac2f..aea372dc 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go @@ -31,6 +31,7 @@ import ( "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/manager/status" "github.com/deckhouse/operator-helm/internal/services" "github.com/deckhouse/operator-helm/internal/utils" @@ -81,6 +82,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco } repoType, repoTypeErr := utils.GetRepositoryType(repo.Spec.URL) + src := adapter.NewAddonRepository(&repo) if !repo.DeletionTimestamp.IsZero() { return r.reconcileDelete(ctx, &repo, repoType) @@ -117,16 +119,16 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // 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) + in.SecretsErr = r.helmRepositoryService.EnsureSecrets(ctx, src, repoType) if in.SecretsErr == nil { switch repoType { case utils.InternalHelmRepository: - in.InternalRepository, in.InternalRepositoryErr = r.helmRepositoryService.EnsureInternalHelmRepository(ctx, &repo) + in.InternalRepository, in.InternalRepositoryErr = r.helmRepositoryService.EnsureInternalHelmRepository(ctx, src) 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.InternalRepositoryErr = r.helmRepositoryService.RemoveHelmRepository(ctx, src.InternalNames()) } } @@ -212,6 +214,8 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.Hel return reconcile.Result{}, nil } + names := adapter.NewAddonRepository(repo).InternalNames() + switch repoType { case utils.InternalOCIRepository: if err := r.ociRepositoryService.CleanupOCIRepository(ctx, repo.Name); err != nil && !apierrors.IsNotFound(err) { @@ -226,7 +230,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.Hel // 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) + helmRepo, err := r.helmRepositoryService.CleanupHelmRepository(ctx, names) if err != nil && !apierrors.IsNotFound(err) { _ = r.statusManager.MarkDeletionFailed(ctx, repo, "internal repository", err) return reconcile.Result{}, err diff --git a/images/operator-helm-controller/internal/services/base.go b/images/operator-helm-controller/internal/services/base.go index 708577ac..d1cb6508 100644 --- a/images/operator-helm-controller/internal/services/base.go +++ b/images/operator-helm-controller/internal/services/base.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -79,7 +80,7 @@ type BaseRepoService struct { // secret is touched. func (s *BaseRepoService) EnsureSecrets( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, + repo source.Repository, repoType utils.InternalRepositoryType, ) error { var err error @@ -105,7 +106,7 @@ func (s *BaseRepoService) EnsureSecrets( // 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 { +func (s *BaseRepoService) 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{ @@ -119,10 +120,10 @@ func (s *BaseRepoService) reconcileBasicAuthSecret(ctx context.Context, repo *he // 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 { +func (s *BaseRepoService) 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.Spec.URL, auth.Username, auth.Password) + config, err := utils.BuildDockerConfigJSON(repo.URL(), auth.Username, auth.Password) if err != nil { return nil, fmt.Errorf("building docker config: %w", err) } @@ -134,29 +135,26 @@ func (s *BaseRepoService) reconcileDockerConfigAuthSecret(ctx context.Context, r func (s *BaseRepoService) reconcileAuthSecret( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, + repo source.Repository, secretType corev1.SecretType, buildData func(auth *helmv1alpha1.RepositoryAuth) (map[string]string, error), ) error { - secretName := utils.GetInternalRepositoryAuthSecretName(repo.Name) + secretName := repo.InternalNames().AuthSecret nn := types.NamespacedName{Name: secretName, Namespace: s.TargetNamespace} - if repo.Spec.Auth == nil { + 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.Spec.Auth) + stringData, err := buildData(repo.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, - } + labels := repo.SourceLabels() staleRemoved, err := s.removeAuthSecretOfOtherType(ctx, nn, secretType) if err != nil { @@ -232,10 +230,10 @@ func (s *BaseRepoService) removeAuthSecretOfOtherType( return true, nil } -func (s *BaseRepoService) reconcileTLSSecret(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository) error { - secretName := utils.GetInternalRepositoryTLSSecretName(repo.Name) +func (s *BaseRepoService) reconcileTLSSecret(ctx context.Context, repo source.Repository) error { + secretName := repo.InternalNames().TLSSecret - if repo.Spec.CACertificate == "" { + 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) @@ -253,13 +251,10 @@ func (s *BaseRepoService) reconcileTLSSecret(ctx context.Context, repo *helmv1al } if _, err := controllerutil.CreateOrPatch(ctx, s.Client, tlsSecret, func() error { - tlsSecret.Labels = map[string]string{ - helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repo.Name, - } + tlsSecret.Labels = repo.SourceLabels() tlsSecret.StringData = map[string]string{ - "ca.crt": repo.Spec.CACertificate, + "ca.crt": repo.CACertificate(), } return nil diff --git a/images/operator-helm-controller/internal/services/base_test.go b/images/operator-helm-controller/internal/services/base_test.go index 973c1153..84e0459e 100644 --- a/images/operator-helm-controller/internal/services/base_test.go +++ b/images/operator-helm-controller/internal/services/base_test.go @@ -31,6 +31,7 @@ import ( "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" ) @@ -74,7 +75,7 @@ func TestEnsureSecretsCreatesAuthAndTLS(t *testing.T) { service, c := newBaseRepoService(t, repo) - if err := service.EnsureSecrets(context.Background(), repo, utils.InternalHelmRepository); err != nil { + if err := service.EnsureSecrets(context.Background(), adapter.NewAddonRepository(repo), utils.InternalHelmRepository); err != nil { t.Fatalf("EnsureSecrets returned %v", err) } @@ -110,7 +111,7 @@ func TestEnsureSecretsRemovesObsoleteSecrets(t *testing.T) { service, c := newBaseRepoService(t, repo, obsolete) - if err := service.EnsureSecrets(context.Background(), repo, utils.InternalHelmRepository); err != nil { + if err := service.EnsureSecrets(context.Background(), adapter.NewAddonRepository(repo), utils.InternalHelmRepository); err != nil { t.Fatalf("EnsureSecrets returned %v", err) } @@ -131,7 +132,7 @@ func TestEnsureSecretsUsesDockerConfigForOCIRepositories(t *testing.T) { service, c := newBaseRepoService(t, repo) - if err := service.EnsureSecrets(context.Background(), repo, utils.InternalOCIRepository); err != nil { + if err := service.EnsureSecrets(context.Background(), adapter.NewAddonRepository(repo), utils.InternalOCIRepository); err != nil { t.Fatalf("EnsureSecrets returned %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..10731bcb 100644 --- a/images/operator-helm-controller/internal/services/helm_repo_service.go +++ b/images/operator-helm-controller/internal/services/helm_repo_service.go @@ -34,7 +34,7 @@ import ( 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 +61,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, }, } @@ -111,9 +111,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) } @@ -126,20 +125,15 @@ func (s *HelmRepoService) RemoveHelmRepository(ctx context.Context, repoName str // 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 // 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 { +func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, names source.InternalNames) (*sourcev1.HelmRepository, 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 nil, fmt.Errorf("cleaning up secret %s: %w", name, err) } } - nn := types.NamespacedName{Name: utils.GetInternalHelmRepositoryName(repoName), Namespace: s.TargetNamespace} + nn := types.NamespacedName{Name: names.HelmRepository, Namespace: s.TargetNamespace} helmRepo := &sourcev1.HelmRepository{} exists, err := s.deleteAndCheck(ctx, nn, helmRepo) if err != nil { @@ -152,7 +146,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 +156,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 84b4c7ff..52fee0ed 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 @@ -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" ) @@ -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") } From 3eee11629daa9972bbf246c462980df8806896f3 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 19:32:47 +0300 Subject: [PATCH 015/145] refactor(controller): synchronize catalogs through the contract RepoSyncService fetches through source.Repository and writes through source.Catalog; its own catalog code is gone, the generic catalog owns it. OCIRepoService cleans up by InternalNames and implements ConsumerForcer for the addon family. Behaviour is unchanged: the synchronization tests pass with their expectations untouched. Signed-off-by: Ilya Drey --- .../helmclusteraddonrepository/controller.go | 3 +- .../helmclusteraddonrepository/reconciler.go | 4 +- .../reconciler_test.go | 3 +- .../internal/services/oci_repo_service.go | 32 +- .../internal/services/repo_sync_service.go | 334 ++---------------- .../services/repo_sync_service_test.go | 29 +- 6 files changed, 59 insertions(+), 346 deletions(-) diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go index 3a8ffd9c..03fdc4e9 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go @@ -26,6 +26,7 @@ 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" @@ -44,7 +45,7 @@ func SetupWithManager(mgr ctrl.Manager) error { client, services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil), - services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient), + services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewAddonCatalog(client)), status.NewManager(client), ) diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go index aea372dc..677f42af 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go @@ -140,7 +140,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return reconcile.Result{}, err } - outcome := r.chartSyncService.Sync(ctx, &repo, repoType) + outcome := r.chartSyncService.Sync(ctx, src, repoType) in.Attempted = true if outcome.FetchAttempted { @@ -218,7 +218,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.Hel switch repoType { case utils.InternalOCIRepository: - if err := r.ociRepositoryService.CleanupOCIRepository(ctx, repo.Name); err != nil && !apierrors.IsNotFound(err) { + if err := r.ociRepositoryService.CleanupOCIRepository(ctx, names); err != nil && !apierrors.IsNotFound(err) { _ = r.statusManager.MarkDeletionFailed(ctx, repo, "internal repository", err) return reconcile.Result{}, err } diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go index e2ed01c2..848b8e1f 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go @@ -36,6 +36,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" 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/manager/status" @@ -106,7 +107,7 @@ func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) c, services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, nil), - services.NewRepoSyncService(c, scheme, factory), + services.NewRepoSyncService(c, scheme, factory, adapter.NewAddonCatalog(c)), status.NewManager(c), ) 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 330a4617..438c7bb4 100644 --- a/images/operator-helm-controller/internal/services/oci_repo_service.go +++ b/images/operator-helm-controller/internal/services/oci_repo_service.go @@ -36,6 +36,7 @@ import ( 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" ) @@ -59,6 +60,8 @@ type OCIRepoService struct { resolver repoclient.ChartResolverInterface } +var _ source.ConsumerForcer = (*OCIRepoService)(nil) + // NewOCIRepoService builds the service. A nil resolver selects the default one; tests // pass their own so they never reach a registry. func NewOCIRepoService( @@ -311,25 +314,18 @@ func (s *OCIRepoService) ForceReconcileInternalRepositories(ctx context.Context, 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{}, - }, - } +// ForceReconcileConsumers is the source.ConsumerForcer of the addon family: the +// consumers of a HelmClusterAddonRepository are the HelmClusterAddon objects +// referencing it, each with its own internal OCIRepository. +func (s *OCIRepoService) ForceReconcileConsumers(ctx context.Context, repo source.Repository) error { + return s.ForceReconcileInternalRepositories(ctx, repo.Name()) +} - 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) +func (s *OCIRepoService) CleanupOCIRepository(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) } } 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 b7968a9b..45f421c5 100644 --- a/images/operator-helm-controller/internal/services/repo_sync_service.go +++ b/images/operator-helm-controller/internal/services/repo_sync_service.go @@ -18,22 +18,13 @@ 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" repoclient "github.com/deckhouse/operator-helm/internal/client/repository" - "github.com/deckhouse/operator-helm/internal/index" + "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -41,13 +32,16 @@ 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) -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,19 @@ 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. +// 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, + repo source.Repository, repoType utils.InternalRepositoryType, ) 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,55 +80,16 @@ 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) - } - - 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 SyncOutcome{ + FetchAttempted: true, + Fetch: fetch, + Catalog: CatalogOutcome{Err: s.catalog.Reconcile(ctx, repo, charts)}, } - - return known, nil } func (s *RepoSyncService) fetchCharts( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, + repo source.Repository, repoType utils.InternalRepositoryType, opts repoclient.FetchOptions, ) ([]repoclient.Chart, FetchOutcome) { @@ -147,7 +103,7 @@ 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)} } @@ -182,262 +138,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.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/services/repo_sync_service_test.go b/images/operator-helm-controller/internal/services/repo_sync_service_test.go index 40375839..0ca4acbd 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,6 +29,7 @@ import ( "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/utils" @@ -76,7 +77,7 @@ func newRepoSyncService(t *testing.T, stub stubRepoClient, objects ...client.Obj 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 { @@ -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), utils.InternalHelmRepository) 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), utils.InternalHelmRepository) 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), utils.InternalHelmRepository) 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), utils.InternalHelmRepository) if outcome.Fetch.Err == nil { t.Fatal("expected a fetch failure") } @@ -228,7 +229,7 @@ func TestSyncPassesKnownVersionsToTheClient(t *testing.T) { 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), utils.InternalOCIRepository); outcome.Fetch.Err != nil { t.Fatalf("fetch failed: %v", outcome.Fetch.Err) } @@ -261,7 +262,7 @@ func TestSyncRequestsFullPassOnForceReconcile(t *testing.T) { return stub, nil } - service.Sync(context.Background(), repo, utils.InternalOCIRepository) + service.Sync(context.Background(), adapter.NewAddonRepository(repo), utils.InternalOCIRepository) if !stub.opts.Full { t.Fatal("force reconcile must request a full re-index") @@ -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), utils.InternalOCIRepository); outcome.Catalog.Err != nil { t.Fatalf("catalog update failed: %v", outcome.Catalog.Err) } @@ -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), utils.InternalOCIRepository); 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), utils.InternalOCIRepository) 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), utils.InternalOCIRepository) if outcome.Catalog.Err != nil { t.Fatalf("catalog update failed: %v", outcome.Catalog.Err) @@ -440,7 +441,7 @@ func TestSyncKeepsChartReferencedByAddon(t *testing.T) { 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), utils.InternalOCIRepository); outcome.Catalog.Err != nil { t.Fatalf("catalog update failed: %v", outcome.Catalog.Err) } @@ -478,9 +479,9 @@ func TestSyncReportsNoFetchAttemptOnClusterReadFailure(t *testing.T) { factory := func(_ utils.InternalRepositoryType) (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), utils.InternalOCIRepository) if outcome.FetchAttempted { t.Fatal("a cluster-side read failure before the fetch must not report FetchAttempted") From dc92728d3346fb610beb1dcffe77356d9421baae Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 19:41:44 +0300 Subject: [PATCH 016/145] refactor(controller): reconcile every repository kind with one reconciler The repository reconciler reads the API object through a source.Repository adapter handed to it as a factory, so the same code serves any repository kind. The package moves from reconcile/helmclusteraddonrepository to reconcile/repository; Evaluate and its tests move with it unchanged. The addon controller is the first, and so far the only, user. Signed-off-by: Ilya Drey --- .../helmclusteraddonrepository/controller.go | 8 +- .../evaluate.go | 2 +- .../evaluate_test.go | 2 +- .../kstatus_test.go | 2 +- .../reconciler.go | 123 ++++++++++-------- .../reconciler_test.go | 8 +- .../schedule_test.go | 2 +- 7 files changed, 83 insertions(+), 64 deletions(-) rename images/operator-helm-controller/internal/reconcile/{helmclusteraddonrepository => repository}/evaluate.go (99%) rename images/operator-helm-controller/internal/reconcile/{helmclusteraddonrepository => repository}/evaluate_test.go (99%) rename images/operator-helm-controller/internal/reconcile/{helmclusteraddonrepository => repository}/kstatus_test.go (99%) rename images/operator-helm-controller/internal/reconcile/{helmclusteraddonrepository => repository}/reconciler.go (69%) rename images/operator-helm-controller/internal/reconcile/{helmclusteraddonrepository => repository}/reconciler_test.go (99%) rename images/operator-helm-controller/internal/reconcile/{helmclusteraddonrepository => repository}/schedule_test.go (99%) diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go index 03fdc4e9..60968096 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go @@ -29,7 +29,7 @@ import ( "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/utils" ) @@ -41,10 +41,14 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() + ociRepositoryService := services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil) + r := reconcile.New( client, + adapter.EmptyAddonRepository, services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil), + ociRepositoryService, + ociRepositoryService, services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewAddonCatalog(client)), status.NewManager(client), ) diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate.go b/images/operator-helm-controller/internal/reconcile/repository/evaluate.go similarity index 99% rename from images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/evaluate.go rename to images/operator-helm-controller/internal/reconcile/repository/evaluate.go index b83ea924..6e854d5f 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" 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 99% 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 b940f785..30db3093 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" 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/helmclusteraddonrepository/reconciler.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go similarity index 69% rename from images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go rename to images/operator-helm-controller/internal/reconcile/repository/reconciler.go index 677f42af..b222cfca 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddonrepository +package repository import ( "context" @@ -31,9 +31,9 @@ import ( "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/manager/status" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -43,17 +43,25 @@ import ( // whose deletion is stuck and stops emitting events. const internalResourceDeletionRequeueInterval = 30 * time.Second +// New builds the reconciler of one repository 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. func New( client client.Client, + newRepository func() source.Repository, helmRepositoryService *services.HelmRepoService, ociRepositoryService *services.OCIRepoService, + consumers source.ConsumerForcer, chartSyncService *services.RepoSyncService, statusManager *status.Manager, ) *Reconciler { return &Reconciler{ Client: client, + newRepository: newRepository, helmRepositoryService: helmRepositoryService, ociRepositoryService: ociRepositoryService, + consumers: consumers, chartSyncService: chartSyncService, statusManager: statusManager, } @@ -62,8 +70,10 @@ func New( type Reconciler struct { client.Client + newRepository func() source.Repository helmRepositoryService *services.HelmRepoService ociRepositoryService *services.OCIRepoService + consumers source.ConsumerForcer chartSyncService *services.RepoSyncService statusManager *status.Manager } @@ -72,26 +82,25 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco logger := log.FromContext(ctx) ctx = log.IntoContext(ctx, logger) - var repo helmv1alpha1.HelmClusterAddonRepository - if err := r.Get(ctx, req.NamespacedName, &repo); err != nil { + repo := r.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 helm cluster addon repository: %w", err) + return reconcile.Result{}, fmt.Errorf("getting repository: %w", err) } - repoType, repoTypeErr := utils.GetRepositoryType(repo.Spec.URL) - src := adapter.NewAddonRepository(&repo) + repoType, repoTypeErr := utils.GetRepositoryType(repo.URL()) - if !repo.DeletionTimestamp.IsZero() { - return r.reconcileDelete(ctx, &repo, repoType) + if !repo.Object().GetDeletionTimestamp().IsZero() { + return r.reconcileDelete(ctx, repo, repoType) } - if !controllerutil.ContainsFinalizer(&repo, helmv1alpha1.FinalizerName) { - controllerutil.AddFinalizer(&repo, helmv1alpha1.FinalizerName) + if !controllerutil.ContainsFinalizer(repo.Object(), helmv1alpha1.FinalizerName) { + controllerutil.AddFinalizer(repo.Object(), helmv1alpha1.FinalizerName) - if err := r.Update(ctx, &repo); err != nil { + 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 @@ -101,10 +110,10 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco } in := Inputs{ - Generation: repo.Generation, + Generation: repo.Generation(), Now: time.Now().UTC(), Jitter: NewJitter(), - Current: *repo.Status.DeepCopy(), + Current: *repo.Status().DeepCopy(), } if repoTypeErr != nil { @@ -114,21 +123,21 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco Err: repoTypeErr, } - return r.finish(ctx, &repo, in, false) + 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, src, repoType) + 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, src) + 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, src.InternalNames()) + in.InternalRepositoryErr = r.helmRepositoryService.RemoveHelmRepository(ctx, repo.InternalNames()) } } @@ -136,11 +145,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco 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 { + if err := r.markSyncInProgress(ctx, repo, in.Forced); err != nil { return reconcile.Result{}, err } - outcome := r.chartSyncService.Sync(ctx, src, repoType) + outcome := r.chartSyncService.Sync(ctx, repo, repoType) in.Attempted = true if outcome.FetchAttempted { @@ -153,7 +162,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco in.Catalog = &outcome.Catalog } - return r.finish(ctx, &repo, in, in.Attempted) + return r.finish(ctx, repo, in, in.Attempted) } // finish applies the decision and consumes the force annotation when an attempt @@ -161,7 +170,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // does not lose the request. func (r *Reconciler) finish( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, + repo source.Repository, in Inputs, attempted bool, ) (reconcile.Result, error) { @@ -170,30 +179,30 @@ func (r *Reconciler) finish( 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) + 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 + if err := r.statusManager.PatchStatus(ctx, repo.Object(), 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. + // 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.ociRepositoryService.ForceReconcileInternalRepositories(ctx, repo.Name); err != nil { + if err := r.consumers.ForceReconcileConsumers(ctx, repo); 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 { + if err := r.reconcileForceAnnotation(ctx, client.ObjectKeyFromObject(repo.Object())); err != nil { return reconcile.Result{}, fmt.Errorf("failed to reconcile force annotation: %w", err) } } @@ -207,19 +216,19 @@ func (r *Reconciler) finish( return reconcile.Result{RequeueAfter: decision.RequeueAfter}, nil } -func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, repoType utils.InternalRepositoryType) (reconcile.Result, error) { +func (r *Reconciler) reconcileDelete(ctx context.Context, repo source.Repository, repoType utils.InternalRepositoryType) (reconcile.Result, error) { logger := log.FromContext(ctx) - if !controllerutil.ContainsFinalizer(repo, helmv1alpha1.FinalizerName) { + if !controllerutil.ContainsFinalizer(repo.Object(), helmv1alpha1.FinalizerName) { return reconcile.Result{}, nil } - names := adapter.NewAddonRepository(repo).InternalNames() + names := repo.InternalNames() switch repoType { case utils.InternalOCIRepository: if err := r.ociRepositoryService.CleanupOCIRepository(ctx, names); err != nil && !apierrors.IsNotFound(err) { - _ = r.statusManager.MarkDeletionFailed(ctx, repo, "internal repository", err) + _ = r.statusManager.MarkDeletionFailed(ctx, repo.Object(), "internal repository", err) return reconcile.Result{}, err } default: @@ -232,7 +241,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.Hel // internal repository — and leaving it out would orphan them. helmRepo, err := r.helmRepositoryService.CleanupHelmRepository(ctx, names) if err != nil && !apierrors.IsNotFound(err) { - _ = r.statusManager.MarkDeletionFailed(ctx, repo, "internal repository", err) + _ = r.statusManager.MarkDeletionFailed(ctx, repo.Object(), "internal repository", err) return reconcile.Result{}, err } if helmRepo != nil { @@ -241,13 +250,13 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.Hel } if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - latestRepo := &helmv1alpha1.HelmClusterAddonRepository{} - if err := r.Get(ctx, client.ObjectKeyFromObject(repo), latestRepo); err != nil { + latest := r.newRepository() + if err := r.Get(ctx, client.ObjectKeyFromObject(repo.Object()), latest.Object()); err != nil { return client.IgnoreNotFound(err) } - if controllerutil.RemoveFinalizer(latestRepo, helmv1alpha1.FinalizerName) { - if err := r.Update(ctx, latestRepo); err != nil { + 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 } } @@ -265,10 +274,10 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo *helmv1alpha1.Hel // 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) { +func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, repo source.Repository, 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 { + if err := r.statusManager.MarkDeletionPending(ctx, repo.Object(), name, resource); client.IgnoreNotFound(err) != nil { return reconcile.Result{}, fmt.Errorf("updating deletion status: %w", err) } @@ -287,7 +296,7 @@ func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, repo *he // own. func (r *Reconciler) markSyncInProgress( ctx context.Context, - repo *helmv1alpha1.HelmClusterAddonRepository, + repo source.Repository, forced bool, ) error { reason, message := helmv1alpha1.ReasonSynchronization, "Repository synchronization in progress" @@ -295,13 +304,13 @@ func (r *Reconciler) markSyncInProgress( reason, message = helmv1alpha1.ReasonForceReconcile, "Forced reconciliation in progress" } - err := r.statusManager.PatchStatus(ctx, repo, func() { - apimeta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{ + err := r.statusManager.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, + ObservedGeneration: repo.Generation(), }) }) if client.IgnoreNotFound(err) != nil { @@ -312,28 +321,30 @@ func (r *Reconciler) markSyncInProgress( } func (r *Reconciler) reconcileForceAnnotation(ctx context.Context, key client.ObjectKey) error { - var repo helmv1alpha1.HelmClusterAddonRepository + repo := r.newRepository() - if err := r.Get(ctx, key, &repo); err != nil { + if err := r.Get(ctx, key, repo.Object()); err != nil { if apierrors.IsNotFound(err) { return nil } - return fmt.Errorf("getting helm cluster addon repository: %w", err) + return fmt.Errorf("getting repository: %w", err) } - if _, found := repo.Annotations[helmv1alpha1.AnnotationForceReconcile]; !found { + annotations := repo.Object().GetAnnotations() + if _, found := 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()) + patchBase := client.MergeFrom(repo.Object().DeepCopyObject().(client.Object)) - delete(repo.Annotations, helmv1alpha1.AnnotationForceReconcile) + delete(annotations, helmv1alpha1.AnnotationForceReconcile) + repo.Object().SetAnnotations(annotations) - if err := r.Patch(ctx, &repo, patchBase); err != nil { + if err := r.Patch(ctx, repo.Object(), patchBase); err != nil { return fmt.Errorf("removing force reconcile annotation: %w", err) } 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 99% 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 848b8e1f..9382383e 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddonrepository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_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 ( "context" @@ -103,10 +103,14 @@ func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) return stub, nil } + ociRepositoryService := services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, nil) + r := New( c, + adapter.EmptyAddonRepository, services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), - services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, nil), + ociRepositoryService, + ociRepositoryService, services.NewRepoSyncService(c, scheme, factory, adapter.NewAddonCatalog(c)), status.NewManager(c), ) 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 99% 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 d9a85f77..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" From 570acf5c686b374330d13b2dc3334346f98f4a75 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 19:47:57 +0300 Subject: [PATCH 017/145] feat(controller): map internal resources back to a namespaced source A namespaced source cannot be identified by its name alone once its internal objects share d8-operator-helm with everything else; the new mapper reads the namespace from the second source label. The existing mapper is untouched. Signed-off-by: Ilya Drey --- .../internal/utils/mapper.go | 39 ++++++++++++ .../internal/utils/mapper_test.go | 62 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/images/operator-helm-controller/internal/utils/mapper.go b/images/operator-helm-controller/internal/utils/mapper.go index cd2e9363..cc6543f9 100644 --- a/images/operator-helm-controller/internal/utils/mapper.go +++ b/images/operator-helm-controller/internal/utils/mapper.go @@ -61,6 +61,45 @@ func MapInternalResources(controllerName, targetNamespace, labelManagedBy, label } } +// 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. +func MapNamespacedInternalResources( + controllerName, targetNamespace, labelManagedBy, labelManagedByValue, labelSourceName, labelSourceNamespace string, +) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + logger := log.FromContext(ctx) + + if obj.GetNamespace() != targetNamespace { + return nil + } + + labels := obj.GetLabels() + if labels[labelManagedBy] != labelManagedByValue { + return nil + } + + sourceName, sourceNamespace := labels[labelSourceName], labels[labelSourceNamespace] + if sourceName == "" || sourceNamespace == "" { + logger.Info("resource missing source labels, skipping", + "controller", controllerName, "name", obj.GetName(), "namespace", obj.GetNamespace()) + + return nil + } + + return []reconcile.Request{ + { + NamespacedName: types.NamespacedName{ + Name: sourceName, + Namespace: sourceNamespace, + }, + }, + } + } +} + func MapRepositoryToAddons(c client.Client) handler.MapFunc { return func(ctx context.Context, obj client.Object) []reconcile.Request { addonList := &helmv1alpha1.HelmClusterAddonList{} diff --git a/images/operator-helm-controller/internal/utils/mapper_test.go b/images/operator-helm-controller/internal/utils/mapper_test.go index 93517cb2..4f789c3b 100644 --- a/images/operator-helm-controller/internal/utils/mapper_test.go +++ b/images/operator-helm-controller/internal/utils/mapper_test.go @@ -18,13 +18,17 @@ 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" + "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" "github.com/deckhouse/operator-helm/api/naming" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" @@ -130,3 +134,61 @@ func TestMapChartToAddonsMissingLabels(t *testing.T) { t.Fatalf("requests = %+v, want none for a chart with no labels", requests) } } + +func TestMapNamespacedInternalResources(t *testing.T) { + const target = "d8-operator-helm" + + mapper := MapNamespacedInternalResources( + "test-controller", target, + helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName, helmv1alpha1.LabelSourceNamespace, + ) + + secret := func(namespace string, labels map[string]string) *corev1.Secret { + return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "internal", Namespace: namespace, Labels: labels}} + } + + full := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: "stable", + helmv1alpha1.LabelSourceNamespace: "team-a", + } + withoutNamespace := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: "stable", + } + withoutName := map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + helmv1alpha1.LabelSourceNamespace: "team-a", + } + foreign := map[string]string{ + helmv1alpha1.LabelManagedBy: "someone-else", + helmv1alpha1.HelmApplicationRepositoryLabelSourceName: "stable", + helmv1alpha1.LabelSourceNamespace: "team-a", + } + + 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)}, + } + + 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) + } + }) + } +} From ee8a3276ba6bdb639c770ff84712e36a9a2ba42b Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 19:54:22 +0300 Subject: [PATCH 018/145] feat(controller): reconcile HelmApplicationRepository and HelmClusterApplicationRepository Both kinds are served by the shared repository reconciler through their adapters. Their internal objects carry namespace-aware derived names and, for the namespaced kind, a source-namespace label the watches map back through. Neither has consumers yet: HelmApplication is the next plan, and until then force requests stop at the repository and no chart version is protected from pruning. Signed-off-by: Ilya Drey --- .../cmd/operator-helm-controller/main.go | 12 +++ .../helmapplicationrepository/controller.go | 101 ++++++++++++++++++ .../controller.go | 98 +++++++++++++++++ .../operator-helm-controller/rbac-for-us.yaml | 8 ++ 4 files changed, 219 insertions(+) create mode 100644 images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go create mode 100644 images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go 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..4c51e330 100644 --- a/images/operator-helm-controller/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-controller/cmd/operator-helm-controller/main.go @@ -31,8 +31,10 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/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" helmclusteraddonwebhook "github.com/deckhouse/operator-helm/internal/webhook/helmclusteraddon" ) @@ -87,6 +89,16 @@ func main() { 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 := helmclusteraddon.SetupWithManager(mgr); err != nil { logger.Error(err, "unable to setup HelmClusterAddon controller") os.Exit(1) 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..07a763d6 --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.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 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/werf/nelm-source-controller/api/v1" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + 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/repository" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + ControllerName = "helmapplicationrepository-controller" +) + +func SetupWithManager(mgr ctrl.Manager) error { + client := mgr.GetClient() + + ociRepositoryService := services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil) + + r := reconcile.New( + client, + adapter.EmptyApplicationRepository, + services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + ociRepositoryService, + // HelmApplication is not reconciled yet: a force request has no consumer + // sources to reach. The HelmApplication controller replaces this. + source.NoConsumers{}, + services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewApplicationCatalog(client)), + 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/helmclusterapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go new file mode 100644 index 00000000..5f8ed3ff --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go @@ -0,0 +1,98 @@ +/* +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/werf/nelm-source-controller/api/v1" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + 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/repository" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/utils" +) + +const ( + ControllerName = "helmclusterapplicationrepository-controller" +) + +func SetupWithManager(mgr ctrl.Manager) error { + client := mgr.GetClient() + + ociRepositoryService := services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil) + + r := reconcile.New( + client, + adapter.EmptyClusterApplicationRepository, + services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), + ociRepositoryService, + // HelmApplication is not reconciled yet: a force request has no consumer + // sources to reach. The HelmApplication controller replaces this. + source.NoConsumers{}, + services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewClusterApplicationCatalog(client)), + 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/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index 1b8db98d..3c0eed3a 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -79,6 +79,14 @@ rules: - helmclusteraddoncharts/status - helmclusteraddonrepositories - helmclusteraddonrepositories/status + - helmapplicationrepositories + - helmapplicationrepositories/status + - helmclusterapplicationrepositories + - helmclusterapplicationrepositories/status + - helmapplicationcharts + - helmapplicationcharts/status + - helmclusterapplicationcharts + - helmclusterapplicationcharts/status verbs: - create - delete From 2fc82f15d59ace2a8b20bd413511c4d41597424e Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 20:22:04 +0300 Subject: [PATCH 019/145] fix(controller): address the whole-branch review of the repository generalization Mapper log lines for other families' internal objects drop to debug verbosity; a reconciler test now covers the namespaced kind end to end; catalog errors name the repository namespace; RBAC gains /finalizers on the new resources; stale comments corrected and the stage-5 stand-ins marked. Signed-off-by: Ilya Drey --- .../helm_cluster_application_chart_test.go | 9 +- .../internal/adapter/catalogs.go | 2 + .../internal/catalog/catalog.go | 7 +- .../helmapplicationrepository/controller.go | 1 + .../controller.go | 1 + .../reconcile/repository/reconciler_test.go | 125 ++++++++++++++++++ .../internal/source/repository.go | 2 +- .../internal/utils/mapper.go | 8 +- .../operator-helm-controller/rbac-for-us.yaml | 4 + 9 files changed, 150 insertions(+), 9 deletions(-) diff --git a/api/v1alpha1/helm_cluster_application_chart_test.go b/api/v1alpha1/helm_cluster_application_chart_test.go index 26895c65..c3878e56 100644 --- a/api/v1alpha1/helm_cluster_application_chart_test.go +++ b/api/v1alpha1/helm_cluster_application_chart_test.go @@ -53,16 +53,21 @@ func TestHelmClusterApplicationChartGetConditionTypesForUpdate(t *testing.T) { } } -// TestChartCatalogStatusIsShared pins that both chart catalog kinds are built on -// one status type. If someone later splits them into per-kind copies, the two +// 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/images/operator-helm-controller/internal/adapter/catalogs.go b/images/operator-helm-controller/internal/adapter/catalogs.go index 4764c28c..5c827c48 100644 --- a/images/operator-helm-controller/internal/adapter/catalogs.go +++ b/images/operator-helm-controller/internal/adapter/catalogs.go @@ -47,6 +47,7 @@ func NewAddonCatalog(c client.Client) source.Catalog { // NewApplicationCatalog builds the HelmApplicationChart catalog. Consumers is nil // until the HelmApplication controller exists: nothing can reference a chart yet. +// TODO(stage 5): replaced by the HelmApplication consumers lookup. func NewApplicationCatalog(c client.Client) source.Catalog { return catalog.New(c, catalog.Config[*helmv1alpha1.HelmApplicationChart, *helmv1alpha1.HelmApplicationChartList]{ Kind: helmv1alpha1.HelmApplicationChartKind, @@ -62,6 +63,7 @@ func NewApplicationCatalog(c client.Client) source.Catalog { // NewClusterApplicationCatalog builds the HelmClusterApplicationChart catalog. // Consumers is nil for the same reason as in NewApplicationCatalog. +// TODO(stage 5): replaced by the HelmApplication consumers lookup. func NewClusterApplicationCatalog(c client.Client) source.Catalog { return catalog.New(c, catalog.Config[*helmv1alpha1.HelmClusterApplicationChart, *helmv1alpha1.HelmClusterApplicationChartList]{ Kind: helmv1alpha1.HelmClusterApplicationChartKind, diff --git a/images/operator-helm-controller/internal/catalog/catalog.go b/images/operator-helm-controller/internal/catalog/catalog.go index 9a62b72f..23d476a5 100644 --- a/images/operator-helm-controller/internal/catalog/catalog.go +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -70,7 +70,8 @@ func (t *typed[C, CL]) list(ctx context.Context, repo source.Repository) ([]C, e client.InNamespace(repo.Namespace()), client.MatchingLabels{helmv1alpha1.LabelRepositoryName: repo.Name()}, ); err != nil { - return nil, fmt.Errorf("listing %s objects of repository %q: %w", t.cfg.Kind, repo.Name(), err) + repoKey := client.ObjectKey{Namespace: repo.Namespace(), Name: repo.Name()} + return nil, fmt.Errorf("listing %s objects of repository %s: %w", t.cfg.Kind, repoKey, err) } return t.cfg.Items(list), nil @@ -146,7 +147,7 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch return nil }) if err != nil { - return fmt.Errorf("creating or updating chart %q: %w", name, err) + return fmt.Errorf("creating or updating chart %s: %w", client.ObjectKeyFromObject(existing), err) } if op != controllerutil.OperationResultNone { @@ -167,7 +168,7 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch 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 %q: %w", name, err) + return fmt.Errorf("updating versions of chart %s: %w", client.ObjectKeyFromObject(existing), err) } } diff --git a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go index 07a763d6..3e5953c0 100644 --- a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go @@ -55,6 +55,7 @@ func SetupWithManager(mgr ctrl.Manager) error { ociRepositoryService, // HelmApplication is not reconciled yet: a force request has no consumer // sources to reach. The HelmApplication controller replaces this. + // TODO(stage 5): replaced by the HelmApplication consumer forcer. source.NoConsumers{}, services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewApplicationCatalog(client)), status.NewManager(client), diff --git a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go index 5f8ed3ff..1ef8bdc3 100644 --- a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go @@ -53,6 +53,7 @@ func SetupWithManager(mgr ctrl.Manager) error { ociRepositoryService, // HelmApplication is not reconciled yet: a force request has no consumer // sources to reach. The HelmApplication controller replaces this. + // TODO(stage 5): replaced by the HelmApplication consumer forcer. source.NoConsumers{}, services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewClusterApplicationCatalog(client)), status.NewManager(client), diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 9382383e..425373fb 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -18,6 +18,7 @@ package repository import ( "context" + "reflect" "testing" "time" @@ -33,6 +34,7 @@ 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/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" @@ -41,6 +43,7 @@ import ( "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/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -118,6 +121,51 @@ func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) 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{}, + ). + Build() + + factory := func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) { + return stub, nil + } + + ociRepositoryService := services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, nil) + + r := New( + c, + adapter.EmptyApplicationRepository, + services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), + ociRepositoryService, + source.NoConsumers{}, + services.NewRepoSyncService(c, scheme, factory, adapter.NewApplicationCatalog(c)), + status.NewManager(c), + ) + + return r, c +} + func ociRepository() *helmv1alpha1.HelmClusterAddonRepository { return &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, @@ -734,3 +782,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/source/repository.go b/images/operator-helm-controller/internal/source/repository.go index e0f2f358..6afd0603 100644 --- a/images/operator-helm-controller/internal/source/repository.go +++ b/images/operator-helm-controller/internal/source/repository.go @@ -26,7 +26,7 @@ import ( ) // InternalNames are the names of the internal objects derived from one -// repository. They are computed once by the adapter: the scheme differs between +// 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. diff --git a/images/operator-helm-controller/internal/utils/mapper.go b/images/operator-helm-controller/internal/utils/mapper.go index cc6543f9..982f3316 100644 --- a/images/operator-helm-controller/internal/utils/mapper.go +++ b/images/operator-helm-controller/internal/utils/mapper.go @@ -44,7 +44,7 @@ func MapInternalResources(controllerName, targetNamespace, labelManagedBy, label sourceName := labels[labelSourceName] if sourceName == "" { - logger.Info("resource missing source label, skipping", + logger.V(1).Info("resource missing source label, skipping", "controller", controllerName, "name", obj.GetName(), "namespace", obj.GetNamespace()) return nil @@ -65,7 +65,9 @@ func MapInternalResources(controllerName, targetNamespace, labelManagedBy, label // 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. +// 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 { @@ -83,7 +85,7 @@ func MapNamespacedInternalResources( sourceName, sourceNamespace := labels[labelSourceName], labels[labelSourceNamespace] if sourceName == "" || sourceNamespace == "" { - logger.Info("resource missing source labels, skipping", + logger.V(1).Info("resource missing source labels, skipping", "controller", controllerName, "name", obj.GetName(), "namespace", obj.GetNamespace()) return nil diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index 3c0eed3a..5346a01d 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -87,6 +87,10 @@ rules: - helmapplicationcharts/status - helmclusterapplicationcharts - helmclusterapplicationcharts/status + - helmapplicationrepositories/finalizers + - helmclusterapplicationrepositories/finalizers + - helmapplicationcharts/finalizers + - helmclusterapplicationcharts/finalizers verbs: - create - delete From 92501eb9aee4bc5e9d06237759cf988ec1ec21d6 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 21:55:13 +0300 Subject: [PATCH 020/145] feat(controller): add the release source contract source.Release is how the services will see any release kind; the companion contracts (RepositoryResolver, ChartClaim, TargetNamespaceEnsurer, AccessManager, ReleaseLister) name the places where the addon and the application families differ, each with a no-op for the family that does not need it. HelmReleaseName bounds a release name to Helm's 53-character limit without touching any name that already fits; Catalog.Lookup lets a release find the version it asks for. Nothing consumes the contract yet. Signed-off-by: Ilya Drey --- images/operator-helm-controller/go.mod | 2 +- .../internal/catalog/catalog.go | 11 ++ .../internal/catalog/catalog_test.go | 27 +++ .../internal/source/catalog.go | 7 + .../internal/source/release.go | 164 ++++++++++++++++++ .../internal/utils/name.go | 16 ++ .../internal/utils/name_test.go | 30 ++++ 7 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 images/operator-helm-controller/internal/source/release.go diff --git a/images/operator-helm-controller/go.mod b/images/operator-helm-controller/go.mod index 4db359c9..57e2b1e8 100644 --- a/images/operator-helm-controller/go.mod +++ b/images/operator-helm-controller/go.mod @@ -86,7 +86,7 @@ require ( 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/apiextensions-apiserver v0.35.1 k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/images/operator-helm-controller/internal/catalog/catalog.go b/images/operator-helm-controller/internal/catalog/catalog.go index 23d476a5..59c684ad 100644 --- a/images/operator-helm-controller/internal/catalog/catalog.go +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -220,3 +220,14 @@ func (t *typed[C, CL]) InUseVersions(ctx context.Context, repo source.Repository 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 +} diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index 1a2ecf4e..72148336 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -21,6 +21,7 @@ import ( "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" @@ -193,3 +194,29 @@ func TestWithoutConsumersEveryUnlistedVersionIsPruned(t *testing.T) { 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) + } +} diff --git a/images/operator-helm-controller/internal/source/catalog.go b/images/operator-helm-controller/internal/source/catalog.go index ed5982ea..ea25b5d2 100644 --- a/images/operator-helm-controller/internal/source/catalog.go +++ b/images/operator-helm-controller/internal/source/catalog.go @@ -19,6 +19,9 @@ 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" ) @@ -36,4 +39,8 @@ type Catalog interface { // 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/release.go b/images/operator-helm-controller/internal/source/release.go new file mode 100644 index 00000000..56a30c1b --- /dev/null +++ b/images/operator-helm-controller/internal/source/release.go @@ -0,0 +1,164 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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/manager/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 +} + +// 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 RepositoryRef) (Repository, 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 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 Release) error + // Release frees the claim on the release's current pair. + Release(ctx context.Context, rel Release) error +} + +// 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 Release) (bool, string, error) { + return true, rel.Name(), nil +} + +func (NoChartClaim) ReleaseStale(context.Context, Release) error { return nil } + +func (NoChartClaim) Release(context.Context, Release) error { return nil } + +// TargetNamespaceEnsurer makes sure the namespace a release deploys into exists. +type TargetNamespaceEnsurer interface { + EnsureTargetNamespace(ctx context.Context, rel Release) error +} + +// 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, Release) error { + return nil +} + +// 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 Release) error + CleanupAccess(ctx context.Context, rel Release) error +} + +// NoAccess is the AccessManager of a family that does not impersonate. +type NoAccess struct{} + +func (NoAccess) EnsureAccess(context.Context, Release) error { return nil } + +func (NoAccess) CleanupAccess(context.Context, Release) error { return nil } + +// 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/utils/name.go b/images/operator-helm-controller/internal/utils/name.go index 2bbe002d..82bbe788 100644 --- a/images/operator-helm-controller/internal/utils/name.go +++ b/images/operator-helm-controller/internal/utils/name.go @@ -170,3 +170,19 @@ func truncatePart(part string) string { return strings.TrimRight(part, "-") } + +// helmReleaseNameLimit is the longest release name Helm accepts. +const helmReleaseNameLimit = 53 + +// 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 to 40 characters and suffixed with a 12-character hash of the +// full name, so two long names that share a prefix stay distinct. The cut is +// trimmed of a trailing dash so the joined name never carries a double dash. +func HelmReleaseName(name string) string { + if len(name) <= helmReleaseNameLimit { + return name + } + + return strings.TrimRight(name[:40], "-") + "-" + GetHash(name) +} diff --git a/images/operator-helm-controller/internal/utils/name_test.go b/images/operator-helm-controller/internal/utils/name_test.go index 44559218..d0f9b6f6 100644 --- a/images/operator-helm-controller/internal/utils/name_test.go +++ b/images/operator-helm-controller/internal/utils/name_test.go @@ -130,3 +130,33 @@ func TestDerivedNameStaysWithinTheLabelLimitForTheLongestPrefix(t *testing.T) { t.Fatalf("%q is %d characters, the limit is 63", got, len(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"}, + } + + 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)) + } + }) + } +} From 9ba96cfd68a916e840dcd8597bbf099d2d58b406 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 22:04:00 +0300 Subject: [PATCH 021/145] feat(controller): adapt HelmClusterAddon to the release contract The addon release adapter reproduces the released names and labels exactly and bounds the release name to Helm's limit. The repository resolver and the release lister give the repository branch a way to reach the addon's consumers without knowing their kind. Signed-off-by: Ilya Drey --- .../internal/adapter/addon_release.go | 178 ++++++++++++++ .../internal/adapter/addon_release_test.go | 224 ++++++++++++++++++ .../internal/adapter/catalogs_test.go | 5 + 3 files changed, 407 insertions(+) create mode 100644 images/operator-helm-controller/internal/adapter/addon_release.go create mode 100644 images/operator-helm-controller/internal/adapter/addon_release_test.go 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..3736e1d6 --- /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/manager/status" + "github.com/deckhouse/operator-helm/internal/source" + "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) source.RepositoryResolver { + 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/catalogs_test.go b/images/operator-helm-controller/internal/adapter/catalogs_test.go index 7adc5002..af3b23b6 100644 --- a/images/operator-helm-controller/internal/adapter/catalogs_test.go +++ b/images/operator-helm-controller/internal/adapter/catalogs_test.go @@ -45,6 +45,11 @@ func addonClient(t *testing.T, objects ...client.Object) client.Client { 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() } From 53b7b2cb2ccde67da52a619bbfa60e287f31b229 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 22:14:56 +0300 Subject: [PATCH 022/145] refactor(controller): drive the chart, release and maintenance services through the release contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChartService, ReleaseService and MaintenanceService take a source.Release (and, for the chart, the source.Repository whose internal HelmRepository it points at). ResolveChartSource takes the repository url so every kind can use it. The HelmRelease gains serviceAccountName and storageNamespace when the family impersonates — no family does yet. The addon reconciler wraps its objects at the boundary; behaviour is unchanged. Signed-off-by: Ilya Drey --- .../reconcile/helmclusteraddon/reconciler.go | 24 +++--- .../helmclusteraddon/reconciler_test.go | 5 +- .../internal/services/chart_service.go | 40 ++++------ .../internal/services/chart_service_test.go | 15 +++- .../internal/services/maintenance_service.go | 24 +++--- .../services/oci_repo_service_test.go | 2 +- .../internal/services/release_service.go | 77 ++++++++++--------- .../internal/utils/repository.go | 8 +- .../internal/utils/repository_test.go | 2 +- 9 files changed, 108 insertions(+), 89 deletions(-) diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go index 206134a5..5253e444 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go @@ -37,6 +37,7 @@ 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/manager/status" "github.com/deckhouse/operator-helm/internal/services" "github.com/deckhouse/operator-helm/internal/utils" @@ -97,6 +98,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return reconcile.Result{}, fmt.Errorf("getting helm cluster addon: %w", err) } + rel := adapter.NewAddonRelease(addon) + if !addon.DeletionTimestamp.IsZero() { return r.reconcileDelete(ctx, addon) } @@ -148,8 +151,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return reconcile.Result{}, fmt.Errorf("releasing stale chart claims: %w", err) } - if r.maintenanceService.IsMaintenanceModeChangeRequired(addon) { - maintenanceRes := r.maintenanceService.EnsureMaintenanceMode(ctx, addon) + if r.maintenanceService.IsMaintenanceModeChangeRequired(rel) { + maintenanceRes := r.maintenanceService.EnsureMaintenanceMode(ctx, rel) if err := r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, maintenanceRes, status.AsCondition(maintenanceRes, "Ready")); err != nil { return reconcile.Result{}, err } @@ -217,7 +220,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // 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) + source, addonChartErr = utils.ResolveChartSource(repo.Spec.URL, chartVersion) } switch { @@ -246,9 +249,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco r.logSourceKindFlip(ctx, addon, source.Kind, superseded != nil) - chartRes = r.chartService.EnsureHelmChart(ctx, addon) + chartRes = r.chartService.EnsureHelmChart(ctx, rel, adapter.NewAddonRepository(repo)) case source.Kind == utils.InternalOCIRepository: - superseded, err := r.chartService.CleanupHelmChart(ctx, addon) + superseded, err := r.chartService.CleanupHelmChart(ctx, rel.InternalNames()) if err != nil { chartRes = services.ChartResult{ Status: status.Failed(addon, helmv1alpha1.ReasonFailed, "Repository change failed", err), @@ -282,7 +285,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco } } - releaseRes = r.releaseService.EnsureHelmRelease(ctx, addon, source.Kind, artifactRevision) + releaseRes = r.releaseService.EnsureHelmRelease(ctx, rel, source.Kind, artifactRevision) } if err := r.statusManager.Update( @@ -318,6 +321,9 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.He return reconcile.Result{}, nil } + rel := adapter.NewAddonRelease(addon) + 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 @@ -328,7 +334,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.He // 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) + release, err := r.releaseService.CleanupHelmRelease(ctx, names) if err != nil { return reconcile.Result{}, err } @@ -337,13 +343,13 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.He // 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 { + if err := r.releaseService.SyncReleaseSpec(ctx, rel, release); err != nil { return reconcile.Result{}, err } return r.awaitInternalResourceDeletion(ctx, addon, "internal release", release) } - chart, err := r.chartService.CleanupHelmChart(ctx, addon) + chart, err := r.chartService.CleanupHelmChart(ctx, names) if err != nil { return reconcile.Result{}, err } diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go index a586e08a..31e2e150 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go @@ -38,6 +38,7 @@ import ( "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/manager/status" "github.com/deckhouse/operator-helm/internal/services" @@ -840,7 +841,7 @@ func TestReconcileSittingInMaintenanceDiscardsForceReconcile(t *testing.T) { r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) - if !addon.MaintenanceModeEnabled() || r.maintenanceService.IsMaintenanceModeChangeRequired(addon) { + if !addon.MaintenanceModeEnabled() || r.maintenanceService.IsMaintenanceModeChangeRequired(adapter.NewAddonRelease(addon)) { t.Fatal("the fixture must already be in maintenance, otherwise the test takes the wrong branch") } @@ -875,7 +876,7 @@ func TestReconcileLeavingMaintenanceKeepsForceReconcile(t *testing.T) { r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) - if addon.MaintenanceModeActivated() || !r.maintenanceService.IsMaintenanceModeChangeRequired(addon) { + if addon.MaintenanceModeActivated() || !r.maintenanceService.IsMaintenanceModeChangeRequired(adapter.NewAddonRelease(addon)) { t.Fatal("the fixture must be leaving maintenance, otherwise the test proves nothing") } diff --git a/images/operator-helm-controller/internal/services/chart_service.go b/images/operator-helm-controller/internal/services/chart_service.go index eeb1a058..4e942abd 100644 --- a/images/operator-helm-controller/internal/services/chart_service.go +++ b/images/operator-helm-controller/internal/services/chart_service.go @@ -29,10 +29,9 @@ 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{ @@ -79,24 +78,24 @@ 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) ChartResult { 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, + rel.Object(), helmv1alpha1.ReasonHelmChartFailed, "Failed to create helm chart", fmt.Errorf("creating or updating helm chart: %w", err), @@ -108,11 +107,11 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, addon *helmv1alpha1. } processedStatus := status.ProcessChildConditions( - existing.GetConditions(), existing.Generation, addon, helmChartErrorRules, + existing.GetConditions(), existing.Generation, rel.Object(), helmChartErrorRules, ) if processedStatus.IsReady() { - logger.Info("Successfully reconciled helm chart", "operation", op, "chart", addon.Spec.Chart.HelmClusterAddonChartName) + logger.Info("Successfully reconciled helm chart", "operation", op, "chart", rel.ChartRef().Chart) } return ChartResult{ @@ -125,8 +124,8 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, addon *helmv1alpha1. // 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 // 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 +138,19 @@ 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) } - if existing.Labels == nil { - existing.Labels = map[string]string{} - } - - existing.Labels[helmv1alpha1.LabelManagedBy] = helmv1alpha1.LabelManagedByValue - existing.Labels[helmv1alpha1.HelmClusterAddonLabelSourceName] = addon.Name - existing.Labels[helmv1alpha1.HelmClusterAddonChartLabelSourceName] = naming.HelmClusterAddonChartName( - addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName, - ) + existing.Labels = rel.HelmChartLabels() - 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..e0032b7b 100644 --- a/images/operator-helm-controller/internal/services/chart_service_test.go +++ b/images/operator-helm-controller/internal/services/chart_service_test.go @@ -22,10 +22,12 @@ import ( "github.com/werf/3p-fluxcd-pkg/apis/meta" sourcev1 "github.com/werf/nelm-source-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "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} diff --git a/images/operator-helm-controller/internal/services/maintenance_service.go b/images/operator-helm-controller/internal/services/maintenance_service.go index dc50495c..24dcf552 100644 --- a/images/operator-helm-controller/internal/services/maintenance_service.go +++ b/images/operator-helm-controller/internal/services/maintenance_service.go @@ -31,7 +31,7 @@ import ( 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 { @@ -68,10 +68,10 @@ 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) MaintenanceResult { logger := log.FromContext(ctx) - suspendState := addon.MaintenanceModeActivated() + suspendState := rel.MaintenanceActivated() status := metav1.ConditionTrue reason := helmv1alpha1.ReasonMaintenanceModeInactive @@ -87,38 +87,38 @@ func (s *MaintenanceService) EnsureMaintenanceMode(ctx context.Context, addon *h message = "Maintenance mode disabled" } - err := s.updateHelmReleaseSuspendState(ctx, addon, suspendState) + err := s.updateHelmReleaseSuspendState(ctx, rel.InternalNames(), suspendState) if err != nil { - return MaintenanceResult{Status: statusmgr.Failed(addon, helmv1alpha1.ReasonFailed, "Failed to change maintenance mode", err)} + return MaintenanceResult{Status: statusmgr.Failed(rel.Object(), helmv1alpha1.ReasonFailed, "Failed to change maintenance mode", err)} } return MaintenanceResult{ Status: statusmgr.Status{ Observed: true, Status: status, - ObservedGeneration: addon.Generation, + ObservedGeneration: rel.Generation(), Message: message, Reason: reason, }, } } -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/oci_repo_service_test.go b/images/operator-helm-controller/internal/services/oci_repo_service_test.go index 19b8a3fe..07bfbadd 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 @@ -118,7 +118,7 @@ func ociTestRepository() *helmv1alpha1.HelmClusterAddonRepository { func ociSource(t *testing.T, repo *helmv1alpha1.HelmClusterAddonRepository, version *helmv1alpha1.ChartVersion) utils.ChartSource { t.Helper() - source, err := utils.ResolveChartSource(repo, version) + source, err := utils.ResolveChartSource(repo.Spec.URL, version) if err != nil { t.Fatalf("resolving chart source: %v", err) } diff --git a/images/operator-helm-controller/internal/services/release_service.go b/images/operator-helm-controller/internal/services/release_service.go index e8fe7637..fa435376 100644 --- a/images/operator-helm-controller/internal/services/release_service.go +++ b/images/operator-helm-controller/internal/services/release_service.go @@ -33,6 +33,7 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/deckhouse/operator-helm/internal/manager/status" + "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -79,22 +80,22 @@ 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 utils.InternalRepositoryType, artifactRevision string) ReleaseResult { 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, + rel.Object(), helmv1alpha1.ReasonReleaseFailed, "Failed to create helm release", fmt.Errorf("reconciling helm release: %w", err), @@ -102,7 +103,7 @@ func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, addon *helmv1alp } processedStatus := status.ProcessChildConditions( - existing.GetConditions(), existing.Generation, addon, helmReleaseErrorRules, + existing.GetConditions(), existing.Generation, rel.Object(), helmReleaseErrorRules, ) // A chart-version change updates only the referenced HelmChart artifact, not @@ -111,8 +112,8 @@ func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, addon *helmv1alp // 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) + if processedStatus.IsReady() && !isDesiredChartDeployed(rel, existing.Status.History.Latest(), artifactRevision) { + processedStatus = status.Unknown(rel.Object(), helmv1alpha1.ReasonReconciling) } if processedStatus.IsReady() { @@ -129,8 +130,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 +153,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 +172,20 @@ 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 utils.InternalRepositoryType, targetNamespace string) error { + if rel.ForceReconcileRequired() { setReconcileRequestAnnotations(existing) } - if existing.Labels == nil { - existing.Labels = map[string]string{} - } - - existing.Labels[helmv1alpha1.LabelManagedBy] = helmv1alpha1.LabelManagedByValue - existing.Labels[helmv1alpha1.HelmClusterAddonLabelSourceName] = addon.Name + existing.Labels = rel.SourceLabels() - existing.Spec.ReleaseName = addon.Name - existing.Spec.TargetNamespace = addon.Spec.Namespace - existing.Spec.Values = addon.Spec.Values + names := rel.InternalNames() - existing.Spec.Suspend = false + existing.Spec.ReleaseName = rel.ReleaseName() + existing.Spec.TargetNamespace = rel.TargetNamespace() + existing.Spec.Values = rel.Values() - 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 +193,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: existing.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ Kind: sourcev1.HelmChartKind, - Name: utils.GetInternalHelmChartName(addon.Name), + Name: names.HelmChart, Namespace: targetNamespace, } case utils.InternalOCIRepository: existing.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ Kind: sourcev1.OCIRepositoryKind, - Name: utils.GetInternalOCIRepositoryName(addon.Name), + Name: names.OCIRepository, Namespace: targetNamespace, } default: @@ -220,22 +225,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/utils/repository.go b/images/operator-helm-controller/internal/utils/repository.go index ba4d8ae4..235b72ac 100644 --- a/images/operator-helm-controller/internal/utils/repository.go +++ b/images/operator-helm-controller/internal/utils/repository.go @@ -51,8 +51,10 @@ type ChartSource struct { // 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. +// It takes the repository url rather than the repository object so that every +// repository kind can use it. func ResolveChartSource( - repo *helmv1alpha1.HelmClusterAddonRepository, + repoURL string, version *helmv1alpha1.ChartVersion, ) (ChartSource, error) { if version.OCIRef != "" { @@ -67,13 +69,13 @@ func ResolveChartSource( return ChartSource{Kind: InternalOCIRepository, URL: url, Tag: tag}, nil } - repoType, err := GetRepositoryType(repo.Spec.URL) + repoType, err := GetRepositoryType(repoURL) 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: InternalOCIRepository, URL: repoURL, Tag: version.Version}, nil } return ChartSource{Kind: InternalHelmRepository}, nil diff --git a/images/operator-helm-controller/internal/utils/repository_test.go b/images/operator-helm-controller/internal/utils/repository_test.go index 4280a3b0..1500dbe7 100644 --- a/images/operator-helm-controller/internal/utils/repository_test.go +++ b/images/operator-helm-controller/internal/utils/repository_test.go @@ -263,7 +263,7 @@ func TestResolveChartSource(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ResolveChartSource(tt.repo, &tt.version) + got, err := ResolveChartSource(tt.repo.Spec.URL, &tt.version) if tt.wantErr { if err == nil { t.Fatalf("expected an error, got %+v", got) From 36c538ead83a74c2d8dc414513fbeac04eeb04ed Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 22:27:30 +0300 Subject: [PATCH 023/145] refactor(controller): reach a repository's consumers through a release lister The release side of OCIRepoService takes the contract. Forcing the consumers' internal OCIRepositories moves into ForceService, which is handed the family's ReleaseLister instead of listing addons itself; the catalog's in-use lookup is built from the same lister. The addon family is the only one wired so far. Signed-off-by: Ilya Drey --- .../internal/adapter/catalogs.go | 39 ++--- .../helmclusteraddonrepository/controller.go | 2 +- .../reconcile/helmclusteraddon/reconciler.go | 6 +- .../reconcile/repository/reconciler_test.go | 2 +- .../internal/services/force_service.go | 87 ++++++++++ .../internal/services/force_service_test.go | 87 ++++++++++ .../internal/services/oci_repo_service.go | 148 ++++++------------ .../services/oci_repo_service_test.go | 93 +++-------- 8 files changed, 260 insertions(+), 204 deletions(-) create mode 100644 images/operator-helm-controller/internal/services/force_service.go create mode 100644 images/operator-helm-controller/internal/services/force_service_test.go diff --git a/images/operator-helm-controller/internal/adapter/catalogs.go b/images/operator-helm-controller/internal/adapter/catalogs.go index 5c827c48..f4a1c8e3 100644 --- a/images/operator-helm-controller/internal/adapter/catalogs.go +++ b/images/operator-helm-controller/internal/adapter/catalogs.go @@ -18,14 +18,12 @@ package adapter import ( "context" - "fmt" "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/index" "github.com/deckhouse/operator-helm/internal/source" ) @@ -41,7 +39,7 @@ func NewAddonCatalog(c client.Client) source.Catalog { }, Status: func(o *helmv1alpha1.HelmClusterAddonChart) *helmv1alpha1.ChartCatalogStatus { return &o.Status }, ObjectName: naming.HelmClusterAddonChartName, - Consumers: addonChartConsumers(c), + Consumers: chartConsumers(ListAddonReleases(c)), }) } @@ -90,31 +88,26 @@ func pointers[T any](items []T) []*T { return out } -// addonChartConsumers returns the chart versions referenced by the addon that uses -// a 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 addonChartConsumers(c client.Client) func(context.Context, source.Repository, string) (map[string]struct{}, error) { +// 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) { - var addons helmv1alpha1.HelmClusterAddonList - if err := c.List(ctx, &addons, client.MatchingFields{ - index.AddonChart: index.AddonChartValue(repo.Name(), chartName), - }); err != nil { - return nil, fmt.Errorf("listing addons of chart %q: %w", chartName, err) + releases, err := list(ctx, repo, chartName) + if err != nil { + return nil, err } - inUse := make(map[string]struct{}, 2) + pair := source.RepositoryRef{Kind: repo.OwnerGVK().Kind, Namespace: repo.Namespace(), Name: repo.Name()} + inUse := make(map[string]struct{}, 2*len(releases)) - for _, addon := range addons.Items { - inUse[addon.Spec.Chart.Version] = struct{}{} + for _, rel := range releases { + inUse[rel.ChartRef().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 == repo.Name() { + if last := rel.LastAppliedChart(); last != nil && last.Chart == chartName && last.Repository == pair { inUse[last.Version] = struct{}{} } } diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go index 60968096..98e352af 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go @@ -48,7 +48,7 @@ func SetupWithManager(mgr ctrl.Manager) error { adapter.EmptyAddonRepository, services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), ociRepositoryService, - ociRepositoryService, + services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListAddonReleases(client)), services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewAddonCatalog(client)), status.NewManager(client), ) diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go index 5253e444..4b1f794e 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go @@ -238,7 +238,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // 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) + superseded, err := r.ociRepositoryService.RemoveOCIRepository(ctx, rel.InternalNames()) if err != nil { chartRes = services.ChartResult{ Status: status.Failed(addon, helmv1alpha1.ReasonFailed, "Repository change failed", err), @@ -262,7 +262,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco r.logSourceKindFlip(ctx, addon, source.Kind, superseded != nil) - repoRes = r.ociRepositoryService.EnsureInternalOCIRepository(ctx, addon, repo, source, chartVersion) + repoRes = r.ociRepositoryService.EnsureInternalOCIRepository(ctx, rel, adapter.NewAddonRepository(repo), source, chartVersion) default: return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( addon, @@ -357,7 +357,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.He return r.awaitInternalResourceDeletion(ctx, addon, "internal chart", chart) } - ociRepo, err := r.ociRepositoryService.RemoveOCIRepository(ctx, addon) + ociRepo, err := r.ociRepositoryService.RemoveOCIRepository(ctx, names) if err != nil { return reconcile.Result{}, err } diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 425373fb..19f49791 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -113,7 +113,7 @@ func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) adapter.EmptyAddonRepository, services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), ociRepositoryService, - ociRepositoryService, + services.NewForceService(c, helmv1alpha1.TargetNamespace, adapter.ListAddonReleases(c)), services.NewRepoSyncService(c, scheme, factory, adapter.NewAddonCatalog(c)), status.NewManager(c), ) 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..077541ce --- /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/werf/nelm-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..c41a42e1 --- /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/werf/3p-fluxcd-pkg/apis/meta" + sourcev1 "github.com/werf/nelm-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/oci_repo_service.go b/images/operator-helm-controller/internal/services/oci_repo_service.go index 438c7bb4..213d4406 100644 --- a/images/operator-helm-controller/internal/services/oci_repo_service.go +++ b/images/operator-helm-controller/internal/services/oci_repo_service.go @@ -24,7 +24,6 @@ import ( "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" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -34,7 +33,6 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" 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" @@ -60,8 +58,6 @@ type OCIRepoService struct { resolver repoclient.ChartResolverInterface } -var _ source.ConsumerForcer = (*OCIRepoService)(nil) - // NewOCIRepoService builds the service. A nil resolver selects the default one; tests // pass their own so they never reach a registry. func NewOCIRepoService( @@ -115,34 +111,34 @@ func (r OCIRepoResult) GetConditionType() string { func (s *OCIRepoService) EnsureInternalOCIRepository( ctx context.Context, - addon *helmv1alpha1.HelmClusterAddon, - repo *helmv1alpha1.HelmClusterAddonRepository, - source utils.ChartSource, + rel source.Release, + repo source.Repository, + src utils.ChartSource, version *helmv1alpha1.ChartVersion, ) OCIRepoResult { 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, + rel.Object(), helmv1alpha1.ReasonFailed, "Failed to reconcile oci repository", fmt.Errorf("creating oci repository: %w", err), @@ -155,7 +151,7 @@ func (s *OCIRepoService) EnsureInternalOCIRepository( } processedStatus := status.ProcessChildConditions( - existing.Status.Conditions, existing.Generation, addon, ociRepositoryErrorRules, + existing.Status.Conditions, existing.Generation, rel.Object(), ociRepositoryErrorRules, ) if version.UnavailableReason == helmv1alpha1.UnavailableReasonRemovedFromRepository && @@ -166,7 +162,7 @@ func (s *OCIRepoService) EnsureInternalOCIRepository( processedStatus.Reason = helmv1alpha1.ReasonChartVersionRemoved processedStatus.Message = fmt.Sprintf( "Version %s is no longer offered by repository %s: %s", - version.Version, repo.Name, processedStatus.Message, + version.Version, repo.Name(), processedStatus.Message, ) } @@ -188,35 +184,35 @@ 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, + rel source.Release, + repo source.Repository, + src utils.ChartSource, version *helmv1alpha1.ChartVersion, ) (string, *OCIRepoResult) { 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), + Status: status.Failed(rel.Object(), terminal.Reason, terminal.Message, err), } } return "", &OCIRepoResult{ Status: status.Failed( - addon, + rel.Object(), helmv1alpha1.ReasonOCIFetchFailed, "Failed to examine the chart artifact: "+err.Error(), err, @@ -229,11 +225,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 utils.ChartSource, ) string { nn := types.NamespacedName{ - Name: utils.GetInternalOCIRepositoryName(addon.Name), + Name: names.OCIRepository, Namespace: s.TargetNamespace, } @@ -242,10 +238,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 "" } @@ -256,69 +252,19 @@ 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 utils.ChartSource) *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, - } -} - -// 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) - } + CACertificate: repo.CACertificate(), + Insecure: repo.InsecureSkipVerify(), } - - return nil -} - -// ForceReconcileConsumers is the source.ConsumerForcer of the addon family: the -// consumers of a HelmClusterAddonRepository are the HelmClusterAddon objects -// referencing it, each with its own internal OCIRepository. -func (s *OCIRepoService) ForceReconcileConsumers(ctx context.Context, repo source.Repository) error { - return s.ForceReconcileInternalRepositories(ctx, repo.Name()) } func (s *OCIRepoService) CleanupOCIRepository(ctx context.Context, names source.InternalNames) error { @@ -336,9 +282,8 @@ func (s *OCIRepoService) CleanupOCIRepository(ctx context.Context, names source. // 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 // 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 { @@ -352,34 +297,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 utils.ChartSource, 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, } } @@ -387,9 +334,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, } } } @@ -403,10 +350,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 @@ -426,8 +370,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 := utils.GetRepositoryType(repo.URL()) return err == nil && repoType == utils.InternalOCIRepository } 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 07bfbadd..6649a1e2 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 @@ -28,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" 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/utils" @@ -149,7 +150,7 @@ func TestEnsureInternalOCIRepositoryUsesRecordedMediaType(t *testing.T) { 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} @@ -175,7 +176,7 @@ func TestEnsureInternalOCIRepositoryReportsRemovedVersion(t *testing.T) { 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) @@ -241,7 +242,7 @@ 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) @@ -271,7 +272,7 @@ func TestEnsureInternalOCIRepositoryForcesReconcileFromAddon(t *testing.T) { 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} @@ -299,7 +300,7 @@ func TestEnsureInternalOCIRepositoryDoesNotForceReconcileWithoutAnnotation(t *te 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 +313,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. @@ -382,7 +327,7 @@ func TestEnsureInternalOCIRepositoryAddressesTheIndexReference(t *testing.T) { } 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{} @@ -427,7 +372,7 @@ func TestEnsureInternalOCIRepositoryCarriesTLSOnTheSameHost(t *testing.T) { } 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{} @@ -463,7 +408,7 @@ func TestEnsureInternalOCIRepositoryKeepsOCIRepositoryCredentials(t *testing.T) } 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{} @@ -494,7 +439,7 @@ func TestEnsureInternalOCIRepositoryProbesHybridVersion(t *testing.T) { } 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 { @@ -528,8 +473,8 @@ func TestEnsureInternalOCIRepositoryReusesTheInternalObjectAsCache(t *testing.T) } 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) @@ -548,13 +493,13 @@ func TestEnsureInternalOCIRepositoryReprobesChangedReference(t *testing.T) { 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.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) @@ -574,10 +519,10 @@ func TestEnsureInternalOCIRepositoryForceBypassesCache(t *testing.T) { } 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) @@ -597,7 +542,7 @@ func TestEnsureInternalOCIRepositoryNeverProbesRecordedMediaType(t *testing.T) { } 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 { @@ -619,7 +564,7 @@ func TestEnsureInternalOCIRepositoryReportsTerminalProbeFailure(t *testing.T) { } 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 { @@ -650,7 +595,7 @@ func TestEnsureInternalOCIRepositoryRequeuesRetriableProbeFailure(t *testing.T) } 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 { From 249c25867408c28cff60cf4b7bb41c1f5e50f09d Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 22:36:15 +0300 Subject: [PATCH 024/145] refactor(controller): claim charts and ensure namespaces through the release contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaimService takes a source.Release and implements source.ChartClaim; it stays an addon service in substance — the holder is read as a HelmClusterAddon. Creating the target namespace moves out of the reconciler into NamespaceService, the addon's TargetNamespaceEnsurer. Signed-off-by: Ilya Drey --- .../reconcile/helmclusteraddon/reconciler.go | 6 +- .../internal/services/chart_claim_service.go | 64 ++++++++---------- .../internal/services/namespace_service.go | 66 +++++++++++++++++++ .../services/namespace_service_test.go | 63 ++++++++++++++++++ .../webhook/helmclusteraddon/webhook.go | 3 +- 5 files changed, 163 insertions(+), 39 deletions(-) create mode 100644 images/operator-helm-controller/internal/services/namespace_service.go create mode 100644 images/operator-helm-controller/internal/services/namespace_service_test.go diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go index 4b1f794e..7c450850 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go @@ -111,7 +111,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // 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) + acquired, holder, err := r.claimService.Acquire(ctx, rel) if err != nil { return reconcile.Result{}, fmt.Errorf("acquiring chart claim: %w", err) } @@ -147,7 +147,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // would not trigger a follow-up reconcile. } - if err := r.claimService.ReleaseStale(ctx, addon); err != nil { + if err := r.claimService.ReleaseStale(ctx, rel); err != nil { return reconcile.Result{}, fmt.Errorf("releasing stale chart claims: %w", err) } @@ -369,7 +369,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.He // 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 { + if err := r.claimService.Release(ctx, rel); err != nil { return reconcile.Result{}, fmt.Errorf("releasing chart claim: %w", err) } 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..a1d7a0b5 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" ) @@ -45,6 +46,8 @@ type ClaimService struct { namespace string } +var _ source.ChartClaim = (*ClaimService)(nil) + func NewClaimService(c client.Client, reader client.Reader, namespace string) *ClaimService { return &ClaimService{ reader: reader, @@ -56,19 +59,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 +83,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 +98,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 +121,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 +158,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 +191,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 +215,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 +253,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 +268,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/namespace_service.go b/images/operator-helm-controller/internal/services/namespace_service.go new file mode 100644 index 00000000..ef02748c --- /dev/null +++ b/images/operator-helm-controller/internal/services/namespace_service.go @@ -0,0 +1,66 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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" +) + +var _ source.TargetNamespaceEnsurer = (*NamespaceService)(nil) + +// NamespaceService creates the namespace an addon deploys into when it does not +// exist yet. It never modifies an existing namespace: the namespace belongs to +// whoever created it. +type NamespaceService struct { + client client.Client +} + +func NewNamespaceService(c client.Client) *NamespaceService { + return &NamespaceService{client: c} +} + +func (s *NamespaceService) EnsureTargetNamespace(ctx context.Context, rel source.Release) error { + ns := &corev1.Namespace{} + + err := s.client.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..7b3ee87b --- /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) + + 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/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) } From b3bdf411b8e444d14f60f33cecbe599887f1713a Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 22:45:41 +0300 Subject: [PATCH 025/145] refactor(controller): reconcile every release kind with one reconciler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release reconciler reads the API object through a source.Release adapter and takes its kind-specific collaborators as Deps: repository resolver, chart claim, target-namespace ensurer and access manager. Each step is the addon's step of today; the package moves from reconcile/helmclusteraddon to reconcile/release. AccessSetupFailed is the reason a release reports when its identity cannot be set up — no family sets one up yet. Signed-off-by: Ilya Drey --- api/v1alpha1/conditions.go | 3 + .../controller/helmclusteraddon/controller.go | 25 +- .../reconciler.go | 426 +++++++++--------- .../reconciler_test.go | 42 +- 4 files changed, 244 insertions(+), 252 deletions(-) rename images/operator-helm-controller/internal/reconcile/{helmclusteraddon => release}/reconciler.go (53%) rename images/operator-helm-controller/internal/reconcile/{helmclusteraddon => release}/reconciler_test.go (95%) diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/conditions.go index 8e625f2b..b2fdb450 100644 --- a/api/v1alpha1/conditions.go +++ b/api/v1alpha1/conditions.go @@ -39,6 +39,9 @@ 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" // ReasonForceReconcile marks the Reconciling condition raised for a pass that // was requested through the force reconcile annotation. ReasonForceReconcile = "ForceReconcile" diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go index 1e12d897..c8fedcbd 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go @@ -26,9 +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" "github.com/deckhouse/operator-helm/internal/manager/status" - reconcile "github.com/deckhouse/operator-helm/internal/reconcile/helmclusteraddon" + reconcile "github.com/deckhouse/operator-helm/internal/reconcile/release" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -39,15 +41,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), + Access: source.NoAccess{}, + Status: status.NewManager(client), + }) return ctrl.NewControllerManagedBy(mgr). Named(ControllerName). diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go b/images/operator-helm-controller/internal/reconcile/release/reconciler.go similarity index 53% rename from images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go rename to images/operator-helm-controller/internal/reconcile/release/reconciler.go index 7c450850..d698f9fd 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler.go @@ -14,32 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddon +package release import ( "context" "fmt" + "strings" "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/adapter" "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -49,59 +47,54 @@ import ( // whose deletion is stuck and stops emitting events. const internalResourceDeletionRequeueInterval = 30 * time.Second -// chartClaimConflictRequeueInterval bounds how often an addon that lost the claim +// 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 addon is deleted or repointed at another chart. +// recover once the conflicting release 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, - } +// 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 source.RepositoryResolver + Chart *services.ChartService + OCI *services.OCIRepoService + Release *services.ReleaseService + Maintenance *services.MaintenanceService + Claim source.ChartClaim + Namespaces source.TargetNamespaceEnsurer + Access source.AccessManager + Status *status.Manager +} + +func New(c client.Client, deps Deps) *Reconciler { + return &Reconciler{Client: c, deps: deps} } type Reconciler struct { client.Client - chartService *services.ChartService - ociRepositoryService *services.OCIRepoService - releaseService *services.ReleaseService - maintenanceService *services.MaintenanceService - claimService *services.ClaimService - statusManager *status.Manager + deps Deps } 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 { + 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 helm cluster addon: %w", err) + return reconcile.Result{}, fmt.Errorf("getting release: %w", err) } - rel := adapter.NewAddonRelease(addon) - - if !addon.DeletionTimestamp.IsZero() { - return r.reconcileDelete(ctx, addon) + if !rel.Object().GetDeletionTimestamp().IsZero() { + return r.reconcileDelete(ctx, rel) } // Claim the repository/chart pair before anything else, including adding the @@ -111,34 +104,34 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // 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, rel) + acquired, holder, err := r.deps.Claim.Acquire(ctx, rel) 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, + return reconcile.Result{RequeueAfter: chartClaimConflictRequeueInterval}, r.deps.Status.Update( + ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - addon, + rel.Object(), helmv1alpha1.ReasonChartClaimConflict, - fmt.Sprintf("chart %q is already used by helmclusteraddon/%s", addon.Spec.Chart.HelmClusterAddonChartName, holder), + fmt.Sprintf("chart %q is already used by %s/%s", rel.ChartRef().Chart, strings.ToLower(rel.Kind()), 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, + if utils.IsSystemNamespace(rel.TargetNamespace()) { + return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( + rel.Object(), helmv1alpha1.ReasonFailed, "Target namespace cannot be a system namespace", - fmt.Errorf("target namespace %q is a system namespace", addon.Spec.Namespace), + fmt.Errorf("target namespace %q is a system namespace", rel.TargetNamespace()), )}) } - if !controllerutil.ContainsFinalizer(addon, helmv1alpha1.FinalizerName) { - controllerutil.AddFinalizer(addon, helmv1alpha1.FinalizerName) - if err := r.Update(ctx, addon); err != nil { + 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 @@ -147,64 +140,76 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // would not trigger a follow-up reconcile. } - if err := r.claimService.ReleaseStale(ctx, rel); err != nil { + if err := r.deps.Claim.ReleaseStale(ctx, rel); err != nil { return reconcile.Result{}, fmt.Errorf("releasing stale chart claims: %w", err) } - if r.maintenanceService.IsMaintenanceModeChangeRequired(rel) { - maintenanceRes := r.maintenanceService.EnsureMaintenanceMode(ctx, rel) - if err := r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, maintenanceRes, status.AsCondition(maintenanceRes, "Ready")); err != nil { + if r.deps.Maintenance.IsMaintenanceModeChangeRequired(rel) { + maintenanceRes := r.deps.Maintenance.EnsureMaintenanceMode(ctx, rel) + if err := r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, maintenanceRes, status.AsCondition(maintenanceRes, "Ready")); err != nil { return reconcile.Result{}, err } - if !addon.MaintenanceModeActivated() { + if !rel.MaintenanceActivated() { // 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) + return reconcile.Result{}, r.discardForceReconcile(ctx, rel) } - if addon.MaintenanceModeActivated() { - return reconcile.Result{}, r.discardForceReconcile(ctx, addon) + if rel.MaintenanceActivated() { + return reconcile.Result{}, r.discardForceReconcile(ctx, rel) } - 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, + repo, catalog, err := r.deps.Repositories.Resolve(ctx, rel.ChartRef().Repository) + if err != nil { + return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( + rel.Object(), helmv1alpha1.ReasonFailed, "Failed to get internal repository", fmt.Errorf("getting internal repository: %w", err), )}) } - repoType, err := utils.GetRepositoryType(repo.Spec.URL) + repoType, err := utils.GetRepositoryType(repo.URL()) if err != nil { - return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - addon, + return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( + rel.Object(), 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, + if err := r.deps.Namespaces.EnsureTargetNamespace(ctx, rel); err != nil { + return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( + rel.Object(), helmv1alpha1.ReasonFailed, fmt.Sprintf("Failed to reconcile target namespace: %s", err.Error()), err, )}) } + // 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 err := r.deps.Access.EnsureAccess(ctx, rel); err != nil { + return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( + rel.Object(), + helmv1alpha1.ReasonAccessSetupFailed, + fmt.Sprintf("Failed to set up the release identity: %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() + forced := rel.ForceReconcileRequired() if forced { - if err := r.markForceReconcileInProgress(ctx, addon); err != nil { + if err := r.markForceReconcileInProgress(ctx, rel); err != nil { return reconcile.Result{}, err } } @@ -213,68 +218,70 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco var repoRes services.OCIRepoResult var releaseRes services.ReleaseResult - _, chartVersion, addonChartErr := r.getHelmClusterAddonChart(ctx, addon, repoType) + _, chartVersion, chartErr := r.getChartVersion(ctx, catalog, repo, rel, 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 + // 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 source utils.ChartSource - if addonChartErr == nil { - source, addonChartErr = utils.ResolveChartSource(repo.Spec.URL, chartVersion) + var src utils.ChartSource + if chartErr == nil { + src, chartErr = utils.ResolveChartSource(repo.URL(), chartVersion) } + names := rel.InternalNames() + switch { - case addonChartErr != nil: + 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. chartRes = services.ChartResult{Status: status.Failed( - addon, + rel.Object(), helmv1alpha1.ReasonChartFetchFailed, "Failed to resolve the desired chart version", - addonChartErr, + chartErr, )} - case source.Kind == utils.InternalHelmRepository: + case src.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, rel.InternalNames()) + superseded, err := r.deps.OCI.RemoveOCIRepository(ctx, names) if err != nil { chartRes = services.ChartResult{ - Status: status.Failed(addon, helmv1alpha1.ReasonFailed, "Repository change failed", err), + Status: status.Failed(rel.Object(), helmv1alpha1.ReasonFailed, "Repository change failed", err), } break } - r.logSourceKindFlip(ctx, addon, source.Kind, superseded != nil) + r.logSourceKindFlip(ctx, rel, src.Kind, superseded != nil) - chartRes = r.chartService.EnsureHelmChart(ctx, rel, adapter.NewAddonRepository(repo)) - case source.Kind == utils.InternalOCIRepository: - superseded, err := r.chartService.CleanupHelmChart(ctx, rel.InternalNames()) + chartRes = r.deps.Chart.EnsureHelmChart(ctx, rel, repo) + case src.Kind == utils.InternalOCIRepository: + superseded, err := r.deps.Chart.CleanupHelmChart(ctx, names) if err != nil { chartRes = services.ChartResult{ - Status: status.Failed(addon, helmv1alpha1.ReasonFailed, "Repository change failed", err), + Status: status.Failed(rel.Object(), helmv1alpha1.ReasonFailed, "Repository change failed", err), } break } - r.logSourceKindFlip(ctx, addon, source.Kind, superseded != nil) + r.logSourceKindFlip(ctx, rel, src.Kind, superseded != nil) - repoRes = r.ociRepositoryService.EnsureInternalOCIRepository(ctx, rel, adapter.NewAddonRepository(repo), source, chartVersion) + repoRes = r.deps.OCI.EnsureInternalOCIRepository(ctx, rel, repo, src, chartVersion) default: - return reconcile.Result{}, r.statusManager.Update(ctx, addon, status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - addon, + return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( + rel.Object(), helmv1alpha1.ReasonFailed, - fmt.Sprintf("Unsupported chart source: %s", source.Kind), - fmt.Errorf("unsupported chart source: %s", source.Kind), + fmt.Sprintf("Unsupported chart source: %s", src.Kind), + fmt.Errorf("unsupported chart source: %s", src.Kind), )}) } if chartRes.HasArtifact() || repoRes.HasArtifact() { var artifactRevision string - switch source.Kind { + switch src.Kind { case utils.InternalHelmRepository: if chartRes.Artifact != nil { artifactRevision = chartRes.Artifact.Revision @@ -285,13 +292,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco } } - releaseRes = r.releaseService.EnsureHelmRelease(ctx, rel, source.Kind, artifactRevision) + releaseRes = r.deps.Release.EnsureHelmRelease(ctx, rel, src.Kind, artifactRevision) } - if err := r.statusManager.Update( + if err := r.deps.Status.Update( ctx, - addon, - setStatusAttrs(source.Kind, chartRes, repoRes, releaseRes, forceReconcileOutcome{ + rel.Object(), + setStatusAttrs(rel, src.Kind, chartRes, repoRes, releaseRes, forceReconcileOutcome{ forced: forced, now: time.Now().UTC(), }), @@ -314,73 +321,79 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return reconcile.Result{RequeueAfter: repoRes.RequeueAfter}, nil } -func (r *Reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.HelmClusterAddon) (reconcile.Result, error) { +func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (reconcile.Result, error) { logger := log.FromContext(ctx) - if !controllerutil.ContainsFinalizer(addon, helmv1alpha1.FinalizerName) { + if !controllerutil.ContainsFinalizer(rel.Object(), helmv1alpha1.FinalizerName) { return reconcile.Result{}, nil } - rel := adapter.NewAddonRelease(addon) 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 HelmClusterAddon and + // 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. Each step waits for the resource to + // 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 - // addon so the reason a deletion stalls is observable. - release, err := r.releaseService.CleanupHelmRelease(ctx, names) + // 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 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, rel, release); err != 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 r.awaitInternalResourceDeletion(ctx, addon, "internal release", release) + return r.awaitInternalResourceDeletion(ctx, rel, "internal release", release) } - chart, err := r.chartService.CleanupHelmChart(ctx, names) + chart, err := r.deps.Chart.CleanupHelmChart(ctx, names) if err != nil { return reconcile.Result{}, err } if chart != nil { - return r.awaitInternalResourceDeletion(ctx, addon, "internal chart", chart) + return r.awaitInternalResourceDeletion(ctx, rel, "internal chart", chart) } - ociRepo, err := r.ociRepositoryService.RemoveOCIRepository(ctx, names) + ociRepo, err := r.deps.OCI.RemoveOCIRepository(ctx, names) if err != nil { return reconcile.Result{}, err } if ociRepo != nil { - return r.awaitInternalResourceDeletion(ctx, addon, "internal repository", ociRepo) + return r.awaitInternalResourceDeletion(ctx, rel, "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 { + 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 addon start reconciling the same chart while this - // one's release is still being uninstalled — exactly the collision the claim + // 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.claimService.Release(ctx, rel); err != nil { + 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 { - latestAddon := &helmv1alpha1.HelmClusterAddon{} - if err := r.Get(ctx, client.ObjectKeyFromObject(addon), latestAddon); err != nil { + latest := r.deps.NewRelease() + if err := r.Get(ctx, client.ObjectKeyFromObject(rel.Object()), latest.Object()); err != nil { return client.IgnoreNotFound(err) } - if controllerutil.RemoveFinalizer(latestAddon, helmv1alpha1.FinalizerName) { - if err := r.Update(ctx, latestAddon); err != nil { + if controllerutil.RemoveFinalizer(latest.Object(), helmv1alpha1.FinalizerName) { + if err := r.Update(ctx, latest.Object()); err != nil { return err } } @@ -395,58 +408,27 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, addon *helmv1alpha1.He } // awaitInternalResourceDeletion surfaces that an internal resource is still being -// deleted on the addon's status (via the shared status manager) and requeues +// deleted on the release'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) { +func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, rel source.Release, 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 { + if err := r.deps.Status.MarkUninstallPending(ctx, rel.Object(), 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{ +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: addon.Generation, + ObservedGeneration: rel.Generation(), }) }) if client.IgnoreNotFound(err) != nil { @@ -456,25 +438,25 @@ func (r *Reconciler) markForceReconcileInProgress(ctx context.Context, addon *he 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 +// discardForceReconcile drops the in-flight force state from a release that is +// entering, or already sitting in, maintenance mode. Every pass on such a release // 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. +// force path is its only producer on a release. // // 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) +func (r *Reconciler) discardForceReconcile(ctx context.Context, rel source.Release) error { + err := r.deps.Status.PatchStatus(ctx, rel.Object(), func() { + apimeta.RemoveStatusCondition(rel.Object().GetConditions(), 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 { + if err := r.reconcileForceAnnotation(ctx, client.ObjectKeyFromObject(rel.Object())); err != nil { return fmt.Errorf("failed to reconcile force annotation: %w", err) } @@ -482,60 +464,62 @@ func (r *Reconciler) discardForceReconcile(ctx context.Context, addon *helmv1alp } func (r *Reconciler) reconcileForceAnnotation(ctx context.Context, key client.ObjectKey) error { - var addon helmv1alpha1.HelmClusterAddon + rel := r.deps.NewRelease() - if err := r.Get(ctx, key, &addon); err != nil { + if err := r.Get(ctx, key, rel.Object()); err != nil { if apierrors.IsNotFound(err) { return nil } - return fmt.Errorf("getting helm cluster addon: %w", err) + return fmt.Errorf("getting release: %w", err) } - if _, found := addon.Annotations[helmv1alpha1.AnnotationForceReconcile]; !found { - // Guard on the annotation itself, not on the map: an addon carrying any + annotations := rel.Object().GetAnnotations() + if _, found := annotations[helmv1alpha1.AnnotationForceReconcile]; !found { + // Guard on the annotation itself, not on the map: a release carrying any // unrelated annotation would otherwise take an empty PATCH on every pass. return nil } - patchBase := client.MergeFrom(addon.DeepCopy()) + patchBase := client.MergeFrom(rel.Object().DeepCopyObject().(client.Object)) - delete(addon.Annotations, helmv1alpha1.AnnotationForceReconcile) + delete(annotations, helmv1alpha1.AnnotationForceReconcile) + rel.Object().SetAnnotations(annotations) - if err := r.Patch(ctx, &addon, patchBase); err != nil { + if err := r.Patch(ctx, rel.Object(), 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 +// 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 addon keeps reconciling +// 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) getHelmClusterAddonChart( +func (r *Reconciler) getChartVersion( ctx context.Context, - addon *helmv1alpha1.HelmClusterAddon, + catalog source.Catalog, + repo source.Repository, + rel source.Release, repoType utils.InternalRepositoryType, -) (*helmv1alpha1.HelmClusterAddonChart, *helmv1alpha1.ChartVersion, error) { - addonChartName := naming.HelmClusterAddonChartName( - addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName, - ) - addonChart := &helmv1alpha1.HelmClusterAddonChart{} +) (client.Object, *helmv1alpha1.ChartVersion, error) { + ref := rel.ChartRef() - if err := r.Get(ctx, types.NamespacedName{Name: addonChartName}, addonChart); err != nil { - return nil, nil, fmt.Errorf("getting helm cluster addon chart: %w", err) + 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 addonChart.Status.Versions { - version := &addonChart.Status.Versions[i] - if version.Version != addon.Spec.Chart.Version { + for i := range catalogStatus.Versions { + version := &catalogStatus.Versions[i] + if version.Version != ref.Version { continue } @@ -557,10 +541,10 @@ func (r *Reconciler) getHelmClusterAddonChart( ) } - return addonChart, version, nil + return obj, version, nil } - return nil, nil, fmt.Errorf("helm cluster addon chart does not have version %q", addon.Spec.Chart.Version) + return nil, nil, fmt.Errorf("chart catalog does not have version %q", ref.Version) } // versionUnavailableDetail explains why a catalog entry is not deployable. @@ -583,7 +567,7 @@ func versionUnavailableDetail(version helmv1alpha1.ChartVersion) string { // kind was actually removed in this pass. func (r *Reconciler) logSourceKindFlip( ctx context.Context, - addon *helmv1alpha1.HelmClusterAddon, + rel source.Release, kind utils.InternalRepositoryType, superseded bool, ) { @@ -591,15 +575,12 @@ func (r *Reconciler) logSourceKindFlip( 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 + 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 @@ -607,7 +588,7 @@ func (r *Reconciler) logSourceKindFlip( 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, + "version", rel.ChartRef().Version, "source", kind, ) } @@ -621,7 +602,11 @@ type forceReconcileOutcome struct { now time.Time } +// setStatusAttrs writes the fields of the status the conditions do not cover. It +// closes over rel rather than asserting the object's type: rel.Object() is the very +// object the status manager hands back, so the writes land on it. func setStatusAttrs( + rel source.Release, sourceKind utils.InternalRepositoryType, chartRes services.ChartResult, repoRes services.OCIRepoResult, @@ -630,14 +615,13 @@ func setStatusAttrs( ) 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) + rel.SetLastForceReconcileTime(metav1.Time{Time: force.now}) + apimeta.RemoveStatusCondition(rel.Object().GetConditions(), helmv1alpha1.ConditionTypeReconciling) } latestRelease := releaseRes.History.Latest() @@ -646,35 +630,31 @@ func setStatusAttrs( switch sourceKind { case utils.InternalHelmRepository: - if chartRes.HasArtifact() && releaseRes.IsReady() && addon.IsChartStatusInfoOutdated() { + if chartRes.HasArtifact() && releaseRes.IsReady() && rel.IsChartStatusInfoOutdated() { updateChart = true } case utils.InternalOCIRepository: - if repoRes.HasArtifact() && releaseRes.IsReady() && addon.IsChartStatusInfoOutdated() { + if repoRes.HasArtifact() && releaseRes.IsReady() && rel.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, - } + rel.SetLastAppliedChart(rel.ChartRef()) } if releaseRes.IsReady() && latestRelease != nil { rawValues := []byte(`{}`) - if addon.Spec.Values != nil { - rawValues = addon.Spec.Values.Raw + if rel.Values() != nil { + rawValues = rel.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 + values, _ := helmchartutil.ReadValues(rawValues) + if latestRelease.Status == "deployed" && latestRelease.ConfigDigest == chartutil.DigestValues(digest.Canonical, values).String() { + if rel.Values() == nil { + rel.SetLastAppliedValues(nil) } else { - addon.Status.LastAppliedValues = addon.Spec.Values.DeepCopy() + rel.SetLastAppliedValues(rel.Values().DeepCopy()) } } } diff --git a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go similarity index 95% rename from images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go rename to images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index 31e2e150..dd3681a9 100644 --- a/images/operator-helm-controller/internal/reconcile/helmclusteraddon/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helmclusteraddon +package release import ( "context" @@ -42,6 +42,7 @@ import ( 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/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -59,13 +60,13 @@ func testScheme(t *testing.T) *runtime.Scheme { return scheme } -func newTestReconciler(t *testing.T, objects ...client.Object) *Reconciler { +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} + return &Reconciler{Client: c}, c } func testAddon() *helmv1alpha1.HelmClusterAddon { @@ -222,9 +223,9 @@ func TestGetHelmClusterAddonChart(t *testing.T) { chart := addonChartFixture( addon.Spec.Chart.HelmClusterAddonRepository, addon.Spec.Chart.HelmClusterAddonChartName, tt.version, ) - r := newTestReconciler(t, chart) + r, c := newTestReconciler(t, chart) - gotChart, gotVersion, err := r.getHelmClusterAddonChart(context.Background(), addon, tt.repoType) + gotChart, gotVersion, err := r.getChartVersion(context.Background(), adapter.NewAddonCatalog(c), adapter.NewAddonRepository(helmRepositoryFixture()), adapter.NewAddonRelease(addon), tt.repoType) if tt.wantErr { if err == nil { @@ -261,9 +262,9 @@ func TestGetHelmClusterAddonChart(t *testing.T) { func TestGetHelmClusterAddonChartMissingChart(t *testing.T) { addon := testAddon() - r := newTestReconciler(t) + r, c := newTestReconciler(t) - gotChart, gotVersion, err := r.getHelmClusterAddonChart(context.Background(), addon, utils.InternalOCIRepository) + gotChart, gotVersion, err := r.getChartVersion(context.Background(), adapter.NewAddonCatalog(c), adapter.NewAddonRepository(helmRepositoryFixture()), adapter.NewAddonRelease(addon), utils.InternalOCIRepository) if err == nil { t.Fatalf("expected an error when the addon chart does not exist, got version %+v", gotVersion) } @@ -331,15 +332,18 @@ func newFullReconciler( 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 + 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), + Access: source.NoAccess{}, + Status: status.NewManager(c), + }), c } func ociRepositoryFixture() *helmv1alpha1.HelmClusterAddonRepository { @@ -609,7 +613,7 @@ func TestLogSourceKindFlipIgnoresStaleEntryFromADifferentChartOrRepository(t *te ctx := log.IntoContext(context.Background(), logger) r := &Reconciler{} - r.logSourceKindFlip(ctx, addon, utils.InternalOCIRepository, true) + r.logSourceKindFlip(ctx, adapter.NewAddonRelease(addon), utils.InternalOCIRepository, true) if logged != tt.wantLogged { t.Fatalf("logged = %v, want %v", logged, tt.wantLogged) @@ -841,7 +845,7 @@ func TestReconcileSittingInMaintenanceDiscardsForceReconcile(t *testing.T) { r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) - if !addon.MaintenanceModeEnabled() || r.maintenanceService.IsMaintenanceModeChangeRequired(adapter.NewAddonRelease(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") } @@ -876,7 +880,7 @@ func TestReconcileLeavingMaintenanceKeepsForceReconcile(t *testing.T) { r, c := newForceTestReconciler(t, interceptor.Funcs{}, append(forceTestFixtures(), addon)...) - if addon.MaintenanceModeActivated() || !r.maintenanceService.IsMaintenanceModeChangeRequired(adapter.NewAddonRelease(addon)) { + if addon.MaintenanceModeActivated() || !r.deps.Maintenance.IsMaintenanceModeChangeRequired(adapter.NewAddonRelease(addon)) { t.Fatal("the fixture must be leaving maintenance, otherwise the test proves nothing") } From 83369b5e98ab27b41d44222e17edeac22fc8a843 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 23:02:26 +0300 Subject: [PATCH 026/145] feat(controller): adapt HelmApplication to the release contract The application adapter is the one place the two mutually exclusive repository fields are read; downstream sees a kind, a namespace and a name. Its internal objects carry namespace-aware derived names, the Helm release name is prefixed so a hand-installed release cannot be taken over, and lastAppliedChart is replaced wholesale. The HelmApplication indexes carry the repository kind and namespace, which is what lets the application catalogs see real consumers scoped to their namespace. Signed-off-by: Ilya Drey --- .../internal/adapter/application_release.go | 239 +++++++++++++++++ .../adapter/application_release_test.go | 242 ++++++++++++++++++ .../internal/adapter/catalogs.go | 10 +- .../internal/adapter/catalogs_test.go | 21 ++ .../internal/catalog/catalog_test.go | 3 + .../internal/index/index.go | 73 ++++++ .../reconcile/repository/reconciler_test.go | 2 + 7 files changed, 584 insertions(+), 6 deletions(-) create mode 100644 images/operator-helm-controller/internal/adapter/application_release.go create mode 100644 images/operator-helm-controller/internal/adapter/application_release_test.go 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..904325eb --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/application_release.go @@ -0,0 +1,239 @@ +/* +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/manager/status" + "github.com/deckhouse/operator-helm/internal/source" + "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. +func (r *ApplicationRelease) ReleaseName() string { + return utils.HelmReleaseName(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) source.RepositoryResolver { + 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: %w", 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..88783080 --- /dev/null +++ b/images/operator-helm-controller/internal/adapter/application_release_test.go @@ -0,0 +1,242 @@ +/* +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()) + } + if rel.ReleaseName() != "hap-my-app" { + t.Fatalf("ReleaseName = %q, want hap-", 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/catalogs.go b/images/operator-helm-controller/internal/adapter/catalogs.go index f4a1c8e3..bf6e074d 100644 --- a/images/operator-helm-controller/internal/adapter/catalogs.go +++ b/images/operator-helm-controller/internal/adapter/catalogs.go @@ -43,9 +43,7 @@ func NewAddonCatalog(c client.Client) source.Catalog { }) } -// NewApplicationCatalog builds the HelmApplicationChart catalog. Consumers is nil -// until the HelmApplication controller exists: nothing can reference a chart yet. -// TODO(stage 5): replaced by the HelmApplication consumers lookup. +// 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, @@ -56,12 +54,11 @@ func NewApplicationCatalog(c client.Client) source.Catalog { }, Status: func(o *helmv1alpha1.HelmApplicationChart) *helmv1alpha1.ChartCatalogStatus { return &o.Status }, ObjectName: naming.ApplicationChartName, + Consumers: chartConsumers(ListApplicationReleases(c)), }) } -// NewClusterApplicationCatalog builds the HelmClusterApplicationChart catalog. -// Consumers is nil for the same reason as in NewApplicationCatalog. -// TODO(stage 5): replaced by the HelmApplication consumers lookup. +// 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, @@ -74,6 +71,7 @@ func NewClusterApplicationCatalog(c client.Client) source.Catalog { }, Status: func(o *helmv1alpha1.HelmClusterApplicationChart) *helmv1alpha1.ChartCatalogStatus { return &o.Status }, ObjectName: naming.ClusterApplicationChartName, + Consumers: chartConsumers(ListApplicationReleases(c)), }) } diff --git a/images/operator-helm-controller/internal/adapter/catalogs_test.go b/images/operator-helm-controller/internal/adapter/catalogs_test.go index af3b23b6..c8d7ec60 100644 --- a/images/operator-helm-controller/internal/adapter/catalogs_test.go +++ b/images/operator-helm-controller/internal/adapter/catalogs_test.go @@ -101,3 +101,24 @@ func TestAddonCatalogInUseVersionsCountsDesiredAndLastApplied(t *testing.T) { 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/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index 72148336..7d71ce78 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -32,6 +32,7 @@ import ( 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" ) @@ -47,6 +48,8 @@ func newClient(t *testing.T, objects ...client.Object) client.Client { WithScheme(scheme). WithObjects(objects...). WithStatusSubresource(&helmv1alpha1.HelmApplicationChart{}, &helmv1alpha1.HelmClusterApplicationChart{}). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationRepository, index.ApplicationRepositoryIndexer). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). Build() } 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/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 19f49791..941e6222 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -145,6 +145,8 @@ func newApplicationReconciler(t *testing.T, stub *stubRepoClient, objects ...cli &helmv1alpha1.HelmApplicationRepository{}, &helmv1alpha1.HelmApplicationChart{}, ). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationRepository, index.ApplicationRepositoryIndexer). + WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). Build() factory := func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) { From 7761ce1a0658339d473e39be95f18e8b9668eda3 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 23:09:22 +0300 Subject: [PATCH 027/145] feat(controller): provide the identity an application release is applied with AccessService creates a ServiceAccount in the operator namespace without a token, seeds a Role with full rights inside the application namespace and binds the two. The Role is created once and never reconciled: it is where a namespace owner cuts the rights down, and a RoleBinding cannot grant anything beyond the namespace regardless of its content. Cleanup removes the account and the binding and keeps the Role. Signed-off-by: Ilya Drey --- .../internal/services/access_service.go | 177 ++++++++++++++++ .../internal/services/access_service_test.go | 191 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 images/operator-helm-controller/internal/services/access_service.go create mode 100644 images/operator-helm-controller/internal/services/access_service_test.go 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..93fab3d1 --- /dev/null +++ b/images/operator-helm-controller/internal/services/access_service.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 services + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + 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: cutting it down once applies to every application +// there, and the edit survives applications being recreated. +const ApplicationRoleName = "operator-helm-application" + +var _ source.AccessManager = (*AccessService)(nil) + +// 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. It is also the one object a namespace owner may edit to cut the rights +// down — which is why it is created once and never reconciled afterwards. +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 and the binding and seeds the Role. 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) error { + name := rel.InternalNames().ServiceAccount + if name == "" { + return nil + } + + if err := s.ensureServiceAccount(ctx, rel, name); err != nil { + return fmt.Errorf("ensuring service account: %w", err) + } + + if err := s.seedRole(ctx, rel.TargetNamespace()); err != nil { + return fmt.Errorf("seeding role: %w", err) + } + + if err := s.ensureRoleBinding(ctx, rel, name); err != nil { + return fmt.Errorf("ensuring role binding: %w", err) + } + + return nil +} + +// CleanupAccess removes the account and the binding. The Role stays: it belongs to +// the namespace, may carry the owner's edits, and grants nothing without a binding. +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.ensureResourceDeleted(ctx, binding, &rbacv1.RoleBinding{}); err != nil { + return fmt.Errorf("deleting role binding: %w", err) + } + + 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 { + account.Labels = rel.SourceLabels() + account.AutomountServiceAccountToken = ptr.To(false) + + return nil + }) + + return err +} + +// seedRole creates the namespace Role with full rights inside the namespace when it +// does not exist, and leaves an existing one untouched: not read, not compared, not +// patched. CreateOrPatch would overwrite an owner's edit; only Create fits. +func (s *AccessService) seedRole(ctx context.Context, namespace string) error { + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: ApplicationRoleName, + Namespace: namespace, + Labels: map[string]string{ + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, + }, + }, + Rules: []rbacv1.PolicyRule{{ + APIGroups: []string{"*"}, + Resources: []string{"*"}, + Verbs: []string{"*"}, + }}, + } + + return client.IgnoreAlreadyExists(s.Client.Create(ctx, role)) +} + +// ensureRoleBinding binds the account to the namespace Role. roleRef is immutable in +// Kubernetes, so it is set only when the binding is created; the subjects and labels +// are reconciled on every pass. +func (s *AccessService) ensureRoleBinding(ctx context.Context, rel source.Release, name string) error { + binding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: rel.TargetNamespace()}, + } + + _, err := controllerutil.CreateOrPatch(ctx, s.Client, binding, func() error { + binding.Labels = rel.SourceLabels() + + if binding.RoleRef.Name == "" { + binding.RoleRef = rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: ApplicationRoleName, + } + } + + binding.Subjects = []rbacv1.Subject{{ + Kind: rbacv1.ServiceAccountKind, + Name: name, + Namespace: s.TargetNamespace, + }} + + return nil + }) + + return err +} 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..be5dfae4 --- /dev/null +++ b/images/operator-helm-controller/internal/services/access_service_test.go @@ -0,0 +1,191 @@ +/* +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" + "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" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" + "github.com/deckhouse/operator-helm/internal/adapter" + "github.com/deckhouse/operator-helm/internal/source" +) + +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 err := service.EnsureAccess(context.Background(), rel); err != nil { + t.Fatalf("EnsureAccess returned %v", 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) + } + + 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) + } +} + +// TestEnsureAccessLeavesAnEditedRoleAlone pins the seed rule: the Role is the place +// where a namespace owner may cut the rights down, so an existing Role is never +// read, compared or rewritten — only a missing one is created. +func TestEnsureAccessLeavesAnEditedRoleAlone(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + edited := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: ApplicationRoleName, Namespace: "team-a"}, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get"}}}, + } + service, c := newAccessService(t, edited) + + if err := service.EnsureAccess(context.Background(), rel); err != nil { + t.Fatalf("EnsureAccess returned %v", 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) + } + if !reflect.DeepEqual(role.Rules, edited.Rules) { + t.Fatalf("an existing role must not be rewritten, rules = %+v", role.Rules) + } +} + +func TestEnsureAccessRecreatesADeletedRole(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + service, c := newAccessService(t) + + if err := service.EnsureAccess(context.Background(), rel); err != nil { + t.Fatalf("first EnsureAccess returned %v", 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 err := service.EnsureAccess(context.Background(), rel); err != nil { + t.Fatalf("second EnsureAccess returned %v", 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) + } +} + +func TestEnsureAccessIsANoopForAFamilyWithoutAServiceAccount(t *testing.T) { + service, c := newAccessService(t) + + if err := service.EnsureAccess(context.Background(), adapter.NewAddonRelease(testAddon())); err != nil { + t.Fatalf("EnsureAccess returned %v", 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 err := service.EnsureAccess(context.Background(), rel); err != nil { + t.Fatalf("EnsureAccess returned %v", 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) + } + + var _ source.AccessManager = service +} From 06f24bc2a3463b3382f699326d37395124ba40e0 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 23:16:14 +0300 Subject: [PATCH 028/145] feat(controller): map repositories and catalogs to the applications using them Two mappers enqueue the HelmApplication objects referencing a repository or one of its charts, through indexes that carry the repository kind and namespace. The application repository controllers now force their real consumers instead of nobody; the last stage-5 stand-ins of the repository plan are gone. Signed-off-by: Ilya Drey --- .../helmapplicationrepository/controller.go | 6 +- .../controller.go | 6 +- .../reconcile/repository/reconciler_test.go | 3 +- .../internal/utils/mapper.go | 57 ++++++++++++ .../internal/utils/mapper_test.go | 91 +++++++++++++++++++ 5 files changed, 151 insertions(+), 12 deletions(-) diff --git a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go index 3e5953c0..8f9060ac 100644 --- a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go @@ -35,7 +35,6 @@ import ( "github.com/deckhouse/operator-helm/internal/manager/status" reconcile "github.com/deckhouse/operator-helm/internal/reconcile/repository" "github.com/deckhouse/operator-helm/internal/services" - "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -53,10 +52,7 @@ func SetupWithManager(mgr ctrl.Manager) error { adapter.EmptyApplicationRepository, services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), ociRepositoryService, - // HelmApplication is not reconciled yet: a force request has no consumer - // sources to reach. The HelmApplication controller replaces this. - // TODO(stage 5): replaced by the HelmApplication consumer forcer. - source.NoConsumers{}, + services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(client)), services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewApplicationCatalog(client)), status.NewManager(client), ) diff --git a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go index 1ef8bdc3..eadf5c56 100644 --- a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go @@ -33,7 +33,6 @@ import ( "github.com/deckhouse/operator-helm/internal/manager/status" reconcile "github.com/deckhouse/operator-helm/internal/reconcile/repository" "github.com/deckhouse/operator-helm/internal/services" - "github.com/deckhouse/operator-helm/internal/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -51,10 +50,7 @@ func SetupWithManager(mgr ctrl.Manager) error { adapter.EmptyClusterApplicationRepository, services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), ociRepositoryService, - // HelmApplication is not reconciled yet: a force request has no consumer - // sources to reach. The HelmApplication controller replaces this. - // TODO(stage 5): replaced by the HelmApplication consumer forcer. - source.NoConsumers{}, + services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(client)), services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewClusterApplicationCatalog(client)), status.NewManager(client), ) diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 941e6222..96e5256c 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -43,7 +43,6 @@ import ( "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/source" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -160,7 +159,7 @@ func newApplicationReconciler(t *testing.T, stub *stubRepoClient, objects ...cli adapter.EmptyApplicationRepository, services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), ociRepositoryService, - source.NoConsumers{}, + services.NewForceService(c, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(c)), services.NewRepoSyncService(c, scheme, factory, adapter.NewApplicationCatalog(c)), status.NewManager(c), ) diff --git a/images/operator-helm-controller/internal/utils/mapper.go b/images/operator-helm-controller/internal/utils/mapper.go index 982f3316..183cb851 100644 --- a/images/operator-helm-controller/internal/utils/mapper.go +++ b/images/operator-helm-controller/internal/utils/mapper.go @@ -159,3 +159,60 @@ func MapChartToAddons(c client.Client) handler.MapFunc { return requests } } + +// 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") + + 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", "name", obj.GetName(), "namespace", obj.GetNamespace()) + + 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") + + 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/utils/mapper_test.go b/images/operator-helm-controller/internal/utils/mapper_test.go index 4f789c3b..d6cd945e 100644 --- a/images/operator-helm-controller/internal/utils/mapper_test.go +++ b/images/operator-helm-controller/internal/utils/mapper_test.go @@ -192,3 +192,94 @@ func TestMapNamespacedInternalResources(t *testing.T) { }) } } + +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 application(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, + application("team-a", "a1", "stable", ""), + application("team-b", "b1", "stable", ""), + application("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, + application("team-a", "a1", "stable", ""), + application("team-a", "other", "stable", ""), + application("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) + } +} From b2adb9681dbef26363f6d8d07308867542d2dca7 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 23:22:48 +0300 Subject: [PATCH 029/145] feat(controller): reconcile HelmApplication HelmApplication is served by the shared release reconciler with no chart claim, no target-namespace creation and an identity of its own: the chart is applied as a ServiceAccount bound to a namespace Role, so the release cannot reach beyond its namespace. The webhook rejects system namespaces and deletion under maintenance. The controller gains rights on roles and role bindings (with escalate and bind) and on service accounts in its own namespace; helm-controller already holds every verb. Signed-off-by: Ilya Drey --- .../cmd/operator-helm-controller/main.go | 22 ++++ .../controller/helmapplication/controller.go | 120 ++++++++++++++++++ .../webhook/helmapplication/webhook.go | 67 ++++++++++ .../operator-helm-controller/rbac-for-us.yaml | 19 +++ .../validation-webhook.yaml | 17 +++ 5 files changed, 245 insertions(+) create mode 100644 images/operator-helm-controller/internal/controller/helmapplication/controller.go create mode 100644 images/operator-helm-controller/internal/webhook/helmapplication/webhook.go 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 4c51e330..43d97eff 100644 --- a/images/operator-helm-controller/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-controller/cmd/operator-helm-controller/main.go @@ -31,11 +31,13 @@ import ( 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" ) @@ -84,6 +86,16 @@ 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) @@ -99,6 +111,11 @@ func main() { 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) @@ -114,6 +131,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/internal/controller/helmapplication/controller.go b/images/operator-helm-controller/internal/controller/helmapplication/controller.go new file mode 100644 index 00000000..ee231b5c --- /dev/null +++ b/images/operator-helm-controller/internal/controller/helmapplication/controller.go @@ -0,0 +1,120 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// 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. +package helmapplication + +import ( + helmv2 "github.com/werf/3p-helm-controller/api/v2" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/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" + "github.com/deckhouse/operator-helm/internal/manager/status" + reconcile "github.com/deckhouse/operator-helm/internal/reconcile/release" + "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/source" + "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: source.NoChartClaim{}, + Namespaces: source.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{}), + ). + Watches( + &helmv1alpha1.HelmApplicationRepository{}, + handler.EnqueueRequestsFromMapFunc(utils.MapRepositoryToApplications(client, helmv1alpha1.HelmApplicationRepositoryKind)), + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). + Watches( + &helmv1alpha1.HelmClusterApplicationRepository{}, + handler.EnqueueRequestsFromMapFunc(utils.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(utils.MapChartToApplications(client, helmv1alpha1.HelmApplicationRepositoryKind)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &helmv1alpha1.HelmClusterApplicationChart{}, + handler.EnqueueRequestsFromMapFunc(utils.MapChartToApplications(client, helmv1alpha1.HelmClusterApplicationRepositoryKind)), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Complete(r) +} 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..34cf6f7d --- /dev/null +++ b/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go @@ -0,0 +1,67 @@ +/* +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. +package helmapplication + +import ( + "context" + "fmt" + + ctrl "sigs.k8s.io/controller-runtime" + "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{}). + Complete() +} + +var _ admission.Validator[*helmv1alpha1.HelmApplication] = (*HelmApplicationWebhookValidator)(nil) + +type HelmApplicationWebhookValidator struct{} + +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(_ context.Context, app *helmv1alpha1.HelmApplication) (admission.Warnings, error) { + if app.MaintenanceModeActivated() { + return nil, fmt.Errorf("helmapplication/%s cannot be deleted while maintenance mode is active", app.Name) + } + + return nil, nil +} + +func validateNotSystemNamespace(app *helmv1alpha1.HelmApplication) error { + if utils.IsSystemNamespace(app.Namespace) { + return fmt.Errorf("helmapplication/%s cannot be created in system namespace %s", app.Name, app.Namespace) + } + + return nil +} diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index 5346a01d..23bc5b7d 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -91,6 +91,9 @@ rules: - helmclusterapplicationrepositories/finalizers - helmapplicationcharts/finalizers - helmclusterapplicationcharts/finalizers + - helmapplications + - helmapplications/status + - helmapplications/finalizers verbs: - create - delete @@ -99,6 +102,21 @@ rules: - patch - update - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - roles + - rolebindings + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - escalate + - bind - apiGroups: - helm.internal.operator-helm.deckhouse.io resources: @@ -184,6 +202,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 From 655511faa5b39da0a9ca69ad988e1c34ae65c031 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 23:29:24 +0300 Subject: [PATCH 030/145] docs(api): state what creating a HelmApplication grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart of an application is applied as a ServiceAccount bound to a Role with every permission inside the namespace, so the right to create the resource is the right to administer the namespace. The CRD description — the source of the module's resource documentation — now says so, and that the Role may be narrowed by the namespace owner. Signed-off-by: Ilya Drey --- api/v1alpha1/helm_application.go | 2 +- crds/doc-ru-helmapplications.yaml | 2 +- crds/helmapplications.yaml | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/api/v1alpha1/helm_application.go b/api/v1alpha1/helm_application.go index 41540b59..b7719c7c 100644 --- a/api/v1alpha1/helm_application.go +++ b/api/v1alpha1/helm_application.go @@ -52,7 +52,7 @@ const ( // These notes are deliberately outside the doc comment below — controller-gen folds // every non-marker line of that block into the resource's API description. -// HelmApplication represents an installation of a Helm chart inside a single namespace. The release is deployed into the namespace of the resource itself. +// 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 is created once and may be narrowed by the namespace owner afterwards. // // +kubebuilder:object:root=true // +kubebuilder:subresource:status diff --git a/crds/doc-ru-helmapplications.yaml b/crds/doc-ru-helmapplications.yaml index 76904f5f..be484a51 100644 --- a/crds/doc-ru-helmapplications.yaml +++ b/crds/doc-ru-helmapplications.yaml @@ -8,7 +8,7 @@ spec: - name: v1alpha1 schema: openAPIV3Schema: - description: HelmApplication представляет собой установку Helm-чарта в пределах одного пространства имён. Релиз развёртывается в том же пространстве имён, где создан ресурс. + description: HelmApplication представляет собой установку Helm-чарта в пределах одного пространства имён. Релиз развёртывается в том же пространстве имён, где создан ресурс. Чарт применяется от имени ServiceAccount, связанного с Role, дающей все права внутри этого пространства имён, поэтому право создавать HelmApplication эквивалентно правам администратора пространства имён; Role создаётся один раз, и владелец пространства имён может затем сузить её. properties: spec: properties: diff --git a/crds/helmapplications.yaml b/crds/helmapplications.yaml index d522b1e5..7285d18e 100644 --- a/crds/helmapplications.yaml +++ b/crds/helmapplications.yaml @@ -48,7 +48,10 @@ spec: 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. + 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 is created once and may be narrowed by the namespace owner afterwards. properties: apiVersion: description: |- From cff775199b7fdf4a8acae54cf6fdbe47798ba6c0 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 00:04:06 +0300 Subject: [PATCH 031/145] fix(controller): address the whole-branch review of the release generalization The application family now has the tests its central claim needs: the chart is applied as its own ServiceAccount with storage in its own namespace, and a reconcile leaves nothing else in the application namespace. Internal object labels are merged again rather than replaced, a role binding with a foreign roleRef is reported instead of adopted, an application in maintenance no longer blocks namespace deletion, and the admission policy covers the catalogs' status subresource. Signed-off-by: Ilya Drey --- .../internal/adapter/application_release.go | 2 +- .../internal/catalog/catalog_test.go | 9 +- .../internal/reconcile/release/doc.go | 30 ++ .../internal/reconcile/release/reconciler.go | 4 + .../reconcile/release/reconciler_test.go | 272 ++++++++++++++++++ .../internal/services/access_service.go | 47 ++- .../internal/services/access_service_test.go | 38 +++ .../internal/services/chart_claim_service.go | 5 + .../internal/services/chart_service.go | 9 +- .../internal/services/chart_service_test.go | 33 +++ .../internal/services/release_service.go | 9 +- .../internal/services/release_service_test.go | 128 +++++++++ .../webhook/helmapplication/webhook.go | 43 ++- .../webhook/helmapplication/webhook_test.go | 135 +++++++++ templates/admision-policy.yaml | 7 + 15 files changed, 742 insertions(+), 29 deletions(-) create mode 100644 images/operator-helm-controller/internal/reconcile/release/doc.go create mode 100644 images/operator-helm-controller/internal/services/release_service_test.go create mode 100644 images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go diff --git a/images/operator-helm-controller/internal/adapter/application_release.go b/images/operator-helm-controller/internal/adapter/application_release.go index 904325eb..0e7f01c0 100644 --- a/images/operator-helm-controller/internal/adapter/application_release.go +++ b/images/operator-helm-controller/internal/adapter/application_release.go @@ -226,7 +226,7 @@ func ListApplicationReleases(c client.Client) source.ReleaseLister { var apps helmv1alpha1.HelmApplicationList if err := c.List(ctx, &apps, selector); err != nil { - return nil, fmt.Errorf("listing applications of repository %s/%s: %w", repo.Namespace(), repo.Name(), err) + 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)) diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index 7d71ce78..4ed24d94 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -166,10 +166,11 @@ func TestClusterCatalogObjectsHaveNoNamespace(t *testing.T) { } } -// TestWithoutConsumersEveryUnlistedVersionIsPruned pins the behaviour of the -// application catalogs until HelmApplication is reconciled: nothing can reference -// a chart, so nothing is protected from pruning. -func TestWithoutConsumersEveryUnlistedVersionIsPruned(t *testing.T) { +// 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") 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..a6e99fef --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/release/doc.go @@ -0,0 +1,30 @@ +/* +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. +package release diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler.go b/images/operator-helm-controller/internal/reconcile/release/reconciler.go index d698f9fd..a92b38da 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler.go @@ -421,6 +421,10 @@ func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, rel sour return reconcile.Result{RequeueAfter: internalResourceDeletionRequeueInterval}, 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{ diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index dd3681a9..9998d3ee 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -18,13 +18,17 @@ package release import ( "context" + "errors" "strings" "testing" "time" "github.com/go-logr/logr/funcr" + fluxmeta "github.com/werf/3p-fluxcd-pkg/apis/meta" helmv2 "github.com/werf/3p-helm-controller/api/v2" sourcev1 "github.com/werf/nelm-source-controller/api/v1" + 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" @@ -40,6 +44,7 @@ import ( 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/manager/status" "github.com/deckhouse/operator-helm/internal/services" "github.com/deckhouse/operator-helm/internal/source" @@ -346,6 +351,273 @@ func newFullReconciler( }), 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 source.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: source.NoChartClaim{}, + Namespaces: source.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 nelm-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) + } +} + +// 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) error { + return errors.New("service account is forbidden") +} + +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. +func TestReconcileApplicationReportsAccessSetupFailure(t *testing.T) { + app := testApplication() + + r, c := newApplicationFullReconciler(t, failingAccess{}, append(applicationFixtures(), app)...) + + reconcileApplication(t, r, app) + + settled := &helmv1alpha1.HelmApplication{} + key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} + 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") + } +} + func ociRepositoryFixture() *helmv1alpha1.HelmClusterAddonRepository { return &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, diff --git a/images/operator-helm-controller/internal/services/access_service.go b/images/operator-helm-controller/internal/services/access_service.go index 93fab3d1..ae3bf3b0 100644 --- a/images/operator-helm-controller/internal/services/access_service.go +++ b/images/operator-helm-controller/internal/services/access_service.go @@ -47,6 +47,10 @@ var _ source.AccessManager = (*AccessService)(nil) // namespace boundary: cluster-scoped resources are unreachable regardless of its // content. It is also the one object a namespace owner may edit to cut the rights // down — which is why it is created once and never reconciled afterwards. +// +// Neither the account nor the binding is watched, so an out-of-band deletion of +// either is not noticed immediately; it is repaired on the release's next +// reconcile. type AccessService struct { BaseService @@ -69,15 +73,17 @@ func (s *AccessService) EnsureAccess(ctx context.Context, rel source.Release) er return nil } + namespace := rel.TargetNamespace() + if err := s.ensureServiceAccount(ctx, rel, name); err != nil { return fmt.Errorf("ensuring service account: %w", err) } - if err := s.seedRole(ctx, rel.TargetNamespace()); err != nil { + if err := s.seedRole(ctx, namespace); err != nil { return fmt.Errorf("seeding role: %w", err) } - if err := s.ensureRoleBinding(ctx, rel, name); err != nil { + if err := s.ensureRoleBinding(ctx, rel, namespace, name); err != nil { return fmt.Errorf("ensuring role binding: %w", err) } @@ -145,25 +151,36 @@ func (s *AccessService) seedRole(ctx context.Context, namespace string) error { return client.IgnoreAlreadyExists(s.Client.Create(ctx, role)) } -// ensureRoleBinding binds the account to the namespace Role. roleRef is immutable in -// Kubernetes, so it is set only when the binding is created; the subjects and labels -// are reconciled on every pass. -func (s *AccessService) ensureRoleBinding(ctx context.Context, rel source.Release, name string) error { +// ensureRoleBinding binds the account to the namespace Role. roleRef is immutable +// in Kubernetes, so it is only ever written on create or written back unchanged; +// the subjects and labels are reconciled on every pass. +// +// A binding that already exists under our name but points at a different role is +// not ours to reuse: adding our account as its subject would grant that account +// whatever the foreign role grants. Such a binding 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 := rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: ApplicationRoleName, + } + binding := &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: rel.TargetNamespace()}, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, } _, err := controllerutil.CreateOrPatch(ctx, s.Client, binding, func() error { - binding.Labels = rel.SourceLabels() - - if binding.RoleRef.Name == "" { - binding.RoleRef = rbacv1.RoleRef{ - APIGroup: rbacv1.GroupName, - Kind: "Role", - Name: ApplicationRoleName, - } + if binding.RoleRef.Name != "" && binding.RoleRef != desiredRef { + return fmt.Errorf( + "role binding %s/%s already binds %s/%s; refusing to adopt it", + namespace, name, binding.RoleRef.Kind, binding.RoleRef.Name, + ) } + binding.Labels = rel.SourceLabels() + binding.RoleRef = desiredRef + binding.Subjects = []rbacv1.Subject{{ Kind: rbacv1.ServiceAccountKind, Name: name, diff --git a/images/operator-helm-controller/internal/services/access_service_test.go b/images/operator-helm-controller/internal/services/access_service_test.go index be5dfae4..8563e71c 100644 --- a/images/operator-helm-controller/internal/services/access_service_test.go +++ b/images/operator-helm-controller/internal/services/access_service_test.go @@ -19,6 +19,7 @@ package services import ( "context" "reflect" + "strings" "testing" corev1 "k8s.io/api/core/v1" @@ -122,6 +123,43 @@ func TestEnsureAccessLeavesAnEditedRoleAlone(t *testing.T) { } } +// TestEnsureAccessRefusesToAdoptAForeignRoleBinding pins that a binding already +// living under our name but pointing at someone else's role is reported, not +// reused: adding our account as its subject would silently grant that account +// whatever that role grants. The object is left exactly as it was — deleting a +// binding we did not create is not ours to do either. +func TestEnsureAccessRefusesToAdoptAForeignRoleBinding(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) + + err := service.EnsureAccess(context.Background(), rel) + if err == nil { + t.Fatal("a binding pointing at a foreign role must be reported, not adopted") + } + if !strings.Contains(err.Error(), "someone-elses-role") { + t.Fatalf("error %q must name the existing roleRef", err.Error()) + } + + stored := &rbacv1.RoleBinding{} + if getErr := c.Get(context.Background(), client.ObjectKeyFromObject(foreign), stored); getErr != nil { + t.Fatalf("the foreign binding must survive: %v", getErr) + } + 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) + } +} + func TestEnsureAccessRecreatesADeletedRole(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) service, c := newAccessService(t) 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 a1d7a0b5..a1f2fe27 100644 --- a/images/operator-helm-controller/internal/services/chart_claim_service.go +++ b/images/operator-helm-controller/internal/services/chart_claim_service.go @@ -38,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. diff --git a/images/operator-helm-controller/internal/services/chart_service.go b/images/operator-helm-controller/internal/services/chart_service.go index 4e942abd..8ef1ab16 100644 --- a/images/operator-helm-controller/internal/services/chart_service.go +++ b/images/operator-helm-controller/internal/services/chart_service.go @@ -19,6 +19,7 @@ package services import ( "context" "fmt" + "maps" "github.com/werf/3p-fluxcd-pkg/apis/meta" sourcev1 "github.com/werf/nelm-source-controller/api/v1" @@ -143,7 +144,13 @@ func applyHelmChartSpec(rel source.Release, repo source.Repository, existing *so setReconcileRequestAnnotations(existing) } - existing.Labels = rel.HelmChartLabels() + // 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()) ref := rel.ChartRef() existing.Spec.Chart = ref.Chart 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 e0032b7b..ce6644d8 100644 --- a/images/operator-helm-controller/internal/services/chart_service_test.go +++ b/images/operator-helm-controller/internal/services/chart_service_test.go @@ -97,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/release_service.go b/images/operator-helm-controller/internal/services/release_service.go index fa435376..40b9f8fd 100644 --- a/images/operator-helm-controller/internal/services/release_service.go +++ b/images/operator-helm-controller/internal/services/release_service.go @@ -19,6 +19,7 @@ package services import ( "context" "fmt" + "maps" "strings" "time" @@ -177,7 +178,13 @@ func applyHelmReleaseSpec(rel source.Release, existing *helmv2.HelmRelease, sour setReconcileRequestAnnotations(existing) } - existing.Labels = rel.SourceLabels() + // 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()) names := rel.InternalNames() 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..996b8c86 --- /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/werf/3p-helm-controller/api/v2" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "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/source" + "github.com/deckhouse/operator-helm/internal/utils" +) + +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, utils.InternalHelmRepository, ""); res.Status.Err != nil { + t.Fatalf("EnsureHelmRelease returned %v", res.Status.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/webhook/helmapplication/webhook.go b/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go index 34cf6f7d..6afc6e47 100644 --- a/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go +++ b/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go @@ -18,14 +18,18 @@ limitations under the License. // 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. +// 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" @@ -34,13 +38,15 @@ import ( func SetupWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr, &helmv1alpha1.HelmApplication{}). - WithValidator(&HelmApplicationWebhookValidator{}). + WithValidator(&HelmApplicationWebhookValidator{Client: mgr.GetClient()}). Complete() } var _ admission.Validator[*helmv1alpha1.HelmApplication] = (*HelmApplicationWebhookValidator)(nil) -type HelmApplicationWebhookValidator struct{} +type HelmApplicationWebhookValidator struct { + Client client.Client +} func (v *HelmApplicationWebhookValidator) ValidateCreate(_ context.Context, app *helmv1alpha1.HelmApplication) (admission.Warnings, error) { return nil, validateNotSystemNamespace(app) @@ -50,12 +56,35 @@ func (v *HelmApplicationWebhookValidator) ValidateUpdate(_ context.Context, _, n return nil, validateNotSystemNamespace(newObj) } -func (v *HelmApplicationWebhookValidator) ValidateDelete(_ context.Context, app *helmv1alpha1.HelmApplication) (admission.Warnings, error) { - if app.MaintenanceModeActivated() { - return nil, fmt.Errorf("helmapplication/%s cannot be deleted while maintenance mode is active", app.Name) +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.Client.Get(ctx, client.ObjectKey{Name: name}, namespace); err != nil { + return apierrors.IsNotFound(err) } - return nil, nil + return !namespace.DeletionTimestamp.IsZero() } func validateNotSystemNamespace(app *helmv1alpha1.HelmApplication) error { 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..a1d0700d --- /dev/null +++ b/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.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 + +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{Client: 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 +} + +// 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/templates/admision-policy.yaml b/templates/admision-policy.yaml index f54f4322..a7845224 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -35,9 +35,16 @@ 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" validations: - expression: | request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || From e26ec01688e522b17bb34dd6ec39c6d91809aa28 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 10:44:17 +0300 Subject: [PATCH 032/145] refactor(chart-values): key auxiliary resources by the repository namespace too Auxiliary source objects of every repository kind share one namespace, so the (kind, repository, chart, version) tuple stops being unique once a repository kind is namespaced: two same-named repositories in different namespaces would converge on one object. The namespace joins the hash and the readable hint; a cluster-scoped kind passes an empty namespace and its names are unchanged, which a test now pins. Signed-off-by: Ilya Drey --- .../internal/naming/naming.go | 54 ++++++++++----- .../internal/naming/naming_test.go | 67 +++++++++++++++---- .../internal/resolver/resolver.go | 8 ++- .../internal/resolver/resolver_test.go | 4 +- 4 files changed, 101 insertions(+), 32 deletions(-) diff --git a/images/chart-values-controller/internal/naming/naming.go b/images/chart-values-controller/internal/naming/naming.go index 8c96831d..66d53701 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) = 60. + 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..3b6ec09c 100644 --- a/images/chart-values-controller/internal/naming/naming_test.go +++ b/images/chart-values-controller/internal/naming/naming_test.go @@ -24,10 +24,15 @@ import ( var dns1123 = regexp.MustCompile(`^[a-z]([-a-z0-9]*[a-z0-9])?$`) -const testKind = "HelmClusterAddonRepository" +const ( + 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 +42,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 +71,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 +79,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-b03cfc6abbfd3f57"}, + {"GitHub", "Pod.Info", "6.7.1", "tmp-github-pod-info-630ac6ecf79eb66f"}, + } + + 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/resolver.go b/images/chart-values-controller/internal/resolver/resolver.go index 9816d952..08396987 100644 --- a/images/chart-values-controller/internal/resolver/resolver.go +++ b/images/chart-values-controller/internal/resolver/resolver.go @@ -68,10 +68,12 @@ const ( OutcomeValuesNotFound Outcome = "values_not_found" ) -// 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 is empty for a cluster-scoped +// repository kind. type Request struct { Kind RepositoryKind + Namespace string RepositoryName string Chart string Version string @@ -217,7 +219,7 @@ func versionDetail(version *helmv1alpha1.ChartVersion) string { // 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) + 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, diff --git a/images/chart-values-controller/internal/resolver/resolver_test.go b/images/chart-values-controller/internal/resolver/resolver_test.go index c268fe20..b9f095a8 100644 --- a/images/chart-values-controller/internal/resolver/resolver_test.go +++ b/images/chart-values-controller/internal/resolver/resolver_test.go @@ -290,7 +290,7 @@ func TestResolveHybridVersionUsesOCIRepository(t *testing.T) { 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{} @@ -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{} From ca6016b254da5b674efdede8266afa05b780b072 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 10:48:36 +0300 Subject: [PATCH 033/145] feat(chart-values): describe a repository kind by its family Reading a repository, its chart catalog and the internal objects derived from it is all a repository kind contributes to resolving values. Collecting that in one value lets the resolver stop naming the addon types directly; the addon family is the only entry so far and behaves exactly as before. Signed-off-by: Ilya Drey --- .../internal/resolver/family.go | 116 ++++++++++++++++++ .../internal/resolver/family_test.go | 108 ++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 images/chart-values-controller/internal/resolver/family.go create mode 100644 images/chart-values-controller/internal/resolver/family_test.go 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..6145523d --- /dev/null +++ b/images/chart-values-controller/internal/resolver/family.go @@ -0,0 +1,116 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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} + }, + }, +} + +// 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 and a cluster-scoped one must not be given one. +func (f repositoryFamily) requireNamespace(namespace string) error { + switch { + case f.Namespaced && namespace == "": + return fmt.Errorf("repository kind %q is namespaced: namespace is required", f.Kind) + case !f.Namespaced && namespace != "": + return fmt.Errorf("repository kind %q is cluster-scoped: namespace must be empty", f.Kind) + default: + 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..7cb785c3 --- /dev/null +++ b/images/chart-values-controller/internal/resolver/family_test.go @@ -0,0 +1,108 @@ +/* +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, + }, + } + chart := chartWithVersions("example", "podinfo", 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) + } +} From a2e2e2773c0eb1c08d3f87e68b187b9cbe5839f7 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 10:58:56 +0300 Subject: [PATCH 034/145] feat(chart-values): resolve charts of the application repositories Resolve dispatches through the family registry instead of naming the addon types, so the two application repository kinds are two entries rather than two code paths. A namespaced kind requires a namespace and a cluster-scoped one refuses it: without that check a request could silently read a repository of the wrong scope. The addon path is unchanged, its tests untouched. Signed-off-by: Ilya Drey --- .../internal/resolver/family.go | 51 +++++++++ .../internal/resolver/family_test.go | 87 +++++++++++++++ .../internal/resolver/resolver.go | 104 +++++++++++------- .../internal/resolver/resolver_test.go | 75 +++++++++++-- 4 files changed, 268 insertions(+), 49 deletions(-) diff --git a/images/chart-values-controller/internal/resolver/family.go b/images/chart-values-controller/internal/resolver/family.go index 6145523d..3f5118c2 100644 --- a/images/chart-values-controller/internal/resolver/family.go +++ b/images/chart-values-controller/internal/resolver/family.go @@ -84,6 +84,57 @@ var families = map[RepositoryKind]repositoryFamily{ 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. diff --git a/images/chart-values-controller/internal/resolver/family_test.go b/images/chart-values-controller/internal/resolver/family_test.go index 7cb785c3..0b9d87b3 100644 --- a/images/chart-values-controller/internal/resolver/family_test.go +++ b/images/chart-values-controller/internal/resolver/family_test.go @@ -24,6 +24,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apinaming "github.com/deckhouse/operator-helm/api/naming" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) @@ -106,3 +107,89 @@ func TestAddonFamilyReportsAMissingCatalogAsNotFound(t *testing.T) { 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"}, + } + namespacedChart := &helmv1alpha1.HelmApplicationChart{ + ObjectMeta: metav1.ObjectMeta{Name: apinaming.ApplicationChartName("stable", "podinfo"), 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: apinaming.ClusterApplicationChartName("shared", "podinfo")}, + 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.Fatal("a cluster-scoped kind with a namespace must be rejected") + } + 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 08396987..8caa8cdb 100644 --- a/images/chart-values-controller/internal/resolver/resolver.go +++ b/images/chart-values-controller/internal/resolver/resolver.go @@ -31,7 +31,6 @@ import ( 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,7 +40,6 @@ 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" ) @@ -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,6 +74,10 @@ 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, or the reverse. + OutcomeInvalidRequest Outcome = "invalid_request" ) // Request identifies a chart by repository kind, repository namespace, repository @@ -121,12 +133,16 @@ 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 + } + + return r.resolveChart(ctx, family, req) } // chartVersion finds the catalog entry for the requested version. It reports only @@ -139,11 +155,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.ChartVersion, *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. @@ -153,8 +167,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 } @@ -215,10 +229,12 @@ func versionDetail(version *helmv1alpha1.ChartVersion) 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) { +// 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 @@ -228,8 +244,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 } @@ -238,7 +254,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 } @@ -254,7 +270,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 } @@ -262,19 +278,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 } @@ -283,7 +299,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 { @@ -312,8 +328,8 @@ 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 } @@ -354,12 +370,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 } @@ -385,12 +402,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} } @@ -408,7 +425,8 @@ 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.ChartVersion, @@ -423,13 +441,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) @@ -448,7 +466,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 } @@ -473,12 +491,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) } @@ -490,12 +512,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 b9f095a8..41566d46 100644 --- a/images/chart-values-controller/internal/resolver/resolver_test.go +++ b/images/chart-values-controller/internal/resolver/resolver_test.go @@ -50,7 +50,7 @@ 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.ChartVersion) *helmv1alpha1.HelmClusterAddonChart { @@ -138,7 +138,7 @@ func TestChartVersion(t *testing.T) { 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) } @@ -158,7 +158,7 @@ func TestChartVersion(t *testing.T) { 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) } @@ -179,7 +179,7 @@ func TestChartVersion(t *testing.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) } @@ -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) } @@ -205,7 +205,7 @@ func TestChartVersion(t *testing.T) { 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) } @@ -286,7 +286,7 @@ 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) } @@ -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) } @@ -378,3 +378,62 @@ 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, a namespaced kind without a namespace and a cluster-scoped kind +// with one are all request errors, distinguishable from "the repository is gone". +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-scoped kind with a namespace", + req: Request{Kind: RepositoryKindHelmClusterAddon, Namespace: "team-a", RepositoryName: "example", Chart: "podinfo", Version: "6.7.1"}, + want: OutcomeInvalidRequest, + }, + { + 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) + } + }) + } +} + +// 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 +} From 0e3b04481a15b54ad8839dc9e3d11f8a34653a55 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 11:10:57 +0300 Subject: [PATCH 035/145] feat(chart-values): authorize a request by the resource its answer feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract gains a namespace, and the permission checked is a create of the resource the values would flow into: HelmClusterAddon for the addon family, HelmApplication in the request's namespace for both application kinds — the cluster-wide repository included, because its values still reach a namespaced release. SubjectAccessReview now carries the namespace; without it the API server answers a cluster-scoped question instead. Signed-off-by: Ilya Drey --- .../internal/auth/auth.go | 20 ++-- .../internal/auth/auth_test.go | 77 +++++++++++++ .../internal/server/server.go | 81 +++++++++++--- .../internal/server/server_test.go | 105 ++++++++++++++++++ 4 files changed, 258 insertions(+), 25 deletions(-) create mode 100644 images/chart-values-controller/internal/auth/auth_test.go 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..c55bbc64 --- /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.NewSimpleClientset() + + 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/server/server.go b/images/chart-values-controller/internal/server/server.go index db0147d7..52767d10 100644 --- a/images/chart-values-controller/internal/server/server.go +++ b/images/chart-values-controller/internal/server/server.go @@ -20,6 +20,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "strconv" "strings" @@ -113,6 +114,7 @@ func (s *Server) Start(ctx context.Context) error { type chartValuesRequest struct { RepositoryKind string `json:"repositoryKind"` + Namespace string `json:"namespace"` RepositoryName string `json:"repositoryName"` Chart string `json:"chart"` Version string `json:"version"` @@ -133,16 +135,26 @@ 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) { - return - } + // 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, ok := accessFor(req.RepositoryKind, req.Namespace) + if !ok { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", + fmt.Sprintf("unsupported repository kind %q", req.RepositoryKind)) + return + } + if access.Namespace == "" && requiresNamespace(req.RepositoryKind) { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "namespace is required for this repository kind") + return + } + if !s.authorize(w, r, access) { + 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,16 +185,55 @@ 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 { +// accessFor maps a repository kind to the permission that answering for it +// requires. 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) (auth.Access, bool) { + switch strings.ToLower(kind) { + case string(resolver.RepositoryKindHelmClusterAddon): + return auth.Access{ + Group: helmv1alpha1.GroupName, + Resource: helmv1alpha1.HelmClusterAddonResource, + Verb: "create", + }, true + case string(resolver.RepositoryKindHelmApplication), string(resolver.RepositoryKindHelmClusterApplication): + return auth.Access{ + Group: helmv1alpha1.GroupName, + Resource: helmv1alpha1.HelmApplicationResource, + Verb: "create", + Namespace: namespace, + }, true + default: + return auth.Access{}, false + } +} + +// requiresNamespace reports whether answering for a kind is a namespaced question. +// Both application kinds are: even the cluster-wide repository's values reach a +// HelmApplication that lives in a namespace. +func requiresNamespace(kind string) bool { + switch strings.ToLower(kind) { + case string(resolver.RepositoryKindHelmApplication), string(resolver.RepositoryKindHelmClusterApplication): + return true + default: + return false + } +} + +// authorize reviews the request's bearer token against access and reports whether +// it may proceed. On any negative outcome it writes the response itself and returns +// false. +func (s *Server) authorize(w http.ResponseWriter, r *http.Request, access auth.Access) bool { logger := log.FromContext(r.Context()) token, ok := bearerToken(r) @@ -191,11 +242,7 @@ func (s *Server) authorizeCreateHelmClusterAddon(w http.ResponseWriter, r *http. return false } - result, err := s.reviewer.Review(r.Context(), token, auth.Access{ - Group: helmv1alpha1.GroupName, - Resource: helmv1alpha1.HelmClusterAddonResource, - Verb: "create", - }) + 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 +253,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", access.Resource)) 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..4b0b449a 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,84 @@ 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) { + rec := do(t, fakeResolver{}, `{"repositoryKind":"HelmApplicationRepository","repositoryName":"stable","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", "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") +} From 5340d2143015f65749a50d4831972ebb272af297 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 11:18:13 +0300 Subject: [PATCH 036/145] fix(chart-values): let a namespace reach a cluster-scoped repository request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireNamespace rejected any namespace on a cluster-scoped repository kind, so every helmclusterapplicationrepository request 400'd (the HTTP layer requires and forwards a namespace there for authorization) and a stray namespace on an addon request, previously ignored, also started failing. The namespace is part of a chart's identity only for a namespaced family. requireNamespace now only rejects a namespaced kind without one, and Resolve normalizes the namespace to empty for a cluster-scoped family before it can reach the auxiliary resource name, the cache key, or the family's lookups — keeping one identity per chart regardless of what the caller sent. Signed-off-by: Ilya Drey --- .../internal/resolver/family.go | 14 ++--- .../internal/resolver/family_test.go | 4 +- .../internal/resolver/resolver.go | 13 ++++- .../internal/resolver/resolver_test.go | 51 +++++++++++++++++-- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/images/chart-values-controller/internal/resolver/family.go b/images/chart-values-controller/internal/resolver/family.go index 3f5118c2..6dd4d359 100644 --- a/images/chart-values-controller/internal/resolver/family.go +++ b/images/chart-values-controller/internal/resolver/family.go @@ -154,14 +154,14 @@ func specOf(spec helmv1alpha1.RepositorySpec) *repositorySpec { } // requireNamespace reports the request-shape error of a family: a namespaced kind -// needs a namespace and a cluster-scoped one must not be given one. +// 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 { - switch { - case f.Namespaced && namespace == "": + if f.Namespaced && namespace == "" { return fmt.Errorf("repository kind %q is namespaced: namespace is required", f.Kind) - case !f.Namespaced && namespace != "": - return fmt.Errorf("repository kind %q is cluster-scoped: namespace must be empty", f.Kind) - default: - return nil } + + return nil } diff --git a/images/chart-values-controller/internal/resolver/family_test.go b/images/chart-values-controller/internal/resolver/family_test.go index 0b9d87b3..193880a7 100644 --- a/images/chart-values-controller/internal/resolver/family_test.go +++ b/images/chart-values-controller/internal/resolver/family_test.go @@ -186,8 +186,8 @@ func TestRequireNamespaceRejectsTheWrongRequestShape(t *testing.T) { } addon, _ := familyFor(RepositoryKindHelmClusterAddon) - if err := addon.requireNamespace("team-a"); err == nil { - t.Fatal("a cluster-scoped kind with a namespace must be rejected") + 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 8caa8cdb..6b285529 100644 --- a/images/chart-values-controller/internal/resolver/resolver.go +++ b/images/chart-values-controller/internal/resolver/resolver.go @@ -81,8 +81,9 @@ const ( ) // Request identifies a chart by repository kind, repository namespace, repository -// name, chart name and chart version. Namespace is empty for a cluster-scoped -// repository kind. +// 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 @@ -142,6 +143,14 @@ func (r *Resolver) Resolve(ctx context.Context, req Request) (Result, error) { 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) } diff --git a/images/chart-values-controller/internal/resolver/resolver_test.go b/images/chart-values-controller/internal/resolver/resolver_test.go index 41566d46..5a89a1f5 100644 --- a/images/chart-values-controller/internal/resolver/resolver_test.go +++ b/images/chart-values-controller/internal/resolver/resolver_test.go @@ -380,8 +380,10 @@ func TestResolveArchiveVersionUsesHelmChart(t *testing.T) { } // TestResolveDispatchesOnTheRequestShape covers what the HTTP layer cannot: an -// unknown kind, a namespaced kind without a namespace and a cluster-scoped kind -// with one are all request errors, distinguishable from "the repository is gone". +// 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) @@ -401,9 +403,17 @@ func TestResolveDispatchesOnTheRequestShape(t *testing.T) { want: OutcomeInvalidRequest, }, { - name: "cluster-scoped kind with a namespace", + 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: OutcomeInvalidRequest, + 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", @@ -425,6 +435,39 @@ func TestResolveDispatchesOnTheRequestShape(t *testing.T) { } } +// 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 { From 3b5f2576a8b9ea326d1a48085333663b1851d1ac Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 11:31:31 +0300 Subject: [PATCH 037/145] fix(chart-values): restore the released response contract Commit 3d87300 regressed two response shapes when it introduced accessFor: an unrecognised repository kind now answered 400 INVALID_REQUEST instead of UNSUPPORTED_REPOSITORY_KIND, and the FORBIDDEN message named the lower-cased plural resource (e.g. "helmclusteraddons") instead of the Kubernetes kind ("HelmClusterAddon"). accessFor now also returns the display kind, kept separate from auth.Access since it exists only to word this message, not to describe a SubjectAccessReview. Signed-off-by: Ilya Drey --- .../internal/server/server.go | 30 ++++++------ .../internal/server/server_test.go | 49 +++++++++++++++++++ 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/images/chart-values-controller/internal/server/server.go b/images/chart-values-controller/internal/server/server.go index 52767d10..292784f7 100644 --- a/images/chart-values-controller/internal/server/server.go +++ b/images/chart-values-controller/internal/server/server.go @@ -138,9 +138,9 @@ func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { // 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, ok := accessFor(req.RepositoryKind, req.Namespace) + access, displayKind, ok := accessFor(req.RepositoryKind, req.Namespace) if !ok { - writeError(w, http.StatusBadRequest, "INVALID_REQUEST", + writeError(w, http.StatusBadRequest, "UNSUPPORTED_REPOSITORY_KIND", fmt.Sprintf("unsupported repository kind %q", req.RepositoryKind)) return } @@ -148,7 +148,7 @@ func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "namespace is required for this repository kind") return } - if !s.authorize(w, r, access) { + if !s.authorize(w, r, access, displayKind) { return } @@ -194,27 +194,28 @@ func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { } // accessFor maps a repository kind to the permission that answering for it -// requires. 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) (auth.Access, bool) { +// requires, plus the Kubernetes kind that permission is expressed in, for use in a +// message to the caller. 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, ok bool) { switch strings.ToLower(kind) { case string(resolver.RepositoryKindHelmClusterAddon): return auth.Access{ Group: helmv1alpha1.GroupName, Resource: helmv1alpha1.HelmClusterAddonResource, Verb: "create", - }, true + }, helmv1alpha1.HelmClusterAddonKind, true case string(resolver.RepositoryKindHelmApplication), string(resolver.RepositoryKindHelmClusterApplication): return auth.Access{ Group: helmv1alpha1.GroupName, Resource: helmv1alpha1.HelmApplicationResource, Verb: "create", Namespace: namespace, - }, true + }, helmv1alpha1.HelmApplicationKind, true default: - return auth.Access{}, false + return auth.Access{}, "", false } } @@ -232,8 +233,9 @@ func requiresNamespace(kind string) bool { // authorize reviews the request's bearer token against access and reports whether // it may proceed. On any negative outcome it writes the response itself and returns -// false. -func (s *Server) authorize(w http.ResponseWriter, r *http.Request, access auth.Access) bool { +// false. displayKind names the Kubernetes kind access.Resource stands for, for the +// FORBIDDEN message. +func (s *Server) authorize(w http.ResponseWriter, r *http.Request, access auth.Access, displayKind string) bool { logger := log.FromContext(r.Context()) token, ok := bearerToken(r) @@ -253,7 +255,7 @@ func (s *Server) authorize(w http.ResponseWriter, r *http.Request, access auth.A return false } if !result.Authorized { - writeError(w, http.StatusForbidden, "FORBIDDEN", fmt.Sprintf("not allowed to create %s", access.Resource)) + 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 4b0b449a..c5f19db7 100644 --- a/images/chart-values-controller/internal/server/server_test.go +++ b/images/chart-values-controller/internal/server/server_test.go @@ -299,3 +299,52 @@ func TestHandleInvalidRequestOutcome(t *testing.T) { } 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) + }) + } +} From 14f94fde0b7ffb69a2fd9308c2cf52a9ed842a55 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 11:33:21 +0300 Subject: [PATCH 038/145] chore(chart-values): fix stale comments and a deprecated test helper Switch auth_test.go off fake.NewSimpleClientset, which staticcheck SA1019 flags as deprecated in the pinned client-go; fake.NewClientset is a drop-in. Also correct three stale comments left behind by prior commits (the removed "or the reverse" invalid-request case, RepositoryKind's now registry-driven dispatch, an addon-specific mention inside the kind-agnostic ensureHelmChart) and a namespaced auxiliary name length comment whose arithmetic summed to 59, not the 60 it stated. Fold accessFor and requiresNamespace into one switch in server.go so a new repository kind's namespaced-ness is declared in one place; the request- shape check still runs, and still rejects an empty namespace with 400 before any SubjectAccessReview is issued. Signed-off-by: Ilya Drey --- .../internal/auth/auth_test.go | 2 +- .../internal/naming/naming.go | 2 +- .../internal/resolver/resolver.go | 6 ++-- .../internal/server/server.go | 36 +++++++------------ 4 files changed, 18 insertions(+), 28 deletions(-) diff --git a/images/chart-values-controller/internal/auth/auth_test.go b/images/chart-values-controller/internal/auth/auth_test.go index c55bbc64..8a748b20 100644 --- a/images/chart-values-controller/internal/auth/auth_test.go +++ b/images/chart-values-controller/internal/auth/auth_test.go @@ -31,7 +31,7 @@ import ( // 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.NewSimpleClientset() + clientset := fake.NewClientset() clientset.PrependReactor("create", "tokenreviews", func(k8stesting.Action) (bool, runtime.Object, error) { return true, &authnv1.TokenReview{ diff --git a/images/chart-values-controller/internal/naming/naming.go b/images/chart-values-controller/internal/naming/naming.go index 66d53701..f0361696 100644 --- a/images/chart-values-controller/internal/naming/naming.go +++ b/images/chart-values-controller/internal/naming/naming.go @@ -32,7 +32,7 @@ const ( // maxNamespacedPartLen is the same bound for a namespaced name, which carries // one part more: "tmp-" (4) + namespace (<=12) + "-" + repo (<=12) + "-" + - // chart (<=12) + "-" + hash (16) = 60. + // chart (<=12) + "-" + hash (16) = 59, within the 63-character limit. maxNamespacedPartLen = 12 ) diff --git a/images/chart-values-controller/internal/resolver/resolver.go b/images/chart-values-controller/internal/resolver/resolver.go index 6b285529..345c418f 100644 --- a/images/chart-values-controller/internal/resolver/resolver.go +++ b/images/chart-values-controller/internal/resolver/resolver.go @@ -44,7 +44,7 @@ import ( ) // 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 ( @@ -76,7 +76,7 @@ const ( 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, or the reverse. + // kind it names — a namespaced kind without a namespace. OutcomeInvalidRequest Outcome = "invalid_request" ) @@ -344,7 +344,7 @@ func (r *Resolver) ensureHelmChart(ctx context.Context, family repositoryFamily, } 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 } diff --git a/images/chart-values-controller/internal/server/server.go b/images/chart-values-controller/internal/server/server.go index 292784f7..c47665a8 100644 --- a/images/chart-values-controller/internal/server/server.go +++ b/images/chart-values-controller/internal/server/server.go @@ -138,13 +138,13 @@ func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { // 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, ok := accessFor(req.RepositoryKind, req.Namespace) + 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 access.Namespace == "" && requiresNamespace(req.RepositoryKind) { + if namespaced && req.Namespace == "" { writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "namespace is required for this repository kind") return } @@ -194,40 +194,30 @@ func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { } // 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. 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, ok bool) { +// 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, true + }, 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 + }, helmv1alpha1.HelmApplicationKind, true, true default: - return auth.Access{}, "", false - } -} - -// requiresNamespace reports whether answering for a kind is a namespaced question. -// Both application kinds are: even the cluster-wide repository's values reach a -// HelmApplication that lives in a namespace. -func requiresNamespace(kind string) bool { - switch strings.ToLower(kind) { - case string(resolver.RepositoryKindHelmApplication), string(resolver.RepositoryKindHelmClusterApplication): - return true - default: - return false + return auth.Access{}, "", false, false } } From 50ece845934bfe426114597e9396ce58727359c0 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 11:34:46 +0300 Subject: [PATCH 039/145] test(chart-values): make two frozen assertions actually bite The addon frozen-name literals in naming_test.go were computed with testKind = "HelmClusterAddonRepository", but Resolve lower-cases the kind before it ever reaches AuxResourceName, so production only ever hashes "helmclusteraddonrepository". The pinned literals guarded strings production never emits; recomputed them for the lower-cased kind. The addon-family production names themselves do not move: the namespace == "" branch of the formula is unchanged, and this only affects the test's own kind literal. The catalog-name assertions in family_test.go were tautological: the fixture objects were named with the very apinaming.*ChartName helper the family under test calls internally, so the test would still pass if the family used a different (even wrong) naming function, as long as it also matched the fixture. Named the fixtures with literal strings instead, the way api/naming/naming_test.go already does; confirmed by temporarily breaking family.go's naming call that the test now fails. Signed-off-by: Ilya Drey --- .../internal/naming/naming_test.go | 10 ++++++---- .../internal/resolver/family_test.go | 15 +++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/images/chart-values-controller/internal/naming/naming_test.go b/images/chart-values-controller/internal/naming/naming_test.go index 3b6ec09c..3f26754d 100644 --- a/images/chart-values-controller/internal/naming/naming_test.go +++ b/images/chart-values-controller/internal/naming/naming_test.go @@ -25,10 +25,12 @@ import ( var dns1123 = regexp.MustCompile(`^[a-z]([-a-z0-9]*[a-z0-9])?$`) const ( - testKind = "HelmClusterAddonRepository" + // 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" + nsKind = "helmapplicationrepository" ) func TestAuxResourceNameReadableHints(t *testing.T) { @@ -90,8 +92,8 @@ func TestAuxResourceNameClusterScopedNamesAreFrozen(t *testing.T) { version string want string }{ - {"github", "podinfo", "6.7.1", "tmp-github-podinfo-b03cfc6abbfd3f57"}, - {"GitHub", "Pod.Info", "6.7.1", "tmp-github-pod-info-630ac6ecf79eb66f"}, + {"github", "podinfo", "6.7.1", "tmp-github-podinfo-1379a792462c3a85"}, + {"GitHub", "Pod.Info", "6.7.1", "tmp-github-pod-info-4aaba5a5ae371dec"}, } for _, tc := range cases { diff --git a/images/chart-values-controller/internal/resolver/family_test.go b/images/chart-values-controller/internal/resolver/family_test.go index 193880a7..37d59eaf 100644 --- a/images/chart-values-controller/internal/resolver/family_test.go +++ b/images/chart-values-controller/internal/resolver/family_test.go @@ -24,7 +24,6 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - apinaming "github.com/deckhouse/operator-helm/api/naming" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) @@ -48,7 +47,13 @@ func TestAddonFamilyReadsTheClusterScopedRepositoryAndCatalog(t *testing.T) { InsecureSkipVerify: true, }, } - chart := chartWithVersions("example", "podinfo", helmv1alpha1.ChartVersion{Version: "6.7.1"}) + // 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"}, + Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "6.7.1"}}}, + } family, ok := familyFor(RepositoryKindHelmClusterAddon) if !ok { @@ -116,8 +121,10 @@ func TestApplicationFamiliesReadTheirOwnObjects(t *testing.T) { 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: apinaming.ApplicationChartName("stable", "podinfo"), Namespace: "team-a"}, + ObjectMeta: metav1.ObjectMeta{Name: "stable-chart-podinfo", Namespace: "team-a"}, Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "6.7.1"}}}, } cluster := &helmv1alpha1.HelmClusterApplicationRepository{ @@ -125,7 +132,7 @@ func TestApplicationFamiliesReadTheirOwnObjects(t *testing.T) { Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/charts"}, } clusterChart := &helmv1alpha1.HelmClusterApplicationChart{ - ObjectMeta: metav1.ObjectMeta{Name: apinaming.ClusterApplicationChartName("shared", "podinfo")}, + ObjectMeta: metav1.ObjectMeta{Name: "shared-chart-podinfo"}, Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "1.2.3"}}}, } From a1a58a69516e8d31c574a0a948736e078b209b64 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 11:43:21 +0300 Subject: [PATCH 040/145] feat(chart-values): grant read access to the application repositories The resolver reads the repositories and the chart catalogs of the application family the same way it reads the addon ones; the catalog versions live in the status subresource, so that is granted too. Read-only: the values controller creates nothing of these kinds. Signed-off-by: Ilya Drey --- templates/chart-values-controller/rbac-for-us.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 589085122e43559730aeff8ced283cb91e4aa3a3 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 11:48:49 +0300 Subject: [PATCH 041/145] test(e2e): add helpers for the application family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletion with a wait, a conflict-retrying update, and the derived name of an application's ServiceAccount — the three things every application suite needs. The name is derived rather than hard-coded so renaming a fixture cannot quietly turn an assertion into a no-op. Signed-off-by: Ilya Drey --- tests/e2e/internal/util/helmapplication.go | 98 ++++++++++++++++++++++ tests/e2e/internal/util/update.go | 23 +++++ 2 files changed, 121 insertions(+) create mode 100644 tests/e2e/internal/util/helmapplication.go diff --git a/tests/e2e/internal/util/helmapplication.go b/tests/e2e/internal/util/helmapplication.go new file mode 100644 index 00000000..a290bb2d --- /dev/null +++ b/tests/e2e/internal/util/helmapplication.go @@ -0,0 +1,98 @@ +/* +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" + "crypto/sha256" + "fmt" + "strings" + "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" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" +) + +// 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); the tests +// derive it rather than hard-coding one so a rename of an application under test +// does not silently stop checking anything. +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], + }, "-") +} + +func truncateNamePart(part string) string { + const limit = 18 + + if len(part) > limit { + part = part[:limit] + } + + return strings.TrimRight(part, "-") +} + +// 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 the application object is gone. +func UntilHelmApplicationDeleted(namespace, name string, timeout time.Duration) { + GinkgoHelper() + + 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) + }).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()) +} 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 +} From 59ab93fab46dc8ac6960eb7aea0b7a9b6fb572fd Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 11:55:15 +0300 Subject: [PATCH 042/145] test(e2e): pin the derived service-account name against the operator Moves ApplicationServiceAccountName out of internal/util, which cannot be unit tested because it pulls in internal/framework's cluster-config init, into a leaf package (internal/naming) that imports nothing cluster-bound. Adds a table test there and a matching case in the operator's TestDerivedName, with each test naming the other as its twin so a change to one that is not mirrored on the other fails a test instead of silently drifting. Verified both sides independently produce the same literal for DerivedName("hap", "HelmApplication", "e2e-app-ns", "e2e-test-app"): hap-e2e-app-ns-e2e-test-app-26155b312741. Signed-off-by: Ilya Drey --- .../internal/utils/name_test.go | 11 ++++ tests/e2e/internal/naming/naming.go | 63 ++++++++++++++++++ tests/e2e/internal/naming/naming_test.go | 64 +++++++++++++++++++ tests/e2e/internal/util/helmapplication.go | 30 ++------- 4 files changed, 143 insertions(+), 25 deletions(-) create mode 100644 tests/e2e/internal/naming/naming.go create mode 100644 tests/e2e/internal/naming/naming_test.go diff --git a/images/operator-helm-controller/internal/utils/name_test.go b/images/operator-helm-controller/internal/utils/name_test.go index d0f9b6f6..cdcfdd13 100644 --- a/images/operator-helm-controller/internal/utils/name_test.go +++ b/images/operator-helm-controller/internal/utils/name_test.go @@ -73,6 +73,17 @@ func TestDerivedName(t *testing.T) { 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", diff --git a/tests/e2e/internal/naming/naming.go b/tests/e2e/internal/naming/naming.go new file mode 100644 index 00000000..c78f9ee0 --- /dev/null +++ b/tests/e2e/internal/naming/naming.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 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 the cut may have left at the end, so the joined name never carries a double +// dash. +func truncateNamePart(part string) string { + if len(part) > applicationDerivedPartLimit { + part = part[:applicationDerivedPartLimit] + } + + return strings.TrimRight(part, "-") +} diff --git a/tests/e2e/internal/naming/naming_test.go b/tests/e2e/internal/naming/naming_test.go new file mode 100644 index 00000000..e1e6900f --- /dev/null +++ b/tests/e2e/internal/naming/naming_test.go @@ -0,0 +1,64 @@ +/* +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", + }, + } + + 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) + } + }) + } +} diff --git a/tests/e2e/internal/util/helmapplication.go b/tests/e2e/internal/util/helmapplication.go index a290bb2d..b5447132 100644 --- a/tests/e2e/internal/util/helmapplication.go +++ b/tests/e2e/internal/util/helmapplication.go @@ -18,9 +18,6 @@ package util import ( "context" - "crypto/sha256" - "fmt" - "strings" "time" . "github.com/onsi/ginkgo/v2" @@ -30,33 +27,16 @@ import ( "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 scheme is -// "hap---" over (kind, namespace, name); the tests -// derive it rather than hard-coding one so a rename of an application under test -// does not silently stop checking anything. +// 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 { - sum := sha256.Sum256([]byte("HelmApplication/" + namespace + "/" + name)) - - return strings.Join([]string{ - "hap", - truncateNamePart(namespace), - truncateNamePart(name), - fmt.Sprintf("%x", sum[:])[:12], - }, "-") -} - -func truncateNamePart(part string) string { - const limit = 18 - - if len(part) > limit { - part = part[:limit] - } - - return strings.TrimRight(part, "-") + return naming.ApplicationServiceAccountName(namespace, name) } // DeleteHelmApplication removes the application and waits until its internal helm From b21de1ea81c8238038bf0f182b915cd7d116aef6 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 12:00:50 +0300 Subject: [PATCH 043/145] test(e2e): cover the namespaced repository lifecycle The suite asserts what only a cluster can show: the catalog of a HelmApplicationRepository appears in the repository's own namespace and nowhere else, and a same-named repository in another namespace neither feeds it nor disturbs it when deleted. Signed-off-by: Ilya Drey --- tests/e2e/e2e_test.go | 1 + .../helmapplicationrepository/lifecycle.go | 161 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/e2e/helmapplicationrepository/lifecycle.go diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index e5323d99..5fdc65a5 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -23,6 +23,7 @@ import ( . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + _ "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/helmapplicationrepository/lifecycle.go b/tests/e2e/helmapplicationrepository/lifecycle.go new file mode 100644 index 00000000..4ba1cf7d --- /dev/null +++ b/tests/e2e/helmapplicationrepository/lifecycle.go @@ -0,0 +1,161 @@ +/* +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") + clusterCharts, err := f.OperatorClient().HelmV1alpha1(). + HelmClusterApplicationCharts(). + List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) + Expect(err).NotTo(HaveOccurred()) + Expect(clusterCharts.Items).To(BeEmpty(), + "a namespaced repository must not publish into the cluster-wide catalog") + }) + + 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() { + 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) + + 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), + ))) + }).WithTimeout(framework.ShortTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) + }) +} + +var _ = Describe("HelmApplicationRepository lifecycle", Ordered, func() { + DefineLifecycleTests("Helm", "https://stefanprodan.github.io/podinfo") + DefineLifecycleTests("OCI", "oci://ghcr.io/stefanprodan/charts/podinfo") +}) From a3c981247d59e60e47873a1957fa2565319c39ec Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 12:08:24 +0300 Subject: [PATCH 044/145] test(e2e): cover the application lifecycle, identity and isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three suites assert what only a cluster shows: the chart is installed in the application's own namespace as a service account that mounts no token and is bound to the namespace role; the operator creates nothing else of its own there — repository credentials in particular never appear; the role survives the application, an owner's narrowing of it survives a reconcile, and deleting it brings it back; and a system namespace is refused at admission. Signed-off-by: Ilya Drey --- tests/e2e/e2e_test.go | 1 + tests/e2e/helmapplication/isolation.go | 186 ++++++++++++++++++ tests/e2e/helmapplication/lifecycle.go | 171 ++++++++++++++++ tests/e2e/helmapplication/system_namespace.go | 61 ++++++ 4 files changed, 419 insertions(+) create mode 100644 tests/e2e/helmapplication/isolation.go create mode 100644 tests/e2e/helmapplication/lifecycle.go create mode 100644 tests/e2e/helmapplication/system_namespace.go diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 5fdc65a5..948251f2 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -23,6 +23,7 @@ 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" diff --git a/tests/e2e/helmapplication/isolation.go b/tests/e2e/helmapplication/isolation.go new file mode 100644 index 00000000..8c738e92 --- /dev/null +++ b/tests/e2e/helmapplication/isolation.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 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/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)) + }) + + It("should create nothing else of its own in the namespace", func() { + By("Secrets in the namespace belong to helm storage and the chart, not to the operator") + secrets, err := f.KubeClient().CoreV1().Secrets(f.NamespaceName()). + List(context.Background(), metav1.ListOptions{ + LabelSelector: apiv1alpha1.LabelManagedBy + "=" + apiv1alpha1.LabelManagedByValue, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(secrets.Items).To(BeEmpty(), + "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)) + }) + + It("should leave an edited role alone and recreate a deleted one", 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("Forcing a reconciliation of the application") + util.UpdateHelmApplication(f.NamespaceName(), appName, func(app *apiv1alpha1.HelmApplication) { + if app.Annotations == nil { + app.Annotations = map[string]string{} + } + app.Annotations[apiv1alpha1.AnnotationForceReconcile] = "true" + }) + + By("The controller must not rewrite the narrowed role") + Consistently(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(Equal(narrowed)) + }).WithTimeout(framework.ShortTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + + By("Deleting the role must bring it back with full rights") + err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + Delete(context.Background(), appRoleName, metav1.DeleteOptions{}) + Expect(err).NotTo(HaveOccurred()) + + util.UpdateHelmApplication(f.NamespaceName(), appName, func(app *apiv1alpha1.HelmApplication) { + app.Annotations[apiv1alpha1.AnnotationForceReconcile] = "again" + }) + + 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()) + }) +}) diff --git a/tests/e2e/helmapplication/lifecycle.go b/tests/e2e/helmapplication/lifecycle.go new file mode 100644 index 00000000..e48c140c --- /dev/null +++ b/tests/e2e/helmapplication/lifecycle.go @@ -0,0 +1,171 @@ +/* +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" + 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 + + labelSelector := fmt.Sprintf("app.kubernetes.io/name=%s", 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(err).To(HaveOccurred(), "the application's service account must be deleted") + + _, err = f.KubeClient().RbacV1().RoleBindings(f.NamespaceName()). + Get(context.Background(), saName, metav1.GetOptions{}) + g.Expect(err).To(HaveOccurred(), "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 belongs to the namespace and may carry the owner's edits") + }) + }) +} + +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..83d1ad93 --- /dev/null +++ b/tests/e2e/helmapplication/system_namespace.go @@ -0,0 +1,61 @@ +/* +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", 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, + }, + }, + } + } + + DescribeTable( + "should reject an application in a system namespace", + func(namespace string) { + _, err := f.OperatorClient().HelmV1alpha1(). + HelmApplications(namespace). + Create(context.Background(), newApplication(namespace), metav1.CreateOptions{}) + + 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), + ) +}) From 8ea9cb432c7466f74976ef150c61f2b0d55ca473 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 12:26:54 +0300 Subject: [PATCH 045/145] test(e2e): make the application spec discriminate The specs would have passed unchanged against a regressed controller: the pod selector matched no chart pods, a second force-reconcile write panicked on a nil annotation map, and impersonation was proven to exist rather than to be in use. They now derive the real release name, read the internal HelmRelease's service account and storage namespace, and list every secret and cluster role binding instead of a label-scoped subset. Signed-off-by: Ilya Drey --- .../internal/utils/name_test.go | 9 ++++ tests/e2e/helmapplication/isolation.go | 48 ++++++++++++++++--- tests/e2e/helmapplication/lifecycle.go | 12 +++-- tests/e2e/helmapplication/system_namespace.go | 12 ++++- tests/e2e/internal/naming/naming.go | 28 +++++++++++ tests/e2e/internal/naming/naming_test.go | 36 ++++++++++++++ tests/e2e/internal/util/helmapplication.go | 32 +++++++++++++ 7 files changed, 166 insertions(+), 11 deletions(-) diff --git a/images/operator-helm-controller/internal/utils/name_test.go b/images/operator-helm-controller/internal/utils/name_test.go index cdcfdd13..f4c12473 100644 --- a/images/operator-helm-controller/internal/utils/name_test.go +++ b/images/operator-helm-controller/internal/utils/name_test.go @@ -157,6 +157,15 @@ func TestHelmReleaseName(t *testing.T) { {"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", + }, } for _, tc := range cases { diff --git a/tests/e2e/helmapplication/isolation.go b/tests/e2e/helmapplication/isolation.go index 8c738e92..19fb53b1 100644 --- a/tests/e2e/helmapplication/isolation.go +++ b/tests/e2e/helmapplication/isolation.go @@ -21,6 +21,7 @@ import ( . "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" @@ -102,17 +103,35 @@ var _ = Describe("HelmApplication identity and isolation", Ordered, func() { 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() { - By("Secrets in the namespace belong to helm storage and the chart, not to the operator") + 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{ - LabelSelector: apiv1alpha1.LabelManagedBy + "=" + apiv1alpha1.LabelManagedByValue, - }) + List(context.Background(), metav1.ListOptions{}) Expect(err).NotTo(HaveOccurred()) - Expect(secrets.Items).To(BeEmpty(), - "repository credentials must never be projected into a consumer namespace") + 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()). @@ -129,6 +148,20 @@ var _ = Describe("HelmApplication identity and isolation", Ordered, func() { 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 leave an edited role alone and recreate a deleted one", func() { @@ -172,6 +205,9 @@ var _ = Describe("HelmApplication identity and isolation", Ordered, func() { Expect(err).NotTo(HaveOccurred()) util.UpdateHelmApplication(f.NamespaceName(), appName, func(app *apiv1alpha1.HelmApplication) { + if app.Annotations == nil { + app.Annotations = map[string]string{} + } app.Annotations[apiv1alpha1.AnnotationForceReconcile] = "again" }) diff --git a/tests/e2e/helmapplication/lifecycle.go b/tests/e2e/helmapplication/lifecycle.go index e48c140c..7af193f9 100644 --- a/tests/e2e/helmapplication/lifecycle.go +++ b/tests/e2e/helmapplication/lifecycle.go @@ -24,6 +24,7 @@ import ( . "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" @@ -47,7 +48,12 @@ func DefineLifecycleTests(repoType, repoURL string) { repoName := "e2e-app-repo-" + suffix appName := "e2e-test-app-" + suffix - labelSelector := fmt.Sprintf("app.kubernetes.io/name=%s", chartName) + // 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) @@ -149,11 +155,11 @@ func DefineLifecycleTests(repoType, repoURL string) { Eventually(func(g Gomega) { _, err := f.KubeClient().CoreV1().ServiceAccounts(moduleNS). Get(context.Background(), saName, metav1.GetOptions{}) - g.Expect(err).To(HaveOccurred(), "the application's service account must be deleted") + 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(err).To(HaveOccurred(), "the application's role binding must be deleted") + 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") diff --git a/tests/e2e/helmapplication/system_namespace.go b/tests/e2e/helmapplication/system_namespace.go index 83d1ad93..f3468682 100644 --- a/tests/e2e/helmapplication/system_namespace.go +++ b/tests/e2e/helmapplication/system_namespace.go @@ -27,7 +27,7 @@ import ( "github.com/deckhouse/operator-helm/tests/e2e/internal/framework" ) -var _ = Describe("HelmApplication system namespace restriction", func() { +var _ = Describe("HelmApplication system namespace restriction", Ordered, func() { f := framework.NewFramework("") newApplication := func(namespace string) *apiv1alpha1.HelmApplication { @@ -43,12 +43,20 @@ var _ = Describe("HelmApplication system namespace restriction", func() { } } + BeforeAll(func() { + DeferCleanup(f.After) + f.Before() + }) + DescribeTable( "should reject an application in a system namespace", func(namespace string) { - _, err := f.OperatorClient().HelmV1alpha1(). + 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")) diff --git a/tests/e2e/internal/naming/naming.go b/tests/e2e/internal/naming/naming.go index c78f9ee0..f0a06bd6 100644 --- a/tests/e2e/internal/naming/naming.go +++ b/tests/e2e/internal/naming/naming.go @@ -61,3 +61,31 @@ func truncateNamePart(part string) string { 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.HelmReleaseName("hap-"+name) in +// images/operator-helm-controller/internal/adapter/application_release.go +// (ApplicationRelease.ReleaseName). A name within the limit is used as is; a +// longer one is cut to 40 characters and suffixed with a 12-character hash of the +// full name, mirroring HelmReleaseName's own truncation branch. +// +// This is the 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. +func ApplicationReleaseName(name string) string { + full := "hap-" + name + if len(full) <= helmReleaseNameLimit { + return full + } + + sum := sha256.Sum256([]byte(full)) + + return strings.TrimRight(full[:40], "-") + "-" + fmt.Sprintf("%x", sum[:])[:12] +} diff --git a/tests/e2e/internal/naming/naming_test.go b/tests/e2e/internal/naming/naming_test.go index e1e6900f..9df12a15 100644 --- a/tests/e2e/internal/naming/naming_test.go +++ b/tests/e2e/internal/naming/naming_test.go @@ -62,3 +62,39 @@ func TestApplicationServiceAccountName(t *testing.T) { }) } } + +// 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 is used as is", + object: "e2e-test-app-helm", + want: "hap-e2e-test-app-helm", + }, + { + // 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) + } + }) + } +} diff --git a/tests/e2e/internal/util/helmapplication.go b/tests/e2e/internal/util/helmapplication.go index b5447132..89a1581c 100644 --- a/tests/e2e/internal/util/helmapplication.go +++ b/tests/e2e/internal/util/helmapplication.go @@ -24,6 +24,7 @@ import ( . "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" @@ -39,6 +40,37 @@ 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) { From ba5ec4facdf89fc47902abf41f9be814a39d2573 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 12:31:18 +0300 Subject: [PATCH 046/145] test(e2e): make the repository isolation spec discriminate The twin repository is deleted while the original must stay healthy, but nothing proved the two were ever kept apart. The spec now asserts the twin publishes its own catalog into its own namespace, that the original's catalog outlives the twin, and that the original's internal HelmRepository, named after its namespace, is still there once the twin is gone. A shared chart name means a controller pruning by label alone is still not caught; only a twin with a different chart set would. Signed-off-by: Ilya Drey --- .../internal/utils/name_test.go | 3 ++ .../helmapplicationrepository/lifecycle.go | 44 ++++++++++++++++--- tests/e2e/internal/naming/naming.go | 19 ++++++++ tests/e2e/internal/naming/naming_test.go | 33 ++++++++++++++ tests/e2e/internal/util/helmapplication.go | 15 +++++++ 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/images/operator-helm-controller/internal/utils/name_test.go b/images/operator-helm-controller/internal/utils/name_test.go index f4c12473..755eb36d 100644 --- a/images/operator-helm-controller/internal/utils/name_test.go +++ b/images/operator-helm-controller/internal/utils/name_test.go @@ -66,6 +66,9 @@ func TestDerivedName(t *testing.T) { 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", diff --git a/tests/e2e/helmapplicationrepository/lifecycle.go b/tests/e2e/helmapplicationrepository/lifecycle.go index 4ba1cf7d..a5a54c5f 100644 --- a/tests/e2e/helmapplicationrepository/lifecycle.go +++ b/tests/e2e/helmapplicationrepository/lifecycle.go @@ -108,18 +108,22 @@ func DefineLifecycleTests(repoType, repoURL string) { }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) By("No catalog object of this repository may appear cluster-wide") - clusterCharts, err := f.OperatorClient().HelmV1alpha1(). - HelmClusterApplicationCharts(). - List(context.Background(), metav1.ListOptions{LabelSelector: labelSelector}) - Expect(err).NotTo(HaveOccurred()) - Expect(clusterCharts.Items).To(BeEmpty(), - "a namespaced repository must not publish into the cluster-wide catalog") + 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() { - util.DeleteNamespace(other.Name, true, framework.LongTimeout) + if framework.IsCleanUpNeeded() { + util.DeleteNamespace(other.Name, true, framework.LongTimeout) + } }) twin := &apiv1alpha1.HelmApplicationRepository{ @@ -138,6 +142,16 @@ func DefineLifecycleTests(repoType, repoURL string) { 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) @@ -150,7 +164,23 @@ func DefineLifecycleTests(repoType, repoURL string) { 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()) + } }) }) } diff --git a/tests/e2e/internal/naming/naming.go b/tests/e2e/internal/naming/naming.go index f0a06bd6..0a1e3b96 100644 --- a/tests/e2e/internal/naming/naming.go +++ b/tests/e2e/internal/naming/naming.go @@ -89,3 +89,22 @@ func ApplicationReleaseName(name string) string { return strings.TrimRight(full[:40], "-") + "-" + 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 index 9df12a15..d794b30f 100644 --- a/tests/e2e/internal/naming/naming_test.go +++ b/tests/e2e/internal/naming/naming_test.go @@ -98,3 +98,36 @@ func TestApplicationReleaseName(t *testing.T) { }) } } + +// 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 index 89a1581c..3d428532 100644 --- a/tests/e2e/internal/util/helmapplication.go +++ b/tests/e2e/internal/util/helmapplication.go @@ -108,3 +108,18 @@ func DeleteHelmApplicationRepository(f *framework.Framework, namespace, name str 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{}) +} From 998821ec86a3c55c866a25b32eafca765183bf5c Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 12:49:25 +0300 Subject: [PATCH 047/145] chore(controller): clear the lint findings this branch introduced Two adapters needed the blank line gofumpt asks for between a one-line method and the multi-line one after it, and the catalog test's client fixture carried a variadic nothing ever passed. Signed-off-by: Ilya Drey --- .../internal/adapter/application_release.go | 1 + .../internal/adapter/cluster_application_repository.go | 2 ++ .../operator-helm-controller/internal/catalog/catalog_test.go | 3 +-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/images/operator-helm-controller/internal/adapter/application_release.go b/images/operator-helm-controller/internal/adapter/application_release.go index 0e7f01c0..73ef1278 100644 --- a/images/operator-helm-controller/internal/adapter/application_release.go +++ b/images/operator-helm-controller/internal/adapter/application_release.go @@ -88,6 +88,7 @@ func (r *ApplicationRelease) ForceReconcileRequired() bool { return r.obj.Force func (r *ApplicationRelease) IsChartStatusInfoOutdated() bool { return r.obj.IsChartStatusInfoOutdated() } + func (r *ApplicationRelease) LastAppliedValues() *apiextensionsv1.JSON { return r.obj.Status.LastAppliedValues } diff --git a/images/operator-helm-controller/internal/adapter/cluster_application_repository.go b/images/operator-helm-controller/internal/adapter/cluster_application_repository.go index d65396c7..c30a70f2 100644 --- a/images/operator-helm-controller/internal/adapter/cluster_application_repository.go +++ b/images/operator-helm-controller/internal/adapter/cluster_application_repository.go @@ -64,9 +64,11 @@ func (r *ClusterApplicationRepository) CACertificate() string { ret 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() } diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index 4ed24d94..9fae7677 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -36,7 +36,7 @@ import ( "github.com/deckhouse/operator-helm/internal/source" ) -func newClient(t *testing.T, objects ...client.Object) client.Client { +func newClient(t *testing.T) client.Client { t.Helper() scheme := runtime.NewScheme() @@ -46,7 +46,6 @@ func newClient(t *testing.T, objects ...client.Object) client.Client { return fake.NewClientBuilder(). WithScheme(scheme). - WithObjects(objects...). WithStatusSubresource(&helmv1alpha1.HelmApplicationChart{}, &helmv1alpha1.HelmClusterApplicationChart{}). WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationRepository, index.ApplicationRepositoryIndexer). WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). From 61bab9cd14caa75397ba7c70d4eab97b8b253d14 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 12:58:58 +0300 Subject: [PATCH 048/145] fix(rbac): narrow the controller's rights over the seeded role The controller only ever creates the namespace role, so reading, patching and deleting roles cluster-wide were rights it never used; role bindings keep the verbs the cached client needs for CreateOrPatch and deletion. The admission policy closed the catalog statuses against an application's own account but left the namespaced application and repository statuses open, which the seeded role reaches just as easily. Signed-off-by: Ilya Drey --- templates/admision-policy.yaml | 5 +++++ templates/operator-helm-controller/rbac-for-us.yaml | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/templates/admision-policy.yaml b/templates/admision-policy.yaml index a7845224..c7b71134 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -45,6 +45,11 @@ spec: - "helmapplicationcharts/status" - "helmclusterapplicationcharts" - "helmclusterapplicationcharts/status" + # Same reasoning for the namespaced kinds an application's account can + # reach: their spec stays open, only the status the controller owns is + # closed. + - "helmapplications/status" + - "helmapplicationrepositories/status" validations: - expression: | request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index 23bc5b7d..ab04069f 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -102,10 +102,19 @@ rules: - patch - update - watch +# The namespace role is seeded once and never read back, so create is the only +# verb the controller uses on it; escalate is what lets it grant more than it +# holds itself. - apiGroups: - rbac.authorization.k8s.io resources: - roles + verbs: + - create + - escalate +- apiGroups: + - rbac.authorization.k8s.io + resources: - rolebindings verbs: - create @@ -115,7 +124,6 @@ rules: - patch - update - watch - - escalate - bind - apiGroups: - helm.internal.operator-helm.deckhouse.io From 5f41da41182e9b6aaf956c7a0f35757a6166c45a Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 13:01:33 +0300 Subject: [PATCH 049/145] fix(controller): name a cluster-scoped object without a leading slash Making the catalog kind-agnostic put an object key into the messages a failed catalog read and write produce, and a key with no namespace renders as "/name". Those messages reach the user through the repository's Synced condition. Signed-off-by: Ilya Drey --- .../internal/catalog/catalog.go | 18 +++++++--- .../internal/catalog/catalog_test.go | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/images/operator-helm-controller/internal/catalog/catalog.go b/images/operator-helm-controller/internal/catalog/catalog.go index 59c684ad..a092561f 100644 --- a/images/operator-helm-controller/internal/catalog/catalog.go +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -70,8 +70,8 @@ func (t *typed[C, CL]) list(ctx context.Context, repo source.Repository) ([]C, e client.InNamespace(repo.Namespace()), client.MatchingLabels{helmv1alpha1.LabelRepositoryName: repo.Name()}, ); err != nil { - repoKey := client.ObjectKey{Namespace: repo.Namespace(), Name: repo.Name()} - return nil, fmt.Errorf("listing %s objects of repository %s: %w", t.cfg.Kind, repoKey, err) + 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 @@ -147,7 +147,7 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch return nil }) if err != nil { - return fmt.Errorf("creating or updating chart %s: %w", client.ObjectKeyFromObject(existing), err) + return fmt.Errorf("creating or updating chart %s: %w", describeKey(client.ObjectKeyFromObject(existing)), err) } if op != controllerutil.OperationResultNone { @@ -168,7 +168,7 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch 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", client.ObjectKeyFromObject(existing), err) + return fmt.Errorf("updating versions of chart %s: %w", describeKey(client.ObjectKeyFromObject(existing)), err) } } @@ -231,3 +231,13 @@ func (t *typed[C, CL]) Lookup(ctx context.Context, repo source.Repository, chart 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 index 9fae7677..b61f50d1 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -18,6 +18,8 @@ package catalog_test import ( "context" + "errors" + "strings" "testing" "github.com/Masterminds/semver/v3" @@ -27,6 +29,7 @@ import ( "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" @@ -223,3 +226,35 @@ func TestLookupReturnsTheCatalogObjectAndItsStatus(t *testing.T) { 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) + } +} From 49c9db7b0855e5d4c110d907e4400fa955fd7850 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 13:07:07 +0300 Subject: [PATCH 050/145] docs: document the namespace-scoped application family README(.ru).md described only the cluster-scoped addon kinds and stated that cluster-admin is required to manage the module, which is no longer true now that HelmApplication and its repository/chart kinds let a namespace owner install charts without cluster-wide rights. Add the five new kinds, correct the limitations to say who needs what, and note the two behaviors that surprise users: an installed application gets namespace-admin-equivalent rights, and system namespaces refuse HelmApplication. EXAMPLE(.ru).md gains one worked namespaced example alongside the existing cluster-scoped one. Signed-off-by: Ilya Drey --- docs/EXAMPLE.md | 37 +++++++++++++++++++++++++++++++++++++ docs/EXAMPLE.ru.md | 37 +++++++++++++++++++++++++++++++++++++ docs/README.md | 19 ++++++++++++++----- docs/README.ru.md | 19 ++++++++++++++----- 4 files changed, 102 insertions(+), 10 deletions(-) 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..ccae4f0c 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. @@ -25,10 +26,18 @@ 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. +- **HelmApplicationChart** — a Helm chart discovered in the connected HelmApplicationRepository. These resources are automatically created and updated by the controller during repository synchronization and are protected from manual changes. +- **HelmClusterApplicationRepository** — a Helm or OCI registry containing Helm charts that can be referenced by HelmApplication resources from any namespace. +- **HelmClusterApplicationChart** — a Helm chart discovered in the connected HelmClusterApplicationRepository. These resources are automatically created and updated by the controller during repository synchronization and are protected from manual changes. +- **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. ## 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 admin privileges (the `cluster-admin` role) are required to manage it. +- The application family (HelmApplication, HelmApplicationChart, HelmApplicationRepository) is namespaced: a namespace owner can create and manage these resources in their own namespace without cluster-wide rights. HelmClusterApplicationRepository and the HelmClusterApplicationChart catalog it publishes are cluster-scoped, so creating a HelmClusterApplicationRepository still requires cluster-wide rights, 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: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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. - 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..90b737a9 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -4,15 +4,16 @@ 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. @@ -25,10 +26,18 @@ weight: 10 - **HelmClusterAddonRepository** — репозиторий Helm или OCI, содержащий Helm-чарты для последующей установки в кластере. - **HelmClusterAddonChart** — Helm-чарт, обнаруженный в подключённом репозитории. Эти ресурсы создаются и обновляются контроллером автоматически при синхронизации репозиториев и защищены от изменений. - **HelmClusterAddon** — декларативное описание конкретного релиза Helm-чарта. Ресурс содержит целевую версию чарта, имя пространства имён для развёртывания и пользовательские значения параметров. +- **HelmApplicationRepository** — репозиторий Helm или OCI, на Helm-чарты которого могут ссылаться ресурсы HelmApplication из того же namespace. +- **HelmApplicationChart** — Helm-чарт, обнаруженный в подключённом репозитории HelmApplicationRepository. Эти ресурсы создаются и обновляются контроллером автоматически при синхронизации репозиториев и защищены от изменений. +- **HelmClusterApplicationRepository** — репозиторий Helm или OCI, на Helm-чарты которого могут ссылаться ресурсы HelmApplication из любого namespace. +- **HelmClusterApplicationChart** — Helm-чарт, обнаруженный в подключённом репозитории HelmClusterApplicationRepository. Эти ресурсы создаются и обновляются контроллером автоматически при синхронизации репозиториев и защищены от изменений. +- **HelmApplication** — декларативное описание установки Helm-чарта в пределах одного namespace. Релиз всегда развёртывается в namespace самого ресурса; ресурс содержит целевую версию чарта, ссылку либо на HelmApplicationRepository из того же namespace, либо на кластерный HelmClusterApplicationRepository, а также пользовательские значения параметров. ## Ограничения -- Для управления ресурсами HelmClusterAddon и HelmClusterAddonRepository требуются права администратора кластера (роль `cluster-admin`). +- Семейство аддонов (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) полностью кластерное, поэтому для управления им требуются права администратора кластера (роль `cluster-admin`). +- Семейство приложений (HelmApplication, HelmApplicationChart, HelmApplicationRepository) является namespaced: владелец namespace может создавать эти ресурсы и управлять ими в своём namespace без прав на весь кластер. HelmClusterApplicationRepository и публикуемый им каталог HelmClusterApplicationChart являются кластерными ресурсами, поэтому для создания HelmClusterApplicationRepository по-прежнему нужны права на весь кластер, но любой HelmApplication может ссылаться на уже существующий HelmClusterApplicationRepository из своего namespace. +- Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание HelmApplication без прочих прав в namespace даёт через устанавливаемый чарт тот же уровень доступа, что и права администратора namespace. +- HelmApplication нельзя создать в системном namespace (`kube-system`, `kube-public`, `kube-node-lease`, а также в любом namespace, имя которого начинается с `d8-`, включая собственный namespace модуля `d8-operator-helm`); admission-контроллер отклоняет такую попытку. - Ресурс HelmClusterAddon, ссылающийся на заданный HelmClusterAddonChart, может быть создан в кластере только в единственном экземпляре. Это обусловлено тем, что Helm-чарты могут содержать определения кастомных ресурсов (CRD), повторная установка которых на уровне кластера недопустима. Примеры использования приведены в разделе [примеры использования](example.html). From a4aa50a84eaa9acae586d69fc3c08fcb76291438 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 13:51:53 +0300 Subject: [PATCH 051/145] fix(rbac): grant bind on the role, not on the role binding Splitting the rule moved bind onto rolebindings, where it grants nothing. Binding a role the controller does not itself hold is authorized by bind on that role, so every application release failed with "attempting to grant RBAC permissions not currently held". Signed-off-by: Ilya Drey --- templates/operator-helm-controller/rbac-for-us.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index ab04069f..510497bd 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -103,8 +103,9 @@ rules: - update - watch # The namespace role is seeded once and never read back, so create is the only -# verb the controller uses on it; escalate is what lets it grant more than it -# holds itself. +# verb the controller uses on it. Both escalate and bind are verbs on the role +# itself: escalate lets the controller write a role granting more than it holds, +# bind lets it point a role binding at such a role. - apiGroups: - rbac.authorization.k8s.io resources: @@ -112,6 +113,7 @@ rules: verbs: - create - escalate + - bind - apiGroups: - rbac.authorization.k8s.io resources: @@ -124,7 +126,6 @@ rules: - patch - update - watch - - bind - apiGroups: - helm.internal.operator-helm.deckhouse.io resources: From 2f46ce09272bf4aadecdebc540b6c63053d502e5 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 14:31:24 +0300 Subject: [PATCH 052/145] chore: align the Go version of api with its consumers Both controllers already declare 1.26.3 and build api through a replace directive. The e2e module is bumped with it: it consumes api too, and its go.mod is what the e2e CI job installs its toolchain from. Signed-off-by: Ilya Drey --- api/go.mod | 2 +- tests/e2e/go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 From 389f01af9778e2678f89ef3472bcc7327a618d1d Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 14:31:35 +0300 Subject: [PATCH 053/145] chore: add a unit-test task to every module Each module gets a test task and the root aggregates them as test:unit, mirroring how lint already walks the same five modules. The e2e module runs only the packages that need no cluster. Signed-off-by: Ilya Drey --- Taskfile.yaml | 9 +++++++++ api/Taskfile.dist.yaml | 5 +++++ images/chart-values-controller/Taskfile.dist.yaml | 6 ++++++ images/hooks/Taskfile.dist.yaml | 6 ++++++ images/operator-helm-controller/Taskfile.dist.yaml | 6 ++++++ tests/e2e/Taskfile.dist.yaml | 8 ++++++++ 6 files changed, 40 insertions(+) diff --git a/Taskfile.yaml b/Taskfile.yaml index 6f928055..2e3e9a4d 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -55,6 +55,15 @@ tasks: cmds: - task: api:ci:generate + test:unit: + desc: "Run the unit tests of every module." + cmds: + - task: api:test + - task: hooks:test + - task: artifact:test + - task: chart-values-artifact:test + - task: e2e:test + test:e2e:setup: desc: "Setup environment for e2e tests." cmds: diff --git a/api/Taskfile.dist.yaml b/api/Taskfile.dist.yaml index 04a95841..4369891e 100644 --- a/api/Taskfile.dist.yaml +++ b/api/Taskfile.dist.yaml @@ -16,6 +16,11 @@ includes: prettierPattern: '../crds/*.yaml' tasks: + test: + desc: "Run the unit tests of this module." + cmds: + - go test ./... + generate: desc: "Regenerate all" cmds: diff --git a/images/chart-values-controller/Taskfile.dist.yaml b/images/chart-values-controller/Taskfile.dist.yaml index 27664273..a2e2a5b4 100644 --- a/images/chart-values-controller/Taskfile.dist.yaml +++ b/images/chart-values-controller/Taskfile.dist.yaml @@ -13,3 +13,9 @@ includes: golangciLintVersion: '{{.golangciLintVersion | default "v2.8.0"}}' golangciPaths: '{{.golangciPaths | default "./..."}}' paths: '{{.paths | default "."}}' + +tasks: + test: + desc: "Run the unit tests of this module." + cmds: + - go test ./... diff --git a/images/hooks/Taskfile.dist.yaml b/images/hooks/Taskfile.dist.yaml index f95e456d..aa21aa44 100644 --- a/images/hooks/Taskfile.dist.yaml +++ b/images/hooks/Taskfile.dist.yaml @@ -13,3 +13,9 @@ includes: golangciLintVersion: '{{.golangciLintVersion | default "v2.8.0"}}' golangciPaths: '{{.golangciPaths | default "./..."}}' paths: '{{.paths | default "."}}' + +tasks: + test: + desc: "Run the unit tests of this module." + cmds: + - go test ./... diff --git a/images/operator-helm-controller/Taskfile.dist.yaml b/images/operator-helm-controller/Taskfile.dist.yaml index 27664273..a2e2a5b4 100644 --- a/images/operator-helm-controller/Taskfile.dist.yaml +++ b/images/operator-helm-controller/Taskfile.dist.yaml @@ -13,3 +13,9 @@ includes: golangciLintVersion: '{{.golangciLintVersion | default "v2.8.0"}}' golangciPaths: '{{.golangciPaths | default "./..."}}' paths: '{{.paths | default "."}}' + +tasks: + test: + desc: "Run the unit tests of this module." + cmds: + - go test ./... diff --git a/tests/e2e/Taskfile.dist.yaml b/tests/e2e/Taskfile.dist.yaml index 8c5d9e70..9887cf42 100644 --- a/tests/e2e/Taskfile.dist.yaml +++ b/tests/e2e/Taskfile.dist.yaml @@ -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: + desc: "Run the unit tests of this module." + cmds: + - go test ./internal/naming/... + kind:ci:setup: desc: Setup kind in CI cmds: From 98b9d0a0b65798cb551ceb1862456de8ab7e109f Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 14:31:54 +0300 Subject: [PATCH 054/145] ci: run the unit tests on every pull request Nothing ran go test: the only Go job lives commented out in the lint workflow, waiting on golangci-lint support for the new toolchain, which the tests do not depend on. Signed-off-by: Ilya Drey --- .github/workflows/tests.yaml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/tests.yaml 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 From c6f07f5f787d9a4711dcad3cf2c0f7550b13bf3b Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 14:59:25 +0300 Subject: [PATCH 055/145] ci: verify the generated files are committed The task existed and was wired to no workflow, so a stale client or CRD reached review unnoticed. Signed-off-by: Ilya Drey --- .github/workflows/lint.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 7364760e..2bb3d934 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -23,6 +23,28 @@ jobs: env: DMT_METRICS_URL: ${{ secrets.DMT_METRICS_URL }} DMT_METRICS_TOKEN: ${{ secrets.DMT_METRICS_TOKEN }} + + 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 # waiting for golangci-lint for go 1.26 # lint: # runs-on: [self-hosted, large] From ffe72c00e8b9e4d2fed57f2f3d605118cdfb4cc9 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 15:02:02 +0300 Subject: [PATCH 056/145] chore: name the per-module test task after what it runs Calling it test put it one letter away from the e2e module's own tests task, which runs the opposite thing, and left the root aggregating test:unit out of tasks named test. Signed-off-by: Ilya Drey --- Taskfile.yaml | 22 +++++++++---------- api/Taskfile.dist.yaml | 2 +- .../Taskfile.dist.yaml | 2 +- images/hooks/Taskfile.dist.yaml | 2 +- .../Taskfile.dist.yaml | 2 +- tests/e2e/Taskfile.dist.yaml | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 2e3e9a4d..215b66c6 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -18,10 +18,10 @@ 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 @@ -58,11 +58,11 @@ tasks: test:unit: desc: "Run the unit tests of every module." cmds: - - task: api:test - - task: hooks:test - - task: artifact:test - - task: chart-values-artifact:test - - task: e2e:test + - task: api:test:unit + - task: hooks:test:unit + - task: operator-helm-controller:test:unit + - task: chart-values-controller:test:unit + - task: e2e:test:unit test:e2e:setup: desc: "Setup environment for e2e tests." @@ -125,16 +125,16 @@ 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 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: lint:doc-ru diff --git a/api/Taskfile.dist.yaml b/api/Taskfile.dist.yaml index 4369891e..0e71fed8 100644 --- a/api/Taskfile.dist.yaml +++ b/api/Taskfile.dist.yaml @@ -16,7 +16,7 @@ includes: prettierPattern: '../crds/*.yaml' tasks: - test: + test:unit: desc: "Run the unit tests of this module." cmds: - go test ./... diff --git a/images/chart-values-controller/Taskfile.dist.yaml b/images/chart-values-controller/Taskfile.dist.yaml index a2e2a5b4..421f2d9c 100644 --- a/images/chart-values-controller/Taskfile.dist.yaml +++ b/images/chart-values-controller/Taskfile.dist.yaml @@ -15,7 +15,7 @@ includes: paths: '{{.paths | default "."}}' tasks: - test: + test:unit: desc: "Run the unit tests of this module." cmds: - go test ./... diff --git a/images/hooks/Taskfile.dist.yaml b/images/hooks/Taskfile.dist.yaml index aa21aa44..0666436f 100644 --- a/images/hooks/Taskfile.dist.yaml +++ b/images/hooks/Taskfile.dist.yaml @@ -15,7 +15,7 @@ includes: paths: '{{.paths | default "."}}' tasks: - test: + test:unit: desc: "Run the unit tests of this module." cmds: - go test ./... diff --git a/images/operator-helm-controller/Taskfile.dist.yaml b/images/operator-helm-controller/Taskfile.dist.yaml index a2e2a5b4..421f2d9c 100644 --- a/images/operator-helm-controller/Taskfile.dist.yaml +++ b/images/operator-helm-controller/Taskfile.dist.yaml @@ -15,7 +15,7 @@ includes: paths: '{{.paths | default "."}}' tasks: - test: + test:unit: desc: "Run the unit tests of this module." cmds: - go test ./... diff --git a/tests/e2e/Taskfile.dist.yaml b/tests/e2e/Taskfile.dist.yaml index 9887cf42..42816213 100644 --- a/tests/e2e/Taskfile.dist.yaml +++ b/tests/e2e/Taskfile.dist.yaml @@ -23,7 +23,7 @@ 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: + test:unit: desc: "Run the unit tests of this module." cmds: - go test ./internal/naming/... From e3768708a6f18dc43afbc9b620f6cc60335619b7 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 18:10:25 +0300 Subject: [PATCH 057/145] fix(naming): drop a dot the truncation leaves behind A resource name is a DNS subdomain, so it may carry dots, and every truncation trimmed only a trailing dash. A cut landing on a dot put the joining dash at the start of a label, and the API server rejected every object named that way, wedging the application until it was renamed. No working name moves: a name this changes was invalid before, so no object could carry it. Signed-off-by: Ilya Drey --- api/naming/naming.go | 7 +++-- api/naming/naming_test.go | 16 ++++++++++++ .../internal/utils/name.go | 23 +++++++++------- .../internal/utils/name_test.go | 26 +++++++++++++++++++ tests/e2e/internal/naming/naming.go | 9 +++---- tests/e2e/internal/naming/naming_test.go | 9 +++++++ 6 files changed, 73 insertions(+), 17 deletions(-) diff --git a/api/naming/naming.go b/api/naming/naming.go index f6f61aba..f377118c 100644 --- a/api/naming/naming.go +++ b/api/naming/naming.go @@ -53,7 +53,10 @@ func chartObjectName(repoName, chartName string) string { var result, postfix string if len(repoName) > 20 { - result += repoName[:20] + "-chart-" + // 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(repoName[:20], "-.") + "-chart-" postfix = "-" + hash } else { result += repoName + "-chart-" @@ -66,7 +69,7 @@ func chartObjectName(repoName, chartName string) string { result += chartName } - return strings.TrimRight(result, "-") + postfix + return strings.TrimRight(result, "-.") + postfix } func hash(s string) string { diff --git a/api/naming/naming_test.go b/api/naming/naming_test.go index ca1483f3..579bc647 100644 --- a/api/naming/naming_test.go +++ b/api/naming/naming_test.go @@ -43,6 +43,14 @@ func TestHelmClusterAddonChartName(t *testing.T) { chart: "", want: "repo-chart", }, + { + // 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-da22920998cb", + }, } for _, tc := range cases { @@ -79,6 +87,14 @@ func TestApplicationChartName(t *testing.T) { chart: "", want: "repo-chart", }, + { + // 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-da22920998cb", + }, } for _, tc := range cases { diff --git a/images/operator-helm-controller/internal/utils/name.go b/images/operator-helm-controller/internal/utils/name.go index 82bbe788..7a02813f 100644 --- a/images/operator-helm-controller/internal/utils/name.go +++ b/images/operator-helm-controller/internal/utils/name.go @@ -45,7 +45,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 +64,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 +81,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 +102,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,7 +131,7 @@ 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 @@ -161,14 +161,16 @@ func DerivedName(prefix, kind, namespace, name string) string { return strings.Join(parts, "-") } -// truncatePart cuts a name part to derivedPartLimit and drops a dash the cut may -// have left at the end, so the joined name never carries a double dash. +// 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, "-") + return strings.TrimRight(part, "-.") } // helmReleaseNameLimit is the longest release name Helm accepts. @@ -178,11 +180,12 @@ const helmReleaseNameLimit = 53 // limit is used as is — that keeps every existing addon release untouched — and a // longer one is cut to 40 characters and suffixed with a 12-character hash of the // full name, so two long names that share a prefix stay distinct. The cut is -// trimmed of a trailing dash so the joined name never carries a double dash. +// trimmed of a trailing dash or dot: a dash would double up against the suffix, +// and a dot would leave the suffix starting a DNS label, which is not a valid name. func HelmReleaseName(name string) string { if len(name) <= helmReleaseNameLimit { return name } - return strings.TrimRight(name[:40], "-") + "-" + GetHash(name) + return strings.TrimRight(name[:40], "-.") + "-" + GetHash(name) } diff --git a/images/operator-helm-controller/internal/utils/name_test.go b/images/operator-helm-controller/internal/utils/name_test.go index 755eb36d..4449d664 100644 --- a/images/operator-helm-controller/internal/utils/name_test.go +++ b/images/operator-helm-controller/internal/utils/name_test.go @@ -19,6 +19,8 @@ package utils import ( "strings" "testing" + + "k8s.io/apimachinery/pkg/util/validation" ) // TestAddonInternalNamesAreFrozen pins the exact output of every addon naming @@ -119,6 +121,17 @@ func TestDerivedName(t *testing.T) { 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 { @@ -130,6 +143,9 @@ func TestDerivedName(t *testing.T) { 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) + } }) } } @@ -169,6 +185,13 @@ func TestHelmReleaseName(t *testing.T) { "hap-very-long-application-name-that-is-definitely-over-fifty-three-characters-long", "hap-very-long-application-name-that-is-d-3080981cd4e1", }, + { + // 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 { @@ -180,6 +203,9 @@ func TestHelmReleaseName(t *testing.T) { 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/tests/e2e/internal/naming/naming.go b/tests/e2e/internal/naming/naming.go index 0a1e3b96..27d8d1df 100644 --- a/tests/e2e/internal/naming/naming.go +++ b/tests/e2e/internal/naming/naming.go @@ -51,15 +51,14 @@ func ApplicationServiceAccountName(namespace, name string) string { }, "-") } -// truncateNamePart cuts a name part to applicationDerivedPartLimit and drops a -// dash the cut may have left at the end, so the joined name never carries a double -// dash. +// 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, "-") + return strings.TrimRight(part, "-.") } // helmReleaseNameLimit is the longest release name Helm accepts. It must match @@ -87,7 +86,7 @@ func ApplicationReleaseName(name string) string { sum := sha256.Sum256([]byte(full)) - return strings.TrimRight(full[:40], "-") + "-" + fmt.Sprintf("%x", sum[:])[:12] + return strings.TrimRight(full[:40], "-.") + "-" + fmt.Sprintf("%x", sum[:])[:12] } // ApplicationRepositoryInternalName reproduces the name operator-helm-controller diff --git a/tests/e2e/internal/naming/naming_test.go b/tests/e2e/internal/naming/naming_test.go index d794b30f..f6a61506 100644 --- a/tests/e2e/internal/naming/naming_test.go +++ b/tests/e2e/internal/naming/naming_test.go @@ -51,6 +51,15 @@ func TestApplicationServiceAccountName(t *testing.T) { 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 { From dc84304a82f00734a9b43afea73461b90f012d73 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 18:12:57 +0300 Subject: [PATCH 058/145] fix(naming): make the application catalog name injective Joining a repository and a chart name with a separator that may appear inside either is not injective: "abc" with "def-chart-ghi" and "abc-chart-def" with "ghi" produce one name, and the two repositories then overwrite each other's catalog object. The application families now always carry the hash of the pair. The addon family keeps its scheme: those objects are live, and the same collision there is a migration of its own. Signed-off-by: Ilya Drey --- api/naming/naming.go | 28 ++++++++++++------- api/naming/naming_test.go | 12 ++++---- .../internal/resolver/family_test.go | 4 +-- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/api/naming/naming.go b/api/naming/naming.go index f377118c..b9fcf2a3 100644 --- a/api/naming/naming.go +++ b/api/naming/naming.go @@ -25,29 +25,33 @@ import ( // HelmClusterAddonChartName derives the name of the HelmClusterAddonChart object // that mirrors one chart of a repository. func HelmClusterAddonChartName(repoName, chartName string) string { - return chartObjectName(repoName, chartName) + return chartObjectName(repoName, chartName, false) } // 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) + return chartObjectName(repoName, chartName, true) } // 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) + return chartObjectName(repoName, chartName, true) } -// chartObjectName is the single 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: the name is a truncated hash, so both -// must derive it identically. Names coincide across families on purpose — the -// objects differ in kind, and the namespaced and cluster variants live in -// different scopes, so a shared name cannot collide. -func chartObjectName(repoName, chartName string) string { +// 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 +// string, and the two repositories then fight over one catalog object. The hash of +// the pair is what separates them, so the application families always carry it. +// The addon family keeps the hash only past the truncation threshold: its objects +// are live, and moving them is a migration of its own. +func chartObjectName(repoName, chartName string, alwaysHash bool) string { hash := hash(fmt.Sprintf("%s-chart-%s", repoName, chartName)) var result, postfix string @@ -69,6 +73,10 @@ func chartObjectName(repoName, chartName string) string { result += chartName } + if alwaysHash { + postfix = "-" + hash + } + return strings.TrimRight(result, "-.") + postfix } diff --git a/api/naming/naming_test.go b/api/naming/naming_test.go index 579bc647..c049f83b 100644 --- a/api/naming/naming_test.go +++ b/api/naming/naming_test.go @@ -70,10 +70,10 @@ func TestApplicationChartName(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-aa661c3516b2", }, { name: "long names are truncated and suffixed with a hash", @@ -82,10 +82,10 @@ func TestApplicationChartName(t *testing.T) { want: "yandex-cloud-marketp-chart-cert-manager-webhook-a3ee4a8a584e", }, { - 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-4e5d8120682c", }, { // A repository name is a DNS subdomain and a chart name comes from @@ -114,10 +114,10 @@ func TestClusterApplicationChartName(t *testing.T) { want string }{ { - name: "short names are joined verbatim", + name: "short names are joined and hashed", repo: "shared", chart: "nginx", - want: "shared-chart-nginx", + want: "shared-chart-nginx-7f9acafe347b", }, { name: "long names are truncated and suffixed with a hash", diff --git a/images/chart-values-controller/internal/resolver/family_test.go b/images/chart-values-controller/internal/resolver/family_test.go index 37d59eaf..8ddeee0c 100644 --- a/images/chart-values-controller/internal/resolver/family_test.go +++ b/images/chart-values-controller/internal/resolver/family_test.go @@ -124,7 +124,7 @@ func TestApplicationFamiliesReadTheirOwnObjects(t *testing.T) { // 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", Namespace: "team-a"}, + ObjectMeta: metav1.ObjectMeta{Name: "stable-chart-podinfo-cb815671ddc8", Namespace: "team-a"}, Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "6.7.1"}}}, } cluster := &helmv1alpha1.HelmClusterApplicationRepository{ @@ -132,7 +132,7 @@ func TestApplicationFamiliesReadTheirOwnObjects(t *testing.T) { Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/charts"}, } clusterChart := &helmv1alpha1.HelmClusterApplicationChart{ - ObjectMeta: metav1.ObjectMeta{Name: "shared-chart-podinfo"}, + ObjectMeta: metav1.ObjectMeta{Name: "shared-chart-podinfo-83fd5aa25c3a"}, Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "1.2.3"}}}, } From aac1c8e243b7802fddeb04fedd3d6fe73f5cc19d Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 18:15:43 +0300 Subject: [PATCH 059/145] fix(chart-values): reject a namespace no cluster could have The required check only tested for an empty string, so a name made of spaces travelled as far as the access review and came back as a 403 or a 404, neither of which tells the caller which field was wrong. Only the namespaced kinds are checked: the addon contract still ignores the field. Signed-off-by: Ilya Drey --- .../internal/server/server.go | 14 ++++++++-- .../internal/server/server_test.go | 28 ++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/images/chart-values-controller/internal/server/server.go b/images/chart-values-controller/internal/server/server.go index c47665a8..00fa4499 100644 --- a/images/chart-values-controller/internal/server/server.go +++ b/images/chart-values-controller/internal/server/server.go @@ -26,6 +26,7 @@ import ( "strings" "time" + "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/controller-runtime/pkg/log" "github.com/deckhouse/chart-values-controller/internal/auth" @@ -144,9 +145,16 @@ func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { fmt.Sprintf("unsupported repository kind %q", req.RepositoryKind)) return } - if namespaced && req.Namespace == "" { - writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "namespace is required for this repository kind") - 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 !s.authorize(w, r, access, displayKind) { return diff --git a/images/chart-values-controller/internal/server/server_test.go b/images/chart-values-controller/internal/server/server_test.go index c5f19db7..94a94388 100644 --- a/images/chart-values-controller/internal/server/server_test.go +++ b/images/chart-values-controller/internal/server/server_test.go @@ -223,12 +223,32 @@ func assertCode(t *testing.T, body []byte, field, want string) { // 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) { - rec := do(t, fakeResolver{}, `{"repositoryKind":"HelmApplicationRepository","repositoryName":"stable","chart":"podinfo","version":"6.7.1"}`) + 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"}`, + }, + } - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", rec.Code) + 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") + }) } - assertCode(t, rec.Body.Bytes(), "code", "INVALID_REQUEST") } // TestHandleAuthorizesPerFamily pins which permission each repository kind demands: From 85020f1f53de854bb66479b164eb0955219b67a3 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 18:16:35 +0300 Subject: [PATCH 060/145] test(controller): pin that a cut release name stays distinct Nothing covered the reason the hash is there: two names longer than the limit that survive the cut identically must still get different releases, or the second application would take over the first one's. Signed-off-by: Ilya Drey --- .../internal/utils/name_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/images/operator-helm-controller/internal/utils/name_test.go b/images/operator-helm-controller/internal/utils/name_test.go index 4449d664..d48221e8 100644 --- a/images/operator-helm-controller/internal/utils/name_test.go +++ b/images/operator-helm-controller/internal/utils/name_test.go @@ -185,6 +185,19 @@ func TestHelmReleaseName(t *testing.T) { "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. From 730062f817bc684f7d59e0170c8e31955a21ecc6 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 18:24:47 +0300 Subject: [PATCH 061/145] feat(rbac): grant user-facing RBAC on the helm.deckhouse.io resources The chart shipped namespace-scoped HelmApplication/HelmApplicationRepository/ HelmApplicationChart and cluster-scoped HelmClusterApplicationRepository/ HelmClusterApplicationChart, and documented that a namespace owner could use them without cluster-wide rights, but no role in either Deckhouse role model granted anything on the API group, so an Admin/d8:use:role:admin user got Forbidden. Add ClusterRoles for both role models, following modules/101-cert-manager: user-authz (User/Admin/ClusterEditor annotations) and rbacv2 (use/manage capabilities). Write access to HelmApplication/HelmApplicationRepository is kept at Admin/admin level, never Editor, because creating a HelmApplication seeds a namespace-admin-equivalent Role; the chart catalog kinds stay read-only since only the controller writes them. Update both README files' Limitations section to say which role level grants what. Signed-off-by: Ilya Drey --- docs/README.md | 1 + docs/README.ru.md | 1 + templates/rbacv2/manage/edit.yaml | 16 +++++++ templates/rbacv2/manage/view.yaml | 15 ++++++ templates/rbacv2/use/admin.yaml | 20 ++++++++ templates/rbacv2/use/view.yaml | 18 +++++++ templates/user-authz-cluster-roles.yaml | 62 +++++++++++++++++++++++++ 7 files changed, 133 insertions(+) create mode 100644 templates/rbacv2/manage/edit.yaml create mode 100644 templates/rbacv2/manage/view.yaml create mode 100644 templates/rbacv2/use/admin.yaml create mode 100644 templates/rbacv2/use/view.yaml create mode 100644 templates/user-authz-cluster-roles.yaml diff --git a/docs/README.md b/docs/README.md index ccae4f0c..737d8c13 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,7 @@ The following custom resources are used to manage Helm charts in the module: - The application family (HelmApplication, HelmApplicationChart, HelmApplicationRepository) is namespaced: a namespace owner can create and manage these resources in their own namespace without cluster-wide rights. HelmClusterApplicationRepository and the HelmClusterApplicationChart catalog it publishes are cluster-scoped, so creating a HelmClusterApplicationRepository still requires cluster-wide rights, 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: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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. +- The module grants no more than read access to a namespace user by default: the `User` role (or, in the new role model, the `viewer` capability from this module's `d8:use:capability:module:operator-helm:view` ClusterRole) gives `get`/`list`/`watch` on all five resources above. Creating or modifying HelmApplication and HelmApplicationRepository requires the `Admin` role (or the `admin` capability from `d8:use:capability:module:operator-helm:admin`) — deliberately kept out of `Editor`, since it carries namespace-admin-equivalent rights, as explained above. Creating or modifying HelmClusterApplicationRepository requires the `ClusterEditor` role (or, in the new role model, the cluster-wide `manager` permission granted through `d8:manage:permission:module:operator-helm:edit`, whose `view` counterpart also covers read access to HelmClusterApplicationRepository and HelmClusterApplicationChart). - 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 90b737a9..00d49d1f 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -38,6 +38,7 @@ weight: 10 - Семейство приложений (HelmApplication, HelmApplicationChart, HelmApplicationRepository) является namespaced: владелец namespace может создавать эти ресурсы и управлять ими в своём namespace без прав на весь кластер. HelmClusterApplicationRepository и публикуемый им каталог HelmClusterApplicationChart являются кластерными ресурсами, поэтому для создания HelmClusterApplicationRepository по-прежнему нужны права на весь кластер, но любой HelmApplication может ссылаться на уже существующий HelmClusterApplicationRepository из своего namespace. - Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание HelmApplication без прочих прав в namespace даёт через устанавливаемый чарт тот же уровень доступа, что и права администратора namespace. - HelmApplication нельзя создать в системном namespace (`kube-system`, `kube-public`, `kube-node-lease`, а также в любом namespace, имя которого начинается с `d8-`, включая собственный namespace модуля `d8-operator-helm`); admission-контроллер отклоняет такую попытку. +- По умолчанию модуль даёт пользователю namespace не больше чем доступ на чтение: роль `User` (или, в новой модели ролей, capability `viewer` из ClusterRole `d8:use:capability:module:operator-helm:view` модуля) даёт `get`/`list`/`watch` на все пять ресурсов выше. Для создания или изменения HelmApplication и HelmApplicationRepository требуется роль `Admin` (или capability `admin` из `d8:use:capability:module:operator-helm:admin`) — она намеренно не входит в `Editor`, поскольку даёт права, равносильные правам администратора namespace, как описано выше. Для создания или изменения HelmClusterApplicationRepository требуется роль `ClusterEditor` (или, в новой модели ролей, кластерное право `manager`, выдаваемое через `d8:manage:permission:module:operator-helm:edit`, парная роль `view` которого также даёт доступ на чтение к HelmClusterApplicationRepository и HelmClusterApplicationChart). - Ресурс HelmClusterAddon, ссылающийся на заданный HelmClusterAddonChart, может быть создан в кластере только в единственном экземпляре. Это обусловлено тем, что Helm-чарты могут содержать определения кастомных ресурсов (CRD), повторная установка которых на уровне кластера недопустима. Примеры использования приведены в разделе [примеры использования](example.html). diff --git a/templates/rbacv2/manage/edit.yaml b/templates/rbacv2/manage/edit.yaml new file mode 100644 index 00000000..5be57562 --- /dev/null +++ b/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,16 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "rbac.deckhouse.io/namespace" (printf "d8-%s" .Chart.Name) "rbac.deckhouse.io/aggregate-to-kubernetes-as" "manager")) | nindent 2 }} + name: d8:manage:permission:module:{{ .Chart.Name }}:edit +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusterapplicationrepositories + verbs: + - create + - delete + - deletecollection + - patch + - update diff --git a/templates/rbacv2/manage/view.yaml b/templates/rbacv2/manage/view.yaml new file mode 100644 index 00000000..1d24e2ac --- /dev/null +++ b/templates/rbacv2/manage/view.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "rbac.deckhouse.io/namespace" (printf "d8-%s" .Chart.Name) "rbac.deckhouse.io/aggregate-to-kubernetes-as" "viewer")) | nindent 2 }} + name: d8:manage:permission:module:{{ .Chart.Name }}:view +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusterapplicationrepositories + - helmclusterapplicationcharts + verbs: + - get + - list + - watch diff --git a/templates/rbacv2/use/admin.yaml b/templates/rbacv2/use/admin.yaml new file mode 100644 index 00000000..98c54270 --- /dev/null +++ b/templates/rbacv2/use/admin.yaml @@ -0,0 +1,20 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "use" "rbac.deckhouse.io/aggregate-to-kubernetes-as" "admin")) | nindent 2 }} + name: d8:use:capability:module:{{ .Chart.Name }}:admin +rules: +# Creating a HelmApplication or HelmApplicationRepository is namespace-admin equivalent +# (see the controller's seeded Role), so write access aggregates only to admin, never +# to a namespace-scoped editor. +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplications + - helmapplicationrepositories + verbs: + - create + - delete + - deletecollection + - patch + - update diff --git a/templates/rbacv2/use/view.yaml b/templates/rbacv2/use/view.yaml new file mode 100644 index 00000000..abe70dbc --- /dev/null +++ b/templates/rbacv2/use/view.yaml @@ -0,0 +1,18 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "use" "rbac.deckhouse.io/aggregate-to-kubernetes-as" "viewer")) | nindent 2 }} + name: d8:use:capability:module:{{ .Chart.Name }}:view +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplications + - helmapplicationrepositories + - helmapplicationcharts + - helmclusterapplicationrepositories + - helmclusterapplicationcharts + verbs: + - get + - list + - watch diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml new file mode 100644 index 00000000..657d6252 --- /dev/null +++ b/templates/user-authz-cluster-roles.yaml @@ -0,0 +1,62 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: + user-authz.deckhouse.io/access-level: User + name: d8:user-authz:{{ .Chart.Name }}:user + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplications + - helmapplicationrepositories + - helmapplicationcharts + - helmclusterapplicationrepositories + - 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: +# Creating a HelmApplication or HelmApplicationRepository is namespace-admin equivalent +# (see the controller's seeded Role), so write access starts at Admin, not Editor. +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplications + - helmapplicationrepositories + verbs: + - create + - delete + - deletecollection + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: + user-authz.deckhouse.io/access-level: ClusterEditor + name: d8:user-authz:{{ .Chart.Name }}:cluster-editor + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusterapplicationrepositories + verbs: + - create + - delete + - deletecollection + - patch + - update From 1b336c33ca0580d0ed715ab1725833109f637664 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 18:25:22 +0300 Subject: [PATCH 062/145] fix(controller): don't delete or clobber a role binding we may not own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CleanupAccess deleted the RoleBinding under the derived name outright, even though the same name is fully computable by anyone and the create path (ensureRoleBinding) already refuses to adopt a binding pointing at a foreign role. Deletion now reads the binding first and only removes it when it carries our roleRef and our managed-by label; a binding that isn't ours is left alone and no longer blocks the rest of the cleanup. The ServiceAccount is not guarded the same way: it lives in the operator's own namespace, not a namespace any application owner can write to, so the exposure the RoleBinding has does not apply to it. While in the same code, ensureServiceAccount and ensureRoleBinding now merge their labels with maps.Copy instead of replacing the map wholesale, matching how release_service.go and chart_service.go treat labels a policy engine or cost allocator may have added — the RoleBinding especially lives in the user's own namespace, where such labels are most likely to land. Signed-off-by: Ilya Drey --- .../internal/services/access_service.go | 59 ++++++++++++-- .../internal/services/access_service_test.go | 77 +++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/images/operator-helm-controller/internal/services/access_service.go b/images/operator-helm-controller/internal/services/access_service.go index ae3bf3b0..7a7ce1a7 100644 --- a/images/operator-helm-controller/internal/services/access_service.go +++ b/images/operator-helm-controller/internal/services/access_service.go @@ -19,6 +19,7 @@ package services import ( "context" "fmt" + "maps" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -99,7 +100,7 @@ func (s *AccessService) CleanupAccess(ctx context.Context, rel source.Release) e } binding := types.NamespacedName{Namespace: rel.TargetNamespace(), Name: name} - if err := s.ensureResourceDeleted(ctx, binding, &rbacv1.RoleBinding{}); err != nil { + if err := s.ensureOwnedRoleBindingDeleted(ctx, binding); err != nil { return fmt.Errorf("deleting role binding: %w", err) } @@ -120,7 +121,13 @@ func (s *AccessService) ensureServiceAccount(ctx context.Context, rel source.Rel } _, err := controllerutil.CreateOrPatch(ctx, s.Client, account, func() error { - account.Labels = rel.SourceLabels() + // 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 @@ -151,6 +158,15 @@ func (s *AccessService) seedRole(ctx context.Context, namespace string) error { return client.IgnoreAlreadyExists(s.Client.Create(ctx, role)) } +// 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. roleRef is immutable // in Kubernetes, so it is only ever written on create or written back unchanged; // the subjects and labels are reconciled on every pass. @@ -160,17 +176,16 @@ func (s *AccessService) seedRole(ctx context.Context, namespace string) error { // whatever the foreign role grants. Such a binding 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 := rbacv1.RoleRef{ - APIGroup: rbacv1.GroupName, - Kind: "Role", - Name: ApplicationRoleName, - } + desiredRef := applicationRoleRef() binding := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, } _, err := controllerutil.CreateOrPatch(ctx, s.Client, binding, func() error { + // roleRef.Name is required by API validation, so it is empty only here: a + // fresh object about to be created. Anything already stored carries it, so a + // mismatch here can only mean an existing binding that points elsewhere. if binding.RoleRef.Name != "" && binding.RoleRef != desiredRef { return fmt.Errorf( "role binding %s/%s already binds %s/%s; refusing to adopt it", @@ -178,7 +193,13 @@ func (s *AccessService) ensureRoleBinding(ctx context.Context, rel source.Releas ) } - binding.Labels = rel.SourceLabels() + // 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()) binding.RoleRef = desiredRef binding.Subjects = []rbacv1.Subject{{ @@ -192,3 +213,25 @@ func (s *AccessService) ensureRoleBinding(ctx context.Context, rel source.Releas return err } + +// ensureOwnedRoleBindingDeleted deletes the role binding at nn only when it is +// ours to delete: it carries our roleRef and 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 reason ensureRoleBinding refuses to adopt a foreign +// binding on create. 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 binding.RoleRef != applicationRoleRef() { + return nil + } + if binding.Labels[helmv1alpha1.LabelManagedBy] != helmv1alpha1.LabelManagedByValue { + 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 index 8563e71c..d8bd0348 100644 --- a/images/operator-helm-controller/internal/services/access_service_test.go +++ b/images/operator-helm-controller/internal/services/access_service_test.go @@ -179,6 +179,56 @@ func TestEnsureAccessRecreatesADeletedRole(t *testing.T) { } } +// 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 err := service.EnsureAccess(context.Background(), rel); err != nil { + t.Fatalf("first EnsureAccess returned %v", 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 err := service.EnsureAccess(context.Background(), rel); err != nil { + t.Fatalf("second EnsureAccess returned %v", 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) @@ -227,3 +277,30 @@ func TestCleanupAccessRemovesTheAccountAndBindingButKeepsTheRole(t *testing.T) { var _ source.AccessManager = service } + +// 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) + } +} From a61e82148d48438ef001b84d163a52d05acaade4 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 18:26:10 +0300 Subject: [PATCH 063/145] fix(controller): stop caching service accounts and role bindings AccessService reaches ServiceAccounts and RoleBindings through mgr.GetClient(), and main.go set no cache options for either kind. A cached typed Get starts an informer for its kind (client.New's doc comment and newClient in controller-runtime@v0.23.1's pkg/client/client.go), and the ClusterRole grants cluster-wide list/watch on both, so every ServiceAccount and RoleBinding in the cluster would end up held in the manager's cache. Both kinds are now listed in ctrl.Options.Client.Cache.DisableFor, the same mechanism pkg/cluster/cluster.go wires up for the manager's own cached client; Get on them now always reaches the API server directly. ClaimService solves the same problem differently, with its own mgr.GetAPIReader() bypassing the cache read path entirely; that shape does not fit AccessService's use of controllerutil.CreateOrPatch, which only takes one client for both the read and the write. The AccessService doc comment is corrected to say what is and isn't watched: an informer still exists for once-cached types even though nothing here triggers a reconcile from it, which no longer applies now that both kinds are excluded from the cache. Signed-off-by: Ilya Drey --- .../cmd/operator-helm-controller/main.go | 13 +++++++++++++ .../internal/services/access_service.go | 8 +++++--- 2 files changed, 18 insertions(+), 3 deletions(-) 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 43d97eff..17e22b3e 100644 --- a/images/operator-helm-controller/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-controller/cmd/operator-helm-controller/main.go @@ -22,10 +22,13 @@ import ( helmv2 "github.com/werf/3p-helm-controller/api/v2" sourcev1 "github.com/werf/nelm-source-controller/api/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" "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/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" @@ -75,6 +78,16 @@ func main() { HealthProbeBindAddress: healthProbeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "operator-helm-controller.helm.deckhouse.io", + Client: client.Options{ + // AccessService reads ServiceAccounts and RoleBindings only to reconcile + // the one object its own release names; nothing watches either kind. The + // ClusterRole nonetheless grants cluster-wide list/watch on both, and a + // cached typed Get starts an informer for its kind, so without this every + // ServiceAccount and RoleBinding in the cluster would be held in memory. + Cache: &client.CacheOptions{ + DisableFor: []client.Object{&corev1.ServiceAccount{}, &rbacv1.RoleBinding{}}, + }, + }, }) if err != nil { logger.Error(err, "unable to create manager") diff --git a/images/operator-helm-controller/internal/services/access_service.go b/images/operator-helm-controller/internal/services/access_service.go index 7a7ce1a7..1474ab75 100644 --- a/images/operator-helm-controller/internal/services/access_service.go +++ b/images/operator-helm-controller/internal/services/access_service.go @@ -49,9 +49,11 @@ var _ source.AccessManager = (*AccessService)(nil) // content. It is also the one object a namespace owner may edit to cut the rights // down — which is why it is created once and never reconciled afterwards. // -// Neither the account nor the binding is watched, so an out-of-band deletion of -// either is not noticed immediately; it is repaired on the release's next -// reconcile. +// Neither the account nor the binding triggers a reconcile on its own: nothing +// watches either kind, so an out-of-band deletion of either is not noticed +// immediately and is repaired on the release's next reconcile. Both are also +// excluded from the manager's client cache (see cmd/operator-helm-controller), +// so reading them here never starts a cluster-wide informer for the kind. type AccessService struct { BaseService From 46b40adc09d9ba992f3eed8217fb6f27eeae9f25 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 18:26:34 +0300 Subject: [PATCH 064/145] fix(controller): stop namespace_service doc overclaiming its scope NamespaceService's doc comment said it "creates the namespace an addon deploys into," but the type implements the family-agnostic source.TargetNamespaceEnsurer and knows nothing about addons. Reworded to describe what the type actually does, noting that only the addon controller wires it in today because the other families deploy into a namespace that must already exist. Signed-off-by: Ilya Drey --- .../internal/services/namespace_service.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/images/operator-helm-controller/internal/services/namespace_service.go b/images/operator-helm-controller/internal/services/namespace_service.go index ef02748c..4ed481c4 100644 --- a/images/operator-helm-controller/internal/services/namespace_service.go +++ b/images/operator-helm-controller/internal/services/namespace_service.go @@ -30,9 +30,11 @@ import ( var _ source.TargetNamespaceEnsurer = (*NamespaceService)(nil) -// NamespaceService creates the namespace an addon deploys into when it does not -// exist yet. It never modifies an existing namespace: the namespace belongs to -// whoever created it. +// 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 { client client.Client } From 39c4d64227c53627520a456705a5a45812ef51c0 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 18:26:53 +0300 Subject: [PATCH 065/145] test(webhook): cover ValidateCreate/ValidateUpdate for system namespaces ValidateCreate and ValidateUpdate had no direct unit coverage of the system-namespace guard, even though the reconciler's backstop and an e2e spec exercise the same rule. Adds table cases for a d8- namespace, kube-system, and an ordinary namespace against both entry points, in the file's existing style. Signed-off-by: Ilya Drey --- .../webhook/helmapplication/webhook_test.go | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go b/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go index a1d0700d..f1386b84 100644 --- a/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go +++ b/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go @@ -72,6 +72,63 @@ func namespaceFixture(name string, terminating bool) *corev1.Namespace { 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) { From fbd0ead3d4bbb25968af6f5226c56242d38dad8c Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 19:51:34 +0300 Subject: [PATCH 066/145] fix(naming): make the addon chart catalog name injective too chartObjectName joined a repository and chart name with a separator that may appear inside either, and only hashed the pair past a truncation threshold. The application families already carry the hash unconditionally; the addon family kept the old scheme even though it is exposed to the same collision. Drop the alwaysHash parameter so every family shares one rule again. Signed-off-by: Ilya Drey --- api/naming/naming.go | 22 ++++++------------ api/naming/naming_test.go | 23 +++++++++++++++---- .../internal/resolver/family_test.go | 2 +- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/api/naming/naming.go b/api/naming/naming.go index b9fcf2a3..bb2c67ab 100644 --- a/api/naming/naming.go +++ b/api/naming/naming.go @@ -25,20 +25,20 @@ import ( // HelmClusterAddonChartName derives the name of the HelmClusterAddonChart object // that mirrors one chart of a repository. func HelmClusterAddonChartName(repoName, chartName string) string { - return chartObjectName(repoName, chartName, false) + 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, true) + 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, true) + return chartObjectName(repoName, chartName) } // chartObjectName is the naming scheme behind every chart catalog kind. It lives in @@ -48,36 +48,28 @@ func ClusterApplicationChartName(repoName, chartName string) string { // 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 // string, and the two repositories then fight over one catalog object. The hash of -// the pair is what separates them, so the application families always carry it. -// The addon family keeps the hash only past the truncation threshold: its objects -// are live, and moving them is a migration of its own. -func chartObjectName(repoName, chartName string, alwaysHash bool) string { +// the pair is what separates them, so every family always carries it. +func chartObjectName(repoName, chartName string) string { hash := hash(fmt.Sprintf("%s-chart-%s", repoName, chartName)) - var result, postfix string + var result string if len(repoName) > 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(repoName[:20], "-.") + "-chart-" - postfix = "-" + hash } else { result += repoName + "-chart-" } if len(chartName) > 20 { result += chartName[:20] - postfix = "-" + hash } else { result += chartName } - if alwaysHash { - postfix = "-" + hash - } - - return strings.TrimRight(result, "-.") + postfix + return strings.TrimRight(result, "-.") + "-" + hash } func hash(s string) string { diff --git a/api/naming/naming_test.go b/api/naming/naming_test.go index c049f83b..7fbd2a19 100644 --- a/api/naming/naming_test.go +++ b/api/naming/naming_test.go @@ -26,10 +26,10 @@ 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-aa661c3516b2", }, { name: "long names are truncated and suffixed with a hash", @@ -38,10 +38,10 @@ func TestHelmClusterAddonChartName(t *testing.T) { want: "yandex-cloud-marketp-chart-cert-manager-webhook-a3ee4a8a584e", }, { - 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-4e5d8120682c", }, { // A repository name is a DNS subdomain and a chart name comes from @@ -157,3 +157,18 @@ func TestChartNameSchemeIsShared(t *testing.T) { t.Fatalf("ClusterApplicationChartName = %q, want the shared scheme result %q", got, addon) } } + +// TestHelmClusterAddonChartNameNoLongerCollidesOnATrailingDot pins that the addon +// family closed its collision gap too. Under its old scheme (a hash only past the +// truncation threshold), the two names below both trimmed to "foo-chart-bar" and +// collided: the trailing dot only vanishes from the untrimmed pair after it is +// already inside the joined string. The hash is computed from that untrimmed pair, +// so making it unconditional is what tells the two names apart now. +func TestHelmClusterAddonChartNameNoLongerCollidesOnATrailingDot(t *testing.T) { + withDot := HelmClusterAddonChartName("foo", "bar.") + withoutDot := HelmClusterAddonChartName("foo", "bar") + + if withDot == withoutDot { + t.Fatalf("HelmClusterAddonChartName(%q, %q) = %q, collides with (%q, %q)", "foo", "bar.", withDot, "foo", "bar") + } +} diff --git a/images/chart-values-controller/internal/resolver/family_test.go b/images/chart-values-controller/internal/resolver/family_test.go index 8ddeee0c..6591efd2 100644 --- a/images/chart-values-controller/internal/resolver/family_test.go +++ b/images/chart-values-controller/internal/resolver/family_test.go @@ -51,7 +51,7 @@ func TestAddonFamilyReadsTheClusterScopedRepositoryAndCatalog(t *testing.T) { // 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"}, + ObjectMeta: metav1.ObjectMeta{Name: "example-chart-podinfo-aa661c3516b2"}, Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "6.7.1"}}}, } From 9173ff66cc53aa0a0836b9effb21fb78cc2add76 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 19:51:54 +0300 Subject: [PATCH 067/145] feat(controller): migrate addon chart catalog objects to their new name The addon naming fix moves every catalog object; a plain rename would strand a legacy object whose chart a consumer still uses (pruning keeps it) and would lose the removed-from-repository version that object's status carried. Reconcile now finds such a leftover by its chart label in the same listing it already fetches for pruning, seeds the new object's status from it once on creation, and deletes the leftover unconditionally once its chart is reconciled under the new name. Every other object keeps today's pruning behavior. Signed-off-by: Ilya Drey --- .../internal/catalog/catalog.go | 70 ++++- .../internal/catalog/catalog_test.go | 253 ++++++++++++++++++ 2 files changed, 314 insertions(+), 9 deletions(-) diff --git a/images/operator-helm-controller/internal/catalog/catalog.go b/images/operator-helm-controller/internal/catalog/catalog.go index a092561f..a8046267 100644 --- a/images/operator-helm-controller/internal/catalog/catalog.go +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -122,9 +122,39 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch logger := log.FromContext(ctx) desired := make(map[string]struct{}, len(charts)) + desiredNameByChart := make(map[string]string, len(charts)) for _, chart := range charts { name := t.cfg.ObjectName(repo.Name(), chart.Name) + desired[name] = struct{}{} + desiredNameByChart[chart.Name] = name + } + + // The pruning loop below needs this same listing, so it is fetched once, before + // any object is created or patched: that is also what lets the loop find a chart + // object still sitting under an earlier naming scheme, for the migration below. + existingCharts, err := t.list(ctx, repo) + if err != nil { + return fmt.Errorf("listing charts for pruning: %w", err) + } + + // TRANSITIONAL: chartObjectName became injective for every catalog kind, moving + // every object to a new name. legacyByChart maps a chart being reconciled to one + // object still sitting under its old name, found by the chart label rather than + // by recomputing the old scheme, so this also covers any earlier scheme. Remove + // this map and its two uses below, and the "an earlier naming scheme" branch in + // the pruning loop, once every cluster has synchronized under the new scheme at + // least once. + legacyByChart := make(map[string]C, len(existingCharts)) + for _, existing := range existingCharts { + chartName := existing.GetLabels()[helmv1alpha1.LabelChartName] + if wantName, reconciling := desiredNameByChart[chartName]; reconciling && existing.GetName() != wantName { + legacyByChart[chartName] = existing + } + } + + for _, chart := range charts { + name := desiredNameByChart[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. @@ -132,8 +162,6 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch 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()), @@ -160,29 +188,53 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch } base := existing.DeepCopyObject().(C) - status := t.cfg.Status(existing) + + // TRANSITIONAL: a freshly created object starts with an empty status, which + // would otherwise make mergeChartVersions forget a version a consumer still + // references. Seeding only the "current" input mergeChartVersions reads + // (base above must still mirror the object's real, empty server state, or + // the status patch below would not carry the seeded fields at all) replays + // that protection once, from whatever the object carried under its old name. + currentVersions := status.Versions + if op == controllerutil.OperationResultCreated { + if legacy, ok := legacyByChart[chart.Name]; ok { + currentVersions = t.cfg.Status(legacy).Versions + } + } + if len(chart.Versions) > 0 { status.IconURL = chart.Versions[0].IconURL } - status.Versions = mergeChartVersions(chart.Versions, status.Versions, inUse) + status.Versions = mergeChartVersions(chart.Versions, currentVersions, 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 _, reconciling := desiredNameByChart[chartName]; reconciling { + // TRANSITIONAL: this object's chart now lives under the name reconciled + // above, so it is a leftover of an earlier naming scheme rather than a + // chart the repository stopped offering. The in-use check below exists to + // protect a chart a consumer still needs, but the consumer now resolves + // to the new name, so this leftover is deleted unconditionally. + logger.Info("Deleting a chart object left over from an earlier naming scheme", "kind", t.cfg.Kind, "name", chart.GetName(), "chart", chartName) + + if err := client.IgnoreNotFound(t.client.Delete(ctx, chart)); err != nil { + return fmt.Errorf("deleting a chart object from an earlier naming scheme: %w", err) + } + + continue + } + 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 diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index b61f50d1..03544484 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -62,6 +62,71 @@ func applicationRepo(namespace, name string) source.Repository { }) } +// 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() + + scheme := runtime.NewScheme() + if err := helmv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering helm scheme: %v", err) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + 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 { + 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 { + 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}, + } +} + func chart(name, version string) repoclient.Chart { return repoclient.Chart{ Name: name, @@ -258,3 +323,191 @@ func TestListErrorNamesAClusterScopedRepositoryWithoutALeadingSlash(t *testing.T 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 := cat.Reconcile(context.Background(), repo, []repoclient.Chart{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 := cat.Reconcile(context.Background(), repo, []repoclient.Chart{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 := cat.Reconcile(context.Background(), repo, []repoclient.Chart{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 := cat.Reconcile(context.Background(), repo, []repoclient.Chart{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 := cat.Reconcile(context.Background(), repo, []repoclient.Chart{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) + } +} From de8aa3d290b916cd2adf629aeaddc095fe3c7de7 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 19:55:15 +0300 Subject: [PATCH 068/145] fix(naming): hash the pair, not the ambiguous join Making the hash unconditional did not separate anything: it was taken over the readable name, which is exactly the string whose ambiguity it was meant to resolve, so a colliding pair produced a colliding hash too. It now covers the two parts joined by a byte no object name can hold. Signed-off-by: Ilya Drey --- api/naming/naming.go | 8 ++-- api/naming/naming_test.go | 46 +++++++++++-------- .../internal/resolver/family_test.go | 6 +-- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/api/naming/naming.go b/api/naming/naming.go index bb2c67ab..a266bedd 100644 --- a/api/naming/naming.go +++ b/api/naming/naming.go @@ -47,10 +47,12 @@ func ClusterApplicationChartName(repoName, chartName string) string { // // 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 -// string, and the two repositories then fight over one catalog object. The hash of -// the pair is what separates them, so every family always carries it. +// 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. func chartObjectName(repoName, chartName string) string { - hash := hash(fmt.Sprintf("%s-chart-%s", repoName, chartName)) + hash := hash(repoName + "\x00" + chartName) var result string diff --git a/api/naming/naming_test.go b/api/naming/naming_test.go index 7fbd2a19..ed4b697a 100644 --- a/api/naming/naming_test.go +++ b/api/naming/naming_test.go @@ -29,19 +29,19 @@ func TestHelmClusterAddonChartName(t *testing.T) { name: "short names are joined and hashed", repo: "example", chart: "podinfo", - want: "example-chart-podinfo-aa661c3516b2", + 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 before the hash", repo: "repo", chart: "", - want: "repo-chart-4e5d8120682c", + want: "repo-chart-8549288388a9", }, { // A repository name is a DNS subdomain and a chart name comes from @@ -49,7 +49,7 @@ func TestHelmClusterAddonChartName(t *testing.T) { name: "a truncation that ends in a dot drops it", repo: "abcdefghijklmnopqrs.x", chart: "podinfo", - want: "abcdefghijklmnopqrs-chart-podinfo-da22920998cb", + want: "abcdefghijklmnopqrs-chart-podinfo-0fe4a214e986", }, } @@ -73,19 +73,19 @@ func TestApplicationChartName(t *testing.T) { name: "short names are joined and hashed", repo: "example", chart: "podinfo", - want: "example-chart-podinfo-aa661c3516b2", + 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 before the hash", repo: "repo", chart: "", - want: "repo-chart-4e5d8120682c", + want: "repo-chart-8549288388a9", }, { // A repository name is a DNS subdomain and a chart name comes from @@ -93,7 +93,7 @@ func TestApplicationChartName(t *testing.T) { name: "a truncation that ends in a dot drops it", repo: "abcdefghijklmnopqrs.x", chart: "podinfo", - want: "abcdefghijklmnopqrs-chart-podinfo-da22920998cb", + want: "abcdefghijklmnopqrs-chart-podinfo-0fe4a214e986", }, } @@ -117,13 +117,13 @@ func TestClusterApplicationChartName(t *testing.T) { name: "short names are joined and hashed", repo: "shared", chart: "nginx", - want: "shared-chart-nginx-7f9acafe347b", + 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-a3ee4a8a584e", + want: "yandex-cloud-marketp-chart-cert-manager-webhook-cb0f7a51035d", }, } @@ -158,17 +158,27 @@ func TestChartNameSchemeIsShared(t *testing.T) { } } -// TestHelmClusterAddonChartNameNoLongerCollidesOnATrailingDot pins that the addon -// family closed its collision gap too. Under its old scheme (a hash only past the -// truncation threshold), the two names below both trimmed to "foo-chart-bar" and -// collided: the trailing dot only vanishes from the untrimmed pair after it is -// already inside the joined string. The hash is computed from that untrimmed pair, -// so making it unconditional is what tells the two names apart now. -func TestHelmClusterAddonChartNameNoLongerCollidesOnATrailingDot(t *testing.T) { +// 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("HelmClusterAddonChartName(%q, %q) = %q, collides with (%q, %q)", "foo", "bar.", withDot, "foo", "bar") + t.Fatalf("(%q, %q) and (%q, %q) both produce %q", "foo", "bar.", "foo", "bar", withDot) } } diff --git a/images/chart-values-controller/internal/resolver/family_test.go b/images/chart-values-controller/internal/resolver/family_test.go index 6591efd2..a5b82eff 100644 --- a/images/chart-values-controller/internal/resolver/family_test.go +++ b/images/chart-values-controller/internal/resolver/family_test.go @@ -51,7 +51,7 @@ func TestAddonFamilyReadsTheClusterScopedRepositoryAndCatalog(t *testing.T) { // 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-aa661c3516b2"}, + ObjectMeta: metav1.ObjectMeta{Name: "example-chart-podinfo-015bdf9886f6"}, Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "6.7.1"}}}, } @@ -124,7 +124,7 @@ func TestApplicationFamiliesReadTheirOwnObjects(t *testing.T) { // 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-cb815671ddc8", Namespace: "team-a"}, + ObjectMeta: metav1.ObjectMeta{Name: "stable-chart-podinfo-d433c642288b", Namespace: "team-a"}, Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "6.7.1"}}}, } cluster := &helmv1alpha1.HelmClusterApplicationRepository{ @@ -132,7 +132,7 @@ func TestApplicationFamiliesReadTheirOwnObjects(t *testing.T) { Spec: helmv1alpha1.RepositorySpec{URL: "oci://ghcr.io/example/charts"}, } clusterChart := &helmv1alpha1.HelmClusterApplicationChart{ - ObjectMeta: metav1.ObjectMeta{Name: "shared-chart-podinfo-83fd5aa25c3a"}, + ObjectMeta: metav1.ObjectMeta{Name: "shared-chart-podinfo-6c7443a6e003"}, Status: helmv1alpha1.ChartCatalogStatus{Versions: []helmv1alpha1.ChartVersion{{Version: "1.2.3"}}}, } From 87772f98afeee3f363a545029090402941b88387 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 21:24:35 +0300 Subject: [PATCH 069/145] chore: install golangci-lint with the toolchain we already have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared install task pipes the upstream install.sh, whose checksum lookup is an unanchored grep. Releases now publish an SBOM entry beside the archive, both lines match, and verification fails for every version past v2.8.0 — which is built with Go 1.25 and refuses to read a module targeting 1.26. Building the binary with go install removes the download, so the pin can move to a linter new enough for this repository. Running lint installs it on demand. Signed-off-by: Ilya Drey --- Taskfile.yaml | 19 ++++++++++++++++++- api/Taskfile.dist.yaml | 2 +- .../Taskfile.dist.yaml | 2 +- images/hooks/Taskfile.dist.yaml | 2 +- .../Taskfile.dist.yaml | 2 +- tests/e2e/Taskfile.dist.yaml | 2 +- 6 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 215b66c6..8baba6fa 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -6,7 +6,7 @@ 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" includes: api: @@ -55,6 +55,21 @@ tasks: cmds: - task: api:ci:generate + # 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: @@ -130,6 +145,8 @@ tasks: - task: e2e:format lint: + deps: + - install:golangci-lint cmds: - task: api:lint - task: hooks:lint diff --git a/api/Taskfile.dist.yaml b/api/Taskfile.dist.yaml index 0e71fed8..b7eb60e6 100644 --- a/api/Taskfile.dist.yaml +++ b/api/Taskfile.dist.yaml @@ -10,7 +10,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 "."}}' prettierPattern: '../crds/*.yaml' diff --git a/images/chart-values-controller/Taskfile.dist.yaml b/images/chart-values-controller/Taskfile.dist.yaml index 421f2d9c..7bf197ea 100644 --- a/images/chart-values-controller/Taskfile.dist.yaml +++ b/images/chart-values-controller/Taskfile.dist.yaml @@ -10,7 +10,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 "."}}' diff --git a/images/hooks/Taskfile.dist.yaml b/images/hooks/Taskfile.dist.yaml index 0666436f..448a6d38 100644 --- a/images/hooks/Taskfile.dist.yaml +++ b/images/hooks/Taskfile.dist.yaml @@ -10,7 +10,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 "."}}' diff --git a/images/operator-helm-controller/Taskfile.dist.yaml b/images/operator-helm-controller/Taskfile.dist.yaml index 421f2d9c..7bf197ea 100644 --- a/images/operator-helm-controller/Taskfile.dist.yaml +++ b/images/operator-helm-controller/Taskfile.dist.yaml @@ -10,7 +10,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 "."}}' diff --git a/tests/e2e/Taskfile.dist.yaml b/tests/e2e/Taskfile.dist.yaml index 42816213..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 "."}}' From de96ff93be6851cc96772f83322b9c02f7f4ca64 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 21:29:12 +0300 Subject: [PATCH 070/145] docs(taskfile): say which image directories are not ours kube-api-rewriter is a separate upstream module vendored in for the build, and two more image directories hold only werf files. Nothing runs against them today; the note keeps the next addition from reaching in. Signed-off-by: Ilya Drey --- Taskfile.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Taskfile.yaml b/Taskfile.yaml index 8baba6fa..754d9a2f 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -8,6 +8,10 @@ vars: VALIDATION_FILES: "tools/validation/{main,messages,diff,doc_changes}.go" golangciLintVersion: "v2.13.2" +# Only the modules this repository authors are listed. images/kube-api-rewriter is a +# separate upstream module vendored in for the build, and images/helm-controller and +# images/nelm-source-controller carry nothing but their werf files, so none of them is +# ours to lint, format or test here. includes: api: taskfile: ./api/Taskfile.dist.yaml From cc9c85bc856c6946b34821e05b3e0183fb9ea880 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 21:42:12 +0300 Subject: [PATCH 071/145] fix(controller): migrate every catalog object, not only the published ones The rename was driven by the charts of the current fetch, so a chart the repository dropped and only a consumer still holds stayed under its old name while the consumer already resolved the new one, leaving its release unable to reconcile. Seeding the status on creation alone lost that version for good when the status write failed and the next pass found the object already there. The rename is now its own step over every object of the repository, keyed by the chart label. The status is carried only into an object that has none, and the old object is deleted only once the copy has landed. Signed-off-by: Ilya Drey --- .../internal/catalog/catalog.go | 139 ++++++++++-------- .../internal/catalog/catalog_test.go | 97 ++++++++++++ 2 files changed, 175 insertions(+), 61 deletions(-) diff --git a/images/operator-helm-controller/internal/catalog/catalog.go b/images/operator-helm-controller/internal/catalog/catalog.go index a8046267..adcb032a 100644 --- a/images/operator-helm-controller/internal/catalog/catalog.go +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -121,40 +121,14 @@ func (t *typed[C, CL]) Known(ctx context.Context, repo source.Repository) (repoc 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)) - desiredNameByChart := make(map[string]string, len(charts)) - - for _, chart := range charts { - name := t.cfg.ObjectName(repo.Name(), chart.Name) - desired[name] = struct{}{} - desiredNameByChart[chart.Name] = name - } - - // The pruning loop below needs this same listing, so it is fetched once, before - // any object is created or patched: that is also what lets the loop find a chart - // object still sitting under an earlier naming scheme, for the migration below. - existingCharts, err := t.list(ctx, repo) - if err != nil { - return fmt.Errorf("listing charts for pruning: %w", err) + if err := t.migrateNames(ctx, repo); err != nil { + return err } - // TRANSITIONAL: chartObjectName became injective for every catalog kind, moving - // every object to a new name. legacyByChart maps a chart being reconciled to one - // object still sitting under its old name, found by the chart label rather than - // by recomputing the old scheme, so this also covers any earlier scheme. Remove - // this map and its two uses below, and the "an earlier naming scheme" branch in - // the pruning loop, once every cluster has synchronized under the new scheme at - // least once. - legacyByChart := make(map[string]C, len(existingCharts)) - for _, existing := range existingCharts { - chartName := existing.GetLabels()[helmv1alpha1.LabelChartName] - if wantName, reconciling := desiredNameByChart[chartName]; reconciling && existing.GetName() != wantName { - legacyByChart[chartName] = existing - } - } + desired := make(map[string]struct{}, len(charts)) for _, chart := range charts { - name := desiredNameByChart[chart.Name] + 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. @@ -162,6 +136,8 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch 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()), @@ -188,53 +164,29 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch } base := existing.DeepCopyObject().(C) - status := t.cfg.Status(existing) - - // TRANSITIONAL: a freshly created object starts with an empty status, which - // would otherwise make mergeChartVersions forget a version a consumer still - // references. Seeding only the "current" input mergeChartVersions reads - // (base above must still mirror the object's real, empty server state, or - // the status patch below would not carry the seeded fields at all) replays - // that protection once, from whatever the object carried under its old name. - currentVersions := status.Versions - if op == controllerutil.OperationResultCreated { - if legacy, ok := legacyByChart[chart.Name]; ok { - currentVersions = t.cfg.Status(legacy).Versions - } - } + status := t.cfg.Status(existing) if len(chart.Versions) > 0 { status.IconURL = chart.Versions[0].IconURL } - status.Versions = mergeChartVersions(chart.Versions, currentVersions, inUse) + 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 _, reconciling := desiredNameByChart[chartName]; reconciling { - // TRANSITIONAL: this object's chart now lives under the name reconciled - // above, so it is a leftover of an earlier naming scheme rather than a - // chart the repository stopped offering. The in-use check below exists to - // protect a chart a consumer still needs, but the consumer now resolves - // to the new name, so this leftover is deleted unconditionally. - logger.Info("Deleting a chart object left over from an earlier naming scheme", "kind", t.cfg.Kind, "name", chart.GetName(), "chart", chartName) - - if err := client.IgnoreNotFound(t.client.Delete(ctx, chart)); err != nil { - return fmt.Errorf("deleting a chart object from an earlier naming scheme: %w", err) - } - - continue - } - 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 @@ -265,6 +217,71 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch 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 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", legacy.GetName(), "to", name, "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 diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index 03544484..942f16a7 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -67,6 +67,12 @@ func applicationRepo(namespace, name string) source.Repository { 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) @@ -74,6 +80,7 @@ func newAddonClient(t *testing.T, objects ...client.Object) client.WithWatch { return fake.NewClientBuilder(). WithScheme(scheme). + WithInterceptorFuncs(funcs). WithStatusSubresource(&helmv1alpha1.HelmClusterAddonChart{}). WithObjects(objects...). WithIndex(&helmv1alpha1.HelmClusterAddon{}, index.AddonChart, func(obj client.Object) []string { @@ -511,3 +518,93 @@ func TestMigrationLeavesUnrelatedChartsToExistingPruning(t *testing.T) { 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 := cat.Reconcile(context.Background(), addonRepo(), []repoclient.Chart{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 := cat.Reconcile(context.Background(), addonRepo(), []repoclient.Chart{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 := cat.Reconcile(context.Background(), addonRepo(), []repoclient.Chart{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) + } +} From b88baf24deb057059019036380f979d4b38df456 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 21:48:12 +0300 Subject: [PATCH 072/145] fix(rbac): grant the manage roles a rule on their own ModuleConfig Every upstream module shipping rbacv2/manage/edit.yaml and view.yaml also carries a rule on deckhouse.io/moduleconfigs restricted to its own module, because reading and editing the module's own ModuleConfig is part of what a manage role grants (see modules/101-cert-manager/templates/rbacv2/manage/ {edit,view}.yaml upstream). Add the equivalent rule here, and record in a comment why both roles keep aggregating into the kubernetes subsystem instead of this module's own delivery subsystem: user-authz ships no delivery manage role, so aggregating there would silently grant nobody anything, and kube-proxy/kube-dns/control-plane-manager are the only other modules using the kubernetes token for a manage role. Signed-off-by: Ilya Drey --- templates/rbacv2/manage/edit.yaml | 17 +++++++++++++++++ templates/rbacv2/manage/view.yaml | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/templates/rbacv2/manage/edit.yaml b/templates/rbacv2/manage/edit.yaml index 5be57562..de3afc61 100644 --- a/templates/rbacv2/manage/edit.yaml +++ b/templates/rbacv2/manage/edit.yaml @@ -1,6 +1,12 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: + # This module's subsystem is delivery (see module.yaml), but user-authz ships no + # delivery manage role to aggregate into, so aggregating there would silently + # grant nobody anything. The right to manage a cluster-scoped application + # repository lands with kubernetes subsystem administrators instead, the same + # subsystem kube-proxy, kube-dns and control-plane-manager aggregate their manage + # roles into. The real fix is adding delivery roles upstream in deckhouse/deckhouse. {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "rbac.deckhouse.io/namespace" (printf "d8-%s" .Chart.Name) "rbac.deckhouse.io/aggregate-to-kubernetes-as" "manager")) | nindent 2 }} name: d8:manage:permission:module:{{ .Chart.Name }}:edit rules: @@ -14,3 +20,14 @@ rules: - deletecollection - patch - update +- apiGroups: + - deckhouse.io + resourceNames: + - {{ .Chart.Name }} + resources: + - moduleconfigs + verbs: + - create + - update + - patch + - delete diff --git a/templates/rbacv2/manage/view.yaml b/templates/rbacv2/manage/view.yaml index 1d24e2ac..16d3e838 100644 --- a/templates/rbacv2/manage/view.yaml +++ b/templates/rbacv2/manage/view.yaml @@ -1,6 +1,12 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: + # This module's subsystem is delivery (see module.yaml), but user-authz ships no + # delivery manage role to aggregate into, so aggregating there would silently + # grant nobody anything. The right to view a cluster-scoped application + # repository lands with kubernetes subsystem administrators instead, the same + # subsystem kube-proxy, kube-dns and control-plane-manager aggregate their manage + # roles into. The real fix is adding delivery roles upstream in deckhouse/deckhouse. {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "rbac.deckhouse.io/namespace" (printf "d8-%s" .Chart.Name) "rbac.deckhouse.io/aggregate-to-kubernetes-as" "viewer")) | nindent 2 }} name: d8:manage:permission:module:{{ .Chart.Name }}:view rules: @@ -13,3 +19,13 @@ rules: - get - list - watch +- apiGroups: + - deckhouse.io + resourceNames: + - {{ .Chart.Name }} + resources: + - moduleconfigs + verbs: + - get + - list + - watch From f62feb6d380f9541b6d7f7f9653ca037a0bf7fb4 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 21:48:23 +0300 Subject: [PATCH 073/145] fix(naming): trim a trailing dash or dot on an untruncated repository name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chartObjectName only trimmed the truncated branch's cut-off repository name; an untruncated one (20 characters or fewer) was appended as-is, so a name ending in a dot or dash produced an invalid object name like "abcdefghijklmnopqrs.-chart-podinfo-" — the final trim only reaches the end of the whole string, not the middle. This is unreachable today because metadata.name cannot end in a dot, but the branch should not depend on that. Trim both branches the same way, and add a table case for it. Checked every pinned literal that could move: naming_test.go's existing cases, chart-values-controller/internal/resolver/family_test.go, and the operator-helm-controller tests that use naming.*ChartName all use repository names that neither exceed 20 characters nor end in "-" or ".", so nothing moves for them. Signed-off-by: Ilya Drey --- api/naming/naming.go | 5 ++++- api/naming/naming_test.go | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/api/naming/naming.go b/api/naming/naming.go index a266bedd..010c9855 100644 --- a/api/naming/naming.go +++ b/api/naming/naming.go @@ -62,7 +62,10 @@ func chartObjectName(repoName, chartName string) string { // the whole name. result += strings.TrimRight(repoName[:20], "-.") + "-chart-" } else { - result += repoName + "-chart-" + // Same reasoning as the truncated branch above: repoName 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(repoName, "-.") + "-chart-" } if len(chartName) > 20 { diff --git a/api/naming/naming_test.go b/api/naming/naming_test.go index ed4b697a..f4b120d3 100644 --- a/api/naming/naming_test.go +++ b/api/naming/naming_test.go @@ -51,6 +51,15 @@ func TestHelmClusterAddonChartName(t *testing.T) { 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", + }, } for _, tc := range cases { @@ -95,6 +104,15 @@ func TestApplicationChartName(t *testing.T) { 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", + }, } for _, tc := range cases { From ebc24bffb5d3c9552af0b1c401a6c7c25509f632 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 21:48:30 +0300 Subject: [PATCH 074/145] docs(controller): explain why the service account delete needs no ownership check The RoleBinding is only deleted after verifying it is ours, but the ServiceAccount was deleted by name alone with nothing saying why that asymmetry is fine. The account lives in the operator's own namespace, where a namespace owner cannot pre-create anything, so there is no foreign object to protect. No behavior change. Signed-off-by: Ilya Drey --- .../internal/services/access_service.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/images/operator-helm-controller/internal/services/access_service.go b/images/operator-helm-controller/internal/services/access_service.go index 1474ab75..448b4b3d 100644 --- a/images/operator-helm-controller/internal/services/access_service.go +++ b/images/operator-helm-controller/internal/services/access_service.go @@ -106,6 +106,11 @@ func (s *AccessService) CleanupAccess(ctx context.Context, rel source.Release) e 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) From a9f0121888e9a07d49906e9828d47c5fd9461462 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 22:00:04 +0300 Subject: [PATCH 075/145] chore: apply the formatting the current linter expects The pinned linter could not run at all, so its formatters had drifted away from the tree. Nothing here is a behaviour change: blank lines between declarations and import grouping only. Signed-off-by: Ilya Drey --- images/hooks/pkg/hooks/cleanup-finalizers/hook.go | 8 +++++--- images/hooks/pkg/hooks/delete-namespace/hook.go | 5 +++-- .../hooks/pkg/hooks/tls-certificates-controller/hook.go | 3 ++- .../internal/adapter/addon_repository.go | 7 ++++--- .../internal/adapter/application_release.go | 7 +++++-- .../internal/adapter/application_repository.go | 7 ++++--- .../internal/adapter/cluster_application_repository.go | 7 +++++-- .../internal/client/repository/helm.go | 3 +-- .../internal/controller/helmclusteraddon/controller.go | 3 ++- .../controller/helmclusteraddonrepository/controller.go | 6 ++++-- .../internal/reconcile/release/reconciler_test.go | 3 ++- .../operator-helm-controller/internal/utils/repository.go | 3 ++- tests/e2e/helmapplicationrepository/lifecycle.go | 3 ++- 13 files changed, 41 insertions(+), 24 deletions(-) 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/operator-helm-controller/internal/adapter/addon_repository.go b/images/operator-helm-controller/internal/adapter/addon_repository.go index 8e98ecc2..63ec67b2 100644 --- a/images/operator-helm-controller/internal/adapter/addon_repository.go +++ b/images/operator-helm-controller/internal/adapter/addon_repository.go @@ -53,9 +53,10 @@ 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) 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() } diff --git a/images/operator-helm-controller/internal/adapter/application_release.go b/images/operator-helm-controller/internal/adapter/application_release.go index 73ef1278..fc0bf74b 100644 --- a/images/operator-helm-controller/internal/adapter/application_release.go +++ b/images/operator-helm-controller/internal/adapter/application_release.go @@ -83,8 +83,11 @@ func (r *ApplicationRelease) ChartRef() source.ChartRef { 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) MaintenanceEnabled() bool { return r.obj.MaintenanceModeEnabled() } + +func (r *ApplicationRelease) ForceReconcileRequired() bool { return r.obj.ForceReconcileRequired() } + func (r *ApplicationRelease) IsChartStatusInfoOutdated() bool { return r.obj.IsChartStatusInfoOutdated() } diff --git a/images/operator-helm-controller/internal/adapter/application_repository.go b/images/operator-helm-controller/internal/adapter/application_repository.go index 2f8ec261..69d40873 100644 --- a/images/operator-helm-controller/internal/adapter/application_repository.go +++ b/images/operator-helm-controller/internal/adapter/application_repository.go @@ -60,9 +60,10 @@ 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) 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() } diff --git a/images/operator-helm-controller/internal/adapter/cluster_application_repository.go b/images/operator-helm-controller/internal/adapter/cluster_application_repository.go index c30a70f2..815ea35d 100644 --- a/images/operator-helm-controller/internal/adapter/cluster_application_repository.go +++ b/images/operator-helm-controller/internal/adapter/cluster_application_repository.go @@ -52,7 +52,8 @@ func EmptyClusterApplicationRepository() source.Repository { 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) Generation() int64 { return r.obj.Generation } func (r *ClusterApplicationRepository) OwnerGVK() schema.GroupVersionKind { return helmv1alpha1.HelmClusterApplicationRepositoryGVK @@ -60,7 +61,9 @@ func (r *ClusterApplicationRepository) OwnerGVK() schema.GroupVersionKind { 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) CACertificate() string { return r.obj.Spec.CACertificate } + func (r *ClusterApplicationRepository) InsecureSkipVerify() bool { return r.obj.Spec.InsecureSkipVerify } 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/helmclusteraddon/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go index c8fedcbd..e99afa7e 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go @@ -101,7 +101,8 @@ func SetupWithManager(mgr ctrl.Manager) error { helmv1alpha1.HelmClusterAddonLabelSourceName, ), ), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). Watches( &helmv1alpha1.HelmClusterAddonRepository{}, handler.EnqueueRequestsFromMapFunc(utils.MapRepositoryToAddons(client)), diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go index 98e352af..e671df78 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go @@ -71,7 +71,8 @@ func SetupWithManager(mgr ctrl.Manager) error { helmv1alpha1.TargetNamespace, helmv1alpha1.LabelManagedBy, helmv1alpha1.LabelManagedByValue, - helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName), + helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName, + ), ), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). @@ -83,7 +84,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/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index 9998d3ee..43e5e078 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -916,7 +916,8 @@ func TestReconcileForcedAddonReportsProgressBeforeWorking(t *testing.T) { observed := &helmv1alpha1.HelmClusterAddon{} if err := c.Get(ctx, types.NamespacedName{Name: addon.Name}, observed); err == nil { inFlight = apimeta.FindStatusCondition( - observed.Status.Conditions, helmv1alpha1.ConditionTypeReconciling) + observed.Status.Conditions, helmv1alpha1.ConditionTypeReconciling, + ) } } diff --git a/images/operator-helm-controller/internal/utils/repository.go b/images/operator-helm-controller/internal/utils/repository.go index 235b72ac..59baedc4 100644 --- a/images/operator-helm-controller/internal/utils/repository.go +++ b/images/operator-helm-controller/internal/utils/repository.go @@ -23,8 +23,9 @@ import ( "net/url" "strings" - helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/google/go-containerregistry/pkg/name" + + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) type InternalRepositoryType string diff --git a/tests/e2e/helmapplicationrepository/lifecycle.go b/tests/e2e/helmapplicationrepository/lifecycle.go index a5a54c5f..6f2190eb 100644 --- a/tests/e2e/helmapplicationrepository/lifecycle.go +++ b/tests/e2e/helmapplicationrepository/lifecycle.go @@ -178,7 +178,8 @@ func DefineLifecycleTests(repoType, repoURL string) { 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)) + util.HelmApplicationRepositoryInternalName(f.NamespaceName(), repoName), + ) Expect(err).NotTo(HaveOccurred()) } }) From db5ab50a1e00bdb02d3663d790be8dc05bac50bb Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 22:00:18 +0300 Subject: [PATCH 076/145] fix: clear the static analysis findings the linter reports One file imported the same package twice under two names, a predicate spelled a boolean expression as a branch, and one error lost its cause to a plain verb where the format string already wraps another error. Signed-off-by: Ilya Drey --- api/v1alpha1/helm_cluster_addon.go | 11 +++++------ .../internal/chartartifact/probe.go | 2 +- .../internal/utils/namespace.go | 6 +----- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/api/v1alpha1/helm_cluster_addon.go b/api/v1alpha1/helm_cluster_addon.go index 86df948f..f8828c2f 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" ) @@ -80,18 +79,18 @@ func (r *HelmClusterAddon) MaintenanceModeEnabled() bool { func (r *HelmClusterAddon) GetConditionTypesForUpdate() []string { conditionTypes := []string{"Ready"} - 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,7 +99,7 @@ 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 } @@ -109,7 +108,7 @@ func (r *HelmClusterAddon) ConfigurationApplyInProgress() bool { } func (r *HelmClusterAddon) UpdateInstallInProgress() bool { - cond := meta.FindStatusCondition(r.Status.Conditions, ConditionTypeUpdateInstalled) + cond := apimeta.FindStatusCondition(r.Status.Conditions, ConditionTypeUpdateInstalled) if cond == nil { return false } diff --git a/images/chart-values-controller/internal/chartartifact/probe.go b/images/chart-values-controller/internal/chartartifact/probe.go index 27356f13..c6f716c7 100644 --- a/images/chart-values-controller/internal/chartartifact/probe.go +++ b/images/chart-values-controller/internal/chartartifact/probe.go @@ -100,7 +100,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)) { diff --git a/images/operator-helm-controller/internal/utils/namespace.go b/images/operator-helm-controller/internal/utils/namespace.go index 35ca8024..81bc1542 100644 --- a/images/operator-helm-controller/internal/utils/namespace.go +++ b/images/operator-helm-controller/internal/utils/namespace.go @@ -29,9 +29,5 @@ func IsSystemNamespace(namespace string) bool { } } - if strings.HasPrefix(namespace, "d8-") { - return true - } - - return false + return strings.HasPrefix(namespace, "d8-") } From 6921cce554b22b68af7337fdfd1573a2a7bc75ab Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 22:00:19 +0300 Subject: [PATCH 077/145] test(controller): keep the fixture parameters the linter would drop Each of these parameters happens to receive one value today, but it is what names that value at the call site: folding it into the body would hide what the fixture stands for from the test reading it. Signed-off-by: Ilya Drey --- .../internal/catalog/catalog_test.go | 6 +++--- .../internal/services/repo_sync_service_test.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index 942f16a7..bc0870c4 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -55,7 +55,7 @@ func newClient(t *testing.T) client.Client { Build() } -func applicationRepo(namespace, name string) source.Repository { +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}, @@ -102,7 +102,7 @@ func addonRepo() source.Repository { // 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 { +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{ @@ -120,7 +120,7 @@ func addonConsumer(name, repoName, chartName, version string) *helmv1alpha1.Helm // 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 { +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, 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 0ca4acbd..fa0d1975 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 @@ -80,11 +80,11 @@ func newRepoSyncService(t *testing.T, stub stubRepoClient, objects ...client.Obj 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.ChartVersion) *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), @@ -108,7 +108,7 @@ func addonUsing(repoName, chartName, version string) *helmv1alpha1.HelmClusterAd } } -func chartStatus(t *testing.T, c client.Client, repoName, chartName string) helmv1alpha1.ChartCatalogStatus { +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{} From fabf6f220f08a9cc83a75b80d85f98859a346af0 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 22:00:30 +0300 Subject: [PATCH 078/145] chore: keep prettier away from the chart-values werf template Its yaml check reached werf.inc.yaml, a template rather than a document, and failed to parse it, so the check could never pass. The other two image modules already carried this file; this one was simply missing it. Signed-off-by: Ilya Drey --- images/chart-values-controller/.prettierignore | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 images/chart-values-controller/.prettierignore 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 + + From c5b5ede7808d4fe8a3745273bae67bc878ba32af Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 22:49:34 +0300 Subject: [PATCH 079/145] fix(controller): rename the catalog objects without waiting on the remote The rename lived inside the catalog synchronization, which runs only after a successful fetch. A consumer resolves the current name as soon as this controller starts, so a repository not yet due for a sync left its consumers unable to resolve a chart for minutes, and one whose registry is gone for good left them that way permanently. It is now a step of its own on the repository reconcile, before anything touches the remote. Signed-off-by: Ilya Drey --- .../internal/catalog/catalog.go | 12 ++--- .../internal/catalog/catalog_test.go | 28 +++++++---- .../reconcile/repository/reconciler.go | 9 ++++ .../reconcile/repository/reconciler_test.go | 47 +++++++++++++++++++ .../internal/services/repo_sync_service.go | 11 +++++ .../internal/source/catalog.go | 7 +++ 6 files changed, 97 insertions(+), 17 deletions(-) diff --git a/images/operator-helm-controller/internal/catalog/catalog.go b/images/operator-helm-controller/internal/catalog/catalog.go index adcb032a..6c8ce996 100644 --- a/images/operator-helm-controller/internal/catalog/catalog.go +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -121,10 +121,6 @@ func (t *typed[C, CL]) Known(ctx context.Context, repo source.Repository) (repoc func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, charts []repoclient.Chart) error { logger := log.FromContext(ctx) - if err := t.migrateNames(ctx, repo); err != nil { - return err - } - desired := make(map[string]struct{}, len(charts)) for _, chart := range charts { @@ -217,7 +213,7 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch return nil } -// migrateNames moves a repository's catalog objects to the names the current scheme +// 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 @@ -229,9 +225,9 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch // 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 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 { +// 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) diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index bc0870c4..b8d4be00 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -134,6 +134,16 @@ func legacyAddonChart(name, repoName, chartName string, versions ...helmv1alpha1 } } +// syncCatalog runs the two steps the repository reconciler runs, in its order: the +// rename first, independent of any fetch, then the catalog write. +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, @@ -252,7 +262,7 @@ func TestUnreferencedVersionsArePruned(t *testing.T) { 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 := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "1.0.0")}); err != nil { + if err := syncCatalog(cat, repo, chart("podinfo", "1.0.0")); err != nil { t.Fatalf("second Reconcile returned %v", err) } @@ -348,7 +358,7 @@ func TestMigratesALegacyNamedObjectStillInUse(t *testing.T) { cat := adapter.NewAddonCatalog(c) repo := addonRepo() - if err := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "2.0.0")}); err != nil { + if err := syncCatalog(cat, repo, chart("podinfo", "2.0.0")); err != nil { t.Fatalf("Reconcile returned %v", err) } @@ -395,7 +405,7 @@ func TestMigratesALegacyNamedObjectNotInUse(t *testing.T) { cat := adapter.NewAddonCatalog(c) repo := addonRepo() - if err := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "2.0.0")}); err != nil { + if err := syncCatalog(cat, repo, chart("podinfo", "2.0.0")); err != nil { t.Fatalf("Reconcile returned %v", err) } @@ -430,7 +440,7 @@ func TestMigrationIsANoOpOnASecondReconcile(t *testing.T) { cat := adapter.NewAddonCatalog(c) repo := addonRepo() - if err := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "2.0.0")}); err != nil { + if err := syncCatalog(cat, repo, chart("podinfo", "2.0.0")); err != nil { t.Fatalf("first Reconcile returned %v", err) } @@ -448,7 +458,7 @@ func TestMigrationIsANoOpOnASecondReconcile(t *testing.T) { c = interceptedClient(t, c, func() { deletes++ }) cat = adapter.NewAddonCatalog(c) - if err := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "2.0.0")}); err != nil { + if err := syncCatalog(cat, repo, chart("podinfo", "2.0.0")); err != nil { t.Fatalf("second Reconcile returned %v", err) } @@ -504,7 +514,7 @@ func TestMigrationLeavesUnrelatedChartsToExistingPruning(t *testing.T) { // 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 := cat.Reconcile(context.Background(), repo, []repoclient.Chart{chart("podinfo", "1.0.0")}); err != nil { + if err := syncCatalog(cat, repo, chart("podinfo", "1.0.0")); err != nil { t.Fatalf("Reconcile returned %v", err) } @@ -535,7 +545,7 @@ func TestMigratesAChartTheRepositoryNoLongerOffers(t *testing.T) { ) cat := adapter.NewAddonCatalog(c) - if err := cat.Reconcile(context.Background(), addonRepo(), []repoclient.Chart{chart("podinfo", "2.0.0")}); err != nil { + if err := syncCatalog(cat, addonRepo(), chart("podinfo", "2.0.0")); err != nil { t.Fatalf("Reconcile returned %v", err) } @@ -581,7 +591,7 @@ func TestMigrationSurvivesAFailedStatusWrite(t *testing.T) { ) cat := adapter.NewAddonCatalog(c) - if err := cat.Reconcile(context.Background(), addonRepo(), []repoclient.Chart{chart("podinfo", "2.0.0")}); err == nil { + if err := syncCatalog(cat, addonRepo(), chart("podinfo", "2.0.0")); err == nil { t.Fatal("Reconcile must report the failed status write") } @@ -589,7 +599,7 @@ func TestMigrationSurvivesAFailedStatusWrite(t *testing.T) { t.Fatalf("legacy object err = %v, want it kept until its status has been carried over", err) } - if err := cat.Reconcile(context.Background(), addonRepo(), []repoclient.Chart{chart("podinfo", "2.0.0")}); err != nil { + if err := syncCatalog(cat, addonRepo(), chart("podinfo", "2.0.0")); err != nil { t.Fatalf("second Reconcile returned %v", err) } diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go index b222cfca..68f18bb5 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go @@ -109,6 +109,15 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // would not trigger a follow-up reconcile. } + // 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. + if err := r.chartSyncService.MigrateNames(ctx, repo); err != nil { + return reconcile.Result{}, fmt.Errorf("migrating chart catalog names: %w", err) + } + in := Inputs{ Generation: repo.Generation(), Now: time.Now().UTC(), diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 96e5256c..5272a5fa 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -37,6 +37,7 @@ import ( "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" repoclient "github.com/deckhouse/operator-helm/internal/client/repository" @@ -269,6 +270,52 @@ 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) + } +} + func TestReconcileTerminalFetchFailureStalls(t *testing.T) { repo := ociRepository() stub := &stubRepoClient{err: &repoclient.TerminalError{ 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 45f421c5..774c894a 100644 --- a/images/operator-helm-controller/internal/services/repo_sync_service.go +++ b/images/operator-helm-controller/internal/services/repo_sync_service.go @@ -59,6 +59,17 @@ func NewRepoSyncService(client client.Client, scheme *runtime.Scheme, factory Re // 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. +// 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) +} + func (s *RepoSyncService) Sync( ctx context.Context, repo source.Repository, diff --git a/images/operator-helm-controller/internal/source/catalog.go b/images/operator-helm-controller/internal/source/catalog.go index ea25b5d2..2dd54dca 100644 --- a/images/operator-helm-controller/internal/source/catalog.go +++ b/images/operator-helm-controller/internal/source/catalog.go @@ -33,6 +33,13 @@ 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 From 215de044e98abf3ab7ab8387a5eb8d48538a5706 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 22:49:36 +0300 Subject: [PATCH 080/145] chore: keep the linter binary out of the chart Running the linter now installs it under bin/, and helm refuses to package a file that large, so the chart stopped rendering locally right after a lint run. Signed-off-by: Ilya Drey --- .helmignore | 1 + 1 file changed, 1 insertion(+) 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 From 1a86de8f0553768876f1c7aabe9d82196e3f4ade Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 23:13:25 +0300 Subject: [PATCH 081/145] fix(controller): report a failed rename instead of going silent Returning the failure out of Reconcile skipped the status write, so a repository whose catalog objects could not be moved carried no conditions at all while its consumers could not resolve their chart. The transitional step had more weight than the permanent ones around it, which already route their failures to the status. It now travels as an input to the same evaluation, surfacing as Synced false with the reason the catalog already uses. Signed-off-by: Ilya Drey --- .../internal/reconcile/repository/evaluate.go | 16 ++++- .../reconcile/repository/reconciler.go | 17 +++-- .../reconcile/repository/reconciler_test.go | 71 +++++++++++++++++++ 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/images/operator-helm-controller/internal/reconcile/repository/evaluate.go b/images/operator-helm-controller/internal/reconcile/repository/evaluate.go index 6e854d5f..c14a7e49 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/evaluate.go +++ b/images/operator-helm-controller/internal/reconcile/repository/evaluate.go @@ -42,6 +42,13 @@ type Inputs struct { 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 @@ -88,7 +95,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 { @@ -187,8 +194,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 diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go index 68f18bb5..a786c939 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go @@ -109,15 +109,6 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // would not trigger a follow-up reconcile. } - // 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. - if err := r.chartSyncService.MigrateNames(ctx, repo); err != nil { - return reconcile.Result{}, fmt.Errorf("migrating chart catalog names: %w", err) - } - in := Inputs{ Generation: repo.Generation(), Now: time.Now().UTC(), @@ -125,6 +116,14 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco 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.chartSyncService.MigrateNames(ctx, repo) + if repoTypeErr != nil { in.ConfigErr = &services.ConfigOutcome{ Reason: helmv1alpha1.ReasonUnsupportedRepositoryType, diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 5272a5fa..5efcc4fd 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -18,6 +18,7 @@ package repository import ( "context" + "errors" "reflect" "testing" "time" @@ -34,6 +35,7 @@ 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" @@ -69,6 +71,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, @@ -82,6 +95,7 @@ func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object) c := fake.NewClientBuilder(). WithScheme(scheme). + WithInterceptorFuncs(funcs). WithObjects(objects...). WithStatusSubresource( &helmv1alpha1.HelmClusterAddonRepository{}, @@ -316,6 +330,63 @@ func TestReconcileMigratesCatalogNamesWithoutAFetch(t *testing.T) { } } +// 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) + } +} + func TestReconcileTerminalFetchFailureStalls(t *testing.T) { repo := ociRepository() stub := &stubRepoClient{err: &repoclient.TerminalError{ From 36c69451a4f8cb7ab191640547cf59744417275d Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 23:13:40 +0300 Subject: [PATCH 082/145] docs(controller): give Sync back its own doc comment Inserting MigrateNames above Sync left Sync's comment attached to the new method, so go doc showed one method documented twice and the other not at all. Signed-off-by: Ilya Drey --- .../internal/services/repo_sync_service.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 774c894a..f6bcc071 100644 --- a/images/operator-helm-controller/internal/services/repo_sync_service.go +++ b/images/operator-helm-controller/internal/services/repo_sync_service.go @@ -56,9 +56,6 @@ func NewRepoSyncService(client client.Client, scheme *runtime.Scheme, factory Re } } -// 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. // 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 @@ -70,6 +67,9 @@ func (s *RepoSyncService) MigrateNames(ctx context.Context, repo source.Reposito 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 source.Repository, From 54656c2a046631e487e1bcc5a54a162f25327240 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 23:13:42 +0300 Subject: [PATCH 083/145] ci: run the linters again The job was commented out waiting for a golangci-lint that could read a module targeting the new toolchain. This branch builds the linter from source instead of downloading a release, so the wait is over and the eighteen findings just cleared have something holding them out. Signed-off-by: Ilya Drey --- .github/workflows/lint.yaml | 41 ++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2bb3d934..96d12c3b 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -45,25 +45,28 @@ jobs: # 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 -# 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 + 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 }} - - # - name: Install golangci-lint - # run: task --yes deps:install:golangci-lint + - name: Install Task + uses: arduino/setup-task@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - # - name: Run linters - # run: task --yes lint + # 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 From ee2f83827eee01503573c0f4dccfafb60b486631 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 23:13:43 +0300 Subject: [PATCH 084/145] chore: align the Go version of the hooks module too It was the one module left behind when the others were aligned, for no reason other than that nothing forced it. Signed-off-by: Ilya Drey --- images/hooks/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 5ff439a0be195ea2d061cfd64ab25c76ec56bf6f Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Fri, 11 Sep 2026 23:32:23 +0300 Subject: [PATCH 085/145] fix(controller): report a failed rename on a pass that syncs nothing The verdict of a failed rename was written only when the pass also attempted a synchronization, but the rename runs on every pass by design. A repository already past its schedule therefore kept the Synced=True its last successful sync had left, reporting health while its consumers could not resolve their chart, and the failure reached neither the work queue nor the logs. Signed-off-by: Ilya Drey --- .../internal/reconcile/repository/evaluate.go | 7 +- .../reconcile/repository/reconciler_test.go | 77 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/images/operator-helm-controller/internal/reconcile/repository/evaluate.go b/images/operator-helm-controller/internal/reconcile/repository/evaluate.go index c14a7e49..2854cacd 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/evaluate.go +++ b/images/operator-helm-controller/internal/reconcile/repository/evaluate.go @@ -110,7 +110,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) } @@ -147,7 +150,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)), } } diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 5efcc4fd..dcd4f9f5 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -20,6 +20,7 @@ import ( "context" "errors" "reflect" + "strings" "testing" "time" @@ -387,6 +388,82 @@ func TestReconcileReportsAFailedMigration(t *testing.T) { } } +// 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) + } +} + func TestReconcileTerminalFetchFailureStalls(t *testing.T) { repo := ociRepository() stub := &stubRepoClient{err: &repoclient.TerminalError{ From 883a3b40031ba8b7f8f39cb5dae588484fd00dda Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 13:18:22 +0300 Subject: [PATCH 086/145] fix(rbac): move repository read to the secrets-reading level HelmApplicationRepository and HelmClusterApplicationRepository carry their registry credentials in plaintext (spec.auth.username/password), so any right to list them is a right to read that password. The viewer capability and the classic User role granted get/list/watch on both kinds; move that read to the level where Deckhouse first permits reading Secrets instead (the new user capability, and the PrivilegedUser role), leaving HelmApplication and the chart catalogs at viewer/User. Write-level roles keep read access transitively through the existing aggregation chain. Signed-off-by: Ilya Drey --- docs/README.md | 3 ++- docs/README.ru.md | 3 ++- templates/rbacv2/use/user.yaml | 17 +++++++++++++++++ templates/rbacv2/use/view.yaml | 4 ++-- templates/user-authz-cluster-roles.yaml | 23 +++++++++++++++++++++-- 5 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 templates/rbacv2/use/user.yaml diff --git a/docs/README.md b/docs/README.md index 737d8c13..7abf2461 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,7 +38,8 @@ The following custom resources are used to manage Helm charts in the module: - The application family (HelmApplication, HelmApplicationChart, HelmApplicationRepository) is namespaced: a namespace owner can create and manage these resources in their own namespace without cluster-wide rights. HelmClusterApplicationRepository and the HelmClusterApplicationChart catalog it publishes are cluster-scoped, so creating a HelmClusterApplicationRepository still requires cluster-wide rights, 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: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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. -- The module grants no more than read access to a namespace user by default: the `User` role (or, in the new role model, the `viewer` capability from this module's `d8:use:capability:module:operator-helm:view` ClusterRole) gives `get`/`list`/`watch` on all five resources above. Creating or modifying HelmApplication and HelmApplicationRepository requires the `Admin` role (or the `admin` capability from `d8:use:capability:module:operator-helm:admin`) — deliberately kept out of `Editor`, since it carries namespace-admin-equivalent rights, as explained above. Creating or modifying HelmClusterApplicationRepository requires the `ClusterEditor` role (or, in the new role model, the cluster-wide `manager` permission granted through `d8:manage:permission:module:operator-helm:edit`, whose `view` counterpart also covers read access to HelmClusterApplicationRepository and HelmClusterApplicationChart). +- `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. For that reason, reading either repository kind requires the `PrivilegedUser` role (or, in the new role model, the `user` capability from this module's `d8:use:capability:module:operator-helm:user` ClusterRole) — the level from which Deckhouse permits reading Secrets — rather than `User`/`viewer`. +- The module grants no more than read access to a namespace user by default: the `User` role (or, in the new role model, the `viewer` capability from this module's `d8:use:capability:module:operator-helm:view` ClusterRole) gives `get`/`list`/`watch` on HelmApplication and both chart catalogs; see the point above for the two repository kinds. Creating or modifying HelmApplication and HelmApplicationRepository requires the `Admin` role (or the `admin` capability from `d8:use:capability:module:operator-helm:admin`) — deliberately kept out of `Editor`, since it carries namespace-admin-equivalent rights, as explained above. Creating or modifying HelmClusterApplicationRepository requires the `ClusterEditor` role (or, in the new role model, the cluster-wide `manager` permission granted through `d8:manage:permission:module:operator-helm:edit`, whose `view` counterpart also covers read access to HelmClusterApplicationRepository and HelmClusterApplicationChart). - 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 00d49d1f..9b5687c9 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -38,7 +38,8 @@ weight: 10 - Семейство приложений (HelmApplication, HelmApplicationChart, HelmApplicationRepository) является namespaced: владелец namespace может создавать эти ресурсы и управлять ими в своём namespace без прав на весь кластер. HelmClusterApplicationRepository и публикуемый им каталог HelmClusterApplicationChart являются кластерными ресурсами, поэтому для создания HelmClusterApplicationRepository по-прежнему нужны права на весь кластер, но любой HelmApplication может ссылаться на уже существующий HelmClusterApplicationRepository из своего namespace. - Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание HelmApplication без прочих прав в namespace даёт через устанавливаемый чарт тот же уровень доступа, что и права администратора namespace. - HelmApplication нельзя создать в системном namespace (`kube-system`, `kube-public`, `kube-node-lease`, а также в любом namespace, имя которого начинается с `d8-`, включая собственный namespace модуля `d8-operator-helm`); admission-контроллер отклоняет такую попытку. -- По умолчанию модуль даёт пользователю namespace не больше чем доступ на чтение: роль `User` (или, в новой модели ролей, capability `viewer` из ClusterRole `d8:use:capability:module:operator-helm:view` модуля) даёт `get`/`list`/`watch` на все пять ресурсов выше. Для создания или изменения HelmApplication и HelmApplicationRepository требуется роль `Admin` (или capability `admin` из `d8:use:capability:module:operator-helm:admin`) — она намеренно не входит в `Editor`, поскольку даёт права, равносильные правам администратора namespace, как описано выше. Для создания или изменения HelmClusterApplicationRepository требуется роль `ClusterEditor` (или, в новой модели ролей, кластерное право `manager`, выдаваемое через `d8:manage:permission:module:operator-helm:edit`, парная роль `view` которого также даёт доступ на чтение к HelmClusterApplicationRepository и HelmClusterApplicationChart). +- HelmApplicationRepository и HelmClusterApplicationRepository хранят учётные данные реестра в открытом виде (`spec.auth.username` и `spec.auth.password`; альтернативы через `secretRef` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. По этой причине чтение обоих видов репозиториев требует роли `PrivilegedUser` (или, в новой модели ролей, capability `user` из ClusterRole `d8:use:capability:module:operator-helm:user` модуля) — уровня, с которого Deckhouse разрешает чтение Secrets, а не `User`/`viewer`. +- По умолчанию модуль даёт пользователю namespace не больше чем доступ на чтение: роль `User` (или, в новой модели ролей, capability `viewer` из ClusterRole `d8:use:capability:module:operator-helm:view` модуля) даёт `get`/`list`/`watch` на HelmApplication и оба каталога чартов; про оба вида репозиториев — см. пункт выше. Для создания или изменения HelmApplication и HelmApplicationRepository требуется роль `Admin` (или capability `admin` из `d8:use:capability:module:operator-helm:admin`) — она намеренно не входит в `Editor`, поскольку даёт права, равносильные правам администратора namespace, как описано выше. Для создания или изменения HelmClusterApplicationRepository требуется роль `ClusterEditor` (или, в новой модели ролей, кластерное право `manager`, выдаваемое через `d8:manage:permission:module:operator-helm:edit`, парная роль `view` которого также даёт доступ на чтение к HelmClusterApplicationRepository и HelmClusterApplicationChart). - Ресурс HelmClusterAddon, ссылающийся на заданный HelmClusterAddonChart, может быть создан в кластере только в единственном экземпляре. Это обусловлено тем, что Helm-чарты могут содержать определения кастомных ресурсов (CRD), повторная установка которых на уровне кластера недопустима. Примеры использования приведены в разделе [примеры использования](example.html). diff --git a/templates/rbacv2/use/user.yaml b/templates/rbacv2/use/user.yaml new file mode 100644 index 00000000..85a6eb25 --- /dev/null +++ b/templates/rbacv2/use/user.yaml @@ -0,0 +1,17 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "use" "rbac.deckhouse.io/aggregate-to-kubernetes-as" "user")) | nindent 2 }} + name: d8:use:capability:module:{{ .Chart.Name }}:user +rules: +# Repository credentials are stored in plaintext, so this is where reading a repository +# starts: the level at which Deckhouse permits reading secrets, not at viewer. +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplicationrepositories + - helmclusterapplicationrepositories + verbs: + - get + - list + - watch diff --git a/templates/rbacv2/use/view.yaml b/templates/rbacv2/use/view.yaml index abe70dbc..b0772256 100644 --- a/templates/rbacv2/use/view.yaml +++ b/templates/rbacv2/use/view.yaml @@ -4,13 +4,13 @@ metadata: {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "use" "rbac.deckhouse.io/aggregate-to-kubernetes-as" "viewer")) | nindent 2 }} name: d8:use:capability:module:{{ .Chart.Name }}:view rules: +# Repository credentials are stored in plaintext, so read access to a repository is read +# access to its password; that right is granted one level up, where secrets become readable. - apiGroups: - helm.deckhouse.io resources: - helmapplications - - helmapplicationrepositories - helmapplicationcharts - - helmclusterapplicationrepositories - helmclusterapplicationcharts verbs: - get diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index 657d6252..6667f7d9 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -11,9 +11,7 @@ rules: - helm.deckhouse.io resources: - helmapplications - - helmapplicationrepositories - helmapplicationcharts - - helmclusterapplicationrepositories - helmclusterapplicationcharts verbs: - get @@ -22,6 +20,27 @@ rules: --- 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: +# Repository credentials (spec.auth.username/password) are stored in plaintext, so read +# access to a repository is read access to its password; that is why it starts here, +# at the level where Deckhouse permits reading secrets, rather than at User. +- apiGroups: + - helm.deckhouse.io + resources: + - helmapplicationrepositories + - helmclusterapplicationrepositories + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: annotations: user-authz.deckhouse.io/access-level: Admin From 2d4f92e2d3506a7ec5debe8cdc52afa76ba00ba1 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 13:19:22 +0300 Subject: [PATCH 087/145] fix(controller): do not synchronize over an unfinished rename 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 made the next pass consider the carry-over done and delete that last copy, so a consumer pinned to that version lost the media type it needs and could not recover: the version is gone from the remote too. The gate before the synchronization now checks the rename alongside the two failures it already checked. Signed-off-by: Ilya Drey --- .../internal/catalog/catalog_test.go | 8 +- .../reconcile/repository/reconciler.go | 6 +- .../reconcile/repository/reconciler_test.go | 96 +++++++++++++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index b8d4be00..697596c6 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -134,8 +134,12 @@ func legacyAddonChart(name, repoName, chartName string, versions ...helmv1alpha1 } } -// syncCatalog runs the two steps the repository reconciler runs, in its order: the -// rename first, independent of any fetch, then the catalog write. +// 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 diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go index a786c939..2ccc2f8c 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go @@ -151,7 +151,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco in.Forced = repo.ForceReconcileRequired() - if in.SecretsErr == nil && in.InternalRepositoryErr == nil && + // 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 diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index dcd4f9f5..27525043 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -464,6 +464,102 @@ func TestReconcileReportsAFailedMigrationWithoutAnAttempt(t *testing.T) { } } +// 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{ From 5076558542fd08e050fe90b91d3585b2a841ee93 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 13:19:33 +0300 Subject: [PATCH 088/145] fix(release): requeue a release whose identity setup failed EnsureAccess failing returned only the status-patch error, which is nil on a successful write, so a failed identity setup was reported once and never retried: nothing watches the ServiceAccount/RoleBinding it manages, and a status-only update is filtered out by the controller's predicates. Return the service error alongside the (best-effort) status write so the work queue's rate limiter picks the release back up. Signed-off-by: Ilya Drey --- .../internal/reconcile/release/reconciler.go | 7 +- .../reconcile/release/reconciler_test.go | 76 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler.go b/images/operator-helm-controller/internal/reconcile/release/reconciler.go index a92b38da..6a3e22c9 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler.go @@ -196,12 +196,17 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // 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 err := r.deps.Access.EnsureAccess(ctx, rel); err != nil { - return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( + // The status write is best-effort: what must not be lost is err itself. + // Nothing watches the ServiceAccount/RoleBinding this step manages, so the + // work queue's rate limiter retrying on the returned error is the only thing + // that brings a transient failure back for another pass. + _ = r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( rel.Object(), helmv1alpha1.ReasonAccessSetupFailed, fmt.Sprintf("Failed to set up the release identity: %s", err.Error()), err, )}) + return reconcile.Result{}, err } // From here on every path reaches the status update at the end of the pass, diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index 43e5e078..ad7ff423 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -589,16 +589,20 @@ func (failingAccess) CleanupAccess(context.Context, source.Release) error { retu // 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. +// 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)...) - reconcileApplication(t, r, 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{} - key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} if err := c.Get(context.Background(), key, settled); err != nil { t.Fatalf("getting application: %v", err) } @@ -618,6 +622,72 @@ func TestReconcileApplicationReportsAccessSetupFailure(t *testing.T) { } } +// 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 source.AccessManager + calls int +} + +func (a *intermittentAccess) EnsureAccess(ctx context.Context, rel source.Release) error { + a.calls++ + if a.calls == 1 { + return errors.New("service account is forbidden") + } + 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") + } +} + func ociRepositoryFixture() *helmv1alpha1.HelmClusterAddonRepository { return &helmv1alpha1.HelmClusterAddonRepository{ ObjectMeta: metav1.ObjectMeta{Name: "example", Generation: 1}, From 077ab70df02aeb40c95387c5c1847d81d1d797fb Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 13:20:31 +0300 Subject: [PATCH 089/145] fix(release): mark a failed release-identity cleanup on the status CleanupAccess failing during deletion already returned an error, so the finalizer correctly stays and the queue retries, but the status was left untouched. Every earlier step of the delete path marks the status through the shared status manager; this one did not, so the object gave no reason for why deletion was stuck. Call MarkDeletionFailed, mirroring the sibling repository reconciler's use of the same method. Signed-off-by: Ilya Drey --- .../internal/reconcile/release/reconciler.go | 5 ++ .../reconcile/release/reconciler_test.go | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler.go b/images/operator-helm-controller/internal/reconcile/release/reconciler.go index 6a3e22c9..42196a37 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler.go @@ -380,6 +380,11 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (r // 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) } diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index ad7ff423..4222fe80 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -37,6 +37,7 @@ import ( "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" @@ -688,6 +689,58 @@ func TestReconcileApplicationRecoversAfterTransientAccessSetupFailure(t *testing } } +// 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) error { return nil } + +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}, From 49be9b49e6a06e54f74891aefd9f7bbbae562aaf Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 13:21:51 +0300 Subject: [PATCH 090/145] fix(api/naming): sanitize chart object name parts before assembly chartObjectName truncated and trimmed the repository and chart name but never lower-cased or replaced characters a Kubernetes object name cannot hold, so an upper-case or space-containing chart from a repository index (both legal there) produced a name the API server rejects, failing CreateOrPatch for that one entry and taking Synced=False for the whole repository with it. Sanitize both readable parts (lower-case, replace anything outside [a-z0-9.-] with a dash) before the existing truncate/trim logic runs, and widen the final trim to both ends so an empty or all-separator readable part no longer leaves a leading dash. The hash still hashes the raw inputs, so uniqueness is unaffected. Signed-off-by: Ilya Drey --- api/naming/naming.go | 46 +++++++++++++++++----- api/naming/naming_test.go | 80 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/api/naming/naming.go b/api/naming/naming.go index 010c9855..fe77e215 100644 --- a/api/naming/naming.go +++ b/api/naming/naming.go @@ -50,31 +50,59 @@ func ClusterApplicationChartName(repoName, chartName string) string { // 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. +// 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) + repoPart := sanitize(repoName) + chartPart := sanitize(chartName) + var result string - if len(repoName) > 20 { + 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(repoName[:20], "-.") + "-chart-" + result += strings.TrimRight(repoPart[:20], "-.") + "-chart-" } else { - // Same reasoning as the truncated branch above: repoName is followed by + // 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(repoName, "-.") + "-chart-" + result += strings.TrimRight(repoPart, "-.") + "-chart-" } - if len(chartName) > 20 { - result += chartName[:20] + 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, "-.") + "-" + hash + return b.String() } func hash(s string) string { diff --git a/api/naming/naming_test.go b/api/naming/naming_test.go index f4b120d3..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 { @@ -60,13 +64,37 @@ func TestHelmClusterAddonChartName(t *testing.T) { 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) + } }) } } @@ -113,13 +141,35 @@ func TestApplicationChartName(t *testing.T) { 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) { - if got := ApplicationChartName(tc.repo, tc.chart); got != tc.want { + 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) + } }) } } @@ -143,13 +193,35 @@ func TestClusterApplicationChartName(t *testing.T) { 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) { - if got := ClusterApplicationChartName(tc.repo, tc.chart); got != tc.want { + 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) + } }) } } From 1243296111aa5e729801309614ce6d88170ff3e5 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 13:22:11 +0300 Subject: [PATCH 091/145] fix(chart-values-controller): bound and validate the chart-values request The handler decoded an unbounded body and validated only presence of the request fields before authorizing, so an unauthenticated caller could send an arbitrarily large body, hold the handler open past the header-only timeout, or send a whitespace-only version that would never match a catalog entry and poll as pending forever; an invalid repositoryName reached the resolver and read back as repository_not_found instead of a bad request. Bound the body with http.MaxBytesReader (answering 413 when exceeded), add ReadTimeout and WriteTimeout alongside the existing ReadHeaderTimeout, and move the cheap bearer-token presence check ahead of body parsing; the actual token/access review still needs the kind and namespace the body carries, so it stays after. Validate repositoryName as an object name and against the length bounds its own repository CRD enforces, and reject a whitespace-only chart or version, bounding both lengths without imposing a naming grammar neither field actually has. Signed-off-by: Ilya Drey --- .../internal/server/server.go | 134 +++++++++++++-- .../internal/server/server_test.go | 158 ++++++++++++++++++ 2 files changed, 277 insertions(+), 15 deletions(-) diff --git a/images/chart-values-controller/internal/server/server.go b/images/chart-values-controller/internal/server/server.go index 00fa4499..a360c68a 100644 --- a/images/chart-values-controller/internal/server/server.go +++ b/images/chart-values-controller/internal/server/server.go @@ -38,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) } @@ -84,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() @@ -113,6 +136,20 @@ 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"` @@ -124,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 } @@ -156,7 +209,14 @@ func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { return } } - if !s.authorize(w, r, access, displayKind) { + 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 } @@ -229,19 +289,63 @@ func accessFor(kind, namespace string) (access auth.Access, displayKind string, } } -// authorize reviews the request's bearer token against access and reports whether -// it may proceed. On any negative outcome it writes the response itself and returns +// 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 + } +} + +// 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, access auth.Access, displayKind string) bool { +func (s *Server) authorize(w http.ResponseWriter, r *http.Request, token string, access auth.Access, displayKind string) bool { logger := log.FromContext(r.Context()) - token, ok := bearerToken(r) - if !ok { - writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing or malformed Authorization header") - return false - } - result, err := s.reviewer.Review(r.Context(), token, access) if err != nil { logger.Error(err, "failed to review request token") diff --git a/images/chart-values-controller/internal/server/server_test.go b/images/chart-values-controller/internal/server/server_test.go index 94a94388..70a9593d 100644 --- a/images/chart-values-controller/internal/server/server_test.go +++ b/images/chart-values-controller/internal/server/server_test.go @@ -368,3 +368,161 @@ func TestHandleForbiddenMessageNamesTheResourceKind(t *testing.T) { }) } } + +// 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()) + } +} From 2f99b5ae67c9cafb9c1b1a5d42a8809c76e1c8dd Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 13:24:32 +0300 Subject: [PATCH 092/145] fix(controller): give an application a release name nothing else can take MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release name hashed only what exceeded Helm's limit, so a short name spelled exactly like the cut and hashed form of a long one produced the same release. Both names are valid, both applications live in one namespace, and two releases under one name share one storage: each overwrites the other's history, and deleting one uninstalls the other. The application family now always carries the hash, which removes the second branch the two names met in. The addon family keeps its scheme — moving it would move every release installed today — and the limit is recorded where the scheme lives. Signed-off-by: Ilya Drey --- .../internal/adapter/application_release.go | 6 ++- .../adapter/application_release_test.go | 7 +++- .../internal/utils/name.go | 42 ++++++++++++++++--- .../internal/utils/name_test.go | 37 ++++++++++++++++ tests/e2e/internal/naming/naming.go | 20 +++++---- tests/e2e/internal/naming/naming_test.go | 4 +- 6 files changed, 95 insertions(+), 21 deletions(-) diff --git a/images/operator-helm-controller/internal/adapter/application_release.go b/images/operator-helm-controller/internal/adapter/application_release.go index fc0bf74b..c613a80f 100644 --- a/images/operator-helm-controller/internal/adapter/application_release.go +++ b/images/operator-helm-controller/internal/adapter/application_release.go @@ -98,9 +98,11 @@ func (r *ApplicationRelease) LastAppliedValues() *apiextensionsv1.JSON { // 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. +// 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.HelmReleaseName(applicationPrefix + "-" + r.obj.Name) + return utils.HashedReleaseName(applicationPrefix + "-" + r.obj.Name) } func (r *ApplicationRelease) SourceLabels() map[string]string { diff --git a/images/operator-helm-controller/internal/adapter/application_release_test.go b/images/operator-helm-controller/internal/adapter/application_release_test.go index 88783080..b44b9573 100644 --- a/images/operator-helm-controller/internal/adapter/application_release_test.go +++ b/images/operator-helm-controller/internal/adapter/application_release_test.go @@ -116,8 +116,11 @@ func TestApplicationReleaseNamesAndLabels(t *testing.T) { if rel.Kind() != helmv1alpha1.HelmApplicationKind { t.Fatalf("Kind = %q", rel.Kind()) } - if rel.ReleaseName() != "hap-my-app" { - t.Fatalf("ReleaseName = %q, want hap-", rel.ReleaseName()) + // 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{ diff --git a/images/operator-helm-controller/internal/utils/name.go b/images/operator-helm-controller/internal/utils/name.go index 7a02813f..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 { @@ -176,16 +179,43 @@ func truncatePart(part string) string { // 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 to 40 characters and suffixed with a 12-character hash of the -// full name, so two long names that share a prefix stay distinct. The cut is -// trimmed of a trailing dash or dot: a dash would double up against the suffix, -// and a dot would leave the suffix starting a DNS label, which is not a valid name. +// 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 strings.TrimRight(name[:40], "-.") + "-" + GetHash(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 index d48221e8..36d453ed 100644 --- a/images/operator-helm-controller/internal/utils/name_test.go +++ b/images/operator-helm-controller/internal/utils/name_test.go @@ -161,6 +161,43 @@ func TestDerivedNameStaysWithinTheLabelLimitForTheLongestPrefix(t *testing.T) { } } +// 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 diff --git a/tests/e2e/internal/naming/naming.go b/tests/e2e/internal/naming/naming.go index 27d8d1df..440d4958 100644 --- a/tests/e2e/internal/naming/naming.go +++ b/tests/e2e/internal/naming/naming.go @@ -68,25 +68,27 @@ const helmReleaseNameLimit = 53 // ApplicationReleaseName reproduces the Helm release name operator-helm-controller // installs a HelmApplication's chart under: the twin of -// utils.HelmReleaseName("hap-"+name) in +// utils.HashedReleaseName("hap-"+name) in // images/operator-helm-controller/internal/adapter/application_release.go -// (ApplicationRelease.ReleaseName). A name within the limit is used as is; a -// longer one is cut to 40 characters and suffixed with a 12-character hash of the -// full name, mirroring HelmReleaseName's own truncation branch. +// (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 the "hap-prefixed name over the limit is cut and hashed" -// case in TestHelmReleaseName +// 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 - if len(full) <= helmReleaseNameLimit { - return full + + readable := full + if len(readable) > helmReleaseNameLimit-13 { + readable = readable[:helmReleaseNameLimit-13] } sum := sha256.Sum256([]byte(full)) - return strings.TrimRight(full[:40], "-.") + "-" + fmt.Sprintf("%x", sum[:])[:12] + return strings.TrimRight(readable, "-.") + "-" + fmt.Sprintf("%x", sum[:])[:12] } // ApplicationRepositoryInternalName reproduces the name operator-helm-controller diff --git a/tests/e2e/internal/naming/naming_test.go b/tests/e2e/internal/naming/naming_test.go index f6a61506..56a3857c 100644 --- a/tests/e2e/internal/naming/naming_test.go +++ b/tests/e2e/internal/naming/naming_test.go @@ -82,9 +82,9 @@ func TestApplicationReleaseName(t *testing.T) { want string }{ { - name: "a short name is used as is", + name: "a short name still carries the hash", object: "e2e-test-app-helm", - want: "hap-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 From 8c0d5b24ce129888f45482f80eea69aef0e12706 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 13:26:12 +0300 Subject: [PATCH 093/145] fix(controller): keep applications out of default and every kube- namespace The rule named three kube- namespaces and the d8- prefix, so an application could be installed into default or into any other kube- one. Installing it there seeds a Role granting everything inside that namespace to whatever the chart contains. The rule mattered less when only a cluster administrator could create a namespaced resource here. Signed-off-by: Ilya Drey --- .../internal/utils/namespace.go | 14 +++--- .../internal/utils/namespace_test.go | 50 +++++++++++++++++++ tests/e2e/helmapplication/system_namespace.go | 1 + 3 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 images/operator-helm-controller/internal/utils/namespace_test.go diff --git a/images/operator-helm-controller/internal/utils/namespace.go b/images/operator-helm-controller/internal/utils/namespace.go index 81bc1542..4c446008 100644 --- a/images/operator-helm-controller/internal/utils/namespace.go +++ b/images/operator-helm-controller/internal/utils/namespace.go @@ -20,14 +20,14 @@ import ( "strings" ) +// IsSystemNamespace reports whether a namespace belongs to the cluster or to +// Deckhouse rather than to a user. The set matches what Deckhouse itself excludes +// from a user's reach: every kube- namespace, every d8- one, and default, which is +// shared by everyone and owned by no one. func IsSystemNamespace(namespace string) bool { - systemNamespaces := []string{"kube-system", "kube-node-lease", "kube-public"} - - for _, s := range systemNamespaces { - if namespace == s { - return true - } + if namespace == "default" { + return true } - return strings.HasPrefix(namespace, "d8-") + 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..5e24a050 --- /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", true}, + {"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/tests/e2e/helmapplication/system_namespace.go b/tests/e2e/helmapplication/system_namespace.go index f3468682..6ccaae79 100644 --- a/tests/e2e/helmapplication/system_namespace.go +++ b/tests/e2e/helmapplication/system_namespace.go @@ -65,5 +65,6 @@ var _ = Describe("HelmApplication system namespace restriction", Ordered, func() Entry("kube-public", "kube-public"), Entry("kube-node-lease", "kube-node-lease"), Entry("the module's own namespace", moduleNS), + Entry("default", "default"), ) }) From ef207217637631f23410694dfc234668dc56065a Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:27:53 +0300 Subject: [PATCH 094/145] fix(api): bound HelmClusterAddonRepository name length via CEL Mirror the CEL rule already on HelmApplicationRepository and HelmClusterApplicationRepository: this repository's name is likewise copied into the "repository" label on its chart catalog objects, and a label value cannot exceed 63 characters. Because this rule also applies to updates of existing objects, any live HelmClusterAddonRepository whose name is shorter than 3 characters becomes un-updatable by this change, including status writes from the controller. Signed-off-by: Ilya Drey --- api/v1alpha1/helm_cluster_addon_repository.go | 8 +++++++- crds/helmclusteraddonrepositories.yaml | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/api/v1alpha1/helm_cluster_addon_repository.go b/api/v1alpha1/helm_cluster_addon_repository.go index 56713b21..af282872 100644 --- a/api/v1alpha1/helm_cluster_addon_repository.go +++ b/api/v1alpha1/helm_cluster_addon_repository.go @@ -32,7 +32,12 @@ const ( // 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 +// The name length is guarded by a CEL rule rather than by the schema because +// metadata.name has no schema of its own. The upper bound is not decorative: the +// repository name is stored as the value of the "repository" label on the objects +// of its chart catalog, and a label value cannot exceed 63 characters. +// +// These notes are 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. @@ -41,6 +46,7 @@ const ( // +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" diff --git a/crds/helmclusteraddonrepositories.yaml b/crds/helmclusteraddonrepositories.yaml index f9be8574..26a9a47c 100644 --- a/crds/helmclusteraddonrepositories.yaml +++ b/crds/helmclusteraddonrepositories.yaml @@ -206,6 +206,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: From d2a39b4ffd5d34bb4077aeb20788beef9d508f9f Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:35:11 +0300 Subject: [PATCH 095/145] chore(api): name the conditions the addon reads The condition type and the reason were still spelled as literals here, while the application twin next to them uses the constants. Signed-off-by: Ilya Drey --- api/v1alpha1/helm_cluster_addon.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/v1alpha1/helm_cluster_addon.go b/api/v1alpha1/helm_cluster_addon.go index f8828c2f..8e198b52 100644 --- a/api/v1alpha1/helm_cluster_addon.go +++ b/api/v1alpha1/helm_cluster_addon.go @@ -77,7 +77,7 @@ func (r *HelmClusterAddon) MaintenanceModeEnabled() bool { } func (r *HelmClusterAddon) GetConditionTypesForUpdate() []string { - conditionTypes := []string{"Ready"} + conditionTypes := []string{ConditionTypeReady} if r.Status.LastAppliedChart == nil || !apimeta.IsStatusConditionPresentAndEqual(r.Status.Conditions, ConditionTypeInstalled, metav1.ConditionTrue) { return append(conditionTypes, ConditionTypeInstalled) @@ -104,7 +104,7 @@ func (r *HelmClusterAddon) ConfigurationApplyInProgress() bool { return false } - return cond.Status == metav1.ConditionUnknown && cond.Reason == "Reconciling" + return cond.Status == metav1.ConditionUnknown && cond.Reason == ReasonReconciling } func (r *HelmClusterAddon) UpdateInstallInProgress() bool { @@ -113,7 +113,7 @@ func (r *HelmClusterAddon) UpdateInstallInProgress() bool { return false } - return cond.Status == metav1.ConditionUnknown && cond.Reason == "Reconciling" + return cond.Status == metav1.ConditionUnknown && cond.Reason == ReasonReconciling } func (r *HelmClusterAddon) IsChartStatusInfoOutdated() bool { From 06905fbb6c75a8b95907e4e47f9f1ea87cadda09 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:35:12 +0300 Subject: [PATCH 096/145] chore(rbac): close the remaining status subresources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy closed the status of every kind an application's account can reach but stopped short of the two cluster-scoped repositories. No role this module seeds reaches them, so nothing depends on it — but an entry missing from a list like this reads as an oversight rather than a decision. Signed-off-by: Ilya Drey --- templates/admision-policy.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/templates/admision-policy.yaml b/templates/admision-policy.yaml index c7b71134..41a5a19b 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -45,11 +45,15 @@ spec: - "helmapplicationcharts/status" - "helmclusterapplicationcharts" - "helmclusterapplicationcharts/status" - # Same reasoning for the namespaced kinds an application's account can - # reach: their spec stays open, only the status the controller owns is - # closed. + # 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:") || From 225f1a98d153c700d41f3ae8bfd024f6ef341632 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:35:25 +0300 Subject: [PATCH 097/145] test(e2e): clean up the namespace and wait for the uninstall The delete helper promised to wait for the internal helm release and polled only the application, a weaker signal than the addon family's own helper gives. And the namespace each suite creates was never registered for deletion, so a run against a live cluster left it behind along with whatever the module had seeded in it. Signed-off-by: Ilya Drey --- tests/e2e/internal/framework/framework.go | 5 +++++ tests/e2e/internal/util/helmapplication.go | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) 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/util/helmapplication.go b/tests/e2e/internal/util/helmapplication.go index 3d428532..4cf85a85 100644 --- a/tests/e2e/internal/util/helmapplication.go +++ b/tests/e2e/internal/util/helmapplication.go @@ -83,14 +83,24 @@ func DeleteHelmApplication(f *framework.Framework, namespace, name string, timeo UntilHelmApplicationDeleted(namespace, name, timeout) } -// UntilHelmApplicationDeleted waits until the application object is gone. +// 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()) } From 0007a0fe6ccb838258f78d9c2d455c4785af457c Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:35:26 +0300 Subject: [PATCH 098/145] docs: describe the chart catalogs once, as the service objects they are The resource list repeated the same sentence for all three catalog kinds and the limitations named one of them among the resources a namespace owner manages, which the policy and the roles both contradict. Signed-off-by: Ilya Drey --- docs/README.md | 7 +++---- docs/README.ru.md | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/README.md b/docs/README.md index 7abf2461..9ce02a6e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,18 +24,17 @@ The module controller monitors the state of HelmClusterAddon and HelmApplication 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. -- **HelmApplicationChart** — a Helm chart discovered in the connected HelmApplicationRepository. These resources are automatically created and updated by the controller during repository synchronization and are protected from manual changes. - **HelmClusterApplicationRepository** — a Helm or OCI registry containing Helm charts that can be referenced by HelmApplication resources from any namespace. -- **HelmClusterApplicationChart** — a Helm chart discovered in the connected HelmClusterApplicationRepository. These resources are automatically created and updated by the controller during repository synchronization and are protected from manual changes. - **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 - The addon family (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) is entirely cluster-scoped, so admin privileges (the `cluster-admin` role) are required to manage it. -- The application family (HelmApplication, HelmApplicationChart, HelmApplicationRepository) is namespaced: a namespace owner can create and manage these resources in their own namespace without cluster-wide rights. HelmClusterApplicationRepository and the HelmClusterApplicationChart catalog it publishes are cluster-scoped, so creating a HelmClusterApplicationRepository still requires cluster-wide rights, but any HelmApplication may reference an existing one from its own namespace. +- The application family is namespaced: a namespace owner can create and manage HelmApplication and HelmApplicationRepository in their own namespace without cluster-wide rights. HelmClusterApplicationRepository is cluster-scoped, so creating one still requires cluster-wide rights, 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: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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. For that reason, reading either repository kind requires the `PrivilegedUser` role (or, in the new role model, the `user` capability from this module's `d8:use:capability:module:operator-helm:user` ClusterRole) — the level from which Deckhouse permits reading Secrets — rather than `User`/`viewer`. diff --git a/docs/README.ru.md b/docs/README.ru.md index 9b5687c9..ff5919dd 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -24,18 +24,17 @@ weight: 10 Для управления Helm-чартами в модуле используются следующие кастомные ресурсы: - **HelmClusterAddonRepository** — репозиторий Helm или OCI, содержащий Helm-чарты для последующей установки в кластере. -- **HelmClusterAddonChart** — Helm-чарт, обнаруженный в подключённом репозитории. Эти ресурсы создаются и обновляются контроллером автоматически при синхронизации репозиториев и защищены от изменений. - **HelmClusterAddon** — декларативное описание конкретного релиза Helm-чарта. Ресурс содержит целевую версию чарта, имя пространства имён для развёртывания и пользовательские значения параметров. - **HelmApplicationRepository** — репозиторий Helm или OCI, на Helm-чарты которого могут ссылаться ресурсы HelmApplication из того же namespace. -- **HelmApplicationChart** — Helm-чарт, обнаруженный в подключённом репозитории HelmApplicationRepository. Эти ресурсы создаются и обновляются контроллером автоматически при синхронизации репозиториев и защищены от изменений. - **HelmClusterApplicationRepository** — репозиторий Helm или OCI, на Helm-чарты которого могут ссылаться ресурсы HelmApplication из любого namespace. -- **HelmClusterApplicationChart** — Helm-чарт, обнаруженный в подключённом репозитории HelmClusterApplicationRepository. Эти ресурсы создаются и обновляются контроллером автоматически при синхронизации репозиториев и защищены от изменений. - **HelmApplication** — декларативное описание установки Helm-чарта в пределах одного namespace. Релиз всегда развёртывается в namespace самого ресурса; ресурс содержит целевую версию чарта, ссылку либо на HelmApplicationRepository из того же namespace, либо на кластерный HelmClusterApplicationRepository, а также пользовательские значения параметров. +Каждый репозиторий дополнительно публикует каталог предлагаемых им чартов — HelmClusterAddonChart, HelmApplicationChart и HelmClusterApplicationChart. Контроллер создаёт и обновляет их при синхронизации репозиториев; эти ресурсы доступны только для чтения и вручную не редактируются. + ## Ограничения - Семейство аддонов (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) полностью кластерное, поэтому для управления им требуются права администратора кластера (роль `cluster-admin`). -- Семейство приложений (HelmApplication, HelmApplicationChart, HelmApplicationRepository) является namespaced: владелец namespace может создавать эти ресурсы и управлять ими в своём namespace без прав на весь кластер. HelmClusterApplicationRepository и публикуемый им каталог HelmClusterApplicationChart являются кластерными ресурсами, поэтому для создания HelmClusterApplicationRepository по-прежнему нужны права на весь кластер, но любой HelmApplication может ссылаться на уже существующий HelmClusterApplicationRepository из своего namespace. +- Семейство приложений является namespaced: владелец namespace может создавать HelmApplication и HelmApplicationRepository в своём namespace и управлять ими без прав на весь кластер. HelmClusterApplicationRepository — кластерный ресурс, поэтому для его создания по-прежнему нужны права на весь кластер, но любой HelmApplication может ссылаться на уже существующий из своего namespace. - Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание 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` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. По этой причине чтение обоих видов репозиториев требует роли `PrivilegedUser` (или, в новой модели ролей, capability `user` из ClusterRole `d8:use:capability:module:operator-helm:user` модуля) — уровня, с которого Deckhouse разрешает чтение Secrets, а не `User`/`viewer`. From 3e725846d176e1beeed910cef9b5fab50a507b3f Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:36:24 +0300 Subject: [PATCH 099/145] fix(rbac): narrow the operator's rights over the seeded application role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit escalate and bind are checked against the role a RoleBinding or Role update would name, so both can be restricted to resourceNames: [operator-helm-application] — the only Role this module ever seeds. create is left unrestricted because the object does not exist yet when the request is authorized. rolebindings also dropped list and watch: the RoleBinding cache is disabled and the code only ever does Get, Create, Patch and Delete on it. Signed-off-by: Ilya Drey --- .../operator-helm-controller/rbac-for-us.yaml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index 510497bd..43b2ee17 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -103,15 +103,24 @@ rules: - update - watch # The namespace role is seeded once and never read back, so create is the only -# verb the controller uses on it. Both escalate and bind are verbs on the role -# itself: escalate lets the controller write a role granting more than it holds, -# bind lets it point a role binding at such a role. +# verb the controller uses on it. create cannot be scoped by resourceNames: the +# object does not exist yet when the request is authorized. escalate and bind are +# checked against the referenced role instead: escalate lets the controller write +# a role granting more than it holds, bind lets it point a role binding at such a +# role. Both are scoped to the single role name this module ever seeds. - apiGroups: - rbac.authorization.k8s.io resources: - roles verbs: - create +- apiGroups: + - rbac.authorization.k8s.io + resources: + - roles + resourceNames: + - operator-helm-application + verbs: - escalate - bind - apiGroups: @@ -122,10 +131,8 @@ rules: - create - delete - get - - list - patch - update - - watch - apiGroups: - helm.internal.operator-helm.deckhouse.io resources: From bb1237a2dce9a4968b3113ac9ac864188c602d5d Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:37:14 +0300 Subject: [PATCH 100/145] fix(operator-helm-controller): read Namespaces through the API reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HelmApplication webhook and NamespaceService read a Namespace through the manager's cached client, whose reflector needs watch — a verb the ClusterRole does not grant. Without it the reflector relists in a loop and the cache is stale between relists, which matters for the webhook's delete path deciding whether the namespace is terminating. Both now read through mgr.GetAPIReader(), the same pattern ClaimService already uses for a decision that must not be made against stale data. Signed-off-by: Ilya Drey --- .../internal/controller/helmclusteraddon/controller.go | 2 +- .../internal/reconcile/release/reconciler_test.go | 2 +- .../internal/services/namespace_service.go | 10 +++++++--- .../internal/services/namespace_service_test.go | 2 +- .../internal/webhook/helmapplication/webhook.go | 10 +++++++--- .../internal/webhook/helmapplication/webhook_test.go | 2 +- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go index e99afa7e..831b55e6 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go @@ -49,7 +49,7 @@ func SetupWithManager(mgr ctrl.Manager) error { 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), + Namespaces: services.NewNamespaceService(client, mgr.GetAPIReader()), Access: source.NoAccess{}, Status: status.NewManager(client), }) diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index 4222fe80..1b1c8a25 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -346,7 +346,7 @@ func newFullReconciler( 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), + Namespaces: services.NewNamespaceService(c, c), Access: source.NoAccess{}, Status: status.NewManager(c), }), c diff --git a/images/operator-helm-controller/internal/services/namespace_service.go b/images/operator-helm-controller/internal/services/namespace_service.go index 4ed481c4..de96ceba 100644 --- a/images/operator-helm-controller/internal/services/namespace_service.go +++ b/images/operator-helm-controller/internal/services/namespace_service.go @@ -36,17 +36,21 @@ var _ source.TargetNamespaceEnsurer = (*NamespaceService)(nil) // 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) *NamespaceService { - return &NamespaceService{client: c} +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.client.Get(ctx, client.ObjectKey{Name: rel.TargetNamespace()}, ns) + err := s.reader.Get(ctx, client.ObjectKey{Name: rel.TargetNamespace()}, ns) if err == nil { 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 index 7b3ee87b..e7bfbe96 100644 --- a/images/operator-helm-controller/internal/services/namespace_service_test.go +++ b/images/operator-helm-controller/internal/services/namespace_service_test.go @@ -33,7 +33,7 @@ import ( func TestEnsureTargetNamespaceCreatesItOnceAndLeavesItAlone(t *testing.T) { addon := testAddon() c := fake.NewClientBuilder().WithScheme(testScheme(t)).Build() - service := NewNamespaceService(c) + service := NewNamespaceService(c, c) if err := service.EnsureTargetNamespace(context.Background(), adapter.NewAddonRelease(addon)); err != nil { t.Fatalf("EnsureTargetNamespace returned %v", err) diff --git a/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go b/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go index 6afc6e47..46c5ec9e 100644 --- a/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go +++ b/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go @@ -38,14 +38,18 @@ import ( func SetupWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr, &helmv1alpha1.HelmApplication{}). - WithValidator(&HelmApplicationWebhookValidator{Client: mgr.GetClient()}). + WithValidator(&HelmApplicationWebhookValidator{Reader: mgr.GetAPIReader()}). Complete() } var _ admission.Validator[*helmv1alpha1.HelmApplication] = (*HelmApplicationWebhookValidator)(nil) type HelmApplicationWebhookValidator struct { - Client client.Client + // 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) { @@ -80,7 +84,7 @@ func (v *HelmApplicationWebhookValidator) ValidateDelete(ctx context.Context, ap // must not turn into a way past it. func (v *HelmApplicationWebhookValidator) namespaceTerminating(ctx context.Context, name string) bool { namespace := &corev1.Namespace{} - if err := v.Client.Get(ctx, client.ObjectKey{Name: name}, namespace); err != nil { + if err := v.Reader.Get(ctx, client.ObjectKey{Name: name}, namespace); err != nil { return apierrors.IsNotFound(err) } diff --git a/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go b/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go index f1386b84..810a3bc1 100644 --- a/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go +++ b/images/operator-helm-controller/internal/webhook/helmapplication/webhook_test.go @@ -56,7 +56,7 @@ func newValidator(t *testing.T, interceptors interceptor.Funcs, objects ...clien WithInterceptorFuncs(interceptors). Build() - return &HelmApplicationWebhookValidator{Client: c} + return &HelmApplicationWebhookValidator{Reader: c} } func namespaceFixture(name string, terminating bool) *corev1.Namespace { From 30cb4a63c850d0b0c192f9fb00053f43f1629ba2 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:37:24 +0300 Subject: [PATCH 101/145] fix(chartartifact): bound the registry probe's transport and deadline Transport built a bare &http.Transport{} per probe, carrying none of the default's dial/idle timeouts and never releasing its connection pool, and ChartLayerMediaType had no deadline of its own, so a caller with none could be held open indefinitely by a hung registry. Transport now clones http.DefaultTransport before setting TLS, and the probe both bounds itself with a probeTimeout floor (kept below the caller's own deadline when it has one) and closes the single-use transport's idle connections once it returns. Signed-off-by: Ilya Drey --- .../internal/chartartifact/probe.go | 38 +++- .../internal/chartartifact/probe_test.go | 214 ++++++++++++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 images/chart-values-controller/internal/chartartifact/probe_test.go diff --git a/images/chart-values-controller/internal/chartartifact/probe.go b/images/chart-values-controller/internal/chartartifact/probe.go index c6f716c7..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) @@ -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) + } +} From 9f2ca0192394cb30a6d2f8f1e75cc768e24d8802 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:37:27 +0300 Subject: [PATCH 102/145] fix(operator-helm-controller): scope the Secret informer to our namespace main.go disabled caching for ServiceAccount and RoleBinding but left Secret alone, and the repository controllers watch Secrets, so the operator held every Secret in the cluster in memory. Every Secret the operator reads or writes lives in the module namespace (the repository services all take helmv1alpha1.TargetNamespace), so cache.Options.ByObject restricts the Secret informer to it. Adding Secret to Client.Cache.DisableFor instead would not help: an explicit Watches(&corev1.Secret{}, ...) starts an informer regardless. Signed-off-by: Ilya Drey --- .../cmd/operator-helm-controller/main.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 17e22b3e..8d5aeeef 100644 --- a/images/operator-helm-controller/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-controller/cmd/operator-helm-controller/main.go @@ -28,6 +28,7 @@ import ( 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" @@ -88,6 +89,18 @@ func main() { DisableFor: []client.Object{&corev1.ServiceAccount{}, &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: {}, + }, + }, + }, + }, }) if err != nil { logger.Error(err, "unable to create manager") From 2751cd9e7407495d6afd277b1a423d3f4ce50cc0 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 14:38:30 +0300 Subject: [PATCH 103/145] fix(webhook): say what the rule forbids, not how it was reached The message is produced on update as well as on create, where "cannot be created" describes neither the request nor the rule. Signed-off-by: Ilya Drey --- .../internal/webhook/helmapplication/webhook.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go b/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go index 46c5ec9e..289f1da6 100644 --- a/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go +++ b/images/operator-helm-controller/internal/webhook/helmapplication/webhook.go @@ -93,7 +93,7 @@ func (v *HelmApplicationWebhookValidator) namespaceTerminating(ctx context.Conte func validateNotSystemNamespace(app *helmv1alpha1.HelmApplication) error { if utils.IsSystemNamespace(app.Namespace) { - return fmt.Errorf("helmapplication/%s cannot be created in system namespace %s", app.Name, app.Namespace) + return fmt.Errorf("helmapplication/%s may not live in system namespace %s", app.Name, app.Namespace) } return nil From d9e46bb79be98856217fa5a8e45fa53fe020cc13 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sun, 13 Sep 2026 15:20:03 +0300 Subject: [PATCH 104/145] fix(rbac): authorize escalate without a resource name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoping escalate by resourceNames denied every seeded role: the check authorizes against the name in the request path, and a create carries none, so the rule matched nothing and each application failed to set up its identity. bind keeps the scope — it is authorized against the name in the binding's roleRef, which is always present. Signed-off-by: Ilya Drey --- templates/operator-helm-controller/rbac-for-us.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index 43b2ee17..2f8ca0b5 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -103,17 +103,19 @@ rules: - update - watch # The namespace role is seeded once and never read back, so create is the only -# verb the controller uses on it. create cannot be scoped by resourceNames: the -# object does not exist yet when the request is authorized. escalate and bind are -# checked against the referenced role instead: escalate lets the controller write -# a role granting more than it holds, bind lets it point a role binding at such a -# role. Both are scoped to the single role name this module ever seeds. +# verb the controller uses on it. Neither create nor escalate can be scoped by +# resourceNames: both are authorized against the name in the request path, which +# a create does not carry (RoleEscalationAuthorized passes requestInfo.Name, empty +# here — see k8s.io/kubernetes/pkg/registry/rbac/escalation_check.go). bind is the +# exception: it is authorized against the name in the binding's roleRef, so it is +# scoped to the single role this module ever seeds. - apiGroups: - rbac.authorization.k8s.io resources: - roles verbs: - create + - escalate - apiGroups: - rbac.authorization.k8s.io resources: @@ -121,7 +123,6 @@ rules: resourceNames: - operator-helm-application verbs: - - escalate - bind - apiGroups: - rbac.authorization.k8s.io From 78f08be525ca2594bf0db683d5ba588fac14a8ab Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Tue, 15 Sep 2026 13:36:55 +0300 Subject: [PATCH 105/145] fix(controller): stop treating default as a system namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It belongs to whoever works there, and that is a user without a namespace of their own — exactly the person this family exists for. Refusing an application in default kept them out for no reason the cluster shares. Signed-off-by: Ilya Drey --- .../internal/utils/namespace.go | 10 +++------- .../internal/utils/namespace_test.go | 2 +- tests/e2e/helmapplication/system_namespace.go | 1 - 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/images/operator-helm-controller/internal/utils/namespace.go b/images/operator-helm-controller/internal/utils/namespace.go index 4c446008..58742fd4 100644 --- a/images/operator-helm-controller/internal/utils/namespace.go +++ b/images/operator-helm-controller/internal/utils/namespace.go @@ -21,13 +21,9 @@ import ( ) // IsSystemNamespace reports whether a namespace belongs to the cluster or to -// Deckhouse rather than to a user. The set matches what Deckhouse itself excludes -// from a user's reach: every kube- namespace, every d8- one, and default, which is -// shared by everyone and owned by no one. +// 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 { - if namespace == "default" { - return true - } - 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 index 5e24a050..a27e4e14 100644 --- a/images/operator-helm-controller/internal/utils/namespace_test.go +++ b/images/operator-helm-controller/internal/utils/namespace_test.go @@ -31,7 +31,7 @@ func TestIsSystemNamespace(t *testing.T) { {"kube-node-lease", true}, {"kube-public", true}, {"kube-anything", true}, - {"default", true}, + {"default", false}, {"d8-operator-helm", true}, {"d8-system", true}, {"team-a", false}, diff --git a/tests/e2e/helmapplication/system_namespace.go b/tests/e2e/helmapplication/system_namespace.go index 6ccaae79..f3468682 100644 --- a/tests/e2e/helmapplication/system_namespace.go +++ b/tests/e2e/helmapplication/system_namespace.go @@ -65,6 +65,5 @@ var _ = Describe("HelmApplication system namespace restriction", Ordered, func() Entry("kube-public", "kube-public"), Entry("kube-node-lease", "kube-node-lease"), Entry("the module's own namespace", moduleNS), - Entry("default", "default"), ) }) From a682fae3f806ddd0ddf285697b4a2ded154affe6 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Tue, 15 Sep 2026 18:43:49 +0300 Subject: [PATCH 106/145] feat(api): report how many charts a repository offers Every repository kind now records the size of the catalog it published at its last successful read, refreshed on each synchronization. The field is a pointer: a repository nobody has managed to read yet answers nothing, which is not the same answer as a repository offering no charts. A failed read leaves the previous count standing rather than replacing it with zero, so the field always describes a real reading. Signed-off-by: Ilya Drey --- api/v1alpha1/repository_types.go | 5 +++ api/v1alpha1/zz_generated.deepcopy.go | 5 +++ crds/doc-ru-helmapplicationrepositories.yaml | 3 ++ crds/doc-ru-helmclusteraddonrepositories.yaml | 3 ++ ...ru-helmclusterapplicationrepositories.yaml | 3 ++ crds/helmapplicationrepositories.yaml | 7 ++++ crds/helmclusteraddonrepositories.yaml | 7 ++++ crds/helmclusterapplicationrepositories.yaml | 7 ++++ .../internal/reconcile/repository/evaluate.go | 8 ++++ .../reconcile/repository/evaluate_test.go | 41 +++++++++++++++++++ .../internal/services/outcomes.go | 4 ++ .../internal/services/repo_sync_service.go | 2 +- 12 files changed, 94 insertions(+), 1 deletion(-) diff --git a/api/v1alpha1/repository_types.go b/api/v1alpha1/repository_types.go index e9961384..2641bd2b 100644 --- a/api/v1alpha1/repository_types.go +++ b/api/v1alpha1/repository_types.go @@ -93,4 +93,9 @@ type RepositoryStatus struct { // 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 4ea43776..4a24d4ad 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -797,6 +797,11 @@ func (in *RepositoryStatus) DeepCopyInto(out *RepositoryStatus) { 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 } diff --git a/crds/doc-ru-helmapplicationrepositories.yaml b/crds/doc-ru-helmapplicationrepositories.yaml index e7c8797b..7219f5f3 100644 --- a/crds/doc-ru-helmapplicationrepositories.yaml +++ b/crds/doc-ru-helmapplicationrepositories.yaml @@ -50,3 +50,6 @@ spec: Время обработки последнего запроса принудительной реконсиляции. Фиксирует, что запрос был обработан, а не что он завершился успешно — результат отражают `Ready` и `Synced`. consecutiveFetchFailures: description: Число подряд идущих неудачных обращений к репозиторию. Определяет задержку повтора и обнуляется при первом успехе. + chartCount: + description: | + Число чартов, которые репозиторий предлагал при последнем успешном чтении. Отсутствует, пока успешного чтения не было, поэтому репозиторий, который ещё не читали, отличим от репозитория без чартов. diff --git a/crds/doc-ru-helmclusteraddonrepositories.yaml b/crds/doc-ru-helmclusteraddonrepositories.yaml index 5030c23d..0005a86a 100644 --- a/crds/doc-ru-helmclusteraddonrepositories.yaml +++ b/crds/doc-ru-helmclusteraddonrepositories.yaml @@ -50,3 +50,6 @@ spec: Время обработки последнего запроса принудительной реконсиляции. Фиксирует, что запрос был обработан, а не что он завершился успешно — результат отражают `Ready` и `Synced`. consecutiveFetchFailures: description: Число подряд идущих неудачных обращений к репозиторию. Определяет задержку повтора и обнуляется при первом успехе. + chartCount: + description: | + Число чартов, которые репозиторий предлагал при последнем успешном чтении. Отсутствует, пока успешного чтения не было, поэтому репозиторий, который ещё не читали, отличим от репозитория без чартов. diff --git a/crds/doc-ru-helmclusterapplicationrepositories.yaml b/crds/doc-ru-helmclusterapplicationrepositories.yaml index b4437c22..5481b54b 100644 --- a/crds/doc-ru-helmclusterapplicationrepositories.yaml +++ b/crds/doc-ru-helmclusterapplicationrepositories.yaml @@ -50,3 +50,6 @@ spec: Время обработки последнего запроса принудительной реконсиляции. Фиксирует, что запрос был обработан, а не что он завершился успешно — результат отражают `Ready` и `Synced`. consecutiveFetchFailures: description: Число подряд идущих неудачных обращений к репозиторию. Определяет задержку повтора и обнуляется при первом успехе. + chartCount: + description: | + Число чартов, которые репозиторий предлагал при последнем успешном чтении. Отсутствует, пока успешного чтения не было, поэтому репозиторий, который ещё не читали, отличим от репозитория без чартов. diff --git a/crds/helmapplicationrepositories.yaml b/crds/helmapplicationrepositories.yaml index ac54c011..78109a99 100644 --- a/crds/helmapplicationrepositories.yaml +++ b/crds/helmapplicationrepositories.yaml @@ -103,6 +103,13 @@ spec: 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. diff --git a/crds/helmclusteraddonrepositories.yaml b/crds/helmclusteraddonrepositories.yaml index 26a9a47c..19db93f0 100644 --- a/crds/helmclusteraddonrepositories.yaml +++ b/crds/helmclusteraddonrepositories.yaml @@ -103,6 +103,13 @@ spec: 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. diff --git a/crds/helmclusterapplicationrepositories.yaml b/crds/helmclusterapplicationrepositories.yaml index b5d6ab05..77b4e238 100644 --- a/crds/helmclusterapplicationrepositories.yaml +++ b/crds/helmclusterapplicationrepositories.yaml @@ -103,6 +103,13 @@ spec: 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. diff --git a/images/operator-helm-controller/internal/reconcile/repository/evaluate.go b/images/operator-helm-controller/internal/reconcile/repository/evaluate.go index 2854cacd..f590d4bb 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/evaluate.go +++ b/images/operator-helm-controller/internal/reconcile/repository/evaluate.go @@ -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" @@ -131,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} } diff --git a/images/operator-helm-controller/internal/reconcile/repository/evaluate_test.go b/images/operator-helm-controller/internal/reconcile/repository/evaluate_test.go index 30db3093..146ceaf3 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/evaluate_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/evaluate_test.go @@ -623,6 +623,47 @@ func TestEvaluateFullSyncAdvancesLastSuccessfulSyncTime(t *testing.T) { } } +// 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() diff --git a/images/operator-helm-controller/internal/services/outcomes.go b/images/operator-helm-controller/internal/services/outcomes.go index deb756a8..5c9996b5 100644 --- a/images/operator-helm-controller/internal/services/outcomes.go +++ b/images/operator-helm-controller/internal/services/outcomes.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/repo_sync_service.go b/images/operator-helm-controller/internal/services/repo_sync_service.go index f6bcc071..57d1b337 100644 --- a/images/operator-helm-controller/internal/services/repo_sync_service.go +++ b/images/operator-helm-controller/internal/services/repo_sync_service.go @@ -116,7 +116,7 @@ func (s *RepoSyncService) fetchCharts( 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 { From cc134b54ffb29fc72bd88fdfebd844f17a2d13c5 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 16 Sep 2026 10:16:26 +0300 Subject: [PATCH 107/145] feat(rbac): reach the module through Editor and Admin only The experimental role model is dropped until it is added deliberately, and the classic one is reduced to the two levels that matter: Editor may do anything with an application and list its repositories and charts, Admin adds the repositories themselves and a read of the chart catalog. Levels accumulate upwards, so Admin carries only the difference. The cluster-scoped repository and its catalog lose their user-facing level with the experimental model and are reachable only cluster-wide. Signed-off-by: Ilya Drey --- docs/README.md | 4 +- docs/README.ru.md | 4 +- templates/rbacv2/manage/edit.yaml | 33 ---------------- templates/rbacv2/manage/view.yaml | 31 --------------- templates/rbacv2/use/admin.yaml | 20 ---------- templates/rbacv2/use/user.yaml | 17 --------- templates/rbacv2/use/view.yaml | 18 --------- templates/user-authz-cluster-roles.yaml | 51 +++++++------------------ 8 files changed, 18 insertions(+), 160 deletions(-) delete mode 100644 templates/rbacv2/manage/edit.yaml delete mode 100644 templates/rbacv2/manage/view.yaml delete mode 100644 templates/rbacv2/use/admin.yaml delete mode 100644 templates/rbacv2/use/user.yaml delete mode 100644 templates/rbacv2/use/view.yaml diff --git a/docs/README.md b/docs/README.md index 9ce02a6e..754252c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,8 +37,8 @@ Each repository also publishes a catalog of the charts it offers — HelmCluster - The application family is namespaced: a namespace owner can create and manage HelmApplication and HelmApplicationRepository in their own namespace without cluster-wide rights. HelmClusterApplicationRepository is cluster-scoped, so creating one still requires cluster-wide rights, 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: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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. For that reason, reading either repository kind requires the `PrivilegedUser` role (or, in the new role model, the `user` capability from this module's `d8:use:capability:module:operator-helm:user` ClusterRole) — the level from which Deckhouse permits reading Secrets — rather than `User`/`viewer`. -- The module grants no more than read access to a namespace user by default: the `User` role (or, in the new role model, the `viewer` capability from this module's `d8:use:capability:module:operator-helm:view` ClusterRole) gives `get`/`list`/`watch` on HelmApplication and both chart catalogs; see the point above for the two repository kinds. Creating or modifying HelmApplication and HelmApplicationRepository requires the `Admin` role (or the `admin` capability from `d8:use:capability:module:operator-helm:admin`) — deliberately kept out of `Editor`, since it carries namespace-admin-equivalent rights, as explained above. Creating or modifying HelmClusterApplicationRepository requires the `ClusterEditor` role (or, in the new role model, the cluster-wide `manager` permission granted through `d8:manage:permission:module:operator-helm:edit`, whose `view` counterpart also covers read access to HelmClusterApplicationRepository and HelmClusterApplicationChart). +- `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. Listing repositories is part of the `Editor` role below, which therefore also grants read access to every repository password in the namespaces that role covers. +- Two Deckhouse roles reach this module, and the levels accumulate upwards. `Editor` may do anything with HelmApplication and may list HelmApplicationRepository and HelmApplicationChart. `Admin` adds full rights over HelmApplicationRepository and `get` on HelmApplicationChart. Note what the first of these means: installing an application is equivalent to namespace-admin rights, as explained above, and that power sits at `Editor`. The cluster-scoped HelmClusterApplicationRepository and HelmClusterApplicationChart have no user-facing role yet and remain reachable only with cluster-wide rights. - 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 ff5919dd..f8a8ccf5 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -37,8 +37,8 @@ weight: 10 - Семейство приложений является namespaced: владелец namespace может создавать HelmApplication и HelmApplicationRepository в своём namespace и управлять ими без прав на весь кластер. HelmClusterApplicationRepository — кластерный ресурс, поэтому для его создания по-прежнему нужны права на весь кластер, но любой HelmApplication может ссылаться на уже существующий из своего namespace. - Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание 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` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. По этой причине чтение обоих видов репозиториев требует роли `PrivilegedUser` (или, в новой модели ролей, capability `user` из ClusterRole `d8:use:capability:module:operator-helm:user` модуля) — уровня, с которого Deckhouse разрешает чтение Secrets, а не `User`/`viewer`. -- По умолчанию модуль даёт пользователю namespace не больше чем доступ на чтение: роль `User` (или, в новой модели ролей, capability `viewer` из ClusterRole `d8:use:capability:module:operator-helm:view` модуля) даёт `get`/`list`/`watch` на HelmApplication и оба каталога чартов; про оба вида репозиториев — см. пункт выше. Для создания или изменения HelmApplication и HelmApplicationRepository требуется роль `Admin` (или capability `admin` из `d8:use:capability:module:operator-helm:admin`) — она намеренно не входит в `Editor`, поскольку даёт права, равносильные правам администратора namespace, как описано выше. Для создания или изменения HelmClusterApplicationRepository требуется роль `ClusterEditor` (или, в новой модели ролей, кластерное право `manager`, выдаваемое через `d8:manage:permission:module:operator-helm:edit`, парная роль `view` которого также даёт доступ на чтение к HelmClusterApplicationRepository и HelmClusterApplicationChart). +- HelmApplicationRepository и HelmClusterApplicationRepository хранят учётные данные реестра в открытом виде (`spec.auth.username` и `spec.auth.password`; альтернативы через `secretRef` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. Право `list` на репозитории входит в роль `Editor`, описанную ниже, а значит эта роль даёт и доступ к паролям всех репозиториев в тех namespace, на которые она распространяется. +- К модулю обращаются две роли Deckhouse, и уровни накапливаются снизу вверх. `Editor` может делать с HelmApplication что угодно и получает `list` на HelmApplicationRepository и HelmApplicationChart. `Admin` добавляет полные права на HelmApplicationRepository и `get` на HelmApplicationChart. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, и это право находится на уровне `Editor`. Кластерные HelmClusterApplicationRepository и HelmClusterApplicationChart пользовательской роли пока не имеют и доступны только с правами на весь кластер. - Ресурс HelmClusterAddon, ссылающийся на заданный HelmClusterAddonChart, может быть создан в кластере только в единственном экземпляре. Это обусловлено тем, что Helm-чарты могут содержать определения кастомных ресурсов (CRD), повторная установка которых на уровне кластера недопустима. Примеры использования приведены в разделе [примеры использования](example.html). diff --git a/templates/rbacv2/manage/edit.yaml b/templates/rbacv2/manage/edit.yaml deleted file mode 100644 index de3afc61..00000000 --- a/templates/rbacv2/manage/edit.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - # This module's subsystem is delivery (see module.yaml), but user-authz ships no - # delivery manage role to aggregate into, so aggregating there would silently - # grant nobody anything. The right to manage a cluster-scoped application - # repository lands with kubernetes subsystem administrators instead, the same - # subsystem kube-proxy, kube-dns and control-plane-manager aggregate their manage - # roles into. The real fix is adding delivery roles upstream in deckhouse/deckhouse. - {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "rbac.deckhouse.io/namespace" (printf "d8-%s" .Chart.Name) "rbac.deckhouse.io/aggregate-to-kubernetes-as" "manager")) | nindent 2 }} - name: d8:manage:permission:module:{{ .Chart.Name }}:edit -rules: -- apiGroups: - - helm.deckhouse.io - resources: - - helmclusterapplicationrepositories - verbs: - - create - - delete - - deletecollection - - patch - - update -- apiGroups: - - deckhouse.io - resourceNames: - - {{ .Chart.Name }} - resources: - - moduleconfigs - verbs: - - create - - update - - patch - - delete diff --git a/templates/rbacv2/manage/view.yaml b/templates/rbacv2/manage/view.yaml deleted file mode 100644 index 16d3e838..00000000 --- a/templates/rbacv2/manage/view.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - # This module's subsystem is delivery (see module.yaml), but user-authz ships no - # delivery manage role to aggregate into, so aggregating there would silently - # grant nobody anything. The right to view a cluster-scoped application - # repository lands with kubernetes subsystem administrators instead, the same - # subsystem kube-proxy, kube-dns and control-plane-manager aggregate their manage - # roles into. The real fix is adding delivery roles upstream in deckhouse/deckhouse. - {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "rbac.deckhouse.io/namespace" (printf "d8-%s" .Chart.Name) "rbac.deckhouse.io/aggregate-to-kubernetes-as" "viewer")) | nindent 2 }} - name: d8:manage:permission:module:{{ .Chart.Name }}:view -rules: -- apiGroups: - - helm.deckhouse.io - resources: - - helmclusterapplicationrepositories - - helmclusterapplicationcharts - verbs: - - get - - list - - watch -- apiGroups: - - deckhouse.io - resourceNames: - - {{ .Chart.Name }} - resources: - - moduleconfigs - verbs: - - get - - list - - watch diff --git a/templates/rbacv2/use/admin.yaml b/templates/rbacv2/use/admin.yaml deleted file mode 100644 index 98c54270..00000000 --- a/templates/rbacv2/use/admin.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "use" "rbac.deckhouse.io/aggregate-to-kubernetes-as" "admin")) | nindent 2 }} - name: d8:use:capability:module:{{ .Chart.Name }}:admin -rules: -# Creating a HelmApplication or HelmApplicationRepository is namespace-admin equivalent -# (see the controller's seeded Role), so write access aggregates only to admin, never -# to a namespace-scoped editor. -- apiGroups: - - helm.deckhouse.io - resources: - - helmapplications - - helmapplicationrepositories - verbs: - - create - - delete - - deletecollection - - patch - - update diff --git a/templates/rbacv2/use/user.yaml b/templates/rbacv2/use/user.yaml deleted file mode 100644 index 85a6eb25..00000000 --- a/templates/rbacv2/use/user.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "use" "rbac.deckhouse.io/aggregate-to-kubernetes-as" "user")) | nindent 2 }} - name: d8:use:capability:module:{{ .Chart.Name }}:user -rules: -# Repository credentials are stored in plaintext, so this is where reading a repository -# starts: the level at which Deckhouse permits reading secrets, not at viewer. -- apiGroups: - - helm.deckhouse.io - resources: - - helmapplicationrepositories - - helmclusterapplicationrepositories - verbs: - - get - - list - - watch diff --git a/templates/rbacv2/use/view.yaml b/templates/rbacv2/use/view.yaml deleted file mode 100644 index b0772256..00000000 --- a/templates/rbacv2/use/view.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "use" "rbac.deckhouse.io/aggregate-to-kubernetes-as" "viewer")) | nindent 2 }} - name: d8:use:capability:module:{{ .Chart.Name }}:view -rules: -# Repository credentials are stored in plaintext, so read access to a repository is read -# access to its password; that right is granted one level up, where secrets become readable. -- apiGroups: - - helm.deckhouse.io - resources: - - helmapplications - - helmapplicationcharts - - helmclusterapplicationcharts - verbs: - - get - - list - - watch diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index 6667f7d9..486b3070 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -3,41 +3,30 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: annotations: - user-authz.deckhouse.io/access-level: User - name: d8:user-authz:{{ .Chart.Name }}:user + user-authz.deckhouse.io/access-level: Editor + name: d8:user-authz:{{ .Chart.Name }}:editor {{- include "helm_lib_module_labels" (list .) | nindent 2 }} rules: - apiGroups: - helm.deckhouse.io resources: - helmapplications - - helmapplicationcharts - - helmclusterapplicationcharts verbs: + - create + - delete + - deletecollection - get - list + - patch + - update - watch ---- -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: -# Repository credentials (spec.auth.username/password) are stored in plaintext, so read -# access to a repository is read access to its password; that is why it starts here, -# at the level where Deckhouse permits reading secrets, rather than at User. - apiGroups: - helm.deckhouse.io resources: - helmapplicationrepositories - - helmclusterapplicationrepositories + - helmapplicationcharts verbs: - - get - list - - watch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -47,35 +36,23 @@ metadata: name: d8:user-authz:{{ .Chart.Name }}:admin {{- include "helm_lib_module_labels" (list .) | nindent 2 }} rules: -# Creating a HelmApplication or HelmApplicationRepository is namespace-admin equivalent -# (see the controller's seeded Role), so write access starts at Admin, not Editor. +# Levels accumulate upwards, so Admin already carries everything the Editor role +# above grants. Only the difference belongs here. - apiGroups: - helm.deckhouse.io resources: - - helmapplications - helmapplicationrepositories verbs: - create - delete - deletecollection + - get - patch - update ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - annotations: - user-authz.deckhouse.io/access-level: ClusterEditor - name: d8:user-authz:{{ .Chart.Name }}:cluster-editor - {{- include "helm_lib_module_labels" (list .) | nindent 2 }} -rules: + - watch - apiGroups: - helm.deckhouse.io resources: - - helmclusterapplicationrepositories + - helmapplicationcharts verbs: - - create - - delete - - deletecollection - - patch - - update + - get From c611836edee052bba8402a259e44bf15fb600b08 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 16 Sep 2026 10:22:15 +0300 Subject: [PATCH 108/145] feat(rbac): move the application level to PrivilegedUser Reading a repository is reading its password, since the credentials live in the object; PrivilegedUser is the level from which Deckhouse permits reading Secrets, so that is where both kinds become readable. Admin keeps only what it adds: creating and modifying a repository. Signed-off-by: Ilya Drey --- docs/README.md | 4 ++-- docs/README.ru.md | 4 ++-- templates/user-authz-cluster-roles.yaml | 19 ++++++++----------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/docs/README.md b/docs/README.md index 754252c0..54e721eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,8 +37,8 @@ Each repository also publishes a catalog of the charts it offers — HelmCluster - The application family is namespaced: a namespace owner can create and manage HelmApplication and HelmApplicationRepository in their own namespace without cluster-wide rights. HelmClusterApplicationRepository is cluster-scoped, so creating one still requires cluster-wide rights, 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: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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. Listing repositories is part of the `Editor` role below, which therefore also grants read access to every repository password in the namespaces that role covers. -- Two Deckhouse roles reach this module, and the levels accumulate upwards. `Editor` may do anything with HelmApplication and may list HelmApplicationRepository and HelmApplicationChart. `Admin` adds full rights over HelmApplicationRepository and `get` on HelmApplicationChart. Note what the first of these means: installing an application is equivalent to namespace-admin rights, as explained above, and that power sits at `Editor`. The cluster-scoped HelmClusterApplicationRepository and HelmClusterApplicationChart have no user-facing role yet and remain reachable only with cluster-wide rights. +- `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 why reading a repository starts at `PrivilegedUser`, the level from which Deckhouse permits reading Secrets, rather than at `User`. +- Two Deckhouse roles reach this module, and the levels accumulate upwards. `PrivilegedUser` may do anything with HelmApplication and may read HelmApplicationRepository and HelmApplicationChart. `Admin` adds the right to create and modify HelmApplicationRepository. Note what the first of these means: installing an application is equivalent to namespace-admin rights, as explained above, and that power sits at `PrivilegedUser`. The cluster-scoped HelmClusterApplicationRepository and HelmClusterApplicationChart have no user-facing role yet and remain reachable only with cluster-wide rights. - 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 f8a8ccf5..38aba725 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -37,8 +37,8 @@ weight: 10 - Семейство приложений является namespaced: владелец namespace может создавать HelmApplication и HelmApplicationRepository в своём namespace и управлять ими без прав на весь кластер. HelmClusterApplicationRepository — кластерный ресурс, поэтому для его создания по-прежнему нужны права на весь кластер, но любой HelmApplication может ссылаться на уже существующий из своего namespace. - Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание 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` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. Право `list` на репозитории входит в роль `Editor`, описанную ниже, а значит эта роль даёт и доступ к паролям всех репозиториев в тех namespace, на которые она распространяется. -- К модулю обращаются две роли Deckhouse, и уровни накапливаются снизу вверх. `Editor` может делать с HelmApplication что угодно и получает `list` на HelmApplicationRepository и HelmApplicationChart. `Admin` добавляет полные права на HelmApplicationRepository и `get` на HelmApplicationChart. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, и это право находится на уровне `Editor`. Кластерные HelmClusterApplicationRepository и HelmClusterApplicationChart пользовательской роли пока не имеют и доступны только с правами на весь кластер. +- HelmApplicationRepository и HelmClusterApplicationRepository хранят учётные данные реестра в открытом виде (`spec.auth.username` и `spec.auth.password`; альтернативы через `secretRef` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. Поэтому чтение репозитория начинается с уровня `PrivilegedUser`, с которого Deckhouse разрешает чтение Secrets, а не с `User`. +- К модулю обращаются две роли Deckhouse, и уровни накапливаются снизу вверх. `PrivilegedUser` может делать с HelmApplication что угодно и получает чтение HelmApplicationRepository и HelmApplicationChart. `Admin` добавляет право создавать и изменять HelmApplicationRepository. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, и это право находится на уровне `PrivilegedUser`. Кластерные HelmClusterApplicationRepository и HelmClusterApplicationChart пользовательской роли пока не имеют и доступны только с правами на весь кластер. - Ресурс HelmClusterAddon, ссылающийся на заданный HelmClusterAddonChart, может быть создан в кластере только в единственном экземпляре. Это обусловлено тем, что Helm-чарты могут содержать определения кастомных ресурсов (CRD), повторная установка которых на уровне кластера недопустима. Примеры использования приведены в разделе [примеры использования](example.html). diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index 486b3070..d4cf595c 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -3,8 +3,8 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: annotations: - user-authz.deckhouse.io/access-level: Editor - name: d8:user-authz:{{ .Chart.Name }}:editor + 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: @@ -20,12 +20,16 @@ rules: - patch - update - watch +# Repository credentials (spec.auth.username and spec.auth.password) live in the +# object itself, so reading a repository is reading its password. That is why this +# starts at PrivilegedUser, the level from which Deckhouse permits reading Secrets. - apiGroups: - helm.deckhouse.io resources: - helmapplicationrepositories - helmapplicationcharts verbs: + - get - list --- apiVersion: rbac.authorization.k8s.io/v1 @@ -36,8 +40,8 @@ metadata: name: d8:user-authz:{{ .Chart.Name }}:admin {{- include "helm_lib_module_labels" (list .) | nindent 2 }} rules: -# Levels accumulate upwards, so Admin already carries everything the Editor role -# above grants. Only the difference belongs here. +# Levels accumulate upwards, so Admin already carries everything the role above +# grants, including reading both kinds. Only the difference belongs here. - apiGroups: - helm.deckhouse.io resources: @@ -46,13 +50,6 @@ rules: - create - delete - deletecollection - - get - patch - update - watch -- apiGroups: - - helm.deckhouse.io - resources: - - helmapplicationcharts - verbs: - - get From ea803d0f2c2bc0d79b8658efb67abbf7cc03b097 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 16 Sep 2026 10:33:30 +0300 Subject: [PATCH 109/145] feat(rbac): give ClusterAdmin the cluster-scoped kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The addon family and the cluster-wide application repository had no role of their own, so they were reachable only by a binding written by hand. ClusterAdmin now carries them, and reads both cluster-scoped catalogs — writing a catalog belongs to the controller and to no one else. Signed-off-by: Ilya Drey --- docs/README.md | 6 ++--- docs/README.ru.md | 6 ++--- templates/user-authz-cluster-roles.yaml | 36 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/README.md b/docs/README.md index 54e721eb..69114d1f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,12 +33,12 @@ Each repository also publishes a catalog of the charts it offers — HelmCluster ## Limitations -- The addon family (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) is entirely cluster-scoped, so admin privileges (the `cluster-admin` role) are required to manage it. -- The application family is namespaced: a namespace owner can create and manage HelmApplication and HelmApplicationRepository in their own namespace without cluster-wide rights. HelmClusterApplicationRepository is cluster-scoped, so creating one still requires cluster-wide rights, but any HelmApplication may reference an existing one from its own namespace. +- 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. 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: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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 why reading a repository starts at `PrivilegedUser`, the level from which Deckhouse permits reading Secrets, rather than at `User`. -- Two Deckhouse roles reach this module, and the levels accumulate upwards. `PrivilegedUser` may do anything with HelmApplication and may read HelmApplicationRepository and HelmApplicationChart. `Admin` adds the right to create and modify HelmApplicationRepository. Note what the first of these means: installing an application is equivalent to namespace-admin rights, as explained above, and that power sits at `PrivilegedUser`. The cluster-scoped HelmClusterApplicationRepository and HelmClusterApplicationChart have no user-facing role yet and remain reachable only with cluster-wide rights. +- Three Deckhouse roles reach this module, and the levels accumulate upwards. `PrivilegedUser` may do anything with HelmApplication and may read HelmApplicationRepository and HelmApplicationChart. `Admin` adds the right to create and modify HelmApplicationRepository. `ClusterAdmin` covers the cluster-scoped kinds: full rights over HelmClusterAddon, HelmClusterAddonRepository and HelmClusterApplicationRepository, and a read of both cluster-scoped chart catalogs. Note what the first of these means: installing an application is equivalent to namespace-admin rights, as explained above, and that power sits at `PrivilegedUser`. 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 38aba725..318e6c2a 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -33,12 +33,12 @@ weight: 10 ## Ограничения -- Семейство аддонов (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) полностью кластерное, поэтому для управления им требуются права администратора кластера (роль `cluster-admin`). -- Семейство приложений является namespaced: владелец namespace может создавать HelmApplication и HelmApplicationRepository в своём namespace и управлять ими без прав на весь кластер. HelmClusterApplicationRepository — кластерный ресурс, поэтому для его создания по-прежнему нужны права на весь кластер, но любой HelmApplication может ссылаться на уже существующий из своего namespace. +- Семейство аддонов (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) полностью кластерное, поэтому для управления им требуется роль `ClusterAdmin`. +- Семейство приложений является namespaced: владелец namespace может создавать HelmApplication и HelmApplicationRepository в своём namespace и управлять ими без прав на весь кластер. HelmClusterApplicationRepository — кластерный ресурс, поэтому для его создания нужна роль `ClusterAdmin`, но любой HelmApplication может ссылаться на уже существующий из своего namespace. - Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание 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` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. Поэтому чтение репозитория начинается с уровня `PrivilegedUser`, с которого Deckhouse разрешает чтение Secrets, а не с `User`. -- К модулю обращаются две роли Deckhouse, и уровни накапливаются снизу вверх. `PrivilegedUser` может делать с HelmApplication что угодно и получает чтение HelmApplicationRepository и HelmApplicationChart. `Admin` добавляет право создавать и изменять HelmApplicationRepository. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, и это право находится на уровне `PrivilegedUser`. Кластерные HelmClusterApplicationRepository и HelmClusterApplicationChart пользовательской роли пока не имеют и доступны только с правами на весь кластер. +- К модулю обращаются три роли Deckhouse, и уровни накапливаются снизу вверх. `PrivilegedUser` может делать с HelmApplication что угодно и получает чтение HelmApplicationRepository и HelmApplicationChart. `Admin` добавляет право создавать и изменять HelmApplicationRepository. `ClusterAdmin` покрывает кластерные виды: полные права на HelmClusterAddon, HelmClusterAddonRepository и HelmClusterApplicationRepository, а также чтение обоих кластерных каталогов чартов. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, и это право находится на уровне `PrivilegedUser`. Записывать каталог чартов не может ни один уровень — его единственный автор контроллер. - Ресурс HelmClusterAddon, ссылающийся на заданный HelmClusterAddonChart, может быть создан в кластере только в единственном экземпляре. Это обусловлено тем, что Helm-чарты могут содержать определения кастомных ресурсов (CRD), повторная установка которых на уровне кластера недопустима. Примеры использования приведены в разделе [примеры использования](example.html). diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index d4cf595c..351f5dd1 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -53,3 +53,39 @@ rules: - patch - update - watch +--- +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: +# The cluster-scoped kinds reach no level below this one, so these rules stand on +# their own rather than extending anything above. +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddons + - helmclusteraddonrepositories + - helmclusterapplicationrepositories + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +# Both chart catalogs are written by the controller alone, so nobody gets more +# than a read of them. +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddoncharts + - helmclusterapplicationcharts + verbs: + - get + - list From dcf2eacebb7e217fa8a81876e3a75420b64a4aac Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 16 Sep 2026 10:53:45 +0300 Subject: [PATCH 110/145] feat(rbac): fold the application rights into Admin Installing an application carries namespace-admin rights, so the level below it had nothing to offer that was safe to offer: reading a repository is reading its password, and everything else it granted led straight to that install. Admin now holds the whole namespaced family. Signed-off-by: Ilya Drey --- docs/README.md | 6 ++--- docs/README.ru.md | 6 ++--- templates/user-authz-cluster-roles.yaml | 35 +++++-------------------- 3 files changed, 13 insertions(+), 34 deletions(-) diff --git a/docs/README.md b/docs/README.md index 69114d1f..9d1597fb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,11 +34,11 @@ Each repository also publishes a catalog of the charts it offers — HelmCluster ## Limitations - 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. HelmClusterApplicationRepository is cluster-scoped, so creating one requires the `ClusterAdmin` role, but any HelmApplication may reference an existing one from its own namespace. +- 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: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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 why reading a repository starts at `PrivilegedUser`, the level from which Deckhouse permits reading Secrets, rather than at `User`. -- Three Deckhouse roles reach this module, and the levels accumulate upwards. `PrivilegedUser` may do anything with HelmApplication and may read HelmApplicationRepository and HelmApplicationChart. `Admin` adds the right to create and modify HelmApplicationRepository. `ClusterAdmin` covers the cluster-scoped kinds: full rights over HelmClusterAddon, HelmClusterAddonRepository and HelmClusterApplicationRepository, and a read of both cluster-scoped chart catalogs. Note what the first of these means: installing an application is equivalent to namespace-admin rights, as explained above, and that power sits at `PrivilegedUser`. No level may write a chart catalog of any kind — the controller is its only author. +- `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 HelmApplicationChart. `ClusterAdmin` covers the cluster-scoped kinds: full rights over HelmClusterAddon, HelmClusterAddonRepository and HelmClusterApplicationRepository, and a read of both cluster-scoped chart catalogs. 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 318e6c2a..071eb8c6 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -34,11 +34,11 @@ weight: 10 ## Ограничения - Семейство аддонов (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) полностью кластерное, поэтому для управления им требуется роль `ClusterAdmin`. -- Семейство приложений является namespaced: владелец namespace может создавать HelmApplication и HelmApplicationRepository в своём namespace и управлять ими без прав на весь кластер. HelmClusterApplicationRepository — кластерный ресурс, поэтому для его создания нужна роль `ClusterAdmin`, но любой HelmApplication может ссылаться на уже существующий из своего namespace. +- Семейство приложений является namespaced: владелец namespace с ролью `Admin` может создавать HelmApplication и HelmApplicationRepository в своём namespace и управлять ими без прав на весь кластер. HelmClusterApplicationRepository — кластерный ресурс, поэтому для его создания нужна роль `ClusterAdmin`, но любой HelmApplication может ссылаться на уже существующий из своего namespace. - Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание 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` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. Поэтому чтение репозитория начинается с уровня `PrivilegedUser`, с которого Deckhouse разрешает чтение Secrets, а не с `User`. -- К модулю обращаются три роли Deckhouse, и уровни накапливаются снизу вверх. `PrivilegedUser` может делать с HelmApplication что угодно и получает чтение HelmApplicationRepository и HelmApplicationChart. `Admin` добавляет право создавать и изменять HelmApplicationRepository. `ClusterAdmin` покрывает кластерные виды: полные права на HelmClusterAddon, HelmClusterAddonRepository и HelmClusterApplicationRepository, а также чтение обоих кластерных каталогов чартов. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, и это право находится на уровне `PrivilegedUser`. Записывать каталог чартов не может ни один уровень — его единственный автор контроллер. +- HelmApplicationRepository и HelmClusterApplicationRepository хранят учётные данные реестра в открытом виде (`spec.auth.username` и `spec.auth.password`; альтернативы через `secretRef` нет), поэтому любое право на чтение ресурса-репозитория — это право на чтение его пароля. В том числе поэтому репозитории доступны не ниже уровня `Admin`. +- К модулю обращаются две роли Deckhouse, и уровни накапливаются снизу вверх. `Admin` может делать что угодно с HelmApplication и HelmApplicationRepository и получает чтение HelmApplicationChart. `ClusterAdmin` покрывает кластерные виды: полные права на HelmClusterAddon, HelmClusterAddonRepository и HelmClusterApplicationRepository, а также чтение обоих кластерных каталогов чартов. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, поэтому `Admin` — самый низкий уровень, которому модуль вообще доступен. Записывать каталог чартов не может ни один уровень — его единственный автор контроллер. - Ресурс HelmClusterAddon, ссылающийся на заданный HelmClusterAddonChart, может быть создан в кластере только в единственном экземпляре. Это обусловлено тем, что Helm-чарты могут содержать определения кастомных ресурсов (CRD), повторная установка которых на уровне кластера недопустима. Примеры использования приведены в разделе [примеры использования](example.html). diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index 351f5dd1..f44f2773 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -3,14 +3,15 @@ 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 + 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: - helmapplications + - helmapplicationrepositories verbs: - create - delete @@ -20,13 +21,13 @@ rules: - patch - update - watch -# Repository credentials (spec.auth.username and spec.auth.password) live in the -# object itself, so reading a repository is reading its password. That is why this -# starts at PrivilegedUser, the level from which Deckhouse permits reading Secrets. +# The chart catalog is written by the controller alone, so nobody gets more than a +# read of it. Repository credentials (spec.auth.username and spec.auth.password) live +# in the repository object itself, so the read granted above is also a read of every +# repository password in reach of this level. - apiGroups: - helm.deckhouse.io resources: - - helmapplicationrepositories - helmapplicationcharts verbs: - get @@ -34,28 +35,6 @@ rules: --- 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: -# Levels accumulate upwards, so Admin already carries everything the role above -# grants, including reading both kinds. Only the difference belongs here. -- apiGroups: - - helm.deckhouse.io - resources: - - helmapplicationrepositories - verbs: - - create - - delete - - deletecollection - - patch - - update - - watch ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole metadata: annotations: user-authz.deckhouse.io/access-level: ClusterAdmin From a1ab74dc744df4b2f681c69bb60e5b17731169d5 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 16 Sep 2026 12:19:05 +0300 Subject: [PATCH 111/145] feat(rbac): let Admin read the cluster-wide chart catalog An application may take its chart from a HelmClusterApplicationRepository that only ClusterAdmin manages, and until now the person writing that application could not see what the repository offers. The catalog moves down to Admin; ClusterAdmin keeps the addon one, which is all that is left to add there. Signed-off-by: Ilya Drey --- docs/README.md | 2 +- docs/README.ru.md | 2 +- templates/user-authz-cluster-roles.yaml | 16 +++++++++------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/README.md b/docs/README.md index 9d1597fb..cb526caa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,7 +38,7 @@ Each repository also publishes a catalog of the charts it offers — HelmCluster - Creating a HelmApplication is effectively equivalent to having administrator rights inside its namespace: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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 HelmApplicationChart. `ClusterAdmin` covers the cluster-scoped kinds: full rights over HelmClusterAddon, HelmClusterAddonRepository and HelmClusterApplicationRepository, and a read of both cluster-scoped chart catalogs. 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. +- 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 071eb8c6..eb2954f1 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -38,7 +38,7 @@ weight: 10 - Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание 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. `ClusterAdmin` покрывает кластерные виды: полные права на HelmClusterAddon, HelmClusterAddonRepository и HelmClusterApplicationRepository, а также чтение обоих кластерных каталогов чартов. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, поэтому `Admin` — самый низкий уровень, которому модуль вообще доступен. Записывать каталог чартов не может ни один уровень — его единственный автор контроллер. +- К модулю обращаются две роли Deckhouse, и уровни накапливаются снизу вверх. `Admin` может делать что угодно с HelmApplication и HelmApplicationRepository и получает чтение обоих каталогов, из которых приложение выбирает чарт: HelmApplicationChart и HelmClusterApplicationChart. `ClusterAdmin` покрывает кластерные виды: полные права на HelmClusterAddon, HelmClusterAddonRepository и HelmClusterApplicationRepository, а также чтение HelmClusterAddonChart. Стоит понимать, что означает первое: установка приложения равносильна правам администратора namespace, как описано выше, поэтому `Admin` — самый низкий уровень, которому модуль вообще доступен. Записывать каталог чартов не может ни один уровень — его единственный автор контроллер. - Ресурс HelmClusterAddon, ссылающийся на заданный HelmClusterAddonChart, может быть создан в кластере только в единственном экземпляре. Это обусловлено тем, что Helm-чарты могут содержать определения кастомных ресурсов (CRD), повторная установка которых на уровне кластера недопустима. Примеры использования приведены в разделе [примеры использования](example.html). diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index f44f2773..7e85d10a 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -21,14 +21,17 @@ rules: - patch - update - watch -# The chart catalog is written by the controller alone, so nobody gets more than a -# read of it. Repository credentials (spec.auth.username and spec.auth.password) live -# in the repository object itself, so the read granted above is also a read of every -# repository password in reach of this level. +# Both catalogs an application can pick a chart from, including the cluster-wide one +# whose repository only ClusterAdmin may manage. A catalog is written by the +# controller alone, so nobody gets more than a read of it. Repository credentials +# (spec.auth.username and spec.auth.password) live in the repository object itself, so +# the read granted above is also a read of every repository password in reach of this +# level. - apiGroups: - helm.deckhouse.io resources: - helmapplicationcharts + - helmclusterapplicationcharts verbs: - get - list @@ -58,13 +61,12 @@ rules: - patch - update - watch -# Both chart catalogs are written by the controller alone, so nobody gets more -# than a read of them. +# The application catalogs are already readable from the level above; only the +# addon one is left to add here. - apiGroups: - helm.deckhouse.io resources: - helmclusteraddoncharts - - helmclusterapplicationcharts verbs: - get - list From 1c42753e5d86a54fcff48502a3fb3bc2f6100991 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 16 Sep 2026 14:36:13 +0300 Subject: [PATCH 112/145] feat(rbac): let every reader watch what it can list A client that lists a catalog usually wants to follow it, and refusing the watch only pushes it into polling. Signed-off-by: Ilya Drey --- templates/user-authz-cluster-roles.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index 7e85d10a..d70fab6e 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -35,6 +35,7 @@ rules: verbs: - get - list + - watch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -70,3 +71,4 @@ rules: verbs: - get - list + - watch From 57d414d515b974a4f576995122db84ac6cd77ec6 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:21:14 +0300 Subject: [PATCH 113/145] feat: migrate to upstream flux controllers (#78) Signed-off-by: Ilya Drey --- .dmtlint.yaml | 12 +- .github/workflows/lint.yaml | 3 + Taskfile.yaml | 79 +- build/components/versions.yml | 4 +- crds/embedded/helm-controller.yaml | 1767 +------ crds/embedded/nelm-source-controller.yaml | 4165 ----------------- crds/embedded/source-controller.yaml | 2134 +++++++++ docs/README.ru.md | 1 - docs/RELEASE_NOTES.md | 1 - docs/RELEASE_NOTES.ru.md | 1 - .../cmd/chart-values-controller/main.go | 2 +- images/chart-values-controller/go.mod | 65 +- images/chart-values-controller/go.sum | 81 + .../internal/controller/controller.go | 4 +- .../internal/resolver/resolver.go | 4 +- .../internal/resolver/resolver_test.go | 2 +- images/helm-controller/werf.inc.yaml | 10 +- .../operatornelm/operatornelm_crds_test.go | 154 + .../pkg/operatornelm/operatornelm_rules.go | 63 +- .../operatornelm/operatornelm_rules_test.go | 112 + .../cmd/operator-helm-controller/main.go | 4 +- images/operator-helm-controller/go.mod | 84 +- images/operator-helm-controller/go.sum | 96 + .../controller/helmapplication/controller.go | 4 +- .../helmapplicationrepository/controller.go | 2 +- .../controller/helmclusteraddon/controller.go | 4 +- .../helmclusteraddonrepository/controller.go | 2 +- .../controller.go | 2 +- .../internal/reconcile/release/reconciler.go | 6 +- .../reconcile/release/reconciler_test.go | 8 +- .../reconcile/repository/reconciler_test.go | 4 +- .../internal/services/base.go | 4 +- .../internal/services/chart_service.go | 6 +- .../internal/services/chart_service_test.go | 4 +- .../internal/services/force_service.go | 2 +- .../internal/services/force_service_test.go | 4 +- .../internal/services/helm_repo_service.go | 6 +- .../services/helm_repo_service_test.go | 2 +- .../internal/services/maintenance_service.go | 2 +- .../internal/services/oci_repo_service.go | 6 +- .../services/oci_repo_service_test.go | 4 +- .../internal/services/release_service.go | 4 +- .../internal/services/release_service_test.go | 4 +- .../werf.inc.yaml | 10 +- oss.yaml | 16 +- templates/admision-policy.yaml | 2 +- .../chart-values-controller/deployment.yaml | 4 +- templates/helm-controller/deployment.yaml | 4 +- .../kube-api-rewriter/_sidecar_helpers.tpl | 2 +- .../operator-helm-controller/deployment.yaml | 4 +- templates/rbac-to-us.yaml | 2 +- .../_helpers.tpl | 2 +- .../deployment.yaml | 48 +- .../rbac-for-us.yaml | 20 +- .../service-metrics.yaml | 6 +- .../service-monitor.yaml | 6 +- .../service.yaml | 6 +- tests/e2e/default_config.yaml | 6 +- tools/internalcrds/.golangci.yaml | 109 + tools/internalcrds/Taskfile.dist.yaml | 21 + tools/internalcrds/go.mod | 5 + tools/internalcrds/go.sum | 4 + tools/internalcrds/main.go | 173 + tools/internalcrds/main_test.go | 74 + tools/internalcrds/rename.go | 186 + tools/internalcrds/rename_test.go | 277 ++ 66 files changed, 3999 insertions(+), 5916 deletions(-) delete mode 100644 crds/embedded/nelm-source-controller.yaml create mode 100644 crds/embedded/source-controller.yaml create mode 100644 images/kube-api-rewriter/pkg/operatornelm/operatornelm_crds_test.go rename images/{nelm-source-controller => source-controller}/werf.inc.yaml (74%) rename templates/{nelm-source-controller => source-controller}/_helpers.tpl (75%) rename templates/{nelm-source-controller => source-controller}/deployment.yaml (75%) rename templates/{nelm-source-controller => source-controller}/rbac-for-us.yaml (86%) rename templates/{nelm-source-controller => source-controller}/service-metrics.yaml (52%) rename templates/{nelm-source-controller => source-controller}/service-monitor.yaml (64%) rename templates/{nelm-source-controller => source-controller}/service.yaml (53%) create mode 100644 tools/internalcrds/.golangci.yaml create mode 100644 tools/internalcrds/Taskfile.dist.yaml create mode 100644 tools/internalcrds/go.mod create mode 100644 tools/internalcrds/go.sum create mode 100644 tools/internalcrds/main.go create mode 100644 tools/internalcrds/main_test.go create mode 100644 tools/internalcrds/rename.go create mode 100644 tools/internalcrds/rename_test.go 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 96d12c3b..560f8ee0 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -46,6 +46,9 @@ jobs: - 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 diff --git a/Taskfile.yaml b/Taskfile.yaml index 754d9a2f..98d9ae61 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -8,10 +8,12 @@ vars: VALIDATION_FILES: "tools/validation/{main,messages,diff,doc_changes}.go" golangciLintVersion: "v2.13.2" -# Only the modules this repository authors are listed. images/kube-api-rewriter is a -# separate upstream module vendored in for the build, and images/helm-controller and -# images/nelm-source-controller carry nothing but their werf files, so none of them is -# ours to lint, format or test here. +# 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 @@ -28,6 +30,9 @@ includes: 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 @@ -59,6 +64,58 @@ 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 @@ -82,6 +139,18 @@ tasks: - 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." @@ -147,6 +216,7 @@ tasks: - task: operator-helm-controller:format - task: chart-values-controller:format - task: e2e:format + - task: internalcrds:format lint: deps: @@ -157,6 +227,7 @@ tasks: - 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/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/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/docs/README.ru.md b/docs/README.ru.md index eb2954f1..c6f57409 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -18,7 +18,6 @@ weight: 10 - Поддержка проверки TLS-сертификатов и аутентификации для приватных OCI и Helm репозиториев. - Управление через CLI (`d8 k`) или веб-интерфейс Deckhouse. - ## Кастомные ресурсы Для управления Helm-чартами в модуле используются следующие кастомные ресурсы: 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/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/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/resolver/resolver.go b/images/chart-values-controller/internal/resolver/resolver.go index 345c418f..c99e30b4 100644 --- a/images/chart-values-controller/internal/resolver/resolver.go +++ b/images/chart-values-controller/internal/resolver/resolver.go @@ -25,8 +25,8 @@ 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" diff --git a/images/chart-values-controller/internal/resolver/resolver_test.go b/images/chart-values-controller/internal/resolver/resolver_test.go index 5a89a1f5..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" 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/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/cmd/operator-helm-controller/main.go b/images/operator-helm-controller/cmd/operator-helm-controller/main.go index 8d5aeeef..39012f75 100644 --- a/images/operator-helm-controller/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-controller/cmd/operator-helm-controller/main.go @@ -20,8 +20,8 @@ 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/runtime" diff --git a/images/operator-helm-controller/go.mod b/images/operator-helm-controller/go.mod index 57e2b1e8..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 - 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/controller/helmapplication/controller.go b/images/operator-helm-controller/internal/controller/helmapplication/controller.go index ee231b5c..eba75e84 100644 --- a/images/operator-helm-controller/internal/controller/helmapplication/controller.go +++ b/images/operator-helm-controller/internal/controller/helmapplication/controller.go @@ -22,8 +22,8 @@ limitations under the License. package helmapplication 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" diff --git a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go index 8f9060ac..b237b0e4 100644 --- a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go @@ -21,7 +21,7 @@ limitations under the License. package helmapplicationrepository 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" diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go index 831b55e6..3e4e413f 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" diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go index e671df78..b0b89b8f 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" diff --git a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go index eadf5c56..fa3a6e11 100644 --- a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go @@ -19,7 +19,7 @@ limitations under the License. package helmclusterapplicationrepository 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" diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler.go b/images/operator-helm-controller/internal/reconcile/release/reconciler.go index 42196a37..19e5e62f 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler.go @@ -22,9 +22,9 @@ import ( "strings" "time" + "github.com/fluxcd/pkg/chartutil" "github.com/opencontainers/go-digest" - "github.com/werf/3p-fluxcd-pkg/chartutil" - helmchartutil "helm.sh/helm/v3/pkg/chartutil" + 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" @@ -663,7 +663,7 @@ func setStatusAttrs( rawValues = rel.Values().Raw } - values, _ := helmchartutil.ReadValues(rawValues) + values, _ := helmcommon.ReadValues(rawValues) if latestRelease.Status == "deployed" && latestRelease.ConfigDigest == chartutil.DigestValues(digest.Canonical, values).String() { if rel.Values() == nil { rel.SetLastAppliedValues(nil) diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index 1b1c8a25..7dbd99ac 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -23,10 +23,10 @@ import ( "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" - fluxmeta "github.com/werf/3p-fluxcd-pkg/apis/meta" - helmv2 "github.com/werf/3p-helm-controller/api/v2" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apimeta "k8s.io/apimachinery/pkg/api/meta" @@ -448,7 +448,7 @@ func reconcileApplication(t *testing.T, r *Reconciler, app *helmv1alpha1.HelmApp } } -// markInternalChartReady stands in for nelm-source-controller: the internal +// 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) { diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 27525043..8e545660 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -25,8 +25,8 @@ import ( "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" diff --git a/images/operator-helm-controller/internal/services/base.go b/images/operator-helm-controller/internal/services/base.go index d1cb6508..fe2d0ffa 100644 --- a/images/operator-helm-controller/internal/services/base.go +++ b/images/operator-helm-controller/internal/services/base.go @@ -21,7 +21,7 @@ import ( "fmt" "time" - "github.com/werf/3p-fluxcd-pkg/apis/meta" + "github.com/fluxcd/pkg/apis/meta" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -47,7 +47,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) { diff --git a/images/operator-helm-controller/internal/services/chart_service.go b/images/operator-helm-controller/internal/services/chart_service.go index 8ef1ab16..d448d65d 100644 --- a/images/operator-helm-controller/internal/services/chart_service.go +++ b/images/operator-helm-controller/internal/services/chart_service.go @@ -21,8 +21,8 @@ import ( "fmt" "maps" - "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" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -123,7 +123,7 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, rel source.Release, // 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, names source.ReleaseNames) (*sourcev1.HelmChart, error) { nn := types.NamespacedName{Name: names.HelmChart, Namespace: s.TargetNamespace} 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 ce6644d8..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,8 +20,8 @@ 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" diff --git a/images/operator-helm-controller/internal/services/force_service.go b/images/operator-helm-controller/internal/services/force_service.go index 077541ce..b24347d9 100644 --- a/images/operator-helm-controller/internal/services/force_service.go +++ b/images/operator-helm-controller/internal/services/force_service.go @@ -20,7 +20,7 @@ import ( "context" "fmt" - sourcev1 "github.com/werf/nelm-source-controller/api/v1" + 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" diff --git a/images/operator-helm-controller/internal/services/force_service_test.go b/images/operator-helm-controller/internal/services/force_service_test.go index c41a42e1..25275e14 100644 --- a/images/operator-helm-controller/internal/services/force_service_test.go +++ b/images/operator-helm-controller/internal/services/force_service_test.go @@ -20,8 +20,8 @@ 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" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/operator-helm/internal/adapter" 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 10731bcb..623b224b 100644 --- a/images/operator-helm-controller/internal/services/helm_repo_service.go +++ b/images/operator-helm-controller/internal/services/helm_repo_service.go @@ -21,8 +21,8 @@ import ( "fmt" "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" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -123,7 +123,7 @@ func (s *HelmRepoService) RemoveHelmRepository(ctx context.Context, names source // 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 +// and wait for source-controller to finish removing it. It returns nil once // the HelmRepository is gone. func (s *HelmRepoService) CleanupHelmRepository(ctx context.Context, names source.InternalNames) (*sourcev1.HelmRepository, error) { for _, name := range []string{names.AuthSecret, names.TLSSecret} { 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 52fee0ed..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" diff --git a/images/operator-helm-controller/internal/services/maintenance_service.go b/images/operator-helm-controller/internal/services/maintenance_service.go index 24dcf552..8cff6bd8 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" 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 213d4406..feea453d 100644 --- a/images/operator-helm-controller/internal/services/oci_repo_service.go +++ b/images/operator-helm-controller/internal/services/oci_repo_service.go @@ -21,8 +21,8 @@ import ( "fmt" "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" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -280,7 +280,7 @@ func (s *OCIRepoService) CleanupOCIRepository(ctx context.Context, names source. // 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, names source.ReleaseNames) (*sourcev1.OCIRepository, error) { nn := types.NamespacedName{Name: names.OCIRepository, Namespace: s.TargetNamespace} 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 6649a1e2..99fc6f3f 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,8 +21,8 @@ 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" diff --git a/images/operator-helm-controller/internal/services/release_service.go b/images/operator-helm-controller/internal/services/release_service.go index 40b9f8fd..083c5b32 100644 --- a/images/operator-helm-controller/internal/services/release_service.go +++ b/images/operator-helm-controller/internal/services/release_service.go @@ -23,8 +23,8 @@ import ( "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" diff --git a/images/operator-helm-controller/internal/services/release_service_test.go b/images/operator-helm-controller/internal/services/release_service_test.go index 996b8c86..4a4f2988 100644 --- a/images/operator-helm-controller/internal/services/release_service_test.go +++ b/images/operator-helm-controller/internal/services/release_service_test.go @@ -20,8 +20,8 @@ import ( "context" "testing" - 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" "sigs.k8s.io/controller-runtime/pkg/client" 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/admision-policy.yaml b/templates/admision-policy.yaml index 41a5a19b..8947d774 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -60,7 +60,7 @@ spec: 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/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/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/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/tests/e2e/default_config.yaml b/tests/e2e/default_config.yaml index 0f7a32c9..85502703 100644 --- a/tests/e2e/default_config.yaml +++ b/tests/e2e/default_config.yaml @@ -30,11 +30,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/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") + } +} From 15eac14e7dfe93695e420ab6c6198dca501e3e81 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sat, 19 Sep 2026 12:26:36 +0300 Subject: [PATCH 114/145] feat(rbac): carry the namespaced family into the RBACv2 model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The experimental role model was dropped from the chart until it could be added deliberately, and the role names changed in DKP 1.78 in the meantime. The capabilities are written for that model alone and gated on the platform version, so a cluster below 1.78 gets none of them and keeps being served by the classic roles. The levels follow what each one is allowed to see. Reading an application, its chart catalog and its repository sits at user: the repository carries its registry credentials in the object itself, and user is the level at which Deckhouse first permits reading secrets in a namespace. Installing an application is namespace-admin equivalent — the controller seeds a Role with unrestricted rights there — so the writes reach no level below admin. The cluster-scoped chart catalog is absent from both: a namespace role is granted through a RoleBinding, where a cluster-scoped rule grants nothing. Signed-off-by: Ilya Drey --- templates/_helpers.tpl | 12 ++++ templates/rbacv2/manage/edit.yaml | 95 +++++++++++++++++++++++++++++++ templates/rbacv2/manage/view.yaml | 64 +++++++++++++++++++++ templates/rbacv2/use/admin.yaml | 24 ++++++++ templates/rbacv2/use/user.yaml | 23 ++++++++ 5 files changed, 218 insertions(+) create mode 100644 templates/rbacv2/manage/edit.yaml create mode 100644 templates/rbacv2/manage/view.yaml create mode 100644 templates/rbacv2/use/admin.yaml create mode 100644 templates/rbacv2/use/user.yaml 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/rbacv2/manage/edit.yaml b/templates/rbacv2/manage/edit.yaml new file mode 100644 index 00000000..d839f0d2 --- /dev/null +++ b/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,95 @@ +{{- if eq (include "operator-helm.rbacv2_new_scheme" .) "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: +# Reading a repository is granted here rather than in the view capability, so that it reaches the +# manager level and no one below it: the registry credentials live in the object in plaintext. +# This is why the rule carries the read verbs a manager would otherwise inherit from the viewer. +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddonrepositories + - helmclusterapplicationrepositories + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +# Read access to the addon releases comes from the view capability; only the writes are left here. +# Neither catalog is writable at any level — the controller is its only author. +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddons + verbs: + - create + - delete + - deletecollection + - patch + - update +- apiGroups: + - deckhouse.io + resourceNames: + - {{ .Chart.Name }} + resources: + - moduleconfigs + verbs: + - create + - delete + - patch + - update +{{- else }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:manage:permission:module:{{ .Chart.Name }}:edit + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "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 + - helmclusterapplicationrepositories + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddons + 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..ae3efb8c --- /dev/null +++ b/templates/rbacv2/manage/view.yaml @@ -0,0 +1,64 @@ +{{- if eq (include "operator-helm.rbacv2_new_scheme" .) "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: +# Both cluster-wide catalogs and the addon releases themselves. The repository kinds are missing +# here and granted one level up instead: they carry their registry credentials in the object +# (spec.auth.username and spec.auth.password), and a viewer of a subsystem is not meant to read +# secrets. A catalog holds no credentials at all — only the chart versions the controller found. +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddons + - helmclusteraddoncharts + - helmclusterapplicationcharts + verbs: + - get + - list + - watch +- apiGroups: + - deckhouse.io + resourceNames: + - {{ .Chart.Name }} + resources: + - moduleconfigs + verbs: + - get + - list + - watch +{{- else }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:manage:permission:module:{{ .Chart.Name }}:view + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "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: + - helmclusteraddons + - helmclusteraddoncharts + - helmclusterapplicationcharts + 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 }} From 2949eadcc15d7069d1929251935922f0536368ea Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sat, 19 Sep 2026 12:26:52 +0300 Subject: [PATCH 115/145] feat(rbac): give the delivery subsystem the cluster-scoped kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cluster-scoped kind the module exposes, and its own configuration, land with the administrators of the delivery subsystem declared in module.yaml rather than with the kubernetes ones they were parked on before. user-authz ships no delivery roles yet, so the capabilities reach nobody until it does. The viewer level reads all of them, repositories included: the split that kept a repository out of it would have to be paid for elsewhere, since a subsystem administrator manages those repositories in the first place. Writing is left to the manager level, and neither chart catalog is writable at any level — the controller is its only author. Signed-off-by: Ilya Drey --- templates/rbacv2/manage/edit.yaml | 61 +------------------------------ templates/rbacv2/manage/view.yaml | 35 ++---------------- 2 files changed, 4 insertions(+), 92 deletions(-) diff --git a/templates/rbacv2/manage/edit.yaml b/templates/rbacv2/manage/edit.yaml index d839f0d2..2056bbb6 100644 --- a/templates/rbacv2/manage/edit.yaml +++ b/templates/rbacv2/manage/edit.yaml @@ -1,4 +1,4 @@ -{{- if eq (include "operator-helm.rbacv2_new_scheme" .) "true" }} +{{- if eq (include "operator-helm.enable_rbacv2" .) "true" }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -10,75 +10,16 @@ metadata: 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: -# Reading a repository is granted here rather than in the view capability, so that it reaches the -# manager level and no one below it: the registry credentials live in the object in plaintext. -# This is why the rule carries the read verbs a manager would otherwise inherit from the viewer. - apiGroups: - helm.deckhouse.io resources: - helmclusteraddonrepositories - - helmclusterapplicationrepositories - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch -# Read access to the addon releases comes from the view capability; only the writes are left here. -# Neither catalog is writable at any level — the controller is its only author. -- apiGroups: - - helm.deckhouse.io - resources: - helmclusteraddons - verbs: - - create - - delete - - deletecollection - - patch - - update -- apiGroups: - - deckhouse.io - resourceNames: - - {{ .Chart.Name }} - resources: - - moduleconfigs - verbs: - - create - - delete - - patch - - update -{{- else }} -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: d8:manage:permission:module:{{ .Chart.Name }}:edit - {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "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 - helmclusterapplicationrepositories verbs: - create - delete - deletecollection - - get - - list - - patch - - update - - watch -- apiGroups: - - helm.deckhouse.io - resources: - - helmclusteraddons - verbs: - - create - - delete - - deletecollection - patch - update - apiGroups: diff --git a/templates/rbacv2/manage/view.yaml b/templates/rbacv2/manage/view.yaml index ae3efb8c..ae581ce2 100644 --- a/templates/rbacv2/manage/view.yaml +++ b/templates/rbacv2/manage/view.yaml @@ -1,4 +1,4 @@ -{{- if eq (include "operator-helm.rbacv2_new_scheme" .) "true" }} +{{- if eq (include "operator-helm.enable_rbacv2" .) "true" }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -10,43 +10,14 @@ metadata: 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: -# Both cluster-wide catalogs and the addon releases themselves. The repository kinds are missing -# here and granted one level up instead: they carry their registry credentials in the object -# (spec.auth.username and spec.auth.password), and a viewer of a subsystem is not meant to read -# secrets. A catalog holds no credentials at all — only the chart versions the controller found. - apiGroups: - helm.deckhouse.io resources: - - helmclusteraddons - helmclusteraddoncharts - - helmclusterapplicationcharts - verbs: - - get - - list - - watch -- apiGroups: - - deckhouse.io - resourceNames: - - {{ .Chart.Name }} - resources: - - moduleconfigs - verbs: - - get - - list - - watch -{{- else }} -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: d8:manage:permission:module:{{ .Chart.Name }}:view - {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/kind" "manage" "rbac.deckhouse.io/level" "module" "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: + - helmclusteraddonrepositories - helmclusteraddons - - helmclusteraddoncharts - helmclusterapplicationcharts + - helmclusterapplicationrepositories verbs: - get - list From 2193fe47d16a6f499197435d5c2d5b52611af4bd Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sat, 19 Sep 2026 12:26:53 +0300 Subject: [PATCH 116/145] feat(rbac): move the namespaced read down to PrivilegedUser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classic roles granted the whole namespaced family to Admin alone, which put reading an application at the same level as installing one. Reading now sits at PrivilegedUser — the level at which Deckhouse first permits reading secrets, which is what reading a repository amounts to, its credentials living in the object — together with both chart catalogs an application may pick from. Admin keeps only what it adds: the writes. This is the level split the RBACv2 capabilities of this module already use, so the two role models now grant the same thing at the same height. Signed-off-by: Ilya Drey --- templates/user-authz-cluster-roles.yaml | 43 ++++++++++++++----------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index d70fab6e..751bf03f 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -3,34 +3,23 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: annotations: - user-authz.deckhouse.io/access-level: Admin - name: d8:user-authz:{{ .Chart.Name }}:admin + 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: - - helmapplications + - helmapplicationcharts - helmapplicationrepositories + - helmapplications verbs: - - create - - delete - - deletecollection - get - list - - patch - - update - watch -# Both catalogs an application can pick a chart from, including the cluster-wide one -# whose repository only ClusterAdmin may manage. A catalog is written by the -# controller alone, so nobody gets more than a read of it. Repository credentials -# (spec.auth.username and spec.auth.password) live in the repository object itself, so -# the read granted above is also a read of every repository password in reach of this -# level. - apiGroups: - helm.deckhouse.io resources: - - helmapplicationcharts - helmclusterapplicationcharts verbs: - get @@ -39,14 +28,32 @@ rules: --- 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: -# The cluster-scoped kinds reach no level below this one, so these rules stand on -# their own rather than extending anything above. - apiGroups: - helm.deckhouse.io resources: @@ -62,8 +69,6 @@ rules: - patch - update - watch -# The application catalogs are already readable from the level above; only the -# addon one is left to add here. - apiGroups: - helm.deckhouse.io resources: From 7683f4985811991bbb0ce87561ef70178b832e78 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sat, 19 Sep 2026 12:53:12 +0300 Subject: [PATCH 117/145] refactor(controller): give the repository secrets a service of their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth and TLS secrets were reconciled by a method on BaseRepoService that neither repository service used itself, so the reconciler reached them through whichever of the two was at hand — and had to explain in a comment that this was safe because both embed the same base with the same namespace. Their removal was worse: the same loop over the two secret names was written twice, once inside CleanupHelmRepository and once inside CleanupOCIRepository, and the deletion path explained that cleaning up "the helm way" also works for an oci repository. RepoSecretsService now owns both ends. The reconciler holds it as a dependency of its own, the deletion path removes the secrets once before it looks at the repository type, and the repository services are left with the objects they actually own. CleanupOCIRepository disappears entirely: despite its name it deleted no OCIRepository — that object belongs to a release, is named in ReleaseNames rather than InternalNames, and is removed by the release reconciler — so the secrets were everything it did. Its branch of the deletion switch collapses into a check for the one type that does have an internal object, and the oci repository service is no longer a dependency of this reconciler. A failure to delete a secret now reports "auxiliary secrets" on the status instead of "internal repository", the name it inherited from the method it used to be part of. Signed-off-by: Ilya Drey --- .../helmapplicationrepository/controller.go | 4 +- .../helmclusteraddonrepository/controller.go | 4 +- .../controller.go | 4 +- .../reconcile/repository/reconciler.go | 37 ++- .../reconcile/repository/reconciler_test.go | 8 +- .../internal/services/base.go | 204 -------------- .../internal/services/base_test.go | 119 +------- .../internal/services/helm_repo_service.go | 13 +- .../internal/services/oci_repo_service.go | 12 - .../internal/services/repo_secrets.go | 264 ++++++++++++++++++ .../internal/services/repo_secrets_test.go | 166 +++++++++++ 11 files changed, 455 insertions(+), 380 deletions(-) create mode 100644 images/operator-helm-controller/internal/services/repo_secrets.go create mode 100644 images/operator-helm-controller/internal/services/repo_secrets_test.go diff --git a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go index b237b0e4..efe983c6 100644 --- a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go @@ -45,13 +45,11 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - ociRepositoryService := services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil) - r := reconcile.New( client, adapter.EmptyApplicationRepository, + services.NewRepoSecretsService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - ociRepositoryService, services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(client)), services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewApplicationCatalog(client)), status.NewManager(client), diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go index b0b89b8f..27a28f23 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go @@ -41,13 +41,11 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - ociRepositoryService := services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil) - r := reconcile.New( client, adapter.EmptyAddonRepository, + services.NewRepoSecretsService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - ociRepositoryService, services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListAddonReleases(client)), services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewAddonCatalog(client)), status.NewManager(client), diff --git a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go index fa3a6e11..7d96d5af 100644 --- a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go @@ -43,13 +43,11 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - ociRepositoryService := services.NewOCIRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace, nil) - r := reconcile.New( client, adapter.EmptyClusterApplicationRepository, + services.NewRepoSecretsService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - ociRepositoryService, services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(client)), services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewClusterApplicationCatalog(client)), status.NewManager(client), diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go index 2ccc2f8c..78d87cef 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go @@ -50,8 +50,8 @@ const internalResourceDeletionRequeueInterval = 30 * time.Second func New( client client.Client, newRepository func() source.Repository, + secretsService *services.RepoSecretsService, helmRepositoryService *services.HelmRepoService, - ociRepositoryService *services.OCIRepoService, consumers source.ConsumerForcer, chartSyncService *services.RepoSyncService, statusManager *status.Manager, @@ -59,8 +59,8 @@ func New( return &Reconciler{ Client: client, newRepository: newRepository, + secretsService: secretsService, helmRepositoryService: helmRepositoryService, - ociRepositoryService: ociRepositoryService, consumers: consumers, chartSyncService: chartSyncService, statusManager: statusManager, @@ -71,8 +71,8 @@ type Reconciler struct { client.Client newRepository func() source.Repository + secretsService *services.RepoSecretsService helmRepositoryService *services.HelmRepoService - ociRepositoryService *services.OCIRepoService consumers source.ConsumerForcer chartSyncService *services.RepoSyncService statusManager *status.Manager @@ -134,9 +134,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco 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) + in.SecretsErr = r.secretsService.Ensure(ctx, repo, repoType) if in.SecretsErr == nil { switch repoType { @@ -237,20 +235,19 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo source.Repository names := repo.InternalNames() - switch repoType { - case utils.InternalOCIRepository: - if err := r.ociRepositoryService.CleanupOCIRepository(ctx, names); err != nil && !apierrors.IsNotFound(err) { - _ = r.statusManager.MarkDeletionFailed(ctx, repo.Object(), "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. + if err := r.secretsService.Cleanup(ctx, names); err != nil && !apierrors.IsNotFound(err) { + _ = r.statusManager.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 != utils.InternalOCIRepository { helmRepo, err := r.helmRepositoryService.CleanupHelmRepository(ctx, names) if err != nil && !apierrors.IsNotFound(err) { _ = r.statusManager.MarkDeletionFailed(ctx, repo.Object(), "internal repository", err) diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 8e545660..9350c71b 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -121,13 +121,11 @@ func newReconcilerWithInterceptor( return stub, nil } - ociRepositoryService := services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, nil) - r := New( c, adapter.EmptyAddonRepository, + services.NewRepoSecretsService(c, scheme, helmv1alpha1.TargetNamespace), services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), - ociRepositoryService, services.NewForceService(c, helmv1alpha1.TargetNamespace, adapter.ListAddonReleases(c)), services.NewRepoSyncService(c, scheme, factory, adapter.NewAddonCatalog(c)), status.NewManager(c), @@ -168,13 +166,11 @@ func newApplicationReconciler(t *testing.T, stub *stubRepoClient, objects ...cli return stub, nil } - ociRepositoryService := services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, nil) - r := New( c, adapter.EmptyApplicationRepository, + services.NewRepoSecretsService(c, scheme, helmv1alpha1.TargetNamespace), services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), - ociRepositoryService, services.NewForceService(c, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(c)), services.NewRepoSyncService(c, scheme, factory, adapter.NewApplicationCatalog(c)), status.NewManager(c), diff --git a/images/operator-helm-controller/internal/services/base.go b/images/operator-helm-controller/internal/services/base.go index fe2d0ffa..00da6a4b 100644 --- a/images/operator-helm-controller/internal/services/base.go +++ b/images/operator-helm-controller/internal/services/base.go @@ -22,17 +22,10 @@ import ( "time" "github.com/fluxcd/pkg/apis/meta" - 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/source" - "github.com/deckhouse/operator-helm/internal/utils" ) type BaseService struct { @@ -68,203 +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 source.Repository, - 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 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 *BaseRepoService) 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 *BaseRepoService) 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 *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 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 -} - // 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 84e0459e..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,26 +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/adapter" - "github.com/deckhouse/operator-helm/internal/utils" ) const testNamespace = "d8-operator-helm" @@ -50,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.RepositorySpec{ - URL: "https://example.invalid/charts", - Auth: &helmv1alpha1.RepositoryAuth{Username: "user", Password: "secret"}, - CACertificate: "-----BEGIN CERTIFICATE-----", - }, - } - - service, c := newBaseRepoService(t, repo) - - if err := service.EnsureSecrets(context.Background(), adapter.NewAddonRepository(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.RepositorySpec{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(), adapter.NewAddonRepository(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.RepositorySpec{ - URL: "oci://ghcr.io/example/podinfo", - Auth: &helmv1alpha1.RepositoryAuth{Username: "user", Password: "secret"}, - }, - } - - service, c := newBaseRepoService(t, repo) - - if err := service.EnsureSecrets(context.Background(), adapter.NewAddonRepository(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/helm_repo_service.go b/images/operator-helm-controller/internal/services/helm_repo_service.go index 623b224b..82389113 100644 --- a/images/operator-helm-controller/internal/services/helm_repo_service.go +++ b/images/operator-helm-controller/internal/services/helm_repo_service.go @@ -23,7 +23,6 @@ import ( "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1" - corev1 "k8s.io/api/core/v1" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -120,19 +119,11 @@ func (s *HelmRepoService) RemoveHelmRepository(ctx context.Context, names source 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 +// 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, names source.InternalNames) (*sourcev1.HelmRepository, 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 nil, fmt.Errorf("cleaning up secret %s: %w", name, err) - } - } - nn := types.NamespacedName{Name: names.HelmRepository, Namespace: s.TargetNamespace} helmRepo := &sourcev1.HelmRepository{} exists, err := s.deleteAndCheck(ctx, nn, helmRepo) 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 feea453d..458a192f 100644 --- a/images/operator-helm-controller/internal/services/oci_repo_service.go +++ b/images/operator-helm-controller/internal/services/oci_repo_service.go @@ -23,7 +23,6 @@ import ( "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -267,17 +266,6 @@ func artifactRepoConfig(repo source.Repository, src utils.ChartSource) *repoclie } } -func (s *OCIRepoService) CleanupOCIRepository(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 -} - // 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 source-controller to finish removing it. It returns nil once the 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..fb837163 --- /dev/null +++ b/images/operator-helm-controller/internal/services/repo_secrets.go @@ -0,0 +1,264 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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/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 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 *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..b52a57c7 --- /dev/null +++ b/images/operator-helm-controller/internal/services/repo_secrets_test.go @@ -0,0 +1,166 @@ +/* +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/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), utils.InternalHelmRepository); 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), utils.InternalHelmRepository); 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), utils.InternalOCIRepository); 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), utils.InternalHelmRepository); 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) + } + } +} From 2dcfef91d9e7e346658b319760efa795de34e14b Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sat, 19 Sep 2026 13:28:27 +0300 Subject: [PATCH 118/145] fix(logging): stop the catalog lines shadowing the reconciled repository The catalog logged the chart object under "name", the key controller-runtime already fills with the repository being reconciled. zap emits both, and every JSON reader downstream keeps the last one, so the repository's identity was silently replaced by a derived chart name and the line read as being about a resource nobody reconciled. The object a line names now travels under a key of its own, which is the rule the rest of the controller follows. Signed-off-by: Ilya Drey --- .../internal/catalog/catalog.go | 15 ++-- .../internal/catalog/log_test.go | 78 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 images/operator-helm-controller/internal/catalog/log_test.go diff --git a/images/operator-helm-controller/internal/catalog/catalog.go b/images/operator-helm-controller/internal/catalog/catalog.go index 6c8ce996..503746b1 100644 --- a/images/operator-helm-controller/internal/catalog/catalog.go +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -97,7 +97,8 @@ func (t *typed[C, CL]) Known(ctx context.Context, repo source.Repository) (repoc // 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, "name", chart.GetName()) + logger.Info("Chart object has no chart label, dropping its recorded verdicts", + "kind", t.cfg.Kind, "chartObject", client.ObjectKeyFromObject(chart)) continue } @@ -151,7 +152,8 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch } if op != controllerutil.OperationResultNone { - logger.Info("Reconciled chart catalog object", "kind", t.cfg.Kind, "operation", op, "name", name) + logger.Info("Reconciled chart catalog object", + "kind", t.cfg.Kind, "operation", op, "chartObject", client.ObjectKeyFromObject(existing)) } inUse, err := t.InUseVersions(ctx, repo, chart.Name) @@ -189,7 +191,8 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch // 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, "name", chart.GetName()) + 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) @@ -200,7 +203,8 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch // 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, "name", chart.GetName()) + logger.Info("Keeping a chart referenced by a consumer", + "kind", t.cfg.Kind, "chartObject", client.ObjectKeyFromObject(chart)) continue } @@ -268,7 +272,8 @@ func (t *typed[C, CL]) MigrateNames(ctx context.Context, repo source.Repository) } } - logger.Info("Renamed a chart catalog object", "kind", t.cfg.Kind, "from", legacy.GetName(), "to", name, "chart", chartName) + 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) 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") + } + } +} From 1b6c89238d0a9ff1dd01fe073e8802f40bfe8cf1 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sat, 19 Sep 2026 13:28:41 +0300 Subject: [PATCH 119/145] fix(logging): identify the object that triggered a watch mapping controller-runtime puts a logger into the context only in its reconcile handler, so log.FromContext in a MapFunc returns the bare root logger: these lines carried no controller, no kind and, in four of six, no object at all. An operator reading one could not tell which resource, or even which controller, it came from. Each line now names the object whose event is being mapped, and the two mappers registered once per repository kind name that kind, so their otherwise identical lines can be told apart. Signed-off-by: Ilya Drey --- .../internal/utils/mapper.go | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/images/operator-helm-controller/internal/utils/mapper.go b/images/operator-helm-controller/internal/utils/mapper.go index 183cb851..cbb2ce3d 100644 --- a/images/operator-helm-controller/internal/utils/mapper.go +++ b/images/operator-helm-controller/internal/utils/mapper.go @@ -45,7 +45,7 @@ func MapInternalResources(controllerName, targetNamespace, labelManagedBy, label sourceName := labels[labelSourceName] if sourceName == "" { logger.V(1).Info("resource missing source label, skipping", - "controller", controllerName, "name", obj.GetName(), "namespace", obj.GetNamespace()) + "controller", controllerName, "watchedObject", client.ObjectKeyFromObject(obj)) return nil } @@ -86,7 +86,7 @@ func MapNamespacedInternalResources( sourceName, sourceNamespace := labels[labelSourceName], labels[labelSourceNamespace] if sourceName == "" || sourceNamespace == "" { logger.V(1).Info("resource missing source labels, skipping", - "controller", controllerName, "name", obj.GetName(), "namespace", obj.GetNamespace()) + "controller", controllerName, "watchedObject", client.ObjectKeyFromObject(obj)) return nil } @@ -106,7 +106,8 @@ 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") + log.FromContext(ctx).Error(err, "Failed to list HelmClusterAddons for repository mapping", + "watchedObject", client.ObjectKeyFromObject(obj)) return nil } @@ -137,7 +138,8 @@ func MapChartToAddons(c client.Client) handler.MapFunc { // 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()) + log.FromContext(ctx).Info("Chart object missing repository or chart label, cannot map to addons", + "watchedObject", client.ObjectKeyFromObject(obj)) return nil } @@ -146,7 +148,8 @@ func MapChartToAddons(c client.Client) handler.MapFunc { 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") + log.FromContext(ctx).Error(err, "Failed to list HelmClusterAddons for chart mapping", + "watchedObject", client.ObjectKeyFromObject(obj), "repository", repoName, "chart", chartName) return nil } @@ -170,7 +173,8 @@ func MapRepositoryToApplications(c client.Client, repositoryKind string) handler 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") + log.FromContext(ctx).Error(err, "Failed to list HelmApplications for repository mapping", + "repositoryKind", repositoryKind, "watchedObject", client.ObjectKeyFromObject(obj)) return nil } @@ -190,7 +194,8 @@ func MapChartToApplications(c client.Client, repositoryKind string) handler.MapF 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", "name", obj.GetName(), "namespace", obj.GetNamespace()) + log.FromContext(ctx).Info("Chart object missing repository or chart label, cannot map to applications", + "repositoryKind", repositoryKind, "watchedObject", client.ObjectKeyFromObject(obj)) return nil } @@ -199,7 +204,9 @@ func MapChartToApplications(c client.Client, repositoryKind string) handler.MapF 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") + log.FromContext(ctx).Error(err, "Failed to list HelmApplications for chart mapping", + "repositoryKind", repositoryKind, "watchedObject", client.ObjectKeyFromObject(obj), + "repository", repoName, "chart", chartName) return nil } From 4ac738ff5edd2de16982fbdc9fa32fe4d8299a09 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sat, 19 Sep 2026 13:28:55 +0300 Subject: [PATCH 120/145] fix(logging): name the internal object a stalled deletion is waiting on Both reconcilers logged the abstract "internal repository", which means a HelmRepository in one and an OCIRepository in the other, and named no object. The name alone would not have settled it either: every internal object of one owner shares a single derived name in one namespace, so only the type says which one is stuck. The status keeps the abstract wording it shows the user; the log now carries the type and the key someone has to reach for. Also drops a no-op context round-trip in both Reconcile methods and a repository name that duplicated what controller-runtime already puts on every line. Signed-off-by: Ilya Drey --- .../internal/manager/status/deletion.go | 8 ++++++-- .../internal/reconcile/release/reconciler.go | 11 ++++++----- .../internal/reconcile/repository/reconciler.go | 13 +++++++------ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/images/operator-helm-controller/internal/manager/status/deletion.go b/images/operator-helm-controller/internal/manager/status/deletion.go index fe56891a..493d8f39 100644 --- a/images/operator-helm-controller/internal/manager/status/deletion.go +++ b/images/operator-helm-controller/internal/manager/status/deletion.go @@ -22,15 +22,19 @@ 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 + client.Object + GetConditions() []metav1.Condition } diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler.go b/images/operator-helm-controller/internal/reconcile/release/reconciler.go index 19e5e62f..a84fc125 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler.go @@ -82,9 +82,6 @@ type Reconciler struct { } func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { - logger := log.FromContext(ctx) - ctx = log.IntoContext(ctx, logger) - rel := r.deps.NewRelease() if err := r.Get(ctx, req.NamespacedName, rel.Object()); err != nil { if apierrors.IsNotFound(err) { @@ -420,9 +417,13 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (r // awaitInternalResourceDeletion surfaces that an internal resource is still being // deleted on the release'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. +// internal type is not leaked to the user; the log line names the object itself, +// which is what someone looking into a stuck deletion has to reach for. func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, rel source.Release, name string, resource status.DeletingResource) (reconcile.Result, error) { - log.FromContext(ctx).Info("Waiting for internal resource to be deleted before removing finalizer", "resource", name) + 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 := r.deps.Status.MarkUninstallPending(ctx, rel.Object(), name, resource); client.IgnoreNotFound(err) != nil { return reconcile.Result{}, fmt.Errorf("updating deletion status: %w", err) diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go index 78d87cef..2d7fbada 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go @@ -79,9 +79,6 @@ type Reconciler struct { } func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { - logger := log.FromContext(ctx) - ctx = log.IntoContext(ctx, logger) - repo := r.newRepository() if err := r.Get(ctx, req.NamespacedName, repo.Object()); err != nil { if apierrors.IsNotFound(err) { @@ -189,7 +186,7 @@ func (r *Reconciler) finish( 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()) + log.FromContext(ctx).Error(in.Fetch.Err, in.Fetch.Message) } if err := r.statusManager.PatchStatus(ctx, repo.Object(), func() { @@ -282,9 +279,13 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo source.Repository // 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. +// internal type is not leaked to the user; the log line names the object itself, +// which is what someone looking into a stuck deletion has to reach for. func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, repo source.Repository, name string, resource status.DeletingResource) (reconcile.Result, error) { - log.FromContext(ctx).Info("Waiting for internal resource to be deleted before removing finalizer", "resource", name) + 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 := r.statusManager.MarkDeletionPending(ctx, repo.Object(), name, resource); client.IgnoreNotFound(err) != nil { return reconcile.Result{}, fmt.Errorf("updating deletion status: %w", err) From f8e7020deba0fe3fa6fb34b811f7c3ddd5725b56 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Sat, 19 Sep 2026 13:29:08 +0300 Subject: [PATCH 121/145] fix(logging): name the internal object the services reconcile The "Reconciled ..." lines reported only the operation. Internal names are derived from a hash, so nobody could get from such a line to the object it wrote without recomputing one by hand. Signed-off-by: Ilya Drey --- .../internal/services/chart_service.go | 6 ++++-- .../internal/services/helm_repo_service.go | 3 ++- .../internal/services/oci_repo_service.go | 3 ++- .../internal/services/release_service.go | 3 ++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/images/operator-helm-controller/internal/services/chart_service.go b/images/operator-helm-controller/internal/services/chart_service.go index d448d65d..04e60789 100644 --- a/images/operator-helm-controller/internal/services/chart_service.go +++ b/images/operator-helm-controller/internal/services/chart_service.go @@ -104,7 +104,8 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, rel source.Release, } 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( @@ -112,7 +113,8 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, rel source.Release, ) if processedStatus.IsReady() { - logger.Info("Successfully reconciled helm chart", "operation", op, "chart", rel.ChartRef().Chart) + logger.Info("Successfully reconciled helm chart", "operation", op, "chart", rel.ChartRef().Chart, + "internalObject", client.ObjectKeyFromObject(existing)) } return ChartResult{ 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 82389113..f8638a41 100644 --- a/images/operator-helm-controller/internal/services/helm_repo_service.go +++ b/images/operator-helm-controller/internal/services/helm_repo_service.go @@ -81,7 +81,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} 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 458a192f..55d946ff 100644 --- a/images/operator-helm-controller/internal/services/oci_repo_service.go +++ b/images/operator-helm-controller/internal/services/oci_repo_service.go @@ -146,7 +146,8 @@ func (s *OCIRepoService) EnsureInternalOCIRepository( } 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( diff --git a/images/operator-helm-controller/internal/services/release_service.go b/images/operator-helm-controller/internal/services/release_service.go index 083c5b32..de87f4da 100644 --- a/images/operator-helm-controller/internal/services/release_service.go +++ b/images/operator-helm-controller/internal/services/release_service.go @@ -118,7 +118,8 @@ func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, rel source.Relea } if processedStatus.IsReady() { - logger.Info("Successfully reconciled helm release", "operation", op) + logger.Info("Successfully reconciled helm release", "operation", op, + "internalObject", client.ObjectKeyFromObject(existing)) } return ReleaseResult{ From 1f58bebe9a26778062ce0b82180be3b72696365d Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Mon, 21 Sep 2026 09:16:44 +0300 Subject: [PATCH 122/145] fix(repository): keep a throttled registry read retriable HTTP 429 fell into the catch-all 4xx arm and was reported as terminal, so a repository saturated its failure counter on the first throttled read and an addon whose artifact probe was throttled scheduled no retry at all. It is the one rejection in that range that says "later" rather than "no". Signed-off-by: Ilya Drey --- .../internal/client/repository/errors.go | 6 ++ .../internal/client/repository/errors_test.go | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 images/operator-helm-controller/internal/client/repository/errors_test.go 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) + } + } +} From f5f555babc40e6ddf8242b8f2508b966968dd84f Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Mon, 21 Sep 2026 09:17:03 +0300 Subject: [PATCH 123/145] chore(rbac): let the controller reconcile the namespace role it creates The namespace role is no longer seeded once and forgotten: it is read back, rewritten and watched for drift, which needs get and patch on the single role name the module writes, and list and watch on the kind for the informer behind that watch. escalate stays granted for every role because, like create, it is authorized against a name the request does not carry. Signed-off-by: Ilya Drey --- .../operator-helm-controller/rbac-for-us.yaml | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/templates/operator-helm-controller/rbac-for-us.yaml b/templates/operator-helm-controller/rbac-for-us.yaml index 2f8ca0b5..01b30f13 100644 --- a/templates/operator-helm-controller/rbac-for-us.yaml +++ b/templates/operator-helm-controller/rbac-for-us.yaml @@ -102,13 +102,17 @@ rules: - patch - update - watch -# The namespace role is seeded once and never read back, so create is the only -# verb the controller uses on it. Neither create nor escalate can be scoped by -# resourceNames: both are authorized against the name in the request path, which -# a create does not carry (RoleEscalationAuthorized passes requestInfo.Name, empty -# here — see k8s.io/kubernetes/pkg/registry/rbac/escalation_check.go). bind is the -# exception: it is authorized against the name in the binding's roleRef, so it is -# scoped to the single role this module ever seeds. +# 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: @@ -116,6 +120,8 @@ rules: verbs: - create - escalate + - list + - watch - apiGroups: - rbac.authorization.k8s.io resources: @@ -124,6 +130,8 @@ rules: - operator-helm-application verbs: - bind + - get + - patch - apiGroups: - rbac.authorization.k8s.io resources: @@ -132,8 +140,10 @@ rules: - create - delete - get + - list - patch - update + - watch - apiGroups: - helm.internal.operator-helm.deckhouse.io resources: From f9bbb1411ba27667a9321812b41c25ea9865eb5c Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Mon, 21 Sep 2026 09:17:42 +0300 Subject: [PATCH 124/145] refactor(controller): split the reconcile decision out of the reconcilers Each reconciler now gathers the facts of one pass and hands them to a pure Evaluate that returns the whole desired status, with the collaborators it calls declared where they are used. The status manager moves to internal/status, the outcome types split per family, the chart source vocabulary and the per-pass helpers get packages of their own, and the watch mappers move from utils to the controllers that own them. The behaviour the status reports comes with it, because it is written against that decision function: a failure no retry resolves raises Stalled and schedules nothing, Reconciling is maintained on every pass rather than only on a forced one, and a Role or RoleBinding occupying the application identity's name without the module's managed-by label is refused rather than adopted. Signed-off-by: Ilya Drey --- api/v1alpha1/conditions.go | 5 + api/v1alpha1/helm_application.go | 21 +- api/v1alpha1/helm_application_chart.go | 4 - api/v1alpha1/helm_application_repository.go | 4 - api/v1alpha1/helm_cluster_addon.go | 18 +- api/v1alpha1/helm_cluster_addon_chart.go | 4 - api/v1alpha1/helm_cluster_addon_repository.go | 4 - .../helm_cluster_application_chart.go | 4 - .../helm_cluster_application_repository.go | 4 - crds/doc-ru-helmapplications.yaml | 4 +- crds/doc-ru-helmclusteraddons.yaml | 2 +- crds/helmapplications.yaml | 18 +- crds/helmclusteraddons.yaml | 14 +- .../cmd/operator-helm-controller/main.go | 28 +- .../internal/adapter/addon_release.go | 4 +- .../internal/adapter/addon_repository.go | 2 +- .../internal/adapter/application_release.go | 4 +- .../adapter/application_repository.go | 2 +- .../adapter/cluster_application_repository.go | 2 +- .../internal/chartsource/chartsource.go | 111 +++ .../internal/chartsource/chartsource_test.go | 105 +++ .../internal/client/repository/client.go | 8 +- .../controller/helmapplication/controller.go | 33 +- .../controller/helmapplication/mapper.go | 163 +++++ .../controller/helmapplication/mapper_test.go | 202 ++++++ .../helmapplicationrepository/controller.go | 19 +- .../controller/helmclusteraddon/controller.go | 9 +- .../controller/helmclusteraddon/mapper.go | 92 +++ .../helmclusteraddon/mapper_test.go | 133 ++++ .../helmclusteraddonrepository/controller.go | 19 +- .../controller.go | 19 +- .../manager/status/condition_rules.go | 69 -- .../internal/manager/status/helpers.go | 77 --- .../internal/manager/status/manager.go | 205 ------ .../internal/reconcile/pass/pass.go | 101 +++ .../internal/reconcile/pass/pass_test.go | 109 +++ .../reconcile/release/collaborators.go | 140 ++++ .../internal/reconcile/release/doc.go | 8 + .../internal/reconcile/release/evaluate.go | 523 ++++++++++++++ .../reconcile/release/evaluate_test.go | 649 ++++++++++++++++++ .../internal/reconcile/release/reconciler.go | 499 ++++++-------- .../reconcile/release/reconciler_test.go | 311 +++++++-- .../reconcile/repository/collaborators.go | 59 ++ .../reconcile/repository/reconciler.go | 146 ++-- .../reconcile/repository/reconciler_test.go | 43 +- .../internal/services/access_service.go | 236 +++++-- .../internal/services/access_service_test.go | 240 +++++-- .../internal/services/chart_claim_service.go | 2 - .../internal/services/chart_service.go | 46 +- .../internal/services/helm_repo_service.go | 3 +- .../internal/services/internal_object.go | 115 ++++ .../internal/services/maintenance_service.go | 50 +- .../internal/services/namespace_service.go | 2 - .../internal/services/oci_repo_service.go | 103 +-- .../services/oci_repo_service_test.go | 50 +- .../internal/services/outcomes_release.go | 95 +++ .../{outcomes.go => outcomes_repository.go} | 0 .../internal/services/release_service.go | 68 +- .../internal/services/release_service_test.go | 6 +- .../internal/services/repo_secrets.go | 5 +- .../internal/services/repo_secrets_test.go | 9 +- .../internal/services/repo_sync_service.go | 8 +- .../services/repo_sync_service_test.go | 34 +- .../internal/source/release.go | 64 +- .../internal/source/repository.go | 2 +- .../internal/{manager => }/status/deletion.go | 144 ++-- .../internal/status/deletion_test.go | 270 ++++++++ .../internal/status/manager.go | 69 ++ .../internal/utils/mapper.go | 125 ---- .../internal/utils/mapper_test.go | 196 ------ .../internal/utils/repository.go | 89 --- .../internal/utils/repository_test.go | 84 --- 72 files changed, 4163 insertions(+), 1952 deletions(-) create mode 100644 images/operator-helm-controller/internal/chartsource/chartsource.go create mode 100644 images/operator-helm-controller/internal/chartsource/chartsource_test.go create mode 100644 images/operator-helm-controller/internal/controller/helmapplication/mapper.go create mode 100644 images/operator-helm-controller/internal/controller/helmapplication/mapper_test.go create mode 100644 images/operator-helm-controller/internal/controller/helmclusteraddon/mapper.go create mode 100644 images/operator-helm-controller/internal/controller/helmclusteraddon/mapper_test.go delete mode 100644 images/operator-helm-controller/internal/manager/status/condition_rules.go delete mode 100644 images/operator-helm-controller/internal/manager/status/helpers.go delete mode 100644 images/operator-helm-controller/internal/manager/status/manager.go create mode 100644 images/operator-helm-controller/internal/reconcile/pass/pass.go create mode 100644 images/operator-helm-controller/internal/reconcile/pass/pass_test.go create mode 100644 images/operator-helm-controller/internal/reconcile/release/collaborators.go create mode 100644 images/operator-helm-controller/internal/reconcile/release/evaluate.go create mode 100644 images/operator-helm-controller/internal/reconcile/release/evaluate_test.go create mode 100644 images/operator-helm-controller/internal/reconcile/repository/collaborators.go create mode 100644 images/operator-helm-controller/internal/services/internal_object.go create mode 100644 images/operator-helm-controller/internal/services/outcomes_release.go rename images/operator-helm-controller/internal/services/{outcomes.go => outcomes_repository.go} (100%) rename images/operator-helm-controller/internal/{manager => }/status/deletion.go (61%) create mode 100644 images/operator-helm-controller/internal/status/deletion_test.go create mode 100644 images/operator-helm-controller/internal/status/manager.go diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/conditions.go index b2fdb450..16a60a5f 100644 --- a/api/v1alpha1/conditions.go +++ b/api/v1alpha1/conditions.go @@ -42,6 +42,11 @@ const ( // 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/helm_application.go b/api/v1alpha1/helm_application.go index b7719c7c..d385f41c 100644 --- a/api/v1alpha1/helm_application.go +++ b/api/v1alpha1/helm_application.go @@ -52,7 +52,7 @@ const ( // These notes are deliberately outside the doc comment below — controller-gen folds // every non-marker line of that block into the resource's API 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 is created once and may be narrowed by the namespace owner afterwards. +// 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 @@ -87,10 +87,6 @@ func (r *HelmApplication) GetObservedGeneration() int64 { return r.Status.ObservedGeneration } -func (r *HelmApplication) GetStatus() any { - return r.Status -} - // 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 { @@ -244,9 +240,18 @@ type HelmApplicationStatus struct { LastAppliedValues *apiextensionsv1.JSON `json:"lastAppliedValues,omitempty"` // Conditions represent the latest available observations of the application 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. + // Reconciling and Stalled follow the kstatus convention: they are present only + // while applicable. Reconciling means the pass left something to wait for — an + // internal object still rolling out, or a failure that has a retry coming — and + // it is taken away by the pass that finds nothing left to do. It carries the + // reason ForceReconcile while a reconciliation requested through the force + // reconcile annotation is running, and hands over to the ordinary verdict once + // that pass is over. Stalled means the pass ended in a failure no retry + // will resolve: a fault in this object's own spec, a repository whose url cannot + // be read, a registry that rejected the pull, or an object already occupying a + // name this object needs. Stalled outranks Reconciling: a release that cannot + // proceed is not making progress. Such a state is left by correcting the cause + // and requesting a reconciliation, not by waiting. // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` // Generation represents resource generation that was last processed by the controller. diff --git a/api/v1alpha1/helm_application_chart.go b/api/v1alpha1/helm_application_chart.go index aa8dd42a..c2c7af9e 100644 --- a/api/v1alpha1/helm_application_chart.go +++ b/api/v1alpha1/helm_application_chart.go @@ -61,10 +61,6 @@ func (r *HelmApplicationChart) GetObservedGeneration() int64 { return r.Status.ObservedGeneration } -func (r *HelmApplicationChart) GetStatus() any { - return r.Status -} - func (r *HelmApplicationChart) GetConditionTypesForUpdate() []string { return []string{ConditionTypeReady} } diff --git a/api/v1alpha1/helm_application_repository.go b/api/v1alpha1/helm_application_repository.go index b567e185..4d919dff 100644 --- a/api/v1alpha1/helm_application_repository.go +++ b/api/v1alpha1/helm_application_repository.go @@ -75,10 +75,6 @@ func (r *HelmApplicationRepository) GetObservedGeneration() int64 { return r.Status.ObservedGeneration } -func (r *HelmApplicationRepository) GetStatus() any { - return r.Status -} - func (r *HelmApplicationRepository) GetConditionTypesForUpdate() []string { return []string{ConditionTypeReady} } diff --git a/api/v1alpha1/helm_cluster_addon.go b/api/v1alpha1/helm_cluster_addon.go index 8e198b52..8f62393e 100644 --- a/api/v1alpha1/helm_cluster_addon.go +++ b/api/v1alpha1/helm_cluster_addon.go @@ -64,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) } @@ -183,9 +179,17 @@ type HelmClusterAddonStatus struct { 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. + // Reconciling and Stalled follow the kstatus convention: they are present only + // while applicable. Reconciling means the pass left something to wait for — an + // internal object still rolling out, or a failure that has a retry coming — and + // it is taken away by the pass that finds nothing left to do. It carries the + // reason ForceReconcile while a reconciliation requested through the force + // reconcile annotation is running, and hands over to the ordinary verdict once + // that pass is over. Stalled means the pass ended in a failure no retry + // will resolve: a fault in this object's own spec, a repository whose url cannot + // be read, or a registry that rejected the pull. Stalled outranks Reconciling: an + // addon that cannot proceed is not making progress. Such a state is left by + // correcting the cause and requesting a reconciliation, not by waiting. // +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 d41867b8..3e005760 100644 --- a/api/v1alpha1/helm_cluster_addon_chart.go +++ b/api/v1alpha1/helm_cluster_addon_chart.go @@ -55,10 +55,6 @@ 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"} } diff --git a/api/v1alpha1/helm_cluster_addon_repository.go b/api/v1alpha1/helm_cluster_addon_repository.go index af282872..2850835d 100644 --- a/api/v1alpha1/helm_cluster_addon_repository.go +++ b/api/v1alpha1/helm_cluster_addon_repository.go @@ -76,10 +76,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"} } diff --git a/api/v1alpha1/helm_cluster_application_chart.go b/api/v1alpha1/helm_cluster_application_chart.go index 7536a86e..d61d348b 100644 --- a/api/v1alpha1/helm_cluster_application_chart.go +++ b/api/v1alpha1/helm_cluster_application_chart.go @@ -65,10 +65,6 @@ func (r *HelmClusterApplicationChart) GetObservedGeneration() int64 { return r.Status.ObservedGeneration } -func (r *HelmClusterApplicationChart) GetStatus() any { - return r.Status -} - func (r *HelmClusterApplicationChart) GetConditionTypesForUpdate() []string { return []string{ConditionTypeReady} } diff --git a/api/v1alpha1/helm_cluster_application_repository.go b/api/v1alpha1/helm_cluster_application_repository.go index 800ecc60..8933c741 100644 --- a/api/v1alpha1/helm_cluster_application_repository.go +++ b/api/v1alpha1/helm_cluster_application_repository.go @@ -80,10 +80,6 @@ func (r *HelmClusterApplicationRepository) GetObservedGeneration() int64 { return r.Status.ObservedGeneration } -func (r *HelmClusterApplicationRepository) GetStatus() any { - return r.Status -} - func (r *HelmClusterApplicationRepository) GetConditionTypesForUpdate() []string { return []string{ConditionTypeReady} } diff --git a/crds/doc-ru-helmapplications.yaml b/crds/doc-ru-helmapplications.yaml index be484a51..90ee74a4 100644 --- a/crds/doc-ru-helmapplications.yaml +++ b/crds/doc-ru-helmapplications.yaml @@ -8,7 +8,7 @@ spec: - name: v1alpha1 schema: openAPIV3Schema: - description: HelmApplication представляет собой установку Helm-чарта в пределах одного пространства имён. Релиз развёртывается в том же пространстве имён, где создан ресурс. Чарт применяется от имени ServiceAccount, связанного с Role, дающей все права внутри этого пространства имён, поэтому право создавать HelmApplication эквивалентно правам администратора пространства имён; Role создаётся один раз, и владелец пространства имён может затем сузить её. + description: HelmApplication представляет собой установку Helm-чарта в пределах одного пространства имён. Релиз развёртывается в том же пространстве имён, где создан ресурс. Чарт применяется от имени ServiceAccount, связанного с Role, дающей все права внутри этого пространства имён, поэтому право создавать HelmApplication эквивалентно правам администратора пространства имён; Role и RoleBinding принадлежат модулю и реконсилируются, поэтому правка любого из них не переживает приложение, которому он нужен. properties: spec: properties: @@ -39,7 +39,7 @@ spec: description: | Условия отражают последние наблюдения за состоянием ресурса. - `Reconciling` присутствует, только когда применимо, следуя соглашению kstatus. Пока выполняется реконсиляция, запрошенная аннотацией принудительной реконсиляции, это условие имеет причину `ForceReconcile`. + `Reconciling` и `Stalled` присутствуют, только когда применимо, следуя соглашению kstatus. `Reconciling` означает, что проход оставил после себя ожидание — внутренний объект ещё раскатывается либо произошёл сбой, у которого запланирован повтор, — и снимается тем проходом, которому больше нечего ждать. Пока идёт реконсиляция, запрошенная аннотацией принудительной реконсиляции, это условие имеет причину `ForceReconcile`, а по её завершении переходит к обычному вердикту. `Stalled` означает, что проход завершился ошибкой, которую не исправит повтор: дефектом в спецификации самого объекта, репозиторием с нечитаемым URL, реестром, отклонившим запрос, или объектом, уже занявшим нужное этому объекту имя. `Stalled` старше `Reconciling`: релиз, который не может продвинуться, не находится в процессе. Из такого состояния выходят, устранив причину и запросив реконсиляцию, а не ожиданием. lastAppliedChart: description: Последний применённый чарт, инициировавший установку или обновление приложения. properties: diff --git a/crds/doc-ru-helmclusteraddons.yaml b/crds/doc-ru-helmclusteraddons.yaml index cb8c16ad..0b30a04a 100644 --- a/crds/doc-ru-helmclusteraddons.yaml +++ b/crds/doc-ru-helmclusteraddons.yaml @@ -38,7 +38,7 @@ spec: description: | Условия отражают последние наблюдения за состоянием ресурса. - `Reconciling` присутствует, только когда применимо, следуя соглашению kstatus. Пока выполняется реконсиляция, запрошенная аннотацией принудительной реконсиляции, это условие имеет причину `ForceReconcile`. + `Reconciling` и `Stalled` присутствуют, только когда применимо, следуя соглашению kstatus. `Reconciling` означает, что проход оставил после себя ожидание — внутренний объект ещё раскатывается либо произошёл сбой, у которого запланирован повтор, — и снимается тем проходом, которому больше нечего ждать. Пока идёт реконсиляция, запрошенная аннотацией принудительной реконсиляции, это условие имеет причину `ForceReconcile`, а по её завершении переходит к обычному вердикту. `Stalled` означает, что проход завершился ошибкой, которую не исправит повтор: дефектом в спецификации самого объекта, репозиторием с нечитаемым URL или реестром, отклонившим запрос. `Stalled` старше `Reconciling`: аддон, который не может продвинуться, не находится в процессе. Из такого состояния выходят, устранив причину и запросив реконсиляцию, а не ожиданием. lastAppliedChart: description: Последний применённый чарт, инициировавший установку или обновление аддона. properties: diff --git a/crds/helmapplications.yaml b/crds/helmapplications.yaml index 7285d18e..f34bbacc 100644 --- a/crds/helmapplications.yaml +++ b/crds/helmapplications.yaml @@ -51,7 +51,8 @@ spec: 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 is created once and may be narrowed by the namespace owner afterwards. + 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: |- @@ -131,9 +132,18 @@ spec: description: |- Conditions represent the latest available observations of the application 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. + Reconciling and Stalled follow the kstatus convention: they are present only + while applicable. Reconciling means the pass left something to wait for — an + internal object still rolling out, or a failure that has a retry coming — and + it is taken away by the pass that finds nothing left to do. It carries the + reason ForceReconcile while a reconciliation requested through the force + reconcile annotation is running, and hands over to the ordinary verdict once + that pass is over. Stalled means the pass ended in a failure no retry + will resolve: a fault in this object's own spec, a repository whose url cannot + be read, a registry that rejected the pull, or an object already occupying a + name this object needs. Stalled outranks Reconciling: a release that cannot + proceed is not making progress. Such a state is left by correcting the cause + and requesting a reconciliation, not by waiting. items: description: Condition contains details for one aspect of the current state of this API Resource. diff --git a/crds/helmclusteraddons.yaml b/crds/helmclusteraddons.yaml index a95e5945..00bf0ee1 100644 --- a/crds/helmclusteraddons.yaml +++ b/crds/helmclusteraddons.yaml @@ -106,9 +106,17 @@ spec: 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. + Reconciling and Stalled follow the kstatus convention: they are present only + while applicable. Reconciling means the pass left something to wait for — an + internal object still rolling out, or a failure that has a retry coming — and + it is taken away by the pass that finds nothing left to do. It carries the + reason ForceReconcile while a reconciliation requested through the force + reconcile annotation is running, and hands over to the ordinary verdict once + that pass is over. Stalled means the pass ended in a failure no retry + will resolve: a fault in this object's own spec, a repository whose url cannot + be read, or a registry that rejected the pull. Stalled outranks Reconciling: an + addon that cannot proceed is not making progress. Such a state is left by + correcting the cause and requesting a reconciliation, not by waiting. items: description: Condition contains details for one aspect of the current state of this API Resource. 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 39012f75..05b3c856 100644 --- a/images/operator-helm-controller/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-controller/cmd/operator-helm-controller/main.go @@ -24,6 +24,7 @@ import ( 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" @@ -47,6 +48,12 @@ import ( 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) @@ -80,13 +87,16 @@ func main() { LeaderElection: enableLeaderElection, LeaderElectionID: "operator-helm-controller.helm.deckhouse.io", Client: client.Options{ - // AccessService reads ServiceAccounts and RoleBindings only to reconcile - // the one object its own release names; nothing watches either kind. The - // ClusterRole nonetheless grants cluster-wide list/watch on both, and a - // cached typed Get starts an informer for its kind, so without this every - // ServiceAccount and RoleBinding in the cluster would be held in memory. + // 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.RoleBinding{}}, + DisableFor: []client.Object{&corev1.ServiceAccount{}, &rbacv1.Role{}, &rbacv1.RoleBinding{}}, }, }, Cache: cache.Options{ @@ -99,6 +109,12 @@ func main() { 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}, }, }, }) diff --git a/images/operator-helm-controller/internal/adapter/addon_release.go b/images/operator-helm-controller/internal/adapter/addon_release.go index 3736e1d6..174ac9bf 100644 --- a/images/operator-helm-controller/internal/adapter/addon_release.go +++ b/images/operator-helm-controller/internal/adapter/addon_release.go @@ -27,8 +27,8 @@ import ( "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/manager/status" "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -141,7 +141,7 @@ type addonRepositoryResolver struct { catalog source.Catalog } -func NewAddonRepositoryResolver(c client.Client) source.RepositoryResolver { +func NewAddonRepositoryResolver(c client.Client) *addonRepositoryResolver { return &addonRepositoryResolver{client: c, catalog: NewAddonCatalog(c)} } diff --git a/images/operator-helm-controller/internal/adapter/addon_repository.go b/images/operator-helm-controller/internal/adapter/addon_repository.go index 63ec67b2..a2cd47ff 100644 --- a/images/operator-helm-controller/internal/adapter/addon_repository.go +++ b/images/operator-helm-controller/internal/adapter/addon_repository.go @@ -20,8 +20,8 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) diff --git a/images/operator-helm-controller/internal/adapter/application_release.go b/images/operator-helm-controller/internal/adapter/application_release.go index c613a80f..15ba220d 100644 --- a/images/operator-helm-controller/internal/adapter/application_release.go +++ b/images/operator-helm-controller/internal/adapter/application_release.go @@ -27,8 +27,8 @@ import ( "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/manager/status" "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -186,7 +186,7 @@ type applicationRepositoryResolver struct { clusterCatalog source.Catalog } -func NewApplicationRepositoryResolver(c client.Client) source.RepositoryResolver { +func NewApplicationRepositoryResolver(c client.Client) *applicationRepositoryResolver { return &applicationRepositoryResolver{ client: c, namespaced: NewApplicationCatalog(c), diff --git a/images/operator-helm-controller/internal/adapter/application_repository.go b/images/operator-helm-controller/internal/adapter/application_repository.go index 69d40873..6496c4bd 100644 --- a/images/operator-helm-controller/internal/adapter/application_repository.go +++ b/images/operator-helm-controller/internal/adapter/application_repository.go @@ -20,8 +20,8 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) diff --git a/images/operator-helm-controller/internal/adapter/cluster_application_repository.go b/images/operator-helm-controller/internal/adapter/cluster_application_repository.go index 815ea35d..1e8c2c79 100644 --- a/images/operator-helm-controller/internal/adapter/cluster_application_repository.go +++ b/images/operator-helm-controller/internal/adapter/cluster_application_repository.go @@ -20,8 +20,8 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/manager/status" "github.com/deckhouse/operator-helm/internal/source" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) 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/controller/helmapplication/controller.go b/images/operator-helm-controller/internal/controller/helmapplication/controller.go index eba75e84..90f28c92 100644 --- a/images/operator-helm-controller/internal/controller/helmapplication/controller.go +++ b/images/operator-helm-controller/internal/controller/helmapplication/controller.go @@ -18,12 +18,14 @@ limitations under the License. // 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. +// 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" @@ -32,10 +34,9 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/deckhouse/operator-helm/internal/adapter" - "github.com/deckhouse/operator-helm/internal/manager/status" reconcile "github.com/deckhouse/operator-helm/internal/reconcile/release" "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" ) @@ -53,8 +54,8 @@ func SetupWithManager(mgr ctrl.Manager) error { 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: source.NoChartClaim{}, - Namespaces: source.ExistingTargetNamespace{}, + Claim: reconcile.NoChartClaim{}, + Namespaces: reconcile.ExistingTargetNamespace{}, Access: services.NewAccessService(client, helmv1alpha1.TargetNamespace), Status: status.NewManager(client), }) @@ -93,14 +94,28 @@ func SetupWithManager(mgr ctrl.Manager) error { 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(utils.MapRepositoryToApplications(client, helmv1alpha1.HelmApplicationRepositoryKind)), + handler.EnqueueRequestsFromMapFunc(mapRepositoryToApplications(client, helmv1alpha1.HelmApplicationRepositoryKind)), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). Watches( &helmv1alpha1.HelmClusterApplicationRepository{}, - handler.EnqueueRequestsFromMapFunc(utils.MapRepositoryToApplications(client, helmv1alpha1.HelmClusterApplicationRepositoryKind)), + handler.EnqueueRequestsFromMapFunc(mapRepositoryToApplications(client, helmv1alpha1.HelmClusterApplicationRepositoryKind)), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). // A catalog write is a status-only change, so a generation predicate would @@ -108,12 +123,12 @@ func SetupWithManager(mgr ctrl.Manager) error { // these two watches firing. Watches( &helmv1alpha1.HelmApplicationChart{}, - handler.EnqueueRequestsFromMapFunc(utils.MapChartToApplications(client, helmv1alpha1.HelmApplicationRepositoryKind)), + handler.EnqueueRequestsFromMapFunc(mapChartToApplications(client, helmv1alpha1.HelmApplicationRepositoryKind)), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). Watches( &helmv1alpha1.HelmClusterApplicationChart{}, - handler.EnqueueRequestsFromMapFunc(utils.MapChartToApplications(client, helmv1alpha1.HelmClusterApplicationRepositoryKind)), + 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 index efe983c6..348c59a6 100644 --- a/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmapplicationrepository/controller.go @@ -32,9 +32,9 @@ import ( 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/repository" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -45,15 +45,14 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - r := reconcile.New( - client, - adapter.EmptyApplicationRepository, - services.NewRepoSecretsService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(client)), - services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewApplicationCatalog(client)), - status.NewManager(client), - ) + 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, diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go index 3e4e413f..267c91ea 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go @@ -27,10 +27,9 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" "github.com/deckhouse/operator-helm/internal/adapter" - "github.com/deckhouse/operator-helm/internal/manager/status" reconcile "github.com/deckhouse/operator-helm/internal/reconcile/release" "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" ) @@ -50,7 +49,7 @@ func SetupWithManager(mgr ctrl.Manager) error { Maintenance: services.NewMaintenanceService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), Claim: services.NewClaimService(client, mgr.GetAPIReader(), helmv1alpha1.TargetNamespace), Namespaces: services.NewNamespaceService(client, mgr.GetAPIReader()), - Access: source.NoAccess{}, + Access: reconcile.NoAccess{}, Status: status.NewManager(client), }) @@ -105,12 +104,12 @@ func SetupWithManager(mgr ctrl.Manager) error { ). 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 27a28f23..79daf2bb 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddonrepository/controller.go @@ -28,9 +28,9 @@ import ( 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/repository" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -41,15 +41,14 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - r := reconcile.New( - client, - adapter.EmptyAddonRepository, - services.NewRepoSecretsService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListAddonReleases(client)), - services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewAddonCatalog(client)), - 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). diff --git a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go index 7d96d5af..96653b17 100644 --- a/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusterapplicationrepository/controller.go @@ -30,9 +30,9 @@ import ( 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/repository" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -43,15 +43,14 @@ const ( func SetupWithManager(mgr ctrl.Manager) error { client := mgr.GetClient() - r := reconcile.New( - client, - adapter.EmptyClusterApplicationRepository, - services.NewRepoSecretsService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewHelmRepoService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), - services.NewForceService(client, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(client)), - services.NewRepoSyncService(client, mgr.GetScheme(), repoclient.NewClient, adapter.NewClusterApplicationCatalog(client)), - status.NewManager(client), - ) + 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, 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/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 index a6e99fef..77aa4037 100644 --- a/images/operator-helm-controller/internal/reconcile/release/doc.go +++ b/images/operator-helm-controller/internal/reconcile/release/doc.go @@ -27,4 +27,12 @@ limitations under the License. // 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..26fcd709 --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/release/evaluate.go @@ -0,0 +1,523 @@ +/* +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) + + verdict, found := decide(chart, release) + if found { + for _, conditionType := range in.ConditionTypes { + decision.set(in, conditionType, verdict) + } + + decision.Reported = reported(verdict) + } + + stalled := in.OCIRepo != nil && in.OCIRepo.ProbeTerminal + if stalled { + // 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. + decision.setAbnormal(in, helmv1alpha1.ConditionTypeStalled, Failure{ + Reason: in.OCIRepo.ProbeReason, + Message: in.OCIRepo.ProbeMessage, + }) + } + + 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. 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..7de112e0 --- /dev/null +++ b/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go @@ -0,0 +1,649 @@ +/* +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) + } +} + +// 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 index a84fc125..c595cebb 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler.go @@ -35,18 +35,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/manager/status" - "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/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" ) -// 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 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 @@ -60,14 +55,14 @@ const chartClaimConflictRequeueInterval = 30 * time.Second // (Namespaces) and which identity the chart is applied with (Access). type Deps struct { NewRelease func() source.Release - Repositories source.RepositoryResolver - Chart *services.ChartService - OCI *services.OCIRepoService - Release *services.ReleaseService - Maintenance *services.MaintenanceService - Claim source.ChartClaim - Namespaces source.TargetNamespaceEnsurer - Access source.AccessManager + Repositories RepositoryResolver + Chart ChartManager + OCI OCIRepoManager + Release ReleaseManager + Maintenance MaintenanceManager + Claim ChartClaim + Namespaces TargetNamespaceEnsurer + Access AccessManager Status *status.Manager } @@ -94,6 +89,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco 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 @@ -106,24 +108,27 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return reconcile.Result{}, fmt.Errorf("acquiring chart claim: %w", err) } if !acquired { - return reconcile.Result{RequeueAfter: chartClaimConflictRequeueInterval}, r.deps.Status.Update( - ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, - services.ReleaseResult{Status: status.Failed( - rel.Object(), - helmv1alpha1.ReasonChartClaimConflict, - fmt.Sprintf("chart %q is already used by %s/%s", rel.ChartRef().Chart, strings.ToLower(rel.Kind()), holder), - nil, - )}, - ) + 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()) { - return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - rel.Object(), - helmv1alpha1.ReasonFailed, - "Target namespace cannot be a system namespace", - fmt.Errorf("target namespace %q is a system namespace", 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) { @@ -142,92 +147,98 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco } if r.deps.Maintenance.IsMaintenanceModeChangeRequired(rel) { - maintenanceRes := r.deps.Maintenance.EnsureMaintenanceMode(ctx, rel) - if err := r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, maintenanceRes, status.AsCondition(maintenanceRes, "Ready")); err != nil { - return reconcile.Result{}, err - } - - if !rel.MaintenanceActivated() { - // 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 - } + 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 reconcile.Result{}, r.discardForceReconcile(ctx, rel) + return r.finish(ctx, rel, in) } if rel.MaintenanceActivated() { - return reconcile.Result{}, r.discardForceReconcile(ctx, rel) + in.DiscardForce = true + + return r.finish(ctx, rel, in) } repo, catalog, err := r.deps.Repositories.Resolve(ctx, rel.ChartRef().Repository) if err != nil { - return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - rel.Object(), - helmv1alpha1.ReasonFailed, - "Failed to get internal repository", - fmt.Errorf("getting internal repository: %w", err), - )}) + 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 := utils.GetRepositoryType(repo.URL()) + repoType, err := chartsource.KindOf(repo.URL()) if err != nil { - return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - rel.Object(), - helmv1alpha1.ReasonFailed, - fmt.Sprintf("Failed to parse repository type: %s", err.Error()), - err, - )}) + // 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 { - return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - rel.Object(), - helmv1alpha1.ReasonFailed, - fmt.Sprintf("Failed to reconcile target namespace: %s", err.Error()), - err, - )}) + 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 err := r.deps.Access.EnsureAccess(ctx, rel); err != nil { - // The status write is best-effort: what must not be lost is err itself. - // Nothing watches the ServiceAccount/RoleBinding this step manages, so the - // work queue's rate limiter retrying on the returned error is the only thing - // that brings a transient failure back for another pass. - _ = r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - rel.Object(), - helmv1alpha1.ReasonAccessSetupFailed, - fmt.Sprintf("Failed to set up the release identity: %s", err.Error()), - err, - )}) - return reconcile.Result{}, err + 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 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 := rel.ForceReconcileRequired() - if forced { + // 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 } } - var chartRes services.ChartResult - var repoRes services.OCIRepoResult - var releaseRes services.ReleaseResult - _, 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 utils.ChartSource + var src chartsource.Source if chartErr == nil { - src, chartErr = utils.ResolveChartSource(repo.URL(), chartVersion) + src, chartErr = chartsource.Resolve(repo.URL(), chartVersion) } names := rel.InternalNames() @@ -237,90 +248,140 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // 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( - rel.Object(), - helmv1alpha1.ReasonChartFetchFailed, - "Failed to resolve the desired chart version", - chartErr, - )} - case src.Kind == utils.InternalHelmRepository: + 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 { - chartRes = services.ChartResult{ - Status: status.Failed(rel.Object(), helmv1alpha1.ReasonFailed, "Repository change failed", err), - } + in.ChartSource = repositoryChangeFailure(err) break } r.logSourceKindFlip(ctx, rel, src.Kind, superseded != nil) - chartRes = r.deps.Chart.EnsureHelmChart(ctx, rel, repo) - case src.Kind == utils.InternalOCIRepository: + 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 { - chartRes = services.ChartResult{ - Status: status.Failed(rel.Object(), helmv1alpha1.ReasonFailed, "Repository change failed", err), - } + in.ChartSource = repositoryChangeFailure(err) break } r.logSourceKindFlip(ctx, rel, src.Kind, superseded != nil) - repoRes = r.deps.OCI.EnsureInternalOCIRepository(ctx, rel, repo, src, chartVersion) + outcome := r.deps.OCI.EnsureInternalOCIRepository(ctx, rel, repo, src, chartVersion) + in.OCIRepo = &outcome default: - return reconcile.Result{}, r.deps.Status.Update(ctx, rel.Object(), status.NoopStatusMutator, status.NoopStatusMapper, services.ReleaseResult{Status: status.Failed( - rel.Object(), - helmv1alpha1.ReasonFailed, - fmt.Sprintf("Unsupported chart source: %s", src.Kind), - fmt.Errorf("unsupported chart source: %s", src.Kind), - )}) - } - - if chartRes.HasArtifact() || repoRes.HasArtifact() { - var artifactRevision string - switch src.Kind { - case utils.InternalHelmRepository: - if chartRes.Artifact != nil { - artifactRevision = chartRes.Artifact.Revision - } - case utils.InternalOCIRepository: - if repoRes.Artifact != nil { - artifactRevision = repoRes.Artifact.Revision - } + 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, } - releaseRes = r.deps.Release.EnsureHelmRelease(ctx, rel, src.Kind, artifactRevision) - } - - if err := r.deps.Status.Update( - ctx, - rel.Object(), - setStatusAttrs(rel, src.Kind, chartRes, repoRes, releaseRes, forceReconcileOutcome{ - forced: forced, - now: time.Now().UTC(), - }), - status.NoopStatusMapper, - chartRes, - repoRes, - releaseRes, - ); client.IgnoreNotFound(err) != nil { + 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) } - // 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) + 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 } - // 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 + values, _ := helmcommon.ReadValues(rawValues) + + return chartutil.DigestValues(digest.Canonical, values).String() } func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (reconcile.Result, error) { @@ -355,7 +416,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (r if err := r.deps.Release.SyncReleaseSpec(ctx, rel, release); err != nil { return reconcile.Result{}, err } - return r.awaitInternalResourceDeletion(ctx, rel, "internal release", release) + return pass.AwaitInternalResourceDeletion(ctx, r.deps.Status.MarkUninstallPending, rel.Object(), "internal release", release) } chart, err := r.deps.Chart.CleanupHelmChart(ctx, names) @@ -363,7 +424,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (r return reconcile.Result{}, err } if chart != nil { - return r.awaitInternalResourceDeletion(ctx, rel, "internal chart", chart) + return pass.AwaitInternalResourceDeletion(ctx, r.deps.Status.MarkUninstallPending, rel.Object(), "internal chart", chart) } ociRepo, err := r.deps.OCI.RemoveOCIRepository(ctx, names) @@ -371,7 +432,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (r return reconcile.Result{}, err } if ociRepo != nil { - return r.awaitInternalResourceDeletion(ctx, rel, "internal repository", ociRepo) + 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 @@ -414,24 +475,6 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (r return reconcile.Result{}, nil } -// awaitInternalResourceDeletion surfaces that an internal resource is still being -// deleted on the release'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; the log line names the object itself, -// which is what someone looking into a stuck deletion has to reach for. -func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, rel source.Release, 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 := r.deps.Status.MarkUninstallPending(ctx, rel.Object(), name, resource); client.IgnoreNotFound(err) != nil { - return reconcile.Result{}, fmt.Errorf("updating deletion status: %w", err) - } - - return reconcile.Result{RequeueAfter: internalResourceDeletionRequeueInterval}, 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 @@ -453,60 +496,6 @@ func (r *Reconciler) markForceReconcileInProgress(ctx context.Context, rel sourc return nil } -// discardForceReconcile drops the in-flight force state from a release that is -// entering, or already sitting in, maintenance mode. Every pass on such a release -// 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 a release. -// -// lastForceReconcileTime is deliberately untouched — the request was discarded, -// not processed, and the stamp means the latter. -func (r *Reconciler) discardForceReconcile(ctx context.Context, rel source.Release) error { - err := r.deps.Status.PatchStatus(ctx, rel.Object(), func() { - apimeta.RemoveStatusCondition(rel.Object().GetConditions(), helmv1alpha1.ConditionTypeReconciling) - }) - if client.IgnoreNotFound(err) != nil { - return fmt.Errorf("dropping forced reconciliation progress: %w", err) - } - - if err := r.reconcileForceAnnotation(ctx, client.ObjectKeyFromObject(rel.Object())); 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 { - rel := r.deps.NewRelease() - - if err := r.Get(ctx, key, rel.Object()); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return fmt.Errorf("getting release: %w", err) - } - - annotations := rel.Object().GetAnnotations() - if _, found := annotations[helmv1alpha1.AnnotationForceReconcile]; !found { - // Guard on the annotation itself, not on the map: a release carrying any - // unrelated annotation would otherwise take an empty PATCH on every pass. - return nil - } - - patchBase := client.MergeFrom(rel.Object().DeepCopyObject().(client.Object)) - - delete(annotations, helmv1alpha1.AnnotationForceReconcile) - rel.Object().SetAnnotations(annotations) - - if err := r.Patch(ctx, rel.Object(), patchBase); err != nil { - return fmt.Errorf("removing force reconcile annotation: %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:// @@ -523,7 +512,7 @@ func (r *Reconciler) getChartVersion( catalog source.Catalog, repo source.Repository, rel source.Release, - repoType utils.InternalRepositoryType, + repoType chartsource.Kind, ) (client.Object, *helmv1alpha1.ChartVersion, error) { ref := rel.ChartRef() @@ -549,7 +538,7 @@ func (r *Reconciler) getChartVersion( ) } - if repoType == utils.InternalOCIRepository && version.OCIRef == "" && version.MediaType == "" { + if repoType == chartsource.OCI && version.OCIRef == "" && version.MediaType == "" { return nil, nil, fmt.Errorf( "chart version %q cannot be deployed: %s", version.Version, versionUnavailableDetail(*version), @@ -583,7 +572,7 @@ func versionUnavailableDetail(version helmv1alpha1.ChartVersion) string { func (r *Reconciler) logSourceKindFlip( ctx context.Context, rel source.Release, - kind utils.InternalRepositoryType, + kind chartsource.Kind, superseded bool, ) { if !superseded { @@ -607,73 +596,3 @@ func (r *Reconciler) logSourceKindFlip( "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 -} - -// setStatusAttrs writes the fields of the status the conditions do not cover. It -// closes over rel rather than asserting the object's type: rel.Object() is the very -// object the status manager hands back, so the writes land on it. -func setStatusAttrs( - rel source.Release, - 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...) - - 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. - rel.SetLastForceReconcileTime(metav1.Time{Time: force.now}) - apimeta.RemoveStatusCondition(rel.Object().GetConditions(), helmv1alpha1.ConditionTypeReconciling) - } - - latestRelease := releaseRes.History.Latest() - - var updateChart bool - - switch sourceKind { - case utils.InternalHelmRepository: - if chartRes.HasArtifact() && releaseRes.IsReady() && rel.IsChartStatusInfoOutdated() { - updateChart = true - } - case utils.InternalOCIRepository: - if repoRes.HasArtifact() && releaseRes.IsReady() && rel.IsChartStatusInfoOutdated() { - updateChart = true - } - } - - if updateChart { - rel.SetLastAppliedChart(rel.ChartRef()) - } - - if releaseRes.IsReady() && latestRelease != nil { - rawValues := []byte(`{}`) - if rel.Values() != nil { - rawValues = rel.Values().Raw - } - - values, _ := helmcommon.ReadValues(rawValues) - if latestRelease.Status == "deployed" && latestRelease.ConfigDigest == chartutil.DigestValues(digest.Canonical, values).String() { - if rel.Values() == nil { - rel.SetLastAppliedValues(nil) - } else { - rel.SetLastAppliedValues(rel.Values().DeepCopy()) - } - } - } - - return obj, results - } -} diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index 7dbd99ac..fb6730ff 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -19,6 +19,7 @@ package release import ( "context" "errors" + "reflect" "strings" "testing" "time" @@ -44,11 +45,12 @@ 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/manager/status" "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" ) @@ -111,7 +113,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { // 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 utils.InternalRepositoryType + repoType chartsource.Kind wantErr bool wantErrContain string }{ @@ -121,7 +123,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { Version: "6.7.1", MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", }, - repoType: utils.InternalOCIRepository, + repoType: chartsource.OCI, }, { // Deliberate: the tag disappeared from the repository, but the entry is @@ -133,7 +135,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { MediaType: "application/tar+gzip", UnavailableReason: helmv1alpha1.UnavailableReasonRemovedFromRepository, }, - repoType: utils.InternalOCIRepository, + repoType: chartsource.OCI, }, { name: "oci version stuck resolving is rejected with reason and message", @@ -142,7 +144,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { UnavailableReason: helmv1alpha1.UnavailableReasonResolvePending, UnavailableMessage: "manifest request failed", }, - repoType: utils.InternalOCIRepository, + repoType: chartsource.OCI, wantErr: true, wantErrContain: "ResolvePending: manifest request failed", }, @@ -152,7 +154,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { Version: "6.7.1", UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, }, - repoType: utils.InternalOCIRepository, + repoType: chartsource.OCI, wantErr: true, wantErrContain: "UnsupportedMediaType", }, @@ -165,7 +167,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { Version: "6.7.1", UnavailableReason: helmv1alpha1.UnavailableReasonUnsupportedMediaType, }, - repoType: utils.InternalHelmRepository, + repoType: chartsource.Helm, }, { // The repository's URL just switched from oci:// to https://: the @@ -176,7 +178,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { Version: "6.7.1", MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", }, - repoType: utils.InternalHelmRepository, + repoType: chartsource.Helm, }, { // The repository's URL just switched from https:// to oci://, but the @@ -187,14 +189,14 @@ func TestGetHelmClusterAddonChart(t *testing.T) { version: helmv1alpha1.ChartVersion{ Version: "6.7.1", }, - repoType: utils.InternalOCIRepository, + 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: utils.InternalOCIRepository, + repoType: chartsource.OCI, wantErr: true, wantErrContain: `does not have version "6.7.1"`, }, @@ -207,7 +209,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { Version: "6.7.1", OCIRef: "oci://registry.example.com/charts/podinfo:6.7.1", }, - repoType: utils.InternalHelmRepository, + repoType: chartsource.Helm, }, { // Left through, this version would be sent down the helm path and would @@ -218,7 +220,7 @@ func TestGetHelmClusterAddonChart(t *testing.T) { UnavailableReason: helmv1alpha1.UnavailableReasonInvalidChartReference, UnavailableMessage: "oci reference \"oci://BAD_HOST//:::\" is not a valid tagged reference", }, - repoType: utils.InternalHelmRepository, + repoType: chartsource.Helm, wantErr: true, wantErrContain: "InvalidChartReference", }, @@ -270,7 +272,7 @@ 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), utils.InternalOCIRepository) + 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) } @@ -347,7 +349,7 @@ func newFullReconciler( Maintenance: services.NewMaintenanceService(c, scheme, helmv1alpha1.TargetNamespace), Claim: services.NewClaimService(c, c, helmv1alpha1.TargetNamespace), Namespaces: services.NewNamespaceService(c, c), - Access: source.NoAccess{}, + Access: NoAccess{}, Status: status.NewManager(c), }), c } @@ -358,7 +360,7 @@ func newFullReconciler( // identity manager; nil selects the real one over the same fake client. func newApplicationFullReconciler( t *testing.T, - access source.AccessManager, + access AccessManager, objects ...client.Object, ) (*Reconciler, client.Client) { t.Helper() @@ -394,8 +396,8 @@ func newApplicationFullReconciler( OCI: services.NewOCIRepoService(c, scheme, helmv1alpha1.TargetNamespace, nil), Release: services.NewReleaseService(c, scheme, helmv1alpha1.TargetNamespace), Maintenance: services.NewMaintenanceService(c, scheme, helmv1alpha1.TargetNamespace), - Claim: source.NoChartClaim{}, - Namespaces: source.ExistingTargetNamespace{}, + Claim: NoChartClaim{}, + Namespaces: ExistingTargetNamespace{}, Access: access, Status: status.NewManager(c), }), c @@ -476,6 +478,81 @@ func markInternalChartReady(t *testing.T, c client.Client, name string) { } } +// 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) + } +} + // 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 @@ -581,8 +658,12 @@ func TestReconcileApplicationCreatesNothingElseInTheApplicationNamespace(t *test // failingAccess is an AccessManager whose identity setup never succeeds. type failingAccess struct{} -func (failingAccess) EnsureAccess(context.Context, source.Release) error { - return errors.New("service account is forbidden") +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 } @@ -628,14 +709,18 @@ func TestReconcileApplicationReportsAccessSetupFailure(t *testing.T) { // 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 source.AccessManager + real AccessManager calls int } -func (a *intermittentAccess) EnsureAccess(ctx context.Context, rel source.Release) error { +func (a *intermittentAccess) EnsureAccess(ctx context.Context, rel source.Release) services.AccessOutcome { a.calls++ if a.calls == 1 { - return errors.New("service account is forbidden") + 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) } @@ -689,11 +774,109 @@ func TestReconcileApplicationRecoversAfterTransientAccessSetupFailure(t *testing } } +// 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) error { return nil } +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") @@ -1008,7 +1191,7 @@ func TestLogSourceKindFlipIgnoresStaleEntryFromADifferentChartOrRepository(t *te ctx := log.IntoContext(context.Background(), logger) r := &Reconciler{} - r.logSourceKindFlip(ctx, adapter.NewAddonRelease(addon), utils.InternalOCIRepository, true) + r.logSourceKindFlip(ctx, adapter.NewAddonRelease(addon), chartsource.OCI, true) if logged != tt.wantLogged { t.Fatalf("logged = %v, want %v", logged, tt.wantLogged) @@ -1104,8 +1287,15 @@ func TestReconcileForcedAddonRecordsCompletion(t *testing.T) { 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) + // 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") @@ -1143,39 +1333,6 @@ func TestReconcileUnforcedAddonRecordsNoForceReconcile(t *testing.T) { } } -// 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. @@ -1291,3 +1448,39 @@ func TestReconcileLeavingMaintenanceKeepsForceReconcile(t *testing.T) { 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/repository/reconciler.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go index 2d7fbada..014eb208 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler.go @@ -31,55 +31,38 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/manager/status" + "github.com/deckhouse/operator-helm/internal/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/utils" + "github.com/deckhouse/operator-helm/internal/status" ) -// 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 - -// New builds the reconciler of one repository 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. -func New( - client client.Client, - newRepository func() source.Repository, - secretsService *services.RepoSecretsService, - helmRepositoryService *services.HelmRepoService, - consumers source.ConsumerForcer, - chartSyncService *services.RepoSyncService, - statusManager *status.Manager, -) *Reconciler { - return &Reconciler{ - Client: client, - newRepository: newRepository, - secretsService: secretsService, - helmRepositoryService: helmRepositoryService, - consumers: consumers, - chartSyncService: chartSyncService, - statusManager: statusManager, - } +// 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 - newRepository func() source.Repository - secretsService *services.RepoSecretsService - helmRepositoryService *services.HelmRepoService - consumers source.ConsumerForcer - chartSyncService *services.RepoSyncService - statusManager *status.Manager + deps Deps } func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { - repo := r.newRepository() + repo := r.deps.NewRepository() if err := r.Get(ctx, req.NamespacedName, repo.Object()); err != nil { if apierrors.IsNotFound(err) { return reconcile.Result{}, nil @@ -88,7 +71,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return reconcile.Result{}, fmt.Errorf("getting repository: %w", err) } - repoType, repoTypeErr := utils.GetRepositoryType(repo.URL()) + repoType, repoTypeErr := chartsource.KindOf(repo.URL()) if !repo.Object().GetDeletionTimestamp().IsZero() { return r.reconcileDelete(ctx, repo, repoType) @@ -119,7 +102,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // 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.chartSyncService.MigrateNames(ctx, repo) + in.MigrateErr = r.deps.Catalog.MigrateNames(ctx, repo) if repoTypeErr != nil { in.ConfigErr = &services.ConfigOutcome{ @@ -131,16 +114,16 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return r.finish(ctx, repo, in, false) } - in.SecretsErr = r.secretsService.Ensure(ctx, repo, repoType) + in.SecretsErr = r.deps.Secrets.Ensure(ctx, repo, repoType) if in.SecretsErr == nil { switch repoType { - case utils.InternalHelmRepository: - in.InternalRepository, in.InternalRepositoryErr = r.helmRepositoryService.EnsureInternalHelmRepository(ctx, repo) - case utils.InternalOCIRepository: + 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.helmRepositoryService.RemoveHelmRepository(ctx, repo.InternalNames()) + in.InternalRepositoryErr = r.deps.Internal.RemoveHelmRepository(ctx, repo.InternalNames()) } } @@ -156,7 +139,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return reconcile.Result{}, err } - outcome := r.chartSyncService.Sync(ctx, repo, repoType) + outcome := r.deps.Catalog.Sync(ctx, repo, repoType) in.Attempted = true if outcome.FetchAttempted { @@ -189,7 +172,7 @@ func (r *Reconciler) finish( log.FromContext(ctx).Error(in.Fetch.Err, in.Fetch.Message) } - if err := r.statusManager.PatchStatus(ctx, repo.Object(), func() { + if err := r.deps.Status.PatchStatus(ctx, repo.Object(), func() { *repo.Status() = decision.Status }); client.IgnoreNotFound(err) != nil { return reconcile.Result{}, err @@ -204,12 +187,12 @@ func (r *Reconciler) finish( // 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.consumers.ForceReconcileConsumers(ctx, repo); err != nil { + 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 := r.reconcileForceAnnotation(ctx, client.ObjectKeyFromObject(repo.Object())); err != nil { + 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) } } @@ -223,7 +206,7 @@ func (r *Reconciler) finish( return reconcile.Result{RequeueAfter: decision.RequeueAfter}, nil } -func (r *Reconciler) reconcileDelete(ctx context.Context, repo source.Repository, repoType utils.InternalRepositoryType) (reconcile.Result, error) { +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) { @@ -232,8 +215,8 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo source.Repository names := repo.InternalNames() - if err := r.secretsService.Cleanup(ctx, names); err != nil && !apierrors.IsNotFound(err) { - _ = r.statusManager.MarkDeletionFailed(ctx, repo.Object(), "auxiliary secrets", err) + 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 } @@ -244,19 +227,19 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo source.Repository // 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 != utils.InternalOCIRepository { - helmRepo, err := r.helmRepositoryService.CleanupHelmRepository(ctx, names) + if repoType != chartsource.OCI { + helmRepo, err := r.deps.Internal.CleanupHelmRepository(ctx, names) if err != nil && !apierrors.IsNotFound(err) { - _ = r.statusManager.MarkDeletionFailed(ctx, repo.Object(), "internal repository", err) + _ = r.deps.Status.MarkDeletionFailed(ctx, repo.Object(), "internal repository", err) return reconcile.Result{}, err } if helmRepo != nil { - return r.awaitInternalResourceDeletion(ctx, repo, "internal repository", helmRepo) + return pass.AwaitInternalResourceDeletion(ctx, r.deps.Status.MarkDeletionPending, repo.Object(), "internal repository", helmRepo) } } if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - latest := r.newRepository() + latest := r.deps.NewRepository() if err := r.Get(ctx, client.ObjectKeyFromObject(repo.Object()), latest.Object()); err != nil { return client.IgnoreNotFound(err) } @@ -276,24 +259,6 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, repo source.Repository 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; the log line names the object itself, -// which is what someone looking into a stuck deletion has to reach for. -func (r *Reconciler) awaitInternalResourceDeletion(ctx context.Context, repo source.Repository, 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 := r.statusManager.MarkDeletionPending(ctx, repo.Object(), 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 @@ -314,7 +279,7 @@ func (r *Reconciler) markSyncInProgress( reason, message = helmv1alpha1.ReasonForceReconcile, "Forced reconciliation in progress" } - err := r.statusManager.PatchStatus(ctx, repo.Object(), func() { + err := r.deps.Status.PatchStatus(ctx, repo.Object(), func() { apimeta.SetStatusCondition(&repo.Status().Conditions, metav1.Condition{ Type: helmv1alpha1.ConditionTypeReconciling, Status: metav1.ConditionTrue, @@ -329,34 +294,3 @@ func (r *Reconciler) markSyncInProgress( return nil } - -func (r *Reconciler) reconcileForceAnnotation(ctx context.Context, key client.ObjectKey) error { - repo := r.newRepository() - - if err := r.Get(ctx, key, repo.Object()); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - - return fmt.Errorf("getting repository: %w", err) - } - - annotations := repo.Object().GetAnnotations() - if _, found := 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.Object().DeepCopyObject().(client.Object)) - - delete(annotations, helmv1alpha1.AnnotationForceReconcile) - repo.Object().SetAnnotations(annotations) - - if err := r.Patch(ctx, repo.Object(), patchBase); err != nil { - return fmt.Errorf("removing force reconcile annotation: %w", err) - } - - return nil -} diff --git a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go index 9350c71b..82b3fc6e 100644 --- a/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/repository/reconciler_test.go @@ -43,10 +43,11 @@ 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/manager/status" "github.com/deckhouse/operator-helm/internal/services" + "github.com/deckhouse/operator-helm/internal/status" "github.com/deckhouse/operator-helm/internal/utils" ) @@ -117,19 +118,18 @@ func newReconcilerWithInterceptor( }). Build() - factory := func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) { + factory := func(_ chartsource.Kind) (repoclient.ClientInterface, error) { return stub, nil } - r := New( - c, - adapter.EmptyAddonRepository, - services.NewRepoSecretsService(c, scheme, helmv1alpha1.TargetNamespace), - services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), - services.NewForceService(c, helmv1alpha1.TargetNamespace, adapter.ListAddonReleases(c)), - services.NewRepoSyncService(c, scheme, factory, adapter.NewAddonCatalog(c)), - 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 } @@ -162,19 +162,18 @@ func newApplicationReconciler(t *testing.T, stub *stubRepoClient, objects ...cli WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). Build() - factory := func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) { + factory := func(_ chartsource.Kind) (repoclient.ClientInterface, error) { return stub, nil } - r := New( - c, - adapter.EmptyApplicationRepository, - services.NewRepoSecretsService(c, scheme, helmv1alpha1.TargetNamespace), - services.NewHelmRepoService(c, scheme, helmv1alpha1.TargetNamespace), - services.NewForceService(c, helmv1alpha1.TargetNamespace, adapter.ListApplicationReleases(c)), - services.NewRepoSyncService(c, scheme, factory, adapter.NewApplicationCatalog(c)), - status.NewManager(c), - ) + 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 } @@ -666,7 +665,7 @@ func TestReconcileDeleteCleansUpWhenURLNoLongerParses(t *testing.T) { 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") } diff --git a/images/operator-helm-controller/internal/services/access_service.go b/images/operator-helm-controller/internal/services/access_service.go index 448b4b3d..a689714c 100644 --- a/images/operator-helm-controller/internal/services/access_service.go +++ b/images/operator-helm-controller/internal/services/access_service.go @@ -18,11 +18,13 @@ 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" @@ -34,26 +36,27 @@ import ( ) // ApplicationRoleName is the Role every application release of a namespace is bound -// to. One Role per namespace: cutting it down once applies to every application -// there, and the edit survives applications being recreated. +// 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" -var _ source.AccessManager = (*AccessService)(nil) - // 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. It is also the one object a namespace owner may edit to cut the rights -// down — which is why it is created once and never reconciled afterwards. +// content. // -// Neither the account nor the binding triggers a reconcile on its own: nothing -// watches either kind, so an out-of-band deletion of either is not noticed -// immediately and is repaired on the release's next reconcile. Both are also -// excluded from the manager's client cache (see cmd/operator-helm-controller), -// so reading them here never starts a cluster-wide informer for the kind. +// 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 @@ -67,34 +70,74 @@ func NewAccessService(c client.Client, targetNamespace string) *AccessService { } } -// EnsureAccess reconciles the account and the binding and seeds the Role. 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) error { +// 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 nil + return AccessOutcome{} } namespace := rel.TargetNamespace() if err := s.ensureServiceAccount(ctx, rel, name); err != nil { - return fmt.Errorf("ensuring service account: %w", err) + return accessFailure(fmt.Errorf("ensuring service account: %w", err)) } - if err := s.seedRole(ctx, namespace); err != nil { - return fmt.Errorf("seeding role: %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 fmt.Errorf("ensuring role binding: %w", err) + return accessFailure(fmt.Errorf("ensuring role binding: %w", err)) } - return nil + 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 belongs to -// the namespace, may carry the owner's edits, and grants nothing without a binding. +// 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 == "" { @@ -143,26 +186,73 @@ func (s *AccessService) ensureServiceAccount(ctx context.Context, rel source.Rel return err } -// seedRole creates the namespace Role with full rights inside the namespace when it -// does not exist, and leaves an existing one untouched: not read, not compared, not -// patched. CreateOrPatch would overwrite an owner's edit; only Create fits. -func (s *AccessService) seedRole(ctx context.Context, namespace string) error { +// 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, - Labels: map[string]string{ - helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, - }, - }, - Rules: []rbacv1.PolicyRule{{ + 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 client.IgnoreAlreadyExists(s.Client.Create(ctx, role)) + 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. @@ -174,30 +264,53 @@ func applicationRoleRef() rbacv1.RoleRef { } } -// ensureRoleBinding binds the account to the namespace Role. roleRef is immutable -// in Kubernetes, so it is only ever written on create or written back unchanged; -// the subjects and labels are reconciled on every pass. +// 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 already exists under our name but points at a different role is -// not ours to reuse: adding our account as its subject would grant that account -// whatever the foreign role grants. Such a binding is reported and left exactly as -// it is — never patched, never deleted — so its owner decides what happens to it. +// 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 { - // roleRef.Name is required by API validation, so it is empty only here: a - // fresh object about to be created. Anything already stored carries it, so a - // mismatch here can only mean an existing binding that points elsewhere. - if binding.RoleRef.Name != "" && binding.RoleRef != desiredRef { - return fmt.Errorf( - "role binding %s/%s already binds %s/%s; refusing to adopt it", - namespace, name, binding.RoleRef.Kind, binding.RoleRef.Name, - ) + // 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 @@ -207,6 +320,7 @@ func (s *AccessService) ensureRoleBinding(ctx context.Context, rel source.Releas binding.Labels = map[string]string{} } maps.Copy(binding.Labels, rel.SourceLabels()) + maps.Copy(binding.Labels, applicationRBACLabels()) binding.RoleRef = desiredRef binding.Subjects = []rbacv1.Subject{{ @@ -221,22 +335,18 @@ func (s *AccessService) ensureRoleBinding(ctx context.Context, rel source.Releas return err } -// ensureOwnedRoleBindingDeleted deletes the role binding at nn only when it is -// ours to delete: it carries our roleRef and 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 reason ensureRoleBinding refuses to adopt a foreign -// binding on create. A binding that is not ours is left alone; that is not an -// error and must not block the rest of the cleanup. +// 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 binding.RoleRef != applicationRoleRef() { - return nil - } - if binding.Labels[helmv1alpha1.LabelManagedBy] != helmv1alpha1.LabelManagedByValue { + if !managedByOperator(binding.Labels) { return nil } diff --git a/images/operator-helm-controller/internal/services/access_service_test.go b/images/operator-helm-controller/internal/services/access_service_test.go index d8bd0348..ce00124d 100644 --- a/images/operator-helm-controller/internal/services/access_service_test.go +++ b/images/operator-helm-controller/internal/services/access_service_test.go @@ -28,10 +28,10 @@ import ( 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" - "github.com/deckhouse/operator-helm/internal/source" ) func testApplication() *helmv1alpha1.HelmApplication { @@ -60,8 +60,8 @@ func TestEnsureAccessCreatesTheIdentity(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) service, c := newAccessService(t) - if err := service.EnsureAccess(context.Background(), rel); err != nil { - t.Fatalf("EnsureAccess returned %v", err) + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureAccess returned %v", out.Err) } names := rel.InternalNames() @@ -85,6 +85,9 @@ func TestEnsureAccessCreatesTheIdentity(t *testing.T) { 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 { @@ -97,38 +100,105 @@ func TestEnsureAccessCreatesTheIdentity(t *testing.T) { 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) + } + } } -// TestEnsureAccessLeavesAnEditedRoleAlone pins the seed rule: the Role is the place -// where a namespace owner may cut the rights down, so an existing Role is never -// read, compared or rewritten — only a missing one is created. -func TestEnsureAccessLeavesAnEditedRoleAlone(t *testing.T) { +// 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"}, - Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get"}}}, + 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 err := service.EnsureAccess(context.Background(), rel); err != nil { - t.Fatalf("EnsureAccess returned %v", err) + 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) } - if !reflect.DeepEqual(role.Rules, edited.Rules) { - t.Fatalf("an existing role must not be rewritten, rules = %+v", role.Rules) + 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) } } -// TestEnsureAccessRefusesToAdoptAForeignRoleBinding pins that a binding already -// living under our name but pointing at someone else's role is reported, not -// reused: adding our account as its subject would silently grant that account -// whatever that role grants. The object is left exactly as it was — deleting a +// 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 TestEnsureAccessRefusesToAdoptAForeignRoleBinding(t *testing.T) { +func TestEnsureAccessRefusesABindingThatIsNotOurs(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) foreign := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{Name: rel.InternalNames().ServiceAccount, Namespace: "team-a"}, @@ -137,17 +207,23 @@ func TestEnsureAccessRefusesToAdoptAForeignRoleBinding(t *testing.T) { } service, c := newAccessService(t, foreign) - err := service.EnsureAccess(context.Background(), rel) - if err == nil { - t.Fatal("a binding pointing at a foreign role must be reported, not adopted") + 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 !strings.Contains(err.Error(), "someone-elses-role") { - t.Fatalf("error %q must name the existing roleRef", err.Error()) + 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 getErr := c.Get(context.Background(), client.ObjectKeyFromObject(foreign), stored); getErr != nil { - t.Fatalf("the foreign binding must survive: %v", getErr) + 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) @@ -160,18 +236,106 @@ func TestEnsureAccessRefusesToAdoptAForeignRoleBinding(t *testing.T) { } } +// 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 err := service.EnsureAccess(context.Background(), rel); err != nil { - t.Fatalf("first EnsureAccess returned %v", err) + 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 err := service.EnsureAccess(context.Background(), rel); err != nil { - t.Fatalf("second EnsureAccess returned %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 { @@ -188,8 +352,8 @@ func TestEnsureAccessKeepsForeignLabelsOnTheServiceAccountAndBinding(t *testing. names := rel.InternalNames() service, c := newAccessService(t) - if err := service.EnsureAccess(context.Background(), rel); err != nil { - t.Fatalf("first EnsureAccess returned %v", err) + if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { + t.Fatalf("first EnsureAccess returned %v", out.Err) } sa := &corev1.ServiceAccount{} @@ -210,8 +374,8 @@ func TestEnsureAccessKeepsForeignLabelsOnTheServiceAccountAndBinding(t *testing. t.Fatalf("labelling role binding: %v", err) } - if err := service.EnsureAccess(context.Background(), rel); err != nil { - t.Fatalf("second EnsureAccess returned %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 { @@ -232,8 +396,8 @@ func TestEnsureAccessKeepsForeignLabelsOnTheServiceAccountAndBinding(t *testing. func TestEnsureAccessIsANoopForAFamilyWithoutAServiceAccount(t *testing.T) { service, c := newAccessService(t) - if err := service.EnsureAccess(context.Background(), adapter.NewAddonRelease(testAddon())); err != nil { - t.Fatalf("EnsureAccess returned %v", err) + if out := service.EnsureAccess(context.Background(), adapter.NewAddonRelease(testAddon())); out.Err != nil { + t.Fatalf("EnsureAccess returned %v", out.Err) } var accounts corev1.ServiceAccountList @@ -252,8 +416,8 @@ func TestCleanupAccessRemovesTheAccountAndBindingButKeepsTheRole(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) service, c := newAccessService(t) - if err := service.EnsureAccess(context.Background(), rel); err != nil { - t.Fatalf("EnsureAccess returned %v", err) + 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) @@ -274,8 +438,6 @@ func TestCleanupAccessRemovesTheAccountAndBindingButKeepsTheRole(t *testing.T) { if err := service.CleanupAccess(context.Background(), rel); err != nil { t.Fatalf("second CleanupAccess returned %v", err) } - - var _ source.AccessManager = service } // TestCleanupAccessLeavesAForeignRoleBindingAlone pins the delete-side mirror of 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 a1f2fe27..1bc2f981 100644 --- a/images/operator-helm-controller/internal/services/chart_claim_service.go +++ b/images/operator-helm-controller/internal/services/chart_claim_service.go @@ -51,8 +51,6 @@ type ClaimService struct { namespace string } -var _ source.ChartClaim = (*ClaimService)(nil) - func NewClaimService(c client.Client, reader client.Reader, namespace string) *ClaimService { return &ClaimService{ reader: reader, diff --git a/images/operator-helm-controller/internal/services/chart_service.go b/images/operator-helm-controller/internal/services/chart_service.go index 04e60789..9066be1c 100644 --- a/images/operator-helm-controller/internal/services/chart_service.go +++ b/images/operator-helm-controller/internal/services/chart_service.go @@ -21,7 +21,6 @@ import ( "fmt" "maps" - "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" @@ -31,11 +30,10 @@ 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/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,30 +54,7 @@ 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, rel source.Release, repo source.Repository) ChartResult { +func (s *ChartService) EnsureHelmChart(ctx context.Context, rel source.Release, repo source.Repository) ChartOutcome { logger := log.FromContext(ctx) existing := &sourcev1.HelmChart{ @@ -95,12 +70,7 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, rel source.Release, return nil }) if err != nil { - return ChartResult{Status: status.Failed( - rel.Object(), - 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 { @@ -108,18 +78,16 @@ func (s *ChartService) EnsureHelmChart(ctx context.Context, rel source.Release, "internalObject", client.ObjectKeyFromObject(existing)) } - processedStatus := status.ProcessChildConditions( - existing.GetConditions(), existing.Generation, rel.Object(), helmChartErrorRules, - ) + internal := reduceInternalConditions(existing.GetConditions(), existing.Generation, helmChartErrorRules) - if processedStatus.IsReady() { + 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, } } 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 f8638a41..62b4f919 100644 --- a/images/operator-helm-controller/internal/services/helm_repo_service.go +++ b/images/operator-helm-controller/internal/services/helm_repo_service.go @@ -32,7 +32,6 @@ 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/source" ) @@ -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" 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..48460a8e --- /dev/null +++ b/images/operator-helm-controller/internal/services/internal_object.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 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 + 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. A running reconciliation outranks +// everything: whatever the object still says about the previous spec is stale until +// it settles. ProgressingWithRetry is excluded 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. +func reduceInternalConditions( + conditions []metav1.Condition, + generation int64, + errorRules []ErrorConditionRule, +) InternalObjectState { + reconciling := InternalObjectState{ + Status: metav1.ConditionUnknown, + Reason: helmv1alpha1.ReasonReconciling, + } + + 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, + Status: metav1.ConditionFalse, + Reason: rule.Reason, + Message: cond.Message, + } + } + } + + ready, observed := conditionObserved(conditions, helmv1alpha1.ConditionTypeReady, generation) + if !observed { + return reconciling + } + + return InternalObjectState{ + Observed: true, + 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/maintenance_service.go b/images/operator-helm-controller/internal/services/maintenance_service.go index 8cff6bd8..e51f0be8 100644 --- a/images/operator-helm-controller/internal/services/maintenance_service.go +++ b/images/operator-helm-controller/internal/services/maintenance_service.go @@ -30,7 +30,6 @@ 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/source" ) @@ -50,56 +49,21 @@ 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, rel source.Release) MaintenanceResult { +func (s *MaintenanceService) EnsureMaintenanceMode(ctx context.Context, rel source.Release) MaintenanceOutcome { logger := log.FromContext(ctx) - suspendState := rel.MaintenanceActivated() - 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, rel.InternalNames(), suspendState) - if err != nil { - return MaintenanceResult{Status: statusmgr.Failed(rel.Object(), helmv1alpha1.ReasonFailed, "Failed to change maintenance mode", err)} - } - return MaintenanceResult{ - Status: statusmgr.Status{ - Observed: true, - Status: status, - ObservedGeneration: rel.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(rel source.Release) bool { diff --git a/images/operator-helm-controller/internal/services/namespace_service.go b/images/operator-helm-controller/internal/services/namespace_service.go index de96ceba..416bad95 100644 --- a/images/operator-helm-controller/internal/services/namespace_service.go +++ b/images/operator-helm-controller/internal/services/namespace_service.go @@ -28,8 +28,6 @@ import ( "github.com/deckhouse/operator-helm/internal/source" ) -var _ source.TargetNamespaceEnsurer = (*NamespaceService)(nil) - // 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 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 55d946ff..a031165e 100644 --- a/images/operator-helm-controller/internal/services/oci_repo_service.go +++ b/images/operator-helm-controller/internal/services/oci_repo_service.go @@ -31,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/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}, @@ -81,40 +81,13 @@ 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, rel source.Release, repo source.Repository, - src utils.ChartSource, + src chartsource.Source, version *helmv1alpha1.ChartVersion, -) OCIRepoResult { +) OCIRepoOutcome { logger := log.FromContext(ctx) mediaType, failure := s.resolveMediaType(ctx, rel, repo, src, version) @@ -135,14 +108,7 @@ func (s *OCIRepoService) EnsureInternalOCIRepository( return nil }) if err != nil { - return OCIRepoResult{ - Status: status.Failed( - rel.Object(), - 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 { @@ -150,25 +116,14 @@ func (s *OCIRepoService) EnsureInternalOCIRepository( "internalObject", client.ObjectKeyFromObject(existing)) } - processedStatus := status.ProcessChildConditions( - existing.Status.Conditions, existing.Generation, rel.Object(), 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(), } } @@ -186,9 +141,9 @@ func (s *OCIRepoService) resolveMediaType( ctx context.Context, rel source.Release, repo source.Repository, - src utils.ChartSource, + src chartsource.Source, version *helmv1alpha1.ChartVersion, -) (string, *OCIRepoResult) { +) (string, *OCIRepoOutcome) { if version.MediaType != "" { return version.MediaType, nil } @@ -205,19 +160,19 @@ func (s *OCIRepoService) resolveMediaType( } if terminal, ok := repoclient.AsTerminal(err); ok { - return "", &OCIRepoResult{ - Status: status.Failed(rel.Object(), terminal.Reason, terminal.Message, err), + return "", &OCIRepoOutcome{ + ProbeErr: err, + ProbeReason: terminal.Reason, + ProbeMessage: terminal.Message, + ProbeTerminal: true, } } - return "", &OCIRepoResult{ - Status: status.Failed( - rel.Object(), - 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,7 +181,7 @@ func (s *OCIRepoService) resolveMediaType( func (s *OCIRepoService) cachedMediaType( ctx context.Context, names source.ReleaseNames, - src utils.ChartSource, + src chartsource.Source, ) string { nn := types.NamespacedName{ Name: names.OCIRepository, @@ -252,7 +207,7 @@ 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 source.Repository, src utils.ChartSource) *repoclient.RepoConfig { +func artifactRepoConfig(repo source.Repository, src chartsource.Source) *repoclient.RepoConfig { if !sameRegistryHost(repo.URL(), src.URL) { return nil } @@ -288,7 +243,7 @@ func (s *OCIRepoService) RemoveOCIRepository(ctx context.Context, names source.R func applyOCIRepositorySpec( rel source.Release, repo source.Repository, - src utils.ChartSource, + src chartsource.Source, mediaType string, existing *sourcev1.OCIRepository, ) { @@ -360,7 +315,7 @@ func sameRegistryHost(repoURL, artifactURL string) bool { } func repositoryIsOCI(repo source.Repository) bool { - repoType, err := utils.GetRepositoryType(repo.URL()) + 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 99fc6f3f..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 @@ -29,6 +29,7 @@ import ( 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" @@ -116,10 +117,10 @@ func ociTestRepository() *helmv1alpha1.HelmClusterAddonRepository { // 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.ChartVersion) utils.ChartSource { +func ociSource(t *testing.T, repo *helmv1alpha1.HelmClusterAddonRepository, version *helmv1alpha1.ChartVersion) chartsource.Source { t.Helper() - source, err := utils.ResolveChartSource(repo.Spec.URL, version) + source, err := chartsource.Resolve(repo.Spec.URL, version) if err != nil { t.Fatalf("resolving chart source: %v", err) } @@ -178,11 +179,11 @@ func TestEnsureInternalOCIRepositoryReportsRemovedVersion(t *testing.T) { 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) } } @@ -244,17 +245,14 @@ func TestEnsureInternalOCIRepositoryDoesNotRelabelReadyChildOnRemovedVersion(t * 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) } } @@ -567,11 +565,14 @@ func TestEnsureInternalOCIRepositoryReportsTerminalProbeFailure(t *testing.T) { 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 @@ -598,10 +599,13 @@ func TestEnsureInternalOCIRepositoryRequeuesRetriableProbeFailure(t *testing.T) 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 100% rename from images/operator-helm-controller/internal/services/outcomes.go rename to images/operator-helm-controller/internal/services/outcomes_repository.go diff --git a/images/operator-helm-controller/internal/services/release_service.go b/images/operator-helm-controller/internal/services/release_service.go index de87f4da..8e54a029 100644 --- a/images/operator-helm-controller/internal/services/release_service.go +++ b/images/operator-helm-controller/internal/services/release_service.go @@ -33,14 +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/chartsource" "github.com/deckhouse/operator-helm/internal/source" - "github.com/deckhouse/operator-helm/internal/utils" ) 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}, @@ -62,26 +61,7 @@ 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, rel source.Release, 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{ @@ -95,36 +75,26 @@ func (s *ReleaseService) EnsureHelmRelease(ctx context.Context, rel source.Relea return applyHelmReleaseSpec(rel, existing, sourceKind, s.TargetNamespace) }) if err != nil { - return ReleaseResult{Status: status.Failed( - rel.Object(), - 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, rel.Object(), 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(rel, existing.Status.History.Latest(), artifactRevision) { - processedStatus = status.Unknown(rel.Object(), helmv1alpha1.ReasonReconciling) - } + internal := reduceInternalConditions(existing.GetConditions(), existing.Generation, helmReleaseErrorRules) + + // 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() { + 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, } } @@ -174,7 +144,7 @@ func (s *ReleaseService) SyncReleaseSpec(ctx context.Context, rel source.Release return nil } -func applyHelmReleaseSpec(rel source.Release, existing *helmv2.HelmRelease, sourceKind utils.InternalRepositoryType, targetNamespace string) error { +func applyHelmReleaseSpec(rel source.Release, existing *helmv2.HelmRelease, sourceKind chartsource.Kind, targetNamespace string) error { if rel.ForceReconcileRequired() { setReconcileRequestAnnotations(existing) } @@ -213,13 +183,13 @@ func applyHelmReleaseSpec(rel source.Release, existing *helmv2.HelmRelease, sour } switch sourceKind { - case utils.InternalHelmRepository: + case chartsource.Helm: existing.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ Kind: sourcev1.HelmChartKind, Name: names.HelmChart, Namespace: targetNamespace, } - case utils.InternalOCIRepository: + case chartsource.OCI: existing.Spec.ChartRef = &helmv2.CrossNamespaceSourceReference{ Kind: sourcev1.OCIRepositoryKind, Name: names.OCIRepository, diff --git a/images/operator-helm-controller/internal/services/release_service_test.go b/images/operator-helm-controller/internal/services/release_service_test.go index 4a4f2988..a0ebde0b 100644 --- a/images/operator-helm-controller/internal/services/release_service_test.go +++ b/images/operator-helm-controller/internal/services/release_service_test.go @@ -28,8 +28,8 @@ import ( "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" - "github.com/deckhouse/operator-helm/internal/utils" ) func newReleaseService(t *testing.T, objects ...client.Object) (*ReleaseService, client.Client) { @@ -53,8 +53,8 @@ func newReleaseService(t *testing.T, objects ...client.Object) (*ReleaseService, func ensureRelease(t *testing.T, service *ReleaseService, c client.Client, rel source.Release) *helmv2.HelmRelease { t.Helper() - if res := service.EnsureHelmRelease(context.Background(), rel, utils.InternalHelmRepository, ""); res.Status.Err != nil { - t.Fatalf("EnsureHelmRelease returned %v", res.Status.Err) + if res := service.EnsureHelmRelease(context.Background(), rel, chartsource.Helm, ""); res.Err != nil { + t.Fatalf("EnsureHelmRelease returned %v", res.Err) } release := &helmv2.HelmRelease{} diff --git a/images/operator-helm-controller/internal/services/repo_secrets.go b/images/operator-helm-controller/internal/services/repo_secrets.go index fb837163..9d70dd9c 100644 --- a/images/operator-helm-controller/internal/services/repo_secrets.go +++ b/images/operator-helm-controller/internal/services/repo_secrets.go @@ -29,6 +29,7 @@ import ( "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" ) @@ -66,12 +67,12 @@ func NewRepoSecretsService(client client.Client, scheme *runtime.Scheme, namespa func (s *RepoSecretsService) Ensure( ctx context.Context, repo source.Repository, - repoType utils.InternalRepositoryType, + repoType chartsource.Kind, ) error { var err error switch repoType { - case utils.InternalOCIRepository: + case chartsource.OCI: err = s.reconcileDockerConfigAuthSecret(ctx, repo) default: err = s.reconcileBasicAuthSecret(ctx, repo) diff --git a/images/operator-helm-controller/internal/services/repo_secrets_test.go b/images/operator-helm-controller/internal/services/repo_secrets_test.go index b52a57c7..dbd1b054 100644 --- a/images/operator-helm-controller/internal/services/repo_secrets_test.go +++ b/images/operator-helm-controller/internal/services/repo_secrets_test.go @@ -30,6 +30,7 @@ import ( 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" ) @@ -54,7 +55,7 @@ func TestEnsureCreatesAuthAndTLS(t *testing.T) { service, c := newRepoSecretsService(t, repo) - if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), utils.InternalHelmRepository); err != nil { + if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm); err != nil { t.Fatalf("Ensure returned %v", err) } @@ -90,7 +91,7 @@ func TestEnsureRemovesObsoleteSecrets(t *testing.T) { service, c := newRepoSecretsService(t, repo, obsolete) - if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), utils.InternalHelmRepository); err != nil { + if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm); err != nil { t.Fatalf("Ensure returned %v", err) } @@ -111,7 +112,7 @@ func TestEnsureUsesDockerConfigForOCIRepositories(t *testing.T) { service, c := newRepoSecretsService(t, repo) - if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), utils.InternalOCIRepository); err != nil { + if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), chartsource.OCI); err != nil { t.Fatalf("Ensure returned %v", err) } @@ -149,7 +150,7 @@ func TestCleanupRemovesBothSecrets(t *testing.T) { service, c := newRepoSecretsService(t, repo) names := adapter.NewAddonRepository(repo).InternalNames() - if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), utils.InternalHelmRepository); err != nil { + if err := service.Ensure(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm); err != nil { t.Fatalf("Ensure returned %v", 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 57d1b337..33d675ad 100644 --- a/images/operator-helm-controller/internal/services/repo_sync_service.go +++ b/images/operator-helm-controller/internal/services/repo_sync_service.go @@ -23,9 +23,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" 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/source" - "github.com/deckhouse/operator-helm/internal/utils" ) type RepoSyncService struct { @@ -37,7 +37,7 @@ type RepoSyncService struct { // 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) // NewRepoSyncService builds the synchronization for one repository kind: the // catalog decides which chart catalog kind the fetched charts are mirrored into. @@ -73,7 +73,7 @@ func (s *RepoSyncService) MigrateNames(ctx context.Context, repo source.Reposito func (s *RepoSyncService) Sync( ctx context.Context, repo source.Repository, - repoType utils.InternalRepositoryType, + repoType chartsource.Kind, ) SyncOutcome { known, err := s.catalog.Known(ctx, repo) if err != nil { @@ -101,7 +101,7 @@ func (s *RepoSyncService) Sync( func (s *RepoSyncService) fetchCharts( ctx context.Context, repo source.Repository, - repoType utils.InternalRepositoryType, + repoType chartsource.Kind, opts repoclient.FetchOptions, ) ([]repoclient.Chart, FetchOutcome) { repoClient, err := s.clientFactory(repoType) 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 fa0d1975..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 @@ -30,9 +30,9 @@ 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 { @@ -73,7 +73,7 @@ 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 } @@ -131,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(), adapter.NewAddonRepository(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) } @@ -160,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(), adapter.NewAddonRepository(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) } @@ -180,7 +180,7 @@ func TestSyncReportsTerminalFetchFailure(t *testing.T) { service, _ := newRepoSyncService(t, stubRepoClient{err: terminal}, repo) - outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), utils.InternalHelmRepository) + outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm) if outcome.Fetch.Err == nil { t.Fatal("expected a fetch failure") } @@ -196,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(), adapter.NewAddonRepository(repo), utils.InternalHelmRepository) + outcome := service.Sync(context.Background(), adapter.NewAddonRepository(repo), chartsource.Helm) if outcome.Fetch.Err == nil { t.Fatal("expected a fetch failure") } @@ -225,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(), adapter.NewAddonRepository(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) } @@ -258,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(), adapter.NewAddonRepository(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") @@ -283,7 +283,7 @@ func TestSyncRetainsReferencedVersionRemovedFromRepository(t *testing.T) { }}} service, c := newRepoSyncService(t, stub, repo, chart, addon) - if outcome := service.Sync(context.Background(), adapter.NewAddonRepository(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) } @@ -344,7 +344,7 @@ func TestSyncRetainsMediaTypeForReferencedUnsupportedVersion(t *testing.T) { }}} service, c := newRepoSyncService(t, stub, repo, chart, addon) - if outcome := service.Sync(context.Background(), adapter.NewAddonRepository(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) } @@ -392,7 +392,7 @@ func TestSyncOrdersVersionsBySemverDescending(t *testing.T) { }}} service, c := newRepoSyncService(t, stub, repo) - service.Sync(context.Background(), adapter.NewAddonRepository(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"} @@ -418,7 +418,7 @@ func TestSyncCreatesChartWithNoUsableVersions(t *testing.T) { }}} service, c := newRepoSyncService(t, stub, repo) - outcome := service.Sync(context.Background(), adapter.NewAddonRepository(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) @@ -441,7 +441,7 @@ func TestSyncKeepsChartReferencedByAddon(t *testing.T) { addon := addonUsing(repo.Name, "podinfo", "6.7.1") service, c := newRepoSyncService(t, stubRepoClient{charts: nil}, repo, chart, addon) - if outcome := service.Sync(context.Background(), adapter.NewAddonRepository(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) } @@ -476,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, adapter.NewAddonCatalog(c)) - outcome := service.Sync(context.Background(), adapter.NewAddonRepository(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") diff --git a/images/operator-helm-controller/internal/source/release.go b/images/operator-helm-controller/internal/source/release.go index 56a30c1b..b5519693 100644 --- a/images/operator-helm-controller/internal/source/release.go +++ b/images/operator-helm-controller/internal/source/release.go @@ -22,7 +22,7 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/deckhouse/operator-helm/internal/manager/status" + "github.com/deckhouse/operator-helm/internal/status" ) // RepositoryRef names the repository a release takes its chart from. Kind is one @@ -96,68 +96,6 @@ type Release interface { IsChartStatusInfoOutdated() bool } -// 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 RepositoryRef) (Repository, 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 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 Release) error - // Release frees the claim on the release's current pair. - Release(ctx context.Context, rel Release) error -} - -// 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 Release) (bool, string, error) { - return true, rel.Name(), nil -} - -func (NoChartClaim) ReleaseStale(context.Context, Release) error { return nil } - -func (NoChartClaim) Release(context.Context, Release) error { return nil } - -// TargetNamespaceEnsurer makes sure the namespace a release deploys into exists. -type TargetNamespaceEnsurer interface { - EnsureTargetNamespace(ctx context.Context, rel Release) error -} - -// 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, Release) error { - return nil -} - -// 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 Release) error - CleanupAccess(ctx context.Context, rel Release) error -} - -// NoAccess is the AccessManager of a family that does not impersonate. -type NoAccess struct{} - -func (NoAccess) EnsureAccess(context.Context, Release) error { return nil } - -func (NoAccess) CleanupAccess(context.Context, Release) error { return nil } - // 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. diff --git a/images/operator-helm-controller/internal/source/repository.go b/images/operator-helm-controller/internal/source/repository.go index 6afd0603..f2a69c3e 100644 --- a/images/operator-helm-controller/internal/source/repository.go +++ b/images/operator-helm-controller/internal/source/repository.go @@ -22,7 +22,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" - "github.com/deckhouse/operator-helm/internal/manager/status" + "github.com/deckhouse/operator-helm/internal/status" ) // InternalNames are the names of the internal objects derived from one diff --git a/images/operator-helm-controller/internal/manager/status/deletion.go b/images/operator-helm-controller/internal/status/deletion.go similarity index 61% rename from images/operator-helm-controller/internal/manager/status/deletion.go rename to images/operator-helm-controller/internal/status/deletion.go index 493d8f39..00eee926 100644 --- a/images/operator-helm-controller/internal/manager/status/deletion.go +++ b/images/operator-helm-controller/internal/status/deletion.go @@ -38,53 +38,6 @@ type DeletingResource interface { 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 } - -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 -} - // MarkUninstallPending records that a Helm release is still being uninstalled // while its owner is deleted. A failing release (its Ready condition is False) // makes the UninstallFailed condition True and Ready False, both with reason @@ -92,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, @@ -123,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 cbb2ce3d..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 { @@ -101,125 +98,3 @@ func MapNamespacedInternalResources( } } } - -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 - } -} - -// 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/utils/mapper_test.go b/images/operator-helm-controller/internal/utils/mapper_test.go index d6cd945e..2ee9e18f 100644 --- a/images/operator-helm-controller/internal/utils/mapper_test.go +++ b/images/operator-helm-controller/internal/utils/mapper_test.go @@ -23,118 +23,13 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - 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" - "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) - } -} - func TestMapNamespacedInternalResources(t *testing.T) { const target = "d8-operator-helm" @@ -192,94 +87,3 @@ func TestMapNamespacedInternalResources(t *testing.T) { }) } } - -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 application(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, - application("team-a", "a1", "stable", ""), - application("team-b", "b1", "stable", ""), - application("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, - application("team-a", "a1", "stable", ""), - application("team-a", "other", "stable", ""), - application("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/utils/repository.go b/images/operator-helm-controller/internal/utils/repository.go index 59baedc4..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 ( @@ -28,79 +12,6 @@ import ( helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" ) -type InternalRepositoryType string - -const ( - InternalHelmRepository InternalRepositoryType = "helm" - InternalOCIRepository InternalRepositoryType = "oci" -) - -// 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. -// It takes the repository url rather than the repository object so that every -// repository kind can use it. -func ResolveChartSource( - repoURL string, - version *helmv1alpha1.ChartVersion, -) (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(repoURL) - 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: repoURL, 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 1500dbe7..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.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 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.ChartVersion{ - 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.ChartVersion{Version: "6.7.1"}, - want: ChartSource{Kind: InternalHelmRepository}, - }, - { - 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: 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.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 := ResolveChartSource(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) - } - }) - } -} From 8a9ba8f7051b4fa6132bf4f3749c92cfc1923dc1 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Mon, 21 Sep 2026 09:17:57 +0300 Subject: [PATCH 125/145] test(e2e): cover a namespace role the module does not own The application identity now refuses a Role or RoleBinding that carries no managed-by label, which retires the case asserting that a stripped label is written back: a label-less object is foreign, so it stalls the application instead. The replacement covers both directions in a block of its own, because a scenario that blocks an application deliberately produces controller error logs the shared assertion would trip over. Signed-off-by: Ilya Drey --- tests/e2e/default_config.yaml | 3 + tests/e2e/helmapplication/foreign_rbac.go | 168 ++++++++++++++++++++++ tests/e2e/helmapplication/isolation.go | 64 ++++++--- tests/e2e/helmapplication/lifecycle.go | 2 +- 4 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/helmapplication/foreign_rbac.go diff --git a/tests/e2e/default_config.yaml b/tests/e2e/default_config.yaml index 85502703..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" 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 index 19fb53b1..f0db775f 100644 --- a/tests/e2e/helmapplication/isolation.go +++ b/tests/e2e/helmapplication/isolation.go @@ -164,7 +164,25 @@ var _ = Describe("HelmApplication identity and isolation", Ordered, func() { } }) - It("should leave an edited role alone and recreate a deleted one", func() { + 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{""}, @@ -183,40 +201,40 @@ var _ = Describe("HelmApplication identity and isolation", Ordered, func() { g.Expect(err).NotTo(HaveOccurred()) }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) - By("Forcing a reconciliation of the application") - util.UpdateHelmApplication(f.NamespaceName(), appName, func(app *apiv1alpha1.HelmApplication) { - if app.Annotations == nil { - app.Annotations = map[string]string{} - } - app.Annotations[apiv1alpha1.AnnotationForceReconcile] = "true" - }) - - By("The controller must not rewrite the narrowed role") - Consistently(func(g Gomega) { + 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(Equal(narrowed)) - }).WithTimeout(framework.ShortTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + g.Expect(role.Rules).To(HaveLen(1)) + g.Expect(role.Rules[0].Verbs).To(ContainElement("*")) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) - By("Deleting the role must bring it back with full rights") - err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). - Delete(context.Background(), appRoleName, metav1.DeleteOptions{}) - Expect(err).NotTo(HaveOccurred()) + It("should recreate a deleted role and a deleted binding on its own", func() { + saName := util.ApplicationServiceAccountName(f.NamespaceName(), appName) - util.UpdateHelmApplication(f.NamespaceName(), appName, func(app *apiv1alpha1.HelmApplication) { - if app.Annotations == nil { - app.Annotations = map[string]string{} - } - app.Annotations[apiv1alpha1.AnnotationForceReconcile] = "again" - }) + 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 index 7af193f9..a9634807 100644 --- a/tests/e2e/helmapplication/lifecycle.go +++ b/tests/e2e/helmapplication/lifecycle.go @@ -166,7 +166,7 @@ func DefineLifecycleTests(repoType, repoURL string) { _, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). Get(context.Background(), appRoleName, metav1.GetOptions{}) Expect(err).NotTo(HaveOccurred(), - "the role belongs to the namespace and may carry the owner's edits") + "the role is shared by every application of the namespace") }) }) } From a23c50125113b5a447f5b1cd03ff72beaa5607d5 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Mon, 21 Sep 2026 09:18:14 +0300 Subject: [PATCH 126/145] docs: say who owns the role and binding an application is applied with The limitation already said both objects are reconciled; it did not say what decides they are the module's, nor what happens when something else holds the name first. Signed-off-by: Ilya Drey --- docs/README.md | 2 +- docs/README.ru.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index cb526caa..1f5bf889 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,7 +35,7 @@ Each repository also publishes a catalog of the charts it offers — HelmCluster - 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: on first use, the controller seeds a Role there with unrestricted rights over the namespace (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) and binds it to the application's ServiceAccount; the namespace owner may narrow this Role afterwards, and the controller never resets it, so the narrowed rights persist even if the HelmApplication is recreated. 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. +- 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. diff --git a/docs/README.ru.md b/docs/README.ru.md index c6f57409..2e1cc807 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -34,7 +34,7 @@ weight: 10 - Семейство аддонов (HelmClusterAddon, HelmClusterAddonChart, HelmClusterAddonRepository) полностью кластерное, поэтому для управления им требуется роль `ClusterAdmin`. - Семейство приложений является namespaced: владелец namespace с ролью `Admin` может создавать HelmApplication и HelmApplicationRepository в своём namespace и управлять ими без прав на весь кластер. HelmClusterApplicationRepository — кластерный ресурс, поэтому для его создания нужна роль `ClusterAdmin`, но любой HelmApplication может ссылаться на уже существующий из своего namespace. -- Создание HelmApplication фактически равносильно правам администратора внутри его namespace: при первом использовании контроллер создаёт в namespace объект Role с неограниченными правами (`apiGroups: ["*"]`, `resources: ["*"]`, `verbs: ["*"]`) и привязывает его к ServiceAccount приложения; владелец namespace может впоследствии сузить эту Role, а контроллер её больше не сбрасывает, поэтому урезанные права сохраняются даже при пересоздании HelmApplication. Поскольку выдаваемые права предоставляет модуль, а не исходные права создателя, право на создание HelmApplication без прочих прав в namespace даёт через устанавливаемый чарт тот же уровень доступа, что и права администратора 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` — самый низкий уровень, которому модуль вообще доступен. Записывать каталог чартов не может ни один уровень — его единственный автор контроллер. From 7c5a213d15ee564bc64854c3fafc9b096d9eb616 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Mon, 21 Sep 2026 09:50:09 +0300 Subject: [PATCH 127/145] fix(controller): stall a release whose internal object gave up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reduction of the internal object's conditions never read its Stalled condition, so a release whose remediation attempts were spent looked like any other failure and was reported as ProgressingWithRetry — a retry that is not coming, which kstatus reads before Ready and so parks the object at InProgress for good. The internal repository has been propagated this way since its own status rework; the release side had no equivalent. The verdict now carries whether the object gave up alongside what went wrong, so the release stalls with the fault the error rules named rather than with a count of spent attempts, and the progress condition stays off. A stalled object may still carry the Reconciling condition of the attempt that gave up, so that short circuit is skipped for it. Signed-off-by: Ilya Drey --- .../internal/reconcile/release/evaluate.go | 33 +++- .../reconcile/release/evaluate_test.go | 69 ++++++++ .../reconcile/release/reconciler_test.go | 66 +++++++ .../internal/services/internal_object.go | 61 +++++-- .../internal/services/internal_object_test.go | 163 ++++++++++++++++++ 5 files changed, 373 insertions(+), 19 deletions(-) create mode 100644 images/operator-helm-controller/internal/services/internal_object_test.go diff --git a/images/operator-helm-controller/internal/reconcile/release/evaluate.go b/images/operator-helm-controller/internal/reconcile/release/evaluate.go index 26fcd709..04a1bddb 100644 --- a/images/operator-helm-controller/internal/reconcile/release/evaluate.go +++ b/images/operator-helm-controller/internal/reconcile/release/evaluate.go @@ -216,6 +216,8 @@ func Evaluate(in Inputs) Decision { chart, release := evaluateChart(in), evaluateRelease(in) + var stalled bool + verdict, found := decide(chart, release) if found { for _, conditionType := range in.ConditionTypes { @@ -225,15 +227,30 @@ func Evaluate(in Inputs) Decision { decision.Reported = reported(verdict) } - stalled := in.OCIRepo != nil && in.OCIRepo.ProbeTerminal - if stalled { + 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 { @@ -264,11 +281,13 @@ func Evaluate(in Inputs) Decision { // this pass got". // // A terminal failure outranks it, as Stalled outranks Reconciling there: a release -// that cannot proceed is not making progress. 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. +// 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 diff --git a/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go b/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go index 7de112e0..cc0af5ef 100644 --- a/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go @@ -304,6 +304,75 @@ func TestEvaluateReportsARetryAfterAFailedRollout(t *testing.T) { } } +// 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. diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index fb6730ff..1e4b4f52 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -553,6 +553,72 @@ func TestReconcileReportsProgressUntilTheReleaseSettles(t *testing.T) { } } +// 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 diff --git a/images/operator-helm-controller/internal/services/internal_object.go b/images/operator-helm-controller/internal/services/internal_object.go index 48460a8e..deb6d785 100644 --- a/images/operator-helm-controller/internal/services/internal_object.go +++ b/images/operator-helm-controller/internal/services/internal_object.go @@ -36,9 +36,17 @@ import ( // observed, and that is neither readiness nor failure. type InternalObjectState struct { Observed bool - Status metav1.ConditionStatus - Reason string - Message string + // 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 @@ -57,31 +65,47 @@ type ErrorConditionRule struct { } // reduceInternalConditions collapses the conditions of an internal object into the -// state the release evaluation works from. A running reconciliation outranks -// everything: whatever the object still says about the previous spec is stale until -// it settles. ProgressingWithRetry is excluded 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. +// 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, } - cond := apimeta.FindStatusCondition(conditions, helmv1alpha1.ConditionTypeReconciling) - if cond != nil && cond.Status == metav1.ConditionTrue && cond.Reason != helmv1alpha1.ReasonProgressingWithRetry { - return reconciling + 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, @@ -91,11 +115,24 @@ func reduceInternalConditions( 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, 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) + } +} From 210b20c3aa11717db1f2a5a3198047fb16c04619 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Mon, 21 Sep 2026 10:29:29 +0300 Subject: [PATCH 128/145] docs(api): cut the CRD descriptions back to what the contract states The condition and chart version descriptions had grown into accounts of how the controller behaves, which belongs next to the code rather than in a schema every consumer renders. The Russian translations follow the same cut and the CRDs are regenerated. Two markers lost along with the prose come back: the +optional on the application's conditions, and the blank line that keeps the note above HelmApplicationLastAppliedChartRef out of the field's description, which is the convention repository_types.go spells out. Neither affects the generated schema. Signed-off-by: Ilya Drey --- api/v1alpha1/chart_catalog_types.go | 8 +--- api/v1alpha1/helm_application.go | 39 ++----------------- api/v1alpha1/helm_application_chart.go | 7 ---- api/v1alpha1/helm_application_repository.go | 12 ------ api/v1alpha1/helm_cluster_addon.go | 12 ------ api/v1alpha1/helm_cluster_addon_repository.go | 12 ------ .../helm_cluster_application_chart.go | 10 ----- .../helm_cluster_application_repository.go | 16 -------- api/v1alpha1/repository_types.go | 12 ------ crds/doc-ru-helmapplicationcharts.yaml | 4 +- crds/doc-ru-helmapplicationrepositories.yaml | 9 +---- crds/doc-ru-helmapplications.yaml | 5 +-- crds/doc-ru-helmclusteraddoncharts.yaml | 4 +- crds/doc-ru-helmclusteraddonrepositories.yaml | 9 +---- crds/doc-ru-helmclusteraddons.yaml | 5 +-- crds/doc-ru-helmclusterapplicationcharts.yaml | 4 +- ...ru-helmclusterapplicationrepositories.yaml | 9 +---- crds/helmapplicationcharts.yaml | 8 +--- crds/helmapplicationrepositories.yaml | 16 +------- crds/helmapplications.yaml | 17 +------- crds/helmclusteraddoncharts.yaml | 8 +--- crds/helmclusteraddonrepositories.yaml | 16 +------- crds/helmclusteraddons.yaml | 16 +------- crds/helmclusterapplicationcharts.yaml | 8 +--- crds/helmclusterapplicationrepositories.yaml | 16 +------- 25 files changed, 32 insertions(+), 250 deletions(-) diff --git a/api/v1alpha1/chart_catalog_types.go b/api/v1alpha1/chart_catalog_types.go index b0fc040a..ef82513d 100644 --- a/api/v1alpha1/chart_catalog_types.go +++ b/api/v1alpha1/chart_catalog_types.go @@ -52,16 +52,12 @@ type ChartVersion struct { // 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. // +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. + // 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 diff --git a/api/v1alpha1/helm_application.go b/api/v1alpha1/helm_application.go index d385f41c..efc5d678 100644 --- a/api/v1alpha1/helm_application.go +++ b/api/v1alpha1/helm_application.go @@ -32,26 +32,6 @@ const ( HelmApplicationLabelSourceName = "helm.deckhouse.io/application" ) -// The release is always deployed into the namespace of the resource itself: there -// is deliberately no field naming a target namespace, because it would turn a -// namespaced resource into a way of writing outside its own namespace. -// -// The repository is printed by two columns instead of one reading -// "kind/": a printer column's jsonPath is a simple JSON path with no -// concatenation and no "first non-empty" choice, so a single cell would require the -// object to already store a composite value. Both columns carry priority=1, so the -// default output shows neither and -o wide shows both, exactly one of them filled. -// -// The name is bounded because the controller stores it as the value of a source -// label on the internal resources it creates, and a label value cannot exceed 63 -// characters. There is no lower bound: nothing references a HelmApplication by -// name. The Helm release name, which Helm caps at 53 characters, is not what this -// rule guards — the controller derives that by truncation plus hash, the way -// utils/name.go already derives internal object names. -// -// These notes are deliberately outside the doc comment below — controller-gen folds -// every non-marker line of that block into the resource's API 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. // // +kubebuilder:object:root=true @@ -239,19 +219,6 @@ type HelmApplicationStatus struct { // +optional LastAppliedValues *apiextensionsv1.JSON `json:"lastAppliedValues,omitempty"` // Conditions represent the latest available observations of the application state. - // - // Reconciling and Stalled follow the kstatus convention: they are present only - // while applicable. Reconciling means the pass left something to wait for — an - // internal object still rolling out, or a failure that has a retry coming — and - // it is taken away by the pass that finds nothing left to do. It carries the - // reason ForceReconcile while a reconciliation requested through the force - // reconcile annotation is running, and hands over to the ordinary verdict once - // that pass is over. Stalled means the pass ended in a failure no retry - // will resolve: a fault in this object's own spec, a repository whose url cannot - // be read, a registry that rejected the pull, or an object already occupying a - // name this object needs. Stalled outranks Reconciling: a release that cannot - // proceed is not making progress. Such a state is left by correcting the cause - // and requesting a reconciliation, not by waiting. // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` // Generation represents resource generation that was last processed by the controller. @@ -269,9 +236,9 @@ type HelmApplicationStatus struct { // 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. // -// The controller must replace this struct wholesale rather than merge into it: a -// merge would leave a stale repository alongside a new clusterRepository, both -// fields would be set, and IsChartStatusInfoOutdated would pin to true forever. +// 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. diff --git a/api/v1alpha1/helm_application_chart.go b/api/v1alpha1/helm_application_chart.go index c2c7af9e..142d7fa7 100644 --- a/api/v1alpha1/helm_application_chart.go +++ b/api/v1alpha1/helm_application_chart.go @@ -27,13 +27,6 @@ const ( HelmApplicationChartLabelSourceName = "helm.deckhouse.io/application-chart" ) -// The object carries no spec on purpose: it is a projection of a repository catalog, -// not user input. Writes by anyone other than the module's service accounts are -// refused by the ValidatingAdmissionPolicy in templates/admision-policy.yaml. -// -// 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. - // 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 diff --git a/api/v1alpha1/helm_application_repository.go b/api/v1alpha1/helm_application_repository.go index 4d919dff..96978f5a 100644 --- a/api/v1alpha1/helm_application_repository.go +++ b/api/v1alpha1/helm_application_repository.go @@ -28,18 +28,6 @@ const ( HelmApplicationRepositoryLabelSourceName = "helm.deckhouse.io/application-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. -// -// The name length is guarded by a CEL rule rather than by the schema because -// metadata.name has no schema of its own. The upper bound is not decorative: the -// repository name is stored as the value of the "repository" label on the objects -// of its chart catalog, and a label value cannot exceed 63 characters. -// -// Both notes are deliberately outside the doc comment below — controller-gen folds -// every non-marker line of that block into the resource's API description. - // 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 diff --git a/api/v1alpha1/helm_cluster_addon.go b/api/v1alpha1/helm_cluster_addon.go index 8f62393e..81ebef93 100644 --- a/api/v1alpha1/helm_cluster_addon.go +++ b/api/v1alpha1/helm_cluster_addon.go @@ -178,18 +178,6 @@ type HelmClusterAddonStatus struct { // +optional LastAppliedValues *apiextensionsv1.JSON `json:"lastAppliedValues,omitempty"` // Conditions represent the latest available observations of the addon state. - // - // Reconciling and Stalled follow the kstatus convention: they are present only - // while applicable. Reconciling means the pass left something to wait for — an - // internal object still rolling out, or a failure that has a retry coming — and - // it is taken away by the pass that finds nothing left to do. It carries the - // reason ForceReconcile while a reconciliation requested through the force - // reconcile annotation is running, and hands over to the ordinary verdict once - // that pass is over. Stalled means the pass ended in a failure no retry - // will resolve: a fault in this object's own spec, a repository whose url cannot - // be read, or a registry that rejected the pull. Stalled outranks Reconciling: an - // addon that cannot proceed is not making progress. Such a state is left by - // correcting the cause and requesting a reconciliation, not by waiting. // +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_repository.go b/api/v1alpha1/helm_cluster_addon_repository.go index 2850835d..d636f1fa 100644 --- a/api/v1alpha1/helm_cluster_addon_repository.go +++ b/api/v1alpha1/helm_cluster_addon_repository.go @@ -28,18 +28,6 @@ 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. -// -// The name length is guarded by a CEL rule rather than by the schema because -// metadata.name has no schema of its own. The upper bound is not decorative: the -// repository name is stored as the value of the "repository" label on the objects -// of its chart catalog, and a label value cannot exceed 63 characters. -// -// These notes are 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 diff --git a/api/v1alpha1/helm_cluster_application_chart.go b/api/v1alpha1/helm_cluster_application_chart.go index d61d348b..c2d8fba4 100644 --- a/api/v1alpha1/helm_cluster_application_chart.go +++ b/api/v1alpha1/helm_cluster_application_chart.go @@ -27,16 +27,6 @@ const ( HelmClusterApplicationChartLabelSourceName = "helm.deckhouse.io/cluster-application-chart" ) -// The status of this kind is the shared ChartCatalogStatus declared in -// chart_catalog_types.go: every chart catalog kind of the module has the same shape. -// -// The object carries no spec on purpose: it is a projection of a repository catalog, -// not user input. Writes by anyone other than the module's service accounts are -// refused by the ValidatingAdmissionPolicy in templates/admision-policy.yaml. -// -// These notes are deliberately outside the doc comment below — controller-gen folds -// every non-marker line of that block into the resource's API 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. // // +kubebuilder:object:root=true diff --git a/api/v1alpha1/helm_cluster_application_repository.go b/api/v1alpha1/helm_cluster_application_repository.go index 8933c741..56bbcaad 100644 --- a/api/v1alpha1/helm_cluster_application_repository.go +++ b/api/v1alpha1/helm_cluster_application_repository.go @@ -28,22 +28,6 @@ const ( HelmClusterApplicationRepositoryLabelSourceName = "helm.deckhouse.io/cluster-application-repository" ) -// The spec and status of this kind are the shared RepositorySpec and -// RepositoryStatus declared in repository_types.go: every repository kind of the -// module has the same shape. -// -// 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. -// -// The name length is guarded by a CEL rule rather than by the schema because -// metadata.name has no schema of its own. The upper bound is not decorative: the -// repository name is stored as the value of the "repository" label on the objects -// of its chart catalog, and a label value cannot exceed 63 characters. -// -// These notes are deliberately outside the doc comment below — controller-gen folds -// every non-marker line of that block into the resource's API description. - // 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 diff --git a/api/v1alpha1/repository_types.go b/api/v1alpha1/repository_types.go index 2641bd2b..06b18399 100644 --- a/api/v1alpha1/repository_types.go +++ b/api/v1alpha1/repository_types.go @@ -61,18 +61,6 @@ type RepositoryAuth struct { type RepositoryStatus 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. diff --git a/crds/doc-ru-helmapplicationcharts.yaml b/crds/doc-ru-helmapplicationcharts.yaml index f34f135e..1af34170 100644 --- a/crds/doc-ru-helmapplicationcharts.yaml +++ b/crds/doc-ru-helmapplicationcharts.yaml @@ -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-helmapplicationrepositories.yaml b/crds/doc-ru-helmapplicationrepositories.yaml index 7219f5f3..2c5c5c51 100644 --- a/crds/doc-ru-helmapplicationrepositories.yaml +++ b/crds/doc-ru-helmapplicationrepositories.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: diff --git a/crds/doc-ru-helmapplications.yaml b/crds/doc-ru-helmapplications.yaml index 90ee74a4..308b5986 100644 --- a/crds/doc-ru-helmapplications.yaml +++ b/crds/doc-ru-helmapplications.yaml @@ -36,10 +36,7 @@ spec: status: properties: conditions: - description: | - Условия отражают последние наблюдения за состоянием ресурса. - - `Reconciling` и `Stalled` присутствуют, только когда применимо, следуя соглашению kstatus. `Reconciling` означает, что проход оставил после себя ожидание — внутренний объект ещё раскатывается либо произошёл сбой, у которого запланирован повтор, — и снимается тем проходом, которому больше нечего ждать. Пока идёт реконсиляция, запрошенная аннотацией принудительной реконсиляции, это условие имеет причину `ForceReconcile`, а по её завершении переходит к обычному вердикту. `Stalled` означает, что проход завершился ошибкой, которую не исправит повтор: дефектом в спецификации самого объекта, репозиторием с нечитаемым URL, реестром, отклонившим запрос, или объектом, уже занявшим нужное этому объекту имя. `Stalled` старше `Reconciling`: релиз, который не может продвинуться, не находится в процессе. Из такого состояния выходят, устранив причину и запросив реконсиляцию, а не ожиданием. + description: Условия отражают последние наблюдения за состоянием ресурса. lastAppliedChart: description: Последний применённый чарт, инициировавший установку или обновление приложения. properties: diff --git a/crds/doc-ru-helmclusteraddoncharts.yaml b/crds/doc-ru-helmclusteraddoncharts.yaml index 9e8e5563..ab763f74 100644 --- a/crds/doc-ru-helmclusteraddoncharts.yaml +++ b/crds/doc-ru-helmclusteraddoncharts.yaml @@ -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 0005a86a..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: diff --git a/crds/doc-ru-helmclusteraddons.yaml b/crds/doc-ru-helmclusteraddons.yaml index 0b30a04a..e9ef696e 100644 --- a/crds/doc-ru-helmclusteraddons.yaml +++ b/crds/doc-ru-helmclusteraddons.yaml @@ -35,10 +35,7 @@ spec: status: properties: conditions: - description: | - Условия отражают последние наблюдения за состоянием ресурса. - - `Reconciling` и `Stalled` присутствуют, только когда применимо, следуя соглашению kstatus. `Reconciling` означает, что проход оставил после себя ожидание — внутренний объект ещё раскатывается либо произошёл сбой, у которого запланирован повтор, — и снимается тем проходом, которому больше нечего ждать. Пока идёт реконсиляция, запрошенная аннотацией принудительной реконсиляции, это условие имеет причину `ForceReconcile`, а по её завершении переходит к обычному вердикту. `Stalled` означает, что проход завершился ошибкой, которую не исправит повтор: дефектом в спецификации самого объекта, репозиторием с нечитаемым URL или реестром, отклонившим запрос. `Stalled` старше `Reconciling`: аддон, который не может продвинуться, не находится в процессе. Из такого состояния выходят, устранив причину и запросив реконсиляцию, а не ожиданием. + description: Условия отражают последние наблюдения за состоянием ресурса. lastAppliedChart: description: Последний применённый чарт, инициировавший установку или обновление аддона. properties: diff --git a/crds/doc-ru-helmclusterapplicationcharts.yaml b/crds/doc-ru-helmclusterapplicationcharts.yaml index 4c255780..4e616efd 100644 --- a/crds/doc-ru-helmclusterapplicationcharts.yaml +++ b/crds/doc-ru-helmclusterapplicationcharts.yaml @@ -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-helmclusterapplicationrepositories.yaml b/crds/doc-ru-helmclusterapplicationrepositories.yaml index 5481b54b..3fbaf1b5 100644 --- a/crds/doc-ru-helmclusterapplicationrepositories.yaml +++ b/crds/doc-ru-helmclusterapplicationrepositories.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: diff --git a/crds/helmapplicationcharts.yaml b/crds/helmapplicationcharts.yaml index 6083cfa7..fc78c06f 100644 --- a/crds/helmapplicationcharts.yaml +++ b/crds/helmapplicationcharts.yaml @@ -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/helmapplicationrepositories.yaml b/crds/helmapplicationrepositories.yaml index 78109a99..13086c9d 100644 --- a/crds/helmapplicationrepositories.yaml +++ b/crds/helmapplicationrepositories.yaml @@ -111,20 +111,8 @@ spec: format: int32 type: integer conditions: - 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. + 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. diff --git a/crds/helmapplications.yaml b/crds/helmapplications.yaml index f34bbacc..923bd1d5 100644 --- a/crds/helmapplications.yaml +++ b/crds/helmapplications.yaml @@ -129,21 +129,8 @@ spec: status: properties: conditions: - description: |- - Conditions represent the latest available observations of the application state. - - Reconciling and Stalled follow the kstatus convention: they are present only - while applicable. Reconciling means the pass left something to wait for — an - internal object still rolling out, or a failure that has a retry coming — and - it is taken away by the pass that finds nothing left to do. It carries the - reason ForceReconcile while a reconciliation requested through the force - reconcile annotation is running, and hands over to the ordinary verdict once - that pass is over. Stalled means the pass ended in a failure no retry - will resolve: a fault in this object's own spec, a repository whose url cannot - be read, a registry that rejected the pull, or an object already occupying a - name this object needs. Stalled outranks Reconciling: a release that cannot - proceed is not making progress. Such a state is left by correcting the cause - and requesting a reconciliation, not by waiting. + 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. diff --git a/crds/helmclusteraddoncharts.yaml b/crds/helmclusteraddoncharts.yaml index 63381361..45c958f2 100644 --- a/crds/helmclusteraddoncharts.yaml +++ b/crds/helmclusteraddoncharts.yaml @@ -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 19db93f0..b8982c44 100644 --- a/crds/helmclusteraddonrepositories.yaml +++ b/crds/helmclusteraddonrepositories.yaml @@ -111,20 +111,8 @@ spec: format: int32 type: integer conditions: - 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. + 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. diff --git a/crds/helmclusteraddons.yaml b/crds/helmclusteraddons.yaml index 00bf0ee1..de35c7ad 100644 --- a/crds/helmclusteraddons.yaml +++ b/crds/helmclusteraddons.yaml @@ -103,20 +103,8 @@ spec: status: properties: conditions: - description: |- - Conditions represent the latest available observations of the addon state. - - Reconciling and Stalled follow the kstatus convention: they are present only - while applicable. Reconciling means the pass left something to wait for — an - internal object still rolling out, or a failure that has a retry coming — and - it is taken away by the pass that finds nothing left to do. It carries the - reason ForceReconcile while a reconciliation requested through the force - reconcile annotation is running, and hands over to the ordinary verdict once - that pass is over. Stalled means the pass ended in a failure no retry - will resolve: a fault in this object's own spec, a repository whose url cannot - be read, or a registry that rejected the pull. Stalled outranks Reconciling: an - addon that cannot proceed is not making progress. Such a state is left by - correcting the cause and requesting a reconciliation, not by waiting. + 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 index 8a9002e7..7a2e9283 100644 --- a/crds/helmclusterapplicationcharts.yaml +++ b/crds/helmclusterapplicationcharts.yaml @@ -122,9 +122,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: |- @@ -132,9 +130,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/helmclusterapplicationrepositories.yaml b/crds/helmclusterapplicationrepositories.yaml index 77b4e238..cba2d039 100644 --- a/crds/helmclusterapplicationrepositories.yaml +++ b/crds/helmclusterapplicationrepositories.yaml @@ -111,20 +111,8 @@ spec: format: int32 type: integer conditions: - 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. + 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. From ab70aff2229def095f4bdae0a8f016aced770dc5 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 11:32:05 +0300 Subject: [PATCH 129/145] refactor(core): name the identity subsystem after RBAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccessService is what builds the ServiceAccount, Role and RoleBinding a release is applied with, so RBACService says what it is. The condition reasons follow: AccessSetupFailed becomes RBACSetupFailed and ForeignAccessObject becomes ForeignRBACObject. The reasons are part of the status API, so anything reading them off a HelmApplication — an alert, a console view — sees the new strings from the first reconcile after the upgrade. Signed-off-by: Ilya Drey --- api/v1alpha1/conditions.go | 8 +- .../cmd/operator-helm-controller/main.go | 2 +- .../controller/helmapplication/controller.go | 2 +- .../controller/helmapplication/mapper.go | 2 +- .../controller/helmclusteraddon/controller.go | 2 +- .../reconcile/release/collaborators.go | 18 +-- .../reconcile/release/evaluate_test.go | 8 +- .../internal/reconcile/release/reconciler.go | 20 +-- .../reconcile/release/reconciler_test.go | 98 ++++++------- .../internal/services/outcomes_release.go | 4 +- .../{access_service.go => rbac_service.go} | 48 +++---- ...s_service_test.go => rbac_service_test.go} | 132 +++++++++--------- tests/e2e/helmapplication/foreign_rbac.go | 2 +- 13 files changed, 173 insertions(+), 173 deletions(-) rename images/operator-helm-controller/internal/services/{access_service.go => rbac_service.go} (88%) rename images/operator-helm-controller/internal/services/{access_service_test.go => rbac_service_test.go} (78%) diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/conditions.go index 16a60a5f..a01d87d8 100644 --- a/api/v1alpha1/conditions.go +++ b/api/v1alpha1/conditions.go @@ -39,14 +39,14 @@ const ( ReasonFailed = "Failed" ReasonUninstallFailed = "UninstallFailed" ReasonChartClaimConflict = "ChartClaimConflict" - // ReasonAccessSetupFailed marks a release whose identity — the ServiceAccount, + // ReasonRBACSetupFailed 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 + ReasonRBACSetupFailed = "RBACSetupFailed" + // ReasonForeignRBACObject 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" + ReasonForeignRBACObject = "ForeignRBACObject" // ReasonForceReconcile marks the Reconciling condition raised for a pass that // was requested through the force reconcile annotation. ReasonForceReconcile = "ForceReconcile" 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 05b3c856..fe152e13 100644 --- a/images/operator-helm-controller/cmd/operator-helm-controller/main.go +++ b/images/operator-helm-controller/cmd/operator-helm-controller/main.go @@ -87,7 +87,7 @@ func main() { LeaderElection: enableLeaderElection, LeaderElectionID: "operator-helm-controller.helm.deckhouse.io", Client: client.Options{ - // AccessService reads these three kinds only to reconcile the objects its + // RBACService 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 diff --git a/images/operator-helm-controller/internal/controller/helmapplication/controller.go b/images/operator-helm-controller/internal/controller/helmapplication/controller.go index 90f28c92..ffb8a918 100644 --- a/images/operator-helm-controller/internal/controller/helmapplication/controller.go +++ b/images/operator-helm-controller/internal/controller/helmapplication/controller.go @@ -56,7 +56,7 @@ func SetupWithManager(mgr ctrl.Manager) error { Maintenance: services.NewMaintenanceService(client, mgr.GetScheme(), helmv1alpha1.TargetNamespace), Claim: reconcile.NoChartClaim{}, Namespaces: reconcile.ExistingTargetNamespace{}, - Access: services.NewAccessService(client, helmv1alpha1.TargetNamespace), + RBAC: services.NewRBACService(client, helmv1alpha1.TargetNamespace), Status: status.NewManager(client), }) diff --git a/images/operator-helm-controller/internal/controller/helmapplication/mapper.go b/images/operator-helm-controller/internal/controller/helmapplication/mapper.go index 452361a9..5b8e30e3 100644 --- a/images/operator-helm-controller/internal/controller/helmapplication/mapper.go +++ b/images/operator-helm-controller/internal/controller/helmapplication/mapper.go @@ -88,7 +88,7 @@ func mapRoleBindingToApplications(c client.Client) handler.MapFunc { 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", + log.FromContext(ctx).Error(err, "Failed to list HelmApplications for RBAC mapping", "controller", ControllerName, "watchedObject", client.ObjectKeyFromObject(obj)) return nil, err diff --git a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go index 267c91ea..cdf44f67 100644 --- a/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go +++ b/images/operator-helm-controller/internal/controller/helmclusteraddon/controller.go @@ -49,7 +49,7 @@ func SetupWithManager(mgr ctrl.Manager) error { 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{}, + RBAC: reconcile.NoRBAC{}, Status: status.NewManager(client), }) diff --git a/images/operator-helm-controller/internal/reconcile/release/collaborators.go b/images/operator-helm-controller/internal/reconcile/release/collaborators.go index db79108e..32ab2dbb 100644 --- a/images/operator-helm-controller/internal/reconcile/release/collaborators.go +++ b/images/operator-helm-controller/internal/reconcile/release/collaborators.go @@ -59,12 +59,12 @@ type TargetNamespaceEnsurer interface { EnsureTargetNamespace(ctx context.Context, rel source.Release) error } -// AccessManager provides the identity a release is applied with. The application +// RBACManager 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 +type RBACManager interface { + EnsureRBAC(ctx context.Context, rel source.Release) services.RBACOutcome + CleanupRBAC(ctx context.Context, rel source.Release) error } // ChartManager owns the internal HelmChart of a release, the source object of a @@ -130,11 +130,11 @@ func (ExistingTargetNamespace) EnsureTargetNamespace(context.Context, source.Rel return nil } -// NoAccess is the AccessManager of a family that does not impersonate. -type NoAccess struct{} +// NoRBAC is the RBACManager of a family that does not impersonate. +type NoRBAC struct{} -func (NoAccess) EnsureAccess(context.Context, source.Release) services.AccessOutcome { - return services.AccessOutcome{} +func (NoRBAC) EnsureRBAC(context.Context, source.Release) services.RBACOutcome { + return services.RBACOutcome{} } -func (NoAccess) CleanupAccess(context.Context, source.Release) error { return nil } +func (NoRBAC) CleanupRBAC(context.Context, source.Release) error { return nil } diff --git a/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go b/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go index cc0af5ef..20ec8e5f 100644 --- a/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/evaluate_test.go @@ -96,7 +96,7 @@ func TestEvaluateTerminalFailureStalls(t *testing.T) { func TestEvaluateTerminalFailureIsNeverRetried(t *testing.T) { in := baseInputs() in.Step = &Failure{ - Reason: helmv1alpha1.ReasonForeignAccessObject, + Reason: helmv1alpha1.ReasonForeignRBACObject, Message: "role team-a/operator-helm-application already exists and is not managed by the operator", Err: errors.New("foreign role"), Terminal: true, @@ -119,7 +119,7 @@ func TestEvaluateTerminalFailureIsNeverRetried(t *testing.T) { func TestEvaluateTerminalFailureDropsTheForceRequest(t *testing.T) { in := baseInputs() in.Step = &Failure{ - Reason: helmv1alpha1.ReasonForeignAccessObject, + Reason: helmv1alpha1.ReasonForeignRBACObject, Message: "role binding team-a/app already exists and is not managed by the operator", Err: errors.New("foreign binding"), Terminal: true, @@ -143,7 +143,7 @@ func TestEvaluateTerminalFailureDropsTheForceRequest(t *testing.T) { func TestEvaluateRecoverableFailureKeepsTheForceRequest(t *testing.T) { in := baseInputs() in.Step = &Failure{ - Reason: helmv1alpha1.ReasonAccessSetupFailed, + Reason: helmv1alpha1.ReasonRBACSetupFailed, Message: "Failed to set up the release identity", Err: errors.New("forbidden"), Retry: true, @@ -213,7 +213,7 @@ func TestEvaluateRecoverableProbeFailureDoesNotStall(t *testing.T) { func TestEvaluateStepFailureOwnsReadyAlone(t *testing.T) { in := baseInputs() in.Step = &Failure{ - Reason: helmv1alpha1.ReasonAccessSetupFailed, + Reason: helmv1alpha1.ReasonRBACSetupFailed, Message: "Failed to set up the release identity", Err: errors.New("forbidden"), Retry: true, diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler.go b/images/operator-helm-controller/internal/reconcile/release/reconciler.go index c595cebb..e67873b9 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler.go @@ -52,7 +52,7 @@ const chartClaimConflictRequeueInterval = 30 * time.Second // 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). +// (Namespaces) and which identity the chart is applied with (RBAC). type Deps struct { NewRelease func() source.Release Repositories RepositoryResolver @@ -62,7 +62,7 @@ type Deps struct { Maintenance MaintenanceManager Claim ChartClaim Namespaces TargetNamespaceEnsurer - Access AccessManager + RBAC RBACManager Status *status.Manager } @@ -203,19 +203,19 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco // 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 { + if rbac := r.deps.RBAC.EnsureRBAC(ctx, rel); rbac.Err != nil { in.Step = &Failure{ - Reason: access.Reason, - Message: access.Message, - Err: access.Err, - Terminal: access.Terminal, + Reason: rbac.Reason, + Message: rbac.Message, + Err: rbac.Err, + Terminal: rbac.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, + Retry: !rbac.Terminal, } return r.finish(ctx, rel, in) @@ -308,7 +308,7 @@ func (r *Reconciler) finish(ctx context.Context, rel source.Release, in Inputs) decision := Evaluate(in) if decision.Reported != nil { - // Only the access failure is handed to the work queue, which logs it on the + // Only the RBAC 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, @@ -437,7 +437,7 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, rel source.Release) (r // 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 { + if err := r.deps.RBAC.CleanupRBAC(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 diff --git a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go index 1e4b4f52..4a98e480 100644 --- a/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go +++ b/images/operator-helm-controller/internal/reconcile/release/reconciler_test.go @@ -349,18 +349,18 @@ func newFullReconciler( Maintenance: services.NewMaintenanceService(c, scheme, helmv1alpha1.TargetNamespace), Claim: services.NewClaimService(c, c, helmv1alpha1.TargetNamespace), Namespaces: services.NewNamespaceService(c, c), - Access: NoAccess{}, + RBAC: NoRBAC{}, 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 +// what lets a test prove that one reconciler serves both families. rbac is the // identity manager; nil selects the real one over the same fake client. func newApplicationFullReconciler( t *testing.T, - access AccessManager, + rbac RBACManager, objects ...client.Object, ) (*Reconciler, client.Client) { t.Helper() @@ -385,8 +385,8 @@ func newApplicationFullReconciler( WithIndex(&helmv1alpha1.HelmApplication{}, index.ApplicationChart, index.ApplicationChartIndexer). Build() - if access == nil { - access = services.NewAccessService(c, helmv1alpha1.TargetNamespace) + if rbac == nil { + rbac = services.NewRBACService(c, helmv1alpha1.TargetNamespace) } return New(c, Deps{ @@ -398,7 +398,7 @@ func newApplicationFullReconciler( Maintenance: services.NewMaintenanceService(c, scheme, helmv1alpha1.TargetNamespace), Claim: NoChartClaim{}, Namespaces: ExistingTargetNamespace{}, - Access: access, + RBAC: rbac, Status: status.NewManager(c), }), c } @@ -721,29 +721,29 @@ func TestReconcileApplicationCreatesNothingElseInTheApplicationNamespace(t *test } } -// failingAccess is an AccessManager whose identity setup never succeeds. -type failingAccess struct{} +// failingRBAC is an RBACManager whose identity setup never succeeds. +type failingRBAC struct{} -func (failingAccess) EnsureAccess(context.Context, source.Release) services.AccessOutcome { - return services.AccessOutcome{ +func (failingRBAC) EnsureRBAC(context.Context, source.Release) services.RBACOutcome { + return services.RBACOutcome{ Err: errors.New("service account is forbidden"), - Reason: helmv1alpha1.ReasonAccessSetupFailed, + Reason: helmv1alpha1.ReasonRBACSetupFailed, Message: "Failed to set up the release identity", } } -func (failingAccess) CleanupAccess(context.Context, source.Release) error { return nil } +func (failingRBAC) CleanupRBAC(context.Context, source.Release) error { return nil } -// TestReconcileApplicationReportsAccessSetupFailure pins that the pass stops at the +// TestReconcileApplicationReportsRBACSetupFailure 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) { +func TestReconcileApplicationReportsRBACSetupFailure(t *testing.T) { app := testApplication() - r, c := newApplicationFullReconciler(t, failingAccess{}, append(applicationFixtures(), app)...) + r, c := newApplicationFullReconciler(t, failingRBAC{}, append(applicationFixtures(), app)...) key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: key}); err == nil { @@ -759,8 +759,8 @@ func TestReconcileApplicationReportsAccessSetupFailure(t *testing.T) { 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) + if ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonRBACSetupFailed { + t.Fatalf("Ready is %s/%s, want False/%s", ready.Status, ready.Reason, helmv1alpha1.ReasonRBACSetupFailed) } names := adapter.NewApplicationRelease(app).InternalNames() @@ -770,41 +770,41 @@ func TestReconcileApplicationReportsAccessSetupFailure(t *testing.T) { } } -// intermittentAccess fails the first call to EnsureAccess and delegates to a real -// AccessManager afterwards, standing in for a transient failure (an API hiccup, a +// intermittentRBAC fails the first call to EnsureRBAC and delegates to a real +// RBACManager 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 +type intermittentRBAC struct { + real RBACManager calls int } -func (a *intermittentAccess) EnsureAccess(ctx context.Context, rel source.Release) services.AccessOutcome { +func (a *intermittentRBAC) EnsureRBAC(ctx context.Context, rel source.Release) services.RBACOutcome { a.calls++ if a.calls == 1 { - return services.AccessOutcome{ + return services.RBACOutcome{ Err: errors.New("service account is forbidden"), - Reason: helmv1alpha1.ReasonAccessSetupFailed, + Reason: helmv1alpha1.ReasonRBACSetupFailed, Message: "Failed to set up the release identity", } } - return a.real.EnsureAccess(ctx, rel) + return a.real.EnsureRBAC(ctx, rel) } -func (a *intermittentAccess) CleanupAccess(ctx context.Context, rel source.Release) error { - return a.real.CleanupAccess(ctx, rel) +func (a *intermittentRBAC) CleanupRBAC(ctx context.Context, rel source.Release) error { + return a.real.CleanupRBAC(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 +// TestReconcileApplicationRecoversAfterTransientRBACSetupFailure pins the fix for +// the gap in TestReconcileApplicationReportsRBACSetupFailure: a failed +// EnsureRBAC 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) { +func TestReconcileApplicationRecoversAfterTransientRBACSetupFailure(t *testing.T) { app := testApplication() - access := &intermittentAccess{} - r, c := newApplicationFullReconciler(t, access, append(applicationFixtures(), app)...) - access.real = services.NewAccessService(c, helmv1alpha1.TargetNamespace) + rbac := &intermittentRBAC{} + r, c := newApplicationFullReconciler(t, rbac, append(applicationFixtures(), app)...) + rbac.real = services.NewRBACService(c, helmv1alpha1.TargetNamespace) key := types.NamespacedName{Namespace: app.Namespace, Name: app.Name} @@ -817,8 +817,8 @@ func TestReconcileApplicationRecoversAfterTransientAccessSetupFailure(t *testing 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 ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonRBACSetupFailed { + t.Fatalf("Ready = %+v, want False/%s after the first pass", ready, helmv1alpha1.ReasonRBACSetupFailed) } if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: key}); err != nil { @@ -829,14 +829,14 @@ func TestReconcileApplicationRecoversAfterTransientAccessSetupFailure(t *testing 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) + t.Fatalf("the identity must be created once EnsureRBAC 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") + if reason := apimeta.FindStatusCondition(settled.Status.Conditions, helmv1alpha1.ConditionTypeReady).Reason; reason == helmv1alpha1.ReasonRBACSetupFailed { + t.Fatal("Ready must move on from RBACSetupFailed once the retried pass sets up the identity") } } @@ -912,13 +912,13 @@ func TestReconcileApplicationStallsOnAForeignRole(t *testing.T) { 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) + if stalled.Status != metav1.ConditionTrue || stalled.Reason != helmv1alpha1.ReasonForeignRBACObject { + t.Fatalf("Stalled is %s/%s, want True/%s", stalled.Status, stalled.Reason, helmv1alpha1.ReasonForeignRBACObject) } 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) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != helmv1alpha1.ReasonForeignRBACObject { + t.Fatalf("Ready = %+v, want False/%s", ready, helmv1alpha1.ReasonForeignRBACObject) } stored := &rbacv1.Role{} @@ -936,23 +936,23 @@ func TestReconcileApplicationStallsOnAForeignRole(t *testing.T) { } } -// accessCleanupFails is an AccessManager whose teardown never succeeds, standing in +// accessCleanupFails is an RBACManager 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) EnsureRBAC(context.Context, source.Release) services.RBACOutcome { + return services.RBACOutcome{} } -func (accessCleanupFails) CleanupAccess(context.Context, source.Release) error { +func (accessCleanupFails) CleanupRBAC(context.Context, source.Release) error { return errors.New("role binding deletion forbidden") } -// TestReconcileDeleteReportsAccessCleanupFailure pins that a failed CleanupAccess +// TestReconcileDeleteReportsRBACCleanupFailure pins that a failed CleanupRBAC // 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) { +func TestReconcileDeleteReportsRBACCleanupFailure(t *testing.T) { now := metav1.Now() app := &helmv1alpha1.HelmApplication{ ObjectMeta: metav1.ObjectMeta{ diff --git a/images/operator-helm-controller/internal/services/outcomes_release.go b/images/operator-helm-controller/internal/services/outcomes_release.go index 0fdb0556..18d72251 100644 --- a/images/operator-helm-controller/internal/services/outcomes_release.go +++ b/images/operator-helm-controller/internal/services/outcomes_release.go @@ -75,12 +75,12 @@ type ReleaseOutcome struct { ChartDeployed bool } -// AccessOutcome is the result of reconciling the identity a release is applied +// RBACOutcome 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 { +type RBACOutcome struct { Err error Terminal bool Reason string diff --git a/images/operator-helm-controller/internal/services/access_service.go b/images/operator-helm-controller/internal/services/rbac_service.go similarity index 88% rename from images/operator-helm-controller/internal/services/access_service.go rename to images/operator-helm-controller/internal/services/rbac_service.go index a689714c..69402d49 100644 --- a/images/operator-helm-controller/internal/services/access_service.go +++ b/images/operator-helm-controller/internal/services/rbac_service.go @@ -40,7 +40,7 @@ import ( // 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 +// RBACService 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. // @@ -57,43 +57,43 @@ const ApplicationRoleName = "operator-helm-application" // 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 { +type RBACService struct { BaseService TargetNamespace string } -func NewAccessService(c client.Client, targetNamespace string) *AccessService { - return &AccessService{ +func NewRBACService(c client.Client, targetNamespace string) *RBACService { + return &RBACService{ BaseService: BaseService{Client: c}, TargetNamespace: targetNamespace, } } -// EnsureAccess reconciles the account, the Role and the binding. A release without a +// EnsureRBAC 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 { +func (s *RBACService) EnsureRBAC(ctx context.Context, rel source.Release) RBACOutcome { name := rel.InternalNames().ServiceAccount if name == "" { - return AccessOutcome{} + return RBACOutcome{} } namespace := rel.TargetNamespace() if err := s.ensureServiceAccount(ctx, rel, name); err != nil { - return accessFailure(fmt.Errorf("ensuring service account: %w", err)) + return rbacFailure(fmt.Errorf("ensuring service account: %w", err)) } if err := s.ensureRole(ctx, namespace); err != nil { - return accessFailure(fmt.Errorf("ensuring role: %w", err)) + return rbacFailure(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 rbacFailure(fmt.Errorf("ensuring role binding: %w", err)) } - return AccessOutcome{} + return RBACOutcome{} } // foreignObjectError marks an object occupying a name the operator derives that is @@ -106,24 +106,24 @@ type foreignObjectError struct { func (e *foreignObjectError) Error() string { return e.message } -// accessFailure names the failure the release reports. A foreign object is terminal: +// rbacFailure 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 { +func rbacFailure(err error) RBACOutcome { var foreign *foreignObjectError if errors.As(err, &foreign) { - return AccessOutcome{ + return RBACOutcome{ Err: err, Terminal: true, - Reason: helmv1alpha1.ReasonForeignAccessObject, + Reason: helmv1alpha1.ReasonForeignRBACObject, Message: foreign.message, } } - return AccessOutcome{ + return RBACOutcome{ Err: err, - Reason: helmv1alpha1.ReasonAccessSetupFailed, + Reason: helmv1alpha1.ReasonRBACSetupFailed, Message: "Failed to set up the release identity: " + err.Error(), } } @@ -135,10 +135,10 @@ 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 +// CleanupRBAC 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 { +func (s *RBACService) CleanupRBAC(ctx context.Context, rel source.Release) error { name := rel.InternalNames().ServiceAccount if name == "" { return nil @@ -165,7 +165,7 @@ func (s *AccessService) CleanupAccess(ctx context.Context, rel source.Release) e // 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 { +func (s *RBACService) ensureServiceAccount(ctx context.Context, rel source.Release, name string) error { account := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: s.TargetNamespace}, } @@ -199,7 +199,7 @@ func (s *AccessService) ensureServiceAccount(ctx context.Context, rel source.Rel // 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 { +func (s *RBACService) ensureRole(ctx context.Context, namespace string) error { err := s.applyRole(ctx, namespace) if apierrors.IsAlreadyExists(err) { err = s.applyRole(ctx, namespace) @@ -208,7 +208,7 @@ func (s *AccessService) ensureRole(ctx context.Context, namespace string) error return err } -func (s *AccessService) applyRole(ctx context.Context, namespace string) error { +func (s *RBACService) applyRole(ctx context.Context, namespace string) error { role := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{Name: ApplicationRoleName, Namespace: namespace}, } @@ -275,7 +275,7 @@ func applicationRoleRef() rbacv1.RoleRef { // 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 { +func (s *RBACService) ensureRoleBinding(ctx context.Context, rel source.Release, namespace, name string) error { desiredRef := applicationRoleRef() existing := &rbacv1.RoleBinding{} @@ -340,7 +340,7 @@ func (s *AccessService) ensureRoleBinding(ctx context.Context, rel source.Releas // 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 { +func (s *RBACService) 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) diff --git a/images/operator-helm-controller/internal/services/access_service_test.go b/images/operator-helm-controller/internal/services/rbac_service_test.go similarity index 78% rename from images/operator-helm-controller/internal/services/access_service_test.go rename to images/operator-helm-controller/internal/services/rbac_service_test.go index ce00124d..45d85f2d 100644 --- a/images/operator-helm-controller/internal/services/access_service_test.go +++ b/images/operator-helm-controller/internal/services/rbac_service_test.go @@ -43,25 +43,25 @@ func testApplication() *helmv1alpha1.HelmApplication { } } -func newAccessService(t *testing.T, objects ...client.Object) (*AccessService, client.Client) { +func newRBACService(t *testing.T, objects ...client.Object) (*RBACService, client.Client) { t.Helper() c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithObjects(objects...).Build() - return NewAccessService(c, testNamespace), c + return NewRBACService(c, testNamespace), c } -// TestEnsureAccessCreatesTheIdentity pins the three objects and their shape: the +// TestEnsureRBACCreatesTheIdentity 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) { +func TestEnsureRBACCreatesTheIdentity(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) - service, c := newAccessService(t) + service, c := newRBACService(t) - if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { - t.Fatalf("EnsureAccess returned %v", out.Err) + if out := service.EnsureRBAC(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureRBAC returned %v", out.Err) } names := rel.InternalNames() @@ -112,10 +112,10 @@ func TestEnsureAccessCreatesTheIdentity(t *testing.T) { } } -// TestEnsureAccessRewritesAnEditedRole pins that a Role of ours is reconciled rather +// TestEnsureRBACRewritesAnEditedRole 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) { +func TestEnsureRBACRewritesAnEditedRole(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) edited := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ @@ -128,10 +128,10 @@ func TestEnsureAccessRewritesAnEditedRole(t *testing.T) { }, Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get"}}}, } - service, c := newAccessService(t, edited) + service, c := newRBACService(t, edited) - if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { - t.Fatalf("EnsureAccess returned %v", out.Err) + if out := service.EnsureRBAC(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureRBAC returned %v", out.Err) } role := &rbacv1.Role{} @@ -152,30 +152,30 @@ func TestEnsureAccessRewritesAnEditedRole(t *testing.T) { } } -// TestEnsureAccessRefusesARoleThatIsNotOurs pins the ownership test on the shared +// TestEnsureRBACRefusesARoleThatIsNotOurs 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) { +func TestEnsureRBACRefusesARoleThatIsNotOurs(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) + service, c := newRBACService(t, foreign) - out := service.EnsureAccess(context.Background(), rel) + out := service.EnsureRBAC(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 out.Reason != helmv1alpha1.ReasonForeignRBACObject { + t.Fatalf("reason = %q, want %q", out.Reason, helmv1alpha1.ReasonForeignRBACObject) } if !strings.Contains(out.Message, "team-a/"+ApplicationRoleName) { t.Fatalf("message %q must name the role", out.Message) @@ -193,29 +193,29 @@ func TestEnsureAccessRefusesARoleThatIsNotOurs(t *testing.T) { } } -// TestEnsureAccessRefusesABindingThatIsNotOurs pins the same ownership test on the +// TestEnsureRBACRefusesABindingThatIsNotOurs 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) { +func TestEnsureRBACRefusesABindingThatIsNotOurs(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) + service, c := newRBACService(t, foreign) - out := service.EnsureAccess(context.Background(), rel) + out := service.EnsureRBAC(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 out.Reason != helmv1alpha1.ReasonForeignRBACObject { + t.Fatalf("reason = %q, want %q", out.Reason, helmv1alpha1.ReasonForeignRBACObject) } if !strings.Contains(out.Message, "team-a/"+rel.InternalNames().ServiceAccount) { t.Fatalf("message %q must name the binding", out.Message) @@ -236,11 +236,11 @@ func TestEnsureAccessRefusesABindingThatIsNotOurs(t *testing.T) { } } -// TestEnsureAccessReplacesOurBindingThatNamesAnotherRole pins the other side of the +// TestEnsureRBACReplacesOurBindingThatNamesAnotherRole 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) { +func TestEnsureRBACReplacesOurBindingThatNamesAnotherRole(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) names := rel.InternalNames() misbound := &rbacv1.RoleBinding{ @@ -252,10 +252,10 @@ func TestEnsureAccessReplacesOurBindingThatNamesAnotherRole(t *testing.T) { 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) + service, c := newRBACService(t, misbound) - if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { - t.Fatalf("EnsureAccess returned %v", out.Err) + if out := service.EnsureRBAC(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureRBAC returned %v", out.Err) } stored := &rbacv1.RoleBinding{} @@ -278,11 +278,11 @@ func TestEnsureAccessReplacesOurBindingThatNamesAnotherRole(t *testing.T) { } } -// TestEnsureAccessSurvivesLosingTheRoleCreateRace pins the one object of the three +// TestEnsureRBACSurvivesLosingTheRoleCreateRace 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) { +func TestEnsureRBACSurvivesLosingTheRoleCreateRace(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) var refused bool @@ -306,10 +306,10 @@ func TestEnsureAccessSurvivesLosingTheRoleCreateRace(t *testing.T) { }, }). Build() - service := NewAccessService(c, testNamespace) + service := NewRBACService(c, testNamespace) - if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { - t.Fatalf("EnsureAccess returned %v", out.Err) + if out := service.EnsureRBAC(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureRBAC returned %v", out.Err) } if !refused { t.Fatal("the test did not exercise the refused create") @@ -324,18 +324,18 @@ func TestEnsureAccessSurvivesLosingTheRoleCreateRace(t *testing.T) { } } -func TestEnsureAccessRecreatesADeletedRole(t *testing.T) { +func TestEnsureRBACRecreatesADeletedRole(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) - service, c := newAccessService(t) + service, c := newRBACService(t) - if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { - t.Fatalf("first EnsureAccess returned %v", out.Err) + if out := service.EnsureRBAC(context.Background(), rel); out.Err != nil { + t.Fatalf("first EnsureRBAC 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 out := service.EnsureRBAC(context.Background(), rel); out.Err != nil { + t.Fatalf("second EnsureRBAC returned %v", out.Err) } if err := c.Get(context.Background(), client.ObjectKey{Namespace: "team-a", Name: ApplicationRoleName}, &rbacv1.Role{}); err != nil { @@ -343,17 +343,17 @@ func TestEnsureAccessRecreatesADeletedRole(t *testing.T) { } } -// TestEnsureAccessKeepsForeignLabelsOnTheServiceAccountAndBinding pins that a label +// TestEnsureRBACKeepsForeignLabelsOnTheServiceAccountAndBinding 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) { +func TestEnsureRBACKeepsForeignLabelsOnTheServiceAccountAndBinding(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) names := rel.InternalNames() - service, c := newAccessService(t) + service, c := newRBACService(t) - if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { - t.Fatalf("first EnsureAccess returned %v", out.Err) + if out := service.EnsureRBAC(context.Background(), rel); out.Err != nil { + t.Fatalf("first EnsureRBAC returned %v", out.Err) } sa := &corev1.ServiceAccount{} @@ -374,8 +374,8 @@ func TestEnsureAccessKeepsForeignLabelsOnTheServiceAccountAndBinding(t *testing. 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 out := service.EnsureRBAC(context.Background(), rel); out.Err != nil { + t.Fatalf("second EnsureRBAC returned %v", out.Err) } if err := c.Get(context.Background(), client.ObjectKey{Namespace: testNamespace, Name: names.ServiceAccount}, sa); err != nil { @@ -393,11 +393,11 @@ func TestEnsureAccessKeepsForeignLabelsOnTheServiceAccountAndBinding(t *testing. } } -func TestEnsureAccessIsANoopForAFamilyWithoutAServiceAccount(t *testing.T) { - service, c := newAccessService(t) +func TestEnsureRBACIsANoopForAFamilyWithoutAServiceAccount(t *testing.T) { + service, c := newRBACService(t) - if out := service.EnsureAccess(context.Background(), adapter.NewAddonRelease(testAddon())); out.Err != nil { - t.Fatalf("EnsureAccess returned %v", out.Err) + if out := service.EnsureRBAC(context.Background(), adapter.NewAddonRelease(testAddon())); out.Err != nil { + t.Fatalf("EnsureRBAC returned %v", out.Err) } var accounts corev1.ServiceAccountList @@ -409,18 +409,18 @@ func TestEnsureAccessIsANoopForAFamilyWithoutAServiceAccount(t *testing.T) { } } -// TestCleanupAccessRemovesTheAccountAndBindingButKeepsTheRole pins spec 9.4: the +// TestCleanupRBACRemovesTheAccountAndBindingButKeepsTheRole 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) { +func TestCleanupRBACRemovesTheAccountAndBindingButKeepsTheRole(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) - service, c := newAccessService(t) + service, c := newRBACService(t) - if out := service.EnsureAccess(context.Background(), rel); out.Err != nil { - t.Fatalf("EnsureAccess returned %v", out.Err) + if out := service.EnsureRBAC(context.Background(), rel); out.Err != nil { + t.Fatalf("EnsureRBAC returned %v", out.Err) } - if err := service.CleanupAccess(context.Background(), rel); err != nil { - t.Fatalf("CleanupAccess returned %v", err) + if err := service.CleanupRBAC(context.Background(), rel); err != nil { + t.Fatalf("CleanupRBAC returned %v", err) } names := rel.InternalNames() @@ -435,16 +435,16 @@ func TestCleanupAccessRemovesTheAccountAndBindingButKeepsTheRole(t *testing.T) { } // 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) + if err := service.CleanupRBAC(context.Background(), rel); err != nil { + t.Fatalf("second CleanupRBAC returned %v", err) } } -// TestCleanupAccessLeavesAForeignRoleBindingAlone pins the delete-side mirror of -// TestEnsureAccessRefusesToAdoptAForeignRoleBinding: the derived name is fully +// TestCleanupRBACLeavesAForeignRoleBindingAlone pins the delete-side mirror of +// TestEnsureRBACRefusesToAdoptAForeignRoleBinding: 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) { +func TestCleanupRBACLeavesAForeignRoleBindingAlone(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) names := rel.InternalNames() foreign := &rbacv1.RoleBinding{ @@ -452,10 +452,10 @@ func TestCleanupAccessLeavesAForeignRoleBindingAlone(t *testing.T) { 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) + service, c := newRBACService(t, foreign) - if err := service.CleanupAccess(context.Background(), rel); err != nil { - t.Fatalf("CleanupAccess returned %v", err) + if err := service.CleanupRBAC(context.Background(), rel); err != nil { + t.Fatalf("CleanupRBAC returned %v", err) } stored := &rbacv1.RoleBinding{} diff --git a/tests/e2e/helmapplication/foreign_rbac.go b/tests/e2e/helmapplication/foreign_rbac.go index abdbd710..cfa668e7 100644 --- a/tests/e2e/helmapplication/foreign_rbac.go +++ b/tests/e2e/helmapplication/foreign_rbac.go @@ -108,7 +108,7 @@ var _ = Describe("HelmApplication over a foreign namespace role", Ordered, func( util.UntilConditionTrue(apiv1alpha1.ConditionTypeStalled, framework.LongTimeout, createdApp) util.UntilConditionReason( apiv1alpha1.ConditionTypeStalled, - apiv1alpha1.ReasonForeignAccessObject, + apiv1alpha1.ReasonForeignRBACObject, framework.LongTimeout, createdApp, ) From 3835a31ab93625352386c73ee256635b1d1c43dd Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 11:46:56 +0300 Subject: [PATCH 130/145] refactor(core): stop labelling the application RBAC objects as deckhouse heritage The Role and the RoleBinding an application is applied with no longer carry heritage: deckhouse. Ownership rests on the managed-by label alone, which is what the ownership test and the label-scoped informers already use. Objects created before this keep the label: the labels are merged onto what is found, never replaced, so nothing removes it from them. Signed-off-by: Ilya Drey --- .../internal/services/rbac_service.go | 6 ++---- .../internal/services/rbac_service_test.go | 4 ++-- tests/e2e/helmapplication/foreign_rbac.go | 3 --- tests/e2e/helmapplication/isolation.go | 4 +--- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/images/operator-helm-controller/internal/services/rbac_service.go b/images/operator-helm-controller/internal/services/rbac_service.go index 69402d49..d36ec301 100644 --- a/images/operator-helm-controller/internal/services/rbac_service.go +++ b/images/operator-helm-controller/internal/services/rbac_service.go @@ -246,12 +246,10 @@ func (s *RBACService) applyRole(ctx context.Context, namespace string) error { // 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. +// object back here and has it refused as foreign. func applicationRBACLabels() map[string]string { return map[string]string{ - helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, - helmv1alpha1.LabelDeckhouseHeritage: helmv1alpha1.LabelDeckhouseHeritageValue, + helmv1alpha1.LabelManagedBy: helmv1alpha1.LabelManagedByValue, } } diff --git a/images/operator-helm-controller/internal/services/rbac_service_test.go b/images/operator-helm-controller/internal/services/rbac_service_test.go index 45d85f2d..38c5252f 100644 --- a/images/operator-helm-controller/internal/services/rbac_service_test.go +++ b/images/operator-helm-controller/internal/services/rbac_service_test.go @@ -113,8 +113,8 @@ func TestEnsureRBACCreatesTheIdentity(t *testing.T) { } // TestEnsureRBACRewritesAnEditedRole 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. +// than seeded: rules narrowed out of band are written back, and the managed-by label +// is kept. A label someone else put there survives, as on the account and the binding. func TestEnsureRBACRewritesAnEditedRole(t *testing.T) { rel := adapter.NewApplicationRelease(testApplication()) edited := &rbacv1.Role{ diff --git a/tests/e2e/helmapplication/foreign_rbac.go b/tests/e2e/helmapplication/foreign_rbac.go index cfa668e7..7fc91aae 100644 --- a/tests/e2e/helmapplication/foreign_rbac.go +++ b/tests/e2e/helmapplication/foreign_rbac.go @@ -160,9 +160,6 @@ var _ = Describe("HelmApplication over a foreign namespace role", Ordered, func( 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 index f0db775f..05edc2eb 100644 --- a/tests/e2e/helmapplication/isolation.go +++ b/tests/e2e/helmapplication/isolation.go @@ -164,20 +164,18 @@ var _ = Describe("HelmApplication identity and isolation", Ordered, func() { } }) - It("should carry the module labels on the role and the binding", func() { + It("should carry the module label 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 From 2b72aa3ce40c6db73d850810c76c32aca038a969 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 15:09:11 +0300 Subject: [PATCH 131/145] test(e2e): fail when a deleted object never disappears waitDeleted discarded the poll result, so an object the cluster keeps bringing back cost LongTimeout in silence. The suite runs under a 30 minute budget, so two of those are enough to end the run on whichever spec is in flight when the suite timeout lands, with nothing in the log pointing at the object at fault. Signed-off-by: Ilya Drey --- tests/e2e/internal/framework/framework.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/e2e/internal/framework/framework.go b/tests/e2e/internal/framework/framework.go index deed953d..a12a2912 100644 --- a/tests/e2e/internal/framework/framework.go +++ b/tests/e2e/internal/framework/framework.go @@ -228,13 +228,20 @@ func (f *Framework) DeferDeleteFunc(fn func() error) { f.deferredDeletes = append(f.deferredDeletes, fn) } +// waitDeleted blocks until every object is gone, and says so when one is not. The +// poll costs LongTimeout per object that never disappears, and the suite runs under +// a budget of its own, so swallowing that spends the budget somewhere far from the +// object responsible: the run dies on whichever spec happens to be in flight when +// the suite timeout lands. func (f *Framework) waitDeleted(objs []client.Object) { + GinkgoHelper() + for _, obj := range objs { key := types.NamespacedName{ Namespace: obj.GetNamespace(), Name: obj.GetName(), } - _ = wait.PollUntilContextTimeout( + err := wait.PollUntilContextTimeout( context.Background(), time.Second, LongTimeout, true, func(ctx context.Context) (bool, error) { err := f.generic.Get(ctx, key, obj) @@ -244,5 +251,6 @@ func (f *Framework) waitDeleted(objs []client.Object) { return false, nil }, ) + Expect(err).NotTo(HaveOccurred(), "%T %s still exists %s after it was deleted", obj, key, LongTimeout) } } From a2634a1919abc938dae70b6b18d11c8398e8ee00 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 15:09:13 +0300 Subject: [PATCH 132/145] test(e2e): stop waiting for a role the module recreates The foreign role is registered for deletion, but once the module adopts it the Role is recreated for as long as an application lives in the namespace, and CleanupRBAC keeps it by design. It goes away with the namespace. Signed-off-by: Ilya Drey --- tests/e2e/helmapplication/foreign_rbac.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/e2e/helmapplication/foreign_rbac.go b/tests/e2e/helmapplication/foreign_rbac.go index 7fc91aae..e98f5d7b 100644 --- a/tests/e2e/helmapplication/foreign_rbac.go +++ b/tests/e2e/helmapplication/foreign_rbac.go @@ -69,10 +69,13 @@ var _ = Describe("HelmApplication over a foreign namespace role", Ordered, func( ObjectMeta: metav1.ObjectMeta{Name: appRoleName, Namespace: f.NamespaceName()}, Rules: foreignRules, } - createdRole, err := f.KubeClient().RbacV1().Roles(f.NamespaceName()). + // Deliberately not registered for deletion. Once the module adopts this Role + // it recreates it for as long as an application lives in the namespace, and + // CleanupRBAC keeps it on purpose, so waiting for it to disappear would wait + // out the full timeout. It goes away with the namespace. + _, 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{ From c9384c3dfe00dd9ecca2476aefc73b589825c622 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:06:03 +0300 Subject: [PATCH 133/145] fix(api): fold a dot the object name grammar cannot hold A chart name reaches the catalog from a third-party index, where a dot may sit next to a separator or another dot. Such a name assembled into an object name is not a DNS-1123 subdomain, and one rejection stops the catalog synchronization of the whole repository. Signed-off-by: Ilya Drey --- api/naming/naming.go | 21 ++++++++++++++++++++- api/naming/naming_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/api/naming/naming.go b/api/naming/naming.go index fe77e215..5b937c80 100644 --- a/api/naming/naming.go +++ b/api/naming/naming.go @@ -82,7 +82,26 @@ func chartObjectName(repoName, chartName string) string { // 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 + readable := []byte(strings.Trim(result, "-.")) + + // Trimming the ends is not enough. sanitize keeps dots, and a dot is only legal + // between two alphanumerics, so an input like "my chart. v2" leaves ".-" inside + // the readable part and the whole name stops being a subdomain the API server + // accepts. Every dot the label rules cannot hold becomes a dash instead; the + // readable part is only a hint, and the hash keeps the name injective whatever + // this does to it. The bounds hold because the trim above already removed every + // leading and trailing separator. + for i := 1; i < len(readable)-1; i++ { + if readable[i] == '.' && (!isAlphanumeric(readable[i-1]) || !isAlphanumeric(readable[i+1])) { + readable[i] = '-' + } + } + + return string(readable) + "-" + hash +} + +func isAlphanumeric(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') } // sanitize lower-cases s and replaces every character that cannot appear in a diff --git a/api/naming/naming_test.go b/api/naming/naming_test.go index 2878c461..7975d221 100644 --- a/api/naming/naming_test.go +++ b/api/naming/naming_test.go @@ -272,3 +272,32 @@ func TestChartNameSeparatesATrailingDot(t *testing.T) { t.Fatalf("(%q, %q) and (%q, %q) both produce %q", "foo", "bar.", "foo", "bar", withDot) } } + +// TestChartNameSanitizesAnIllegalDot pins that a chart name from an index or an OCI +// tag list cannot produce an invalid object name. A dot is legal in a DNS-1123 +// subdomain only between two alphanumerics, so a name that carries one next to a +// separator — or next to another dot — must not reach the API server as is: +// CreateOrPatch would reject the object, and the catalog synchronization of the +// whole repository stops on the first rejection. +func TestChartNameSanitizesAnIllegalDot(t *testing.T) { + charts := []string{ + "my chart. v2", + ".foo", + "a..b", + "a.-b", + "foo.-bar", + "名.app", + "foo-.bar", + ".", + "..", + } + + for _, chart := range charts { + t.Run(chart, func(t *testing.T) { + got := HelmClusterAddonChartName("repo", chart) + if errs := validation.IsDNS1123Subdomain(got); len(errs) > 0 { + t.Fatalf("HelmClusterAddonChartName(%q, %q) = %q, which is not a valid DNS-1123 subdomain: %v", "repo", chart, got, errs) + } + }) + } +} From 2a436726004fc9b67a755bdf210c09f65a0b1fb1 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:06:18 +0300 Subject: [PATCH 134/145] fix(controller): skip a chart whose name cannot be a label value The chart label carries the name verbatim and is the query surface a user and the UI find a catalog object by, so it can be neither sanitized nor omitted. An index may publish a name no label value can hold; offering no such chart costs less than losing the pass, which left every chart listed after it unwritten and never reached the pruning loop. Signed-off-by: Ilya Drey --- .../internal/catalog/catalog.go | 15 ++++ .../internal/catalog/catalog_test.go | 81 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/images/operator-helm-controller/internal/catalog/catalog.go b/images/operator-helm-controller/internal/catalog/catalog.go index 503746b1..116df65a 100644 --- a/images/operator-helm-controller/internal/catalog/catalog.go +++ b/images/operator-helm-controller/internal/catalog/catalog.go @@ -19,8 +19,10 @@ package catalog import ( "context" "fmt" + "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" @@ -125,6 +127,19 @@ func (t *typed[C, CL]) Reconcile(ctx context.Context, repo source.Repository, ch desired := make(map[string]struct{}, len(charts)) for _, chart := range charts { + if errs := validation.IsValidLabelValue(chart.Name); len(errs) > 0 { + // The chart label carries the name verbatim, and it is a query surface: + // a user finds a catalog object by it. Writing a sanitized value would + // answer the wrong query, and omitting the label would hide the object + // from every such query while leaving it in the catalog. Neither is + // better than not offering a chart whose name the API cannot hold — and + // an index may legally publish one, so this must not cost the pass. + logger.Info("Skipping a chart whose name cannot be recorded on a catalog object", + "kind", t.cfg.Kind, "chart", chart.Name, "reason", strings.Join(errs, "; ")) + + continue + } + 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 diff --git a/images/operator-helm-controller/internal/catalog/catalog_test.go b/images/operator-helm-controller/internal/catalog/catalog_test.go index 697596c6..85877dd5 100644 --- a/images/operator-helm-controller/internal/catalog/catalog_test.go +++ b/images/operator-helm-controller/internal/catalog/catalog_test.go @@ -27,6 +27,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" @@ -622,3 +624,82 @@ func TestMigrationSurvivesAFailedStatusWrite(t *testing.T) { t.Fatalf("versions = %+v, want 1.0.0 and its media type carried over on the retry", got.Status.Versions) } } + +// rejectInvalidLabels makes the fake client behave like the API server on the one +// rule this test turns on: a label value outside the label grammar is rejected. The +// fake client accepts any string, so without this the failure under test cannot +// happen at all. +func rejectInvalidLabels() interceptor.Funcs { + check := func(obj client.Object) error { + for key, value := range obj.GetLabels() { + if errs := validation.IsValidLabelValue(value); len(errs) > 0 { + return apierrors.NewInvalid( + obj.GetObjectKind().GroupVersionKind().GroupKind(), obj.GetName(), + field.ErrorList{field.Invalid(field.NewPath("metadata", "labels", key), value, strings.Join(errs, "; "))}, + ) + } + } + + return nil + } + + return interceptor.Funcs{ + Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if err := check(obj); err != nil { + return err + } + + return c.Create(ctx, obj, opts...) + }, + Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if err := check(obj); err != nil { + return err + } + + return c.Patch(ctx, obj, patch, opts...) + }, + } +} + +// TestReconcileSkipsAChartWhoseNameCannotBeALabel pins that one chart the catalog +// cannot record does not cost the whole repository its synchronization. The chart +// name is copied verbatim into a label value, and an index may legally publish a +// name no label value can hold; the write is rejected, and a pass that returned +// there would leave every chart listed after it unwritten and never reach the +// pruning loop at all — so a chart removed from the repository would stay in the +// catalog forever and the repository would never report a successful sync. +func TestReconcileSkipsAChartWhoseNameCannotBeALabel(t *testing.T) { + stale := legacyAddonChart(naming.HelmClusterAddonChartName("example", "gone"), "example", "gone", + helmv1alpha1.ChartVersion{Version: "1.0.0"}) + + c := newAddonClientWithInterceptor(t, rejectInvalidLabels(), stale) + cat := adapter.NewAddonCatalog(c) + repo := addonRepo() + + // The unwritable chart comes first, so a pass that stopped on it would also be + // missing the one after it. + err := cat.Reconcile(context.Background(), repo, []repoclient.Chart{ + chart("my chart", "1.0.0"), + chart("podinfo", "6.7.1"), + }) + if err != nil { + t.Fatalf("Reconcile returned %v, want the pass to complete", err) + } + + good := &helmv1alpha1.HelmClusterAddonChart{} + if err := c.Get(context.Background(), client.ObjectKey{Name: naming.HelmClusterAddonChartName("example", "podinfo")}, good); err != nil { + t.Fatalf("the chart listed after the unwritable one was not created: %v", err) + } + + skipped := &helmv1alpha1.HelmClusterAddonChart{} + err = c.Get(context.Background(), client.ObjectKey{Name: naming.HelmClusterAddonChartName("example", "my chart")}, skipped) + if !apierrors.IsNotFound(err) { + t.Fatalf("getting the skipped chart returned %v, want NotFound", err) + } + + pruned := &helmv1alpha1.HelmClusterAddonChart{} + err = c.Get(context.Background(), client.ObjectKey{Name: stale.Name}, pruned) + if !apierrors.IsNotFound(err) { + t.Fatalf("the stale chart was not pruned (%v), so the pass never reached the pruning loop", err) + } +} From c55feeb239c1f1ac31e1f5d0d5bbb5a466f823ac Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:06:20 +0300 Subject: [PATCH 135/145] fix(repository): retry a throttled index read instead of decoding it A throttle is deliberately not terminal, but the nil that says so let the response reach the YAML decoder, where the JSON body a registry sends with it decodes into an empty index without error. The repository then read as offering no charts and its whole catalog was pruned. Signed-off-by: Ilya Drey --- .../internal/client/repository/helm.go | 10 ++++++++ .../internal/client/repository/helm_test.go | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/images/operator-helm-controller/internal/client/repository/helm.go b/images/operator-helm-controller/internal/client/repository/helm.go index 2c0a1046..071a803c 100644 --- a/images/operator-helm-controller/internal/client/repository/helm.go +++ b/images/operator-helm-controller/internal/client/repository/helm.go @@ -107,6 +107,16 @@ func (c *helmRepositoryClient) FetchCharts(ctx context.Context, url string, conf return false, nil } + // A throttle is not terminal, and the nil that says so must not be read here + // as permission to go on: the body a registry sends with it is JSON, which + // decodes into an empty index without error, and the caller would then prune + // the repository's whole catalog as if it had gone empty. + if resp.StatusCode == http.StatusTooManyRequests { + lastErr = fmt.Errorf("repository %s throttled the request (HTTP %d)", url, resp.StatusCode) + + return false, nil + } + if terminal := TerminalFromStatusCode(resp.StatusCode, url); terminal != nil { return true, terminal } diff --git a/images/operator-helm-controller/internal/client/repository/helm_test.go b/images/operator-helm-controller/internal/client/repository/helm_test.go index ae316a07..1ea78ae9 100644 --- a/images/operator-helm-controller/internal/client/repository/helm_test.go +++ b/images/operator-helm-controller/internal/client/repository/helm_test.go @@ -183,3 +183,27 @@ func TestFetchChartsHelmRecordsOCIReferences(t *testing.T) { t.Fatalf("21.0.0 oci ref = %q, want empty: the entry has no urls", got) } } + +// TestFetchChartsThrottleIsRetriedNotDecoded pins that a throttled read is never +// mistaken for an empty repository. A throttle is deliberately not terminal, and the +// body registries send with it is JSON, which decodes into an empty index without +// error — so letting it reach the decoder would report a successful fetch of zero +// charts and prune the repository's whole catalog. +func TestFetchChartsThrottleIsRetriedNotDecoded(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"errors":[{"code":"TOOMANYREQUESTS","message":"pull rate limit exceeded"}]}`)) + })) + defer srv.Close() + + charts, err := HelmRepositoryDefaultClient.FetchCharts(context.Background(), srv.URL, nil, FetchOptions{}) + if err == nil { + t.Fatalf("expected an error for repeated 429 responses, got %d charts", len(charts)) + } + if _, ok := AsTerminal(err); ok { + t.Fatalf("a throttle must stay retriable, got terminal error: %v", err) + } + if !strings.Contains(err.Error(), "429") { + t.Fatalf("expected the status code in the reported cause, got %v", err) + } +} From a6a0af40da8b602f480e66aeedbe14dc98a2cdd1 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:06:31 +0300 Subject: [PATCH 136/145] fix(controller): stop panicking when a release changes its chart source A release installed from a registry keeps an OCI digest in its history. Once the index republishes that version as an archive, the revision it is compared against is a bare version with no digest part to index into. Signed-off-by: Ilya Drey --- .../internal/services/release_service.go | 12 +++++-- .../internal/services/release_service_test.go | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/images/operator-helm-controller/internal/services/release_service.go b/images/operator-helm-controller/internal/services/release_service.go index 8e54a029..cf9e2349 100644 --- a/images/operator-helm-controller/internal/services/release_service.go +++ b/images/operator-helm-controller/internal/services/release_service.go @@ -212,8 +212,16 @@ func isDesiredChartDeployed(rel source.Release, latest *helmv2.Snapshot, artifac desired := rel.ChartRef().Version if latest.OCIDigest != "" { - ociDigestParts := strings.Split(artifactRevision, "@") - latestDigest := ociDigestParts[1] + // The history says the deployed chart came from a registry, but the revision + // the source now offers need not: a version the index republishes as an + // archive is resolved through a HelmChart, whose revision is a bare version + // with no digest to compare against. That is a chart other than the deployed + // one, not a reason to index into a revision that has no digest part. + _, latestDigest, ok := strings.Cut(artifactRevision, "@") + if !ok || len(latestDigest) < 19 { + return false + } + desiredVersion := desired + "+" + latestDigest[7:19] return latest.OCIDigest == latestDigest && latest.ChartVersion == desiredVersion diff --git a/images/operator-helm-controller/internal/services/release_service_test.go b/images/operator-helm-controller/internal/services/release_service_test.go index a0ebde0b..2bbb1936 100644 --- a/images/operator-helm-controller/internal/services/release_service_test.go +++ b/images/operator-helm-controller/internal/services/release_service_test.go @@ -126,3 +126,35 @@ func TestEnsureHelmReleaseKeepsForeignLabels(t *testing.T) { } } } + +// TestIsDesiredChartDeployedSurvivesASourceKindFlip pins that a revision carrying no +// digest is read as "not the desired chart" rather than indexed into. A release +// installed from a registry keeps an OCI digest in its history; when the repository +// index republishes that version as an archive, the chart is resolved through a +// HelmChart whose revision is a bare version, and the two shapes meet here. +func TestIsDesiredChartDeployedSurvivesASourceKindFlip(t *testing.T) { + rel := adapter.NewApplicationRelease(testApplication()) + + cases := []struct { + name string + artifactRevision string + }{ + {"a helm chart revision carries no digest", "6.7.1"}, + {"an empty revision", ""}, + {"a digest too short to slice", "6.7.1@sha256:ab"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + latest := &helmv2.Snapshot{ + Status: "deployed", + OCIDigest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ChartVersion: "6.7.1+0123456789ab", + } + + if isDesiredChartDeployed(rel, latest, tc.artifactRevision) { + t.Fatalf("isDesiredChartDeployed(_, _, %q) = true, want false", tc.artifactRevision) + } + }) + } +} From 4c80cbf57fba30de10a6a2332aee8160d4c5a894 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:06:32 +0300 Subject: [PATCH 137/145] fix(controller): narrow the system namespace rule back to the reserved three Rejecting every kube- namespace would move an addon already installed in one to permanently failed, and the webhook refuses every edit to such an object, so there is no way back short of deleting it. Signed-off-by: Ilya Drey --- .../internal/utils/namespace.go | 18 ++++++++++++++---- .../internal/utils/namespace_test.go | 6 +++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/images/operator-helm-controller/internal/utils/namespace.go b/images/operator-helm-controller/internal/utils/namespace.go index 58742fd4..42d734fa 100644 --- a/images/operator-helm-controller/internal/utils/namespace.go +++ b/images/operator-helm-controller/internal/utils/namespace.go @@ -21,9 +21,19 @@ import ( ) // 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. +// Deckhouse rather than to a user: the three namespaces Kubernetes creates for +// itself, and every d8- one. +// +// The kube- prefix as a whole is deliberately not rejected, however reserved it is. +// This predicate also decides where an addon may already be installed, and turning +// it into a prefix test would move an existing addon in, say, kube-prometheus from +// reconciling to permanently failed — with no way back, since the webhook refuses +// every edit to an object whose target namespace it rejects. func IsSystemNamespace(namespace string) bool { - return strings.HasPrefix(namespace, "kube-") || strings.HasPrefix(namespace, "d8-") + switch namespace { + case "kube-system", "kube-node-lease", "kube-public": + return true + } + + return 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 index a27e4e14..29cb9074 100644 --- a/images/operator-helm-controller/internal/utils/namespace_test.go +++ b/images/operator-helm-controller/internal/utils/namespace_test.go @@ -30,7 +30,11 @@ func TestIsSystemNamespace(t *testing.T) { {"kube-system", true}, {"kube-node-lease", true}, {"kube-public", true}, - {"kube-anything", true}, + // A reserved-looking prefix is not itself a verdict: an addon may already + // be installed in such a namespace, and this predicate is what would park + // it as permanently failed. + {"kube-anything", false}, + {"kube-prometheus", false}, {"default", false}, {"d8-operator-helm", true}, {"d8-system", true}, From 7f857136c7dcdcafc34579d88220b49d0f69ac4c Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:06:43 +0300 Subject: [PATCH 138/145] fix(rbac): reserve cluster addon writes for a cluster administrator An addon names no service account, so helm-controller applies its chart as itself over every resource in the cluster. Whoever may create one may therefore grant themselves anything, which is why the legacy access levels reserve it too. Signed-off-by: Ilya Drey --- templates/rbacv2/manage/edit.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/rbacv2/manage/edit.yaml b/templates/rbacv2/manage/edit.yaml index 2056bbb6..e2a0a1b5 100644 --- a/templates/rbacv2/manage/edit.yaml +++ b/templates/rbacv2/manage/edit.yaml @@ -10,11 +10,15 @@ metadata: 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: +# helmclusteraddons is deliberately absent, and stays reserved for a cluster +# administrator as the legacy access levels already reserve it. An addon names no +# service account, so helm-controller applies its chart as itself — with rights over +# every resource in the cluster — and whoever may create one may therefore grant +# themselves anything. A repository only publishes charts and grants nothing. - apiGroups: - helm.deckhouse.io resources: - helmclusteraddonrepositories - - helmclusteraddons - helmclusterapplicationrepositories verbs: - create From 5fce6fd005392c0e2adfd5251494cca7410ee718 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:06:44 +0300 Subject: [PATCH 139/145] fix(rbac): close the cluster addon status subresource The one kind missing from the list the policy closes. Signed-off-by: Ilya Drey --- templates/admision-policy.yaml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/templates/admision-policy.yaml b/templates/admision-policy.yaml index 8947d774..3dae1ea7 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -35,25 +35,17 @@ 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" + - "helmclusteraddons/status" validations: - expression: | request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || From 46af1b47f027227a53fce0d8eae463e84edf94fe Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:06:54 +0300 Subject: [PATCH 140/145] fix(api): drop the name minimum from the shipped addon repository kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root CEL rule is re-evaluated on every write to the object, a status patch included, so a minimum added to a kind already in use would reject every status patch of a repository created under a shorter name — and a name is immutable. The reference from an addon loses the same minimum, or such a repository would exist but stay unreferenceable. The cap stays: the name travels on as a label value. Signed-off-by: Ilya Drey --- api/v1alpha1/helm_cluster_addon.go | 7 ++++++- api/v1alpha1/helm_cluster_addon_repository.go | 10 +++++++++- crds/helmclusteraddonrepositories.yaml | 4 ++-- crds/helmclusteraddons.yaml | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/api/v1alpha1/helm_cluster_addon.go b/api/v1alpha1/helm_cluster_addon.go index 81ebef93..e0ea11ec 100644 --- a/api/v1alpha1/helm_cluster_addon.go +++ b/api/v1alpha1/helm_cluster_addon.go @@ -160,10 +160,15 @@ type HelmClusterAddonChartRef struct { // from the defined repository (e.g., "ingress-nginx" or "redis"). // +kubebuilder:validation:MinLength=1 HelmClusterAddonChartName string `json:"helmClusterAddonChart"` + // The minimum below is 1, not 3: the referenced kind shipped without a minimum of + // its own, so a repository created under a shorter name must stay referenceable. + // This note is outside the doc comment on purpose — a doc comment becomes the + // field's description in the CRD. + // Specifies the name of the HelmClusterAddonRepository custom resource that contains // the connection details and credentials for the repository where // the chart is located. - // +kubebuilder:validation:MinLength=3 + // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=63 HelmClusterAddonRepository string `json:"helmClusterAddonRepository"` // Versions holds the HelmClusterAddon chart version. diff --git a/api/v1alpha1/helm_cluster_addon_repository.go b/api/v1alpha1/helm_cluster_addon_repository.go index d636f1fa..e73e302b 100644 --- a/api/v1alpha1/helm_cluster_addon_repository.go +++ b/api/v1alpha1/helm_cluster_addon_repository.go @@ -28,13 +28,21 @@ const ( HelmClusterAddonRepositoryLabelSourceName = "helm.deckhouse.io/cluster-addon-repository" ) +// The name rule below is only the upper half of the one the application repositories +// carry. This kind shipped without any rule, and a root rule is re-evaluated on every +// write to the object — a status patch included — so a minimum added now would not +// merely forbid new short names, it would freeze every repository already created with +// one, with no way out but deleting it and its whole catalog. The cap is different: a +// name longer than a label value can hold already breaks the internal objects that +// carry it. + // 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:validation:XValidation:rule="self.metadata.name.size() <= 63",message="repository name must be at most 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" diff --git a/crds/helmclusteraddonrepositories.yaml b/crds/helmclusteraddonrepositories.yaml index b8982c44..18bb24dc 100644 --- a/crds/helmclusteraddonrepositories.yaml +++ b/crds/helmclusteraddonrepositories.yaml @@ -202,8 +202,8 @@ spec: - 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 + - message: repository name must be at most 63 characters long + rule: self.metadata.name.size() <= 63 served: true storage: true subresources: diff --git a/crds/helmclusteraddons.yaml b/crds/helmclusteraddons.yaml index de35c7ad..6a31b1e8 100644 --- a/crds/helmclusteraddons.yaml +++ b/crds/helmclusteraddons.yaml @@ -67,7 +67,7 @@ spec: the connection details and credentials for the repository where the chart is located. maxLength: 63 - minLength: 3 + minLength: 1 type: string version: description: Versions holds the HelmClusterAddon chart version. From 913735a9550e17f0ba3c24a894cd6e6139f84a4c Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:07:08 +0300 Subject: [PATCH 141/145] fix(chart-values): let the write timeout outlast the work it bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WriteTimeout runs from the moment the headers are read, and the handler's own work — a registry probe, then a chart artifact download — can take longer than it did. The client was reset instead of being told to retry or handed the values. Signed-off-by: Ilya Drey --- images/chart-values-controller/internal/server/server.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/images/chart-values-controller/internal/server/server.go b/images/chart-values-controller/internal/server/server.go index a360c68a..dcd9fc0f 100644 --- a/images/chart-values-controller/internal/server/server.go +++ b/images/chart-values-controller/internal/server/server.go @@ -50,8 +50,15 @@ const ( // 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. + // + // WriteTimeout runs from the moment the headers are read, so it covers the + // handler's own work as well as the write. It therefore has to outlast the + // slowest answer the resolver can legitimately produce — a registry probe + // followed by the download of a whole chart artifact — or a client that should + // have been told to retry, or handed the values, gets a reset connection + // instead. readTimeout = 10 * time.Second - writeTimeout = 10 * time.Second + writeTimeout = 2 * time.Minute // 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 From 7d0b5c55ede72f3c3f4367199f819aaa13421029 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:07:20 +0300 Subject: [PATCH 142/145] fix(chart-values): bound the addon repository name like the others MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind does cap its name at the length a label value holds, so a name the API server would have rejected travelled as far as an access review and came back as repository_not_found — the very answer this check exists to prevent. Only the minimum is per-kind. Signed-off-by: Ilya Drey --- .../internal/server/server.go | 33 +++++++++++-------- .../internal/server/server_test.go | 15 ++++++--- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/images/chart-values-controller/internal/server/server.go b/images/chart-values-controller/internal/server/server.go index dcd9fc0f..ffadc1c7 100644 --- a/images/chart-values-controller/internal/server/server.go +++ b/images/chart-values-controller/internal/server/server.go @@ -65,6 +65,11 @@ const ( // generous length ceiling is enforced. maxChartLen = 253 + // maxRepositoryNameLen bounds the repositoryName field at what every repository + // CRD enforces on metadata.name: the name travels on as a label value, and that + // is the length one can hold. + maxRepositoryNameLen = 63 + // 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 @@ -296,19 +301,20 @@ func accessFor(kind, namespace string) (access auth.Access, displayKind string, } } -// 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) { +// repositoryNameMinLen returns the minimum length 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. Zero means the CRD imposes none: HelmClusterAddonRepository +// shipped without a minimum and cannot gain one without invalidating the short names +// already in use, while HelmApplicationRepository and HelmClusterApplicationRepository +// both require three characters. The maximum is not per-kind — every repository CRD +// caps the name at the length a label value can hold. +func repositoryNameMinLen(kind string) int { switch strings.ToLower(kind) { case string(resolver.RepositoryKindHelmApplication), string(resolver.RepositoryKindHelmClusterApplication): - return 3, 63 + return 3 default: - return 0, 0 + return 0 } } @@ -318,12 +324,11 @@ 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 { + if minLen := repositoryNameMinLen(req.RepositoryKind); 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) + if len(req.RepositoryName) > maxRepositoryNameLen { + return fmt.Errorf("repositoryName must be at most %d characters long", maxRepositoryNameLen) } // chart and version carry no naming grammar of their own — a repository index diff --git a/images/chart-values-controller/internal/server/server_test.go b/images/chart-values-controller/internal/server/server_test.go index 70a9593d..763d1662 100644 --- a/images/chart-values-controller/internal/server/server_test.go +++ b/images/chart-values-controller/internal/server/server_test.go @@ -448,6 +448,11 @@ func TestHandleValidatesRepositoryName(t *testing.T) { body: `{"repositoryKind":"HelmClusterApplicationRepository","namespace":"team-a","repositoryName":"` + strings.Repeat("a", 64) + `","chart":"podinfo","version":"6.7.1"}`, }, + { + name: "longer than the addon repository CRD allows", + body: `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"` + + strings.Repeat("a", 64) + `","chart":"podinfo","version":"6.7.1"}`, + }, } for _, tc := range cases { @@ -462,11 +467,11 @@ func TestHandleValidatesRepositoryName(t *testing.T) { } } -// 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) { +// TestHandleAcceptsAShortAddonRepositoryName pins the half of the bound that is +// not shared: HelmClusterAddonRepository shipped without a minimum length and +// cannot gain one, so a name under 3 characters — which the application repository +// CRDs reject — must still be accepted for this kind. +func TestHandleAcceptsAShortAddonRepositoryName(t *testing.T) { rec := do(t, fakeResolver{result: resolver.Result{Outcome: resolver.OutcomeReady}}, `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"ab","chart":"podinfo","version":"6.7.1"}`) From 10b67a26cea1cf112ff27b7b56cb5532a24242c7 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:17:42 +0300 Subject: [PATCH 143/145] chore(controller): add the license header the module linter requires Signed-off-by: Ilya Drey --- .../internal/utils/repository.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/images/operator-helm-controller/internal/utils/repository.go b/images/operator-helm-controller/internal/utils/repository.go index 1399ffd9..1a2dd946 100644 --- a/images/operator-helm-controller/internal/utils/repository.go +++ b/images/operator-helm-controller/internal/utils/repository.go @@ -1,3 +1,19 @@ +/* +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 ( From 0bfdb3ea04701635b5556a4300310aa0a2396430 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 23 Sep 2026 18:17:43 +0300 Subject: [PATCH 144/145] chore: record the dmt findings the module accepts The RBACv2 capability ClusterRoles carry ru.meta.deckhouse.io/title and description by contract, so the Cyrillic the linter finds is the annotation Deckhouse reads. The one finding left standing is noted at the top of the file: it is a warning, and the linter reporting it takes no exclusion list. Signed-off-by: Ilya Drey --- .dmtlint.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.dmtlint.yaml b/.dmtlint.yaml index 4502d8d3..ed0d1632 100644 --- a/.dmtlint.yaml +++ b/.dmtlint.yaml @@ -1,3 +1,11 @@ +# One finding is knowingly left standing and cannot be recorded below: markdownlint +# reports MD013/line-length on docs/README.ru.md, where the paragraph on what +# creating a HelmApplication grants runs past the limit. Breaking it would split a +# single argument the reader has to follow whole. The documentation linter takes no +# exclusion list — only a severity — so silencing that one line would mean turning +# off every documentation check. It stays a warning, and a warning does not fail the +# lint. + linters-settings: openapi: exclude-rules: @@ -16,6 +24,15 @@ linters-settings: - "spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.postRenderStrategy" - "properties.logLevel" - "properties.logFormat" + # The RBACv2 capability ClusterRoles are bilingual by contract: Deckhouse reads + # the module's capabilities out of ru.meta.deckhouse.io/title and + # ru.meta.deckhouse.io/description, which is where the Cyrillic the linter finds + # lives. Excluded as a directory rather than file by file, because every template + # this tree will ever hold carries the same pair of annotations. + no-cyrillic: + exclude-rules: + directories: + - "templates/rbacv2" rbac: exclude-rules: wildcards: From 3b3fda1f3f8a62791f1d116598b68eaab75f8d72 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 24 Sep 2026 00:23:18 +0300 Subject: [PATCH 145/145] feat: prohibit client namespace modification under application sa Signed-off-by: Ilya Drey --- templates/admision-policy.yaml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/templates/admision-policy.yaml b/templates/admision-policy.yaml index 3dae1ea7..e1a31de0 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -70,4 +70,37 @@ spec: matchResources: namespaceSelector: {} objectSelector: {} +--- +{{- $applicationSubject := printf "system:serviceaccount:d8-%s:hap-" .Chart.Name }} +apiVersion: {{ $apiVersion }} +kind: ValidatingAdmissionPolicy +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: operator-helm-application-namespace-policy +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE", "DELETE"] + resources: + - "namespaces" + - "namespaces/status" + - "namespaces/finalize" + validations: + - expression: '!request.userInfo.username.startsWith("{{ $applicationSubject }}")' + message: "An application may not modify or delete the namespace it is deployed into." +--- +apiVersion: {{ $apiVersion }} +kind: ValidatingAdmissionPolicyBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: operator-helm-application-namespace-policy-binding +spec: + policyName: operator-helm-application-namespace-policy + validationActions: ["Deny"] + matchResources: + namespaceSelector: {} + objectSelector: {} {{- end }}