From fee4e24a22b5f58ef48a60c12dd33737fb610121 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Thu, 10 Sep 2026 11:49:01 +0300 Subject: [PATCH 001/113] 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 b6c8286..f6f61ab 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 2b5f8df..ca1483f 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/113] 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 29edccb..a3748c3 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 5b3bbb8..85c7c27 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 0000000..33fc518 --- /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 911c8ea..ecf064d 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 0000000..3520b63 --- /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 0000000..e181f0a --- /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 e8deecc..7d8bed2 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 aca9bbb..0f6af37 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 8e4f30f..a39267d 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 0000000..faef480 --- /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 0000000..2c3dfec --- /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 0000000..37909d2 --- /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 1e7fd46..cca316c 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 e02b8b1..27bd40d 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 0000000..e7c8797 --- /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 0000000..ac54c01 --- /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/113] 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 a3748c3..ca446f8 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 85c7c27..0b7d586 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 0000000..209669e --- /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 ecf064d..97cf2c9 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 0000000..9baf4e3 --- /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 0000000..fc1dfef --- /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 7d8bed2..9fc6275 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 0f6af37..7048a32 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 a39267d..75e3142 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 0000000..16fe9cf --- /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 0000000..1f1d10b --- /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 0000000..55be0b3 --- /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 cca316c..18aa4bc 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 27bd40d..57a9ce3 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 0000000..b4437c2 --- /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 0000000..b5d6ab0 --- /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/113] 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 ca446f8..e304376 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 0b7d586..3453fff 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 0000000..0c0ac65 --- /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 97cf2c9..2d16afe 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 0000000..095695a --- /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 0000000..76526c7 --- /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 9fc6275..d847810 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 7048a32..7b8de6e 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 75e3142..29a8a49 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 0000000..78c9f05 --- /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 2b83633..59e9236 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 0000000..1de0d22 --- /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 0000000..b680eda --- /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 3c042c1..5bfa1c0 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 18aa4bc..2826a6c 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 57a9ce3..894f4dd 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 0000000..f34f135 --- /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 0000000..6083cfa --- /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 5b7b86a..40b5773 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/113] 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 e304376..e898760 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 3453fff..5b06077 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 0000000..c05bdb6 --- /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 2d16afe..18f92d8 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 0000000..bc8ea2f --- /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 0000000..ce60ced --- /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 d847810..6b4bfc4 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 7b8de6e..d91417d 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 29a8a49..85fc597 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 0000000..01b6382 --- /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 0000000..290e6f4 --- /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 0000000..645e883 --- /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 2826a6c..e794ce5 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 894f4dd..d5ae603 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 0000000..4c25578 --- /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 0000000..8a9002e --- /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 40b5773..f54f432 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/113] 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 e898760..cbbd9b3 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 5b06077..9668e6d 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 0000000..e6412e0 --- /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 18f92d8..1f6c6f3 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 0000000..1527b67 --- /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 0000000..b2318ca --- /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 6b4bfc4..09e4800 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 d91417d..45ec419 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 85fc597..eb9d1a4 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 0000000..c815418 --- /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 0000000..4405d49 --- /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 0000000..b273bac --- /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 e794ce5..79b0d25 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 d5ae603..a0a9986 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 0000000..b5871ee --- /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 0000000..63bf400 --- /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/113] 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 4405d49..5812c86 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 b273bac..f65080a 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 63bf400..61fa3c0 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/113] 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 5812c86..41540b5 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 b5871ee..76904f5 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 61fa3c0..d522b1e 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/113] 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 2c3dfec..b567e18 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 7e24b14..56713b2 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 1f1d10b..800ecc6 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 0000000..e996138 --- /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 a0a9986..bcb06dd 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 38514e3..f21655f 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 bebc8f7..9cd531b 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 936aee5..b83ea92 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 42e35ea..b940f78 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 21d8f40..e2ed01c 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 6891361..d9a85f7 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 87f51ff..708577a 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 316f6a1..973c115 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 6fb33b2..84b4c7f 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 0ff4bd5..2f3786e 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 d7179c5..b60df68 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 ce5150b..712125d 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 f4e9706..d93ba1e 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 6095506..e033252 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 6eb476a..b557f12 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 588e8d4..1dd7f5e 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/113] 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 cadf98e..1ba2706 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 0000000..b0fc040 --- /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 1de0d22..aa8dd42 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 5bfa1c0..d41867b 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 290e6f4..7536a86 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 645e883..26895c6 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 bcb06dd..4ea4377 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 34da442..9e8e556 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 11302f5..6338136 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 b780c36..9816d95 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 f21655f..c268fe2 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 1609160..206134a 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 9cd531b..a586e08 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 b9287e6..330a461 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 2f3786e..19b8a3f 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 77dc56e..b7968a9 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 2f1fa6b..0e39f84 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 009a01a..ba4d8ae 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 b60df68..4280a3b 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/113] 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 59e9236..3cab49c 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 12edbc5..2bbe002 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 0000000..4455921 --- /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/113] 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 0000000..8e98ecc --- /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 0000000..2f8ec26 --- /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 0000000..d65396c --- /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 0000000..3cb0430 --- /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 0000000..54f9d0c --- /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 0000000..ed5982e --- /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 0000000..861d383 --- /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 0000000..e0f2f35 --- /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/113] 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 0000000..4764c28 --- /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 0000000..7adc500 --- /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 0000000..9a62b72 --- /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 0000000..1a2ecf4 --- /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 0000000..cbdb44c --- /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 0000000..e18317f --- /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 0000000..1c7a87b --- /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 0e39f84..4037583 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/113] 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 94b3ac2..aea372d 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 708577a..d1cb650 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 973c115..84e0459 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 64f8976..10731bc 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 84b4c7f..52fee0e 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/113] 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 3a8ffd9..03fdc4e 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 aea372d..677f42a 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 e2ed01c..848b8e1 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 330a461..438c7bb 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 b7968a9..45f421c 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 4037583..0ca4acb 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/113] 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 03fdc4e..6096809 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 b83ea92..6e854d5 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 b940f78..30db309 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 2594c6f..da4bb70 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 677f42a..b222cfc 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 848b8e1..9382383 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 d9a85f7..f72168c 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/113] 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 cd2e936..cc6543f 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 93517cb..4f789c3 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/113] 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 fdf9f8d..4c51e33 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 0000000..07a763d --- /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 0000000..5f8ed3f --- /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 1b8db98..3c0eed3 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/113] 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 26895c6..c3878e5 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 4764c28..5c827c4 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 9a62b72..23d476a 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 07a763d..3e5953c 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 5f8ed3f..1ef8bdc 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 9382383..425373f 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 e0f2f35..6afd060 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 cc6543f..982f331 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 3c0eed3..5346a01 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/113] 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 4db359c..57e2b1e 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 23d476a..59c684a 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 1a2ecf4..7214833 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 ed5982e..ea25b5d 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 0000000..56a30c1 --- /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 2bbe002..82bbe78 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 4455921..d0f9b6f 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/113] 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 0000000..3736e1d --- /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 0000000..a40e50a --- /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 7adc500..af3b23b 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/113] 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 206134a..5253e44 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 a586e08..31e2e15 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 eeb1a05..4e942ab 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 f5e844b..e0032b7 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 dc50495..24dcf55 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 19b8a3f..07bfbad 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 e8fe763..fa43537 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 ba4d8ae..235b72a 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 4280a3b..1500dbe 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/113] 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 5c827c4..f4a1c8e 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 6096809..98e352a 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 5253e44..4b1f794 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 425373f..19f4979 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 0000000..077541c --- /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 0000000..c41a42e --- /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 438c7bb..213d440 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 07bfbad..6649a1e 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/113] 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 4b1f794..7c45085 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 ccccdee..a1d7a0b 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 0000000..ef02748 --- /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 0000000..7b3ee87 --- /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 4bb12af..d399c58 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/113] 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 8e625f2..b2fdb45 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 1e12d89..c8fedcb 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 7c45085..d698f9f 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 31e2e15..dd3681a 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/113] 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 0000000..904325e --- /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 0000000..8878308 --- /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 f4a1c8e..bf6e074 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 af3b23b..c8d7ec6 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 7214833..7d71ce7 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 aa9114e..68ea78a 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 19f4979..941e622 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/113] 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 0000000..93fab3d --- /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 0000000..be5dfae --- /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/113] 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 3e5953c..8f9060a 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 1ef8bdc..eadf5c5 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 941e622..96e5256 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 982f331..183cb85 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 4f789c3..d6cd945 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/113] 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 4c51e33..43d97ef 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 0000000..ee231b5 --- /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 0000000..34cf6f7 --- /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 5346a01..23bc5b7 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 460f37f..346bfc3 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/113] 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 41540b5..b7719c7 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 76904f5..be484a5 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 d522b1e..7285d18 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/113] 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 904325e..0e7f01c 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 7d71ce7..4ed24d9 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 0000000..a6e99fe --- /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 d698f9f..a92b38d 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 dd3681a..9998d3e 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 93fab3d..ae3bf3b 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 be5dfae..8563e71 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 a1d7a0b..a1f2fe2 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 4e942ab..8ef1ab1 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 e0032b7..ce6644d 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 fa43537..40b9f8f 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 0000000..996b8c8 --- /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 34cf6f7..6afc6e4 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 0000000..a1d0700 --- /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 f54f432..a784522 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/113] 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 8c96831..66d5370 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 7267bd9..3b6ec09 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 9816d95..0839698 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 c268fe2..b9f095a 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/113] 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 0000000..6145523 --- /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 0000000..7cb785c --- /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/113] 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 6145523..3f5118c 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 7cb785c..0b9d87b 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 0839698..8caa8cd 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 b9f095a..41566d4 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/113] 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 827f477..8de6d6d 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 0000000..c55bbc6 --- /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 db0147d..52767d1 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 035d680..4b0b449 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/113] 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 3f5118c..6dd4d35 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 0b9d87b..193880a 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 8caa8cd..6b28552 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 41566d4..5a89a1f 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/113] 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 52767d1..292784f 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 4b0b449..c5f19db 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/113] 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 c55bbc6..8a748b2 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 66d5370..f036169 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 6b28552..345c418 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 292784f..c47665a 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/113] 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 3b6ec09..3f26754 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 193880a..37d59ea 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/113] 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 06f859b..5f2b8b5 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/113] 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 0000000..a290bb2 --- /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 9c64554..dc7655c 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/113] 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 d0f9b6f..cdcfdd1 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 0000000..c78f9ee --- /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 0000000..e1e6900 --- /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 a290bb2..b544713 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/113] 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 e5323d9..5fdc65a 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 0000000..4ba1cf7 --- /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/113] 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 5fdc65a..948251f 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 0000000..8c738e9 --- /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 0000000..e48c140 --- /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 0000000..83d1ad9 --- /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/113] 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 cdcfdd1..f4c1247 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 8c738e9..19fb53b 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 e48c140..7af193f 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 83d1ad9..f346868 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 c78f9ee..f0a06bd 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 e1e6900..9df12a1 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 b544713..89a1581 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/113] 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 f4c1247..755eb36 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 4ba1cf7..a5a54c5 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 f0a06bd..0a1e3b9 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 9df12a1..d794b30 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 89a1581..3d42853 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/113] 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 0e7f01c..73ef127 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 d65396c..c30a70f 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 4ed24d9..9fae767 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/113] 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 a784522..c7b7113 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 23bc5b7..ab04069 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/113] 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 59c684a..a092561 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 9fae767..b61f50d 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/113] 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 699d6cc..e111916 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 5e54c64..5116879 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 929ebc3..ccae4f0 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 c15c6cc..90b737a 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/113] 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 ab04069..510497b 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/113] 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 63d1f0c..029c0a9 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 fcc81cc..8ff19d2 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/113] 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 6f92805..2e3e9a4 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 04a9584..4369891 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 2766427..a2e2a5b 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 f95e456..aa21aa4 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 2766427..a2e2a5b 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 8c5d9e7..9887cf4 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/113] 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 0000000..994e657 --- /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/113] 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 7364760..2bb3d93 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/113] 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 2e3e9a4..215b66c 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 4369891..0e71fed 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 a2e2a5b..421f2d9 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 aa21aa4..0666436 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 a2e2a5b..421f2d9 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 9887cf4..4281621 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/113] 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 f6f61ab..f377118 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 ca1483f..579bc64 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 82bbe78..7a02813 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 755eb36..4449d66 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 0a1e3b9..27d8d1d 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 d794b30..f6a6150 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/113] 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 f377118..b9fcf2a 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 579bc64..c049f83 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 37d59ea..8ddeee0 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/113] 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 c47665a..00fa449 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 c5f19db..94a9438 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/113] 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 4449d66..d48221e 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/113] 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 ccae4f0..737d8c1 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 90b737a..00d49d1 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 0000000..5be5756 --- /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 0000000..1d24e2a --- /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 0000000..98c5427 --- /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 0000000..abe70db --- /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 0000000..657d625 --- /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/113] 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 ae3bf3b..7a7ce1a 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 8563e71..d8bd034 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/113] 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 43d97ef..17e22b3 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 7a7ce1a..1474ab7 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/113] 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 ef02748..4ed481c 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/113] 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 a1d0700..f1386b8 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/113] 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 b9fcf2a..bb2c67a 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 c049f83..7fbd2a1 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 8ddeee0..6591efd 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/113] 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 a092561..a804626 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 b61f50d..0354448 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/113] 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 bb2c67a..a266bed 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 7fbd2a1..ed4b697 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 6591efd..a5b82ef 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/113] 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 215b66c..8baba6f 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 0e71fed..b7eb60e 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 421f2d9..7bf197e 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 0666436..448a6d3 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 421f2d9..7bf197e 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 4281621..222328a 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/113] 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 8baba6f..754d9a2 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/113] 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 a804626..adcb032 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 0354448..942f16a 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/113] 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 5be5756..de3afc6 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 1d24e2a..16d3e83 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/113] 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 a266bed..010c985 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 ed4b697..f4b120d 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/113] 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 1474ab7..448b4b3 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/113] 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 41cb6a8..8ab7870 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 93f339d..fd28935 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 c58223c..4114392 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 8e98ecc..63ec67b 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 73ef127..fc0bf74 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 2f8ec26..69d4087 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 c30a70f..815ea35 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 228b34a..2c0a104 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 c8fedcb..e99afa7 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 98e352a..e671df7 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 9998d3e..43e5e07 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 235b72a..59baedc 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 a5a54c5..6f2190e 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/113] 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 86df948..f8828c2 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 27356f1..c6f716c 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 35ca802..81bc154 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/113] 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 942f16a..bc0870c 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 0ca4acb..fa0d197 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/113] 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 0000000..b867554 --- /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/113] 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 adcb032..6c8ce99 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 bc0870c..b8d4be0 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 b222cfc..68f18bb 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 96e5256..5272a5f 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 45f421c..774c894 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 ea25b5d..2dd54dc 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/113] 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 94a1ea9..4e4628f 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/113] 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 6e854d5..c14a7e4 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 68f18bb..a786c93 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 5272a5f..5efcc4f 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/113] 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 774c894..f6bcc07 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/113] 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 2bb3d93..96d12c3 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/113] 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 a97c0f6..515accc 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/113] 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 c14a7e4..2854cac 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 5efcc4f..dcd4f9f 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/113] 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 737d8c1..7abf246 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 00d49d1..9b5687c 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 0000000..85a6eb2 --- /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 abe70db..b077225 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 657d625..6667f7d 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/113] 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 b8d4be0..697596c 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 a786c93..2ccc2f8 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 dcd4f9f..2752504 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/113] 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 a92b38d..6a3e22c 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 43e5e07..ad7ff42 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/113] 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 6a3e22c..42196a3 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 ad7ff42..4222fe8 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/113] 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 010c985..fe77e21 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 f4b120d..2878c46 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/113] 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 00fa449..a360c68 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 94a9438..70a9593 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/113] 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 fc0bf74..c613a80 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 8878308..b44b957 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 7a02813..31f416c 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 d48221e..36d453e 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 27d8d1d..440d495 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 f6a6150..56a3857 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/113] 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 81bc154..4c44600 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 0000000..5e24a05 --- /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 f346868..6ccaae7 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/113] 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 56713b2..af28287 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 f9be857..26a9a47 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/113] 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 f8828c2..8e198b5 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/113] 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 c7b7113..41a5a19 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/113] 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 b404dfb..deed953 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 3d42853..4cf85a8 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/113] 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 7abf246..9ce02a6 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 9b5687c..ff5919d 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/113] 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 510497b..43b2ee1 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/113] 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 e99afa7..831b55e 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 4222fe8..1b1c8a2 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 4ed481c..de96ceb 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 7b3ee87..e7bfbe9 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 6afc6e4..46c5ec9 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 f1386b8..810a3bc 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/113] 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 c6f716c..ca4f5fe 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 0000000..aebe83a --- /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/113] 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 17e22b3..8d5aeee 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/113] 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 46c5ec9..289f1da 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/113] 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 43b2ee1..2f8ca0b 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/113] 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 4c44600..58742fd 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 5e24a05..a27e4e1 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 6ccaae7..f346868 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/113] 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 e996138..2641bd2 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 4ea4377..4a24d4a 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 e7c8797..7219f5f 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 5030c23..0005a86 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 b4437c2..5481b54 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 ac54c01..78109a9 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 26a9a47..19db93f 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 b5d6ab0..77b4e23 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 2854cac..f590d4b 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 30db309..146ceaf 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 deb756a..5c9996b 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 f6bcc07..57d1b33 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/113] 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 9ce02a6..754252c 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 ff5919d..f8a8ccf 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 de3afc6..0000000 --- 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 16d3e83..0000000 --- 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 98c5427..0000000 --- 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 85a6eb2..0000000 --- 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 b077225..0000000 --- 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 6667f7d..486b307 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/113] 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 754252c..54e721e 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 f8a8ccf..38aba72 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 486b307..d4cf595 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/113] 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 54e721e..69114d1 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 38aba72..318e6c2 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 d4cf595..351f5dd 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/113] 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 69114d1..9d1597f 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 318e6c2..071eb8c 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 351f5dd..f44f277 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/113] 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 9d1597f..cb526ca 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 071eb8c..eb2954f 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 f44f277..7e85d10 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/113] 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 7e85d10..d70fab6 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/113] 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 3e86481..4502d8d 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 96d12c3..560f8ee 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 754d9a2..98d9ae6 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 06ab726..06cb73d 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 20bd22c..eb0e03e 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 08f3e50..0000000 --- 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 0000000..2d5f8a6 --- /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 eb2954f..c6f5740 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 460b441..da74f8e 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 ad77d86..d7ce3d2 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 df13083..83ac809 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 3da490b..49f48bd 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 1ce8ad9..d9bcb4b 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 d9b23e4..b4ce9f8 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 345c418..c99e30b 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 5a89a1f..3c5b5f7 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 5619cf2..b251fa4 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 0000000..48c7bd2 --- /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 e8e45e0..9beecb9 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 876ed3f..d0be66b 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 8d5aeee..39012f7 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 57e2b1e..9cb6e47 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 bbec7d6..63dc957 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 ee231b5..eba75e8 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 8f9060a..b237b0e 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 831b55e..3e4e413 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 e671df7..b0b89b8 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 eadf5c5..fa3a6e1 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 42196a3..19e5e62 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 1b1c8a2..7dbd99a 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 2752504..8e54566 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 d1cb650..fe2d0ff 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 8ef1ab1..d448d65 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 ce6644d..12546f7 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 077541c..b24347d 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 c41a42e..25275e1 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 10731bc..623b224 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 52fee0e..2a03be9 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 24dcf55..8cff6bd 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 213d440..feea453 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 6649a1e..99fc6f3 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 40b9f8f..083c5b3 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 996b8c8..4a4f298 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 573e684..db14967 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 23be831..e1ad4d8 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 41a5a19..8947d77 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 00361d5..596a11d 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 1a422ed..e986e1a 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 0dda0bf..e22b90c 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 41a92b9..08cb7c8 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 ead1ca5..fc15dd2 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 e0b5dc4..175cde6 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 593ad9d..6e47470 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 6c414ae..96e9f44 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 361f572..1bfed59 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 6e8e306..c7f239c 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 1d1bfa2..85df170 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 0f7a32c..8550270 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 0000000..9e05226 --- /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 0000000..7bf197e --- /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 0000000..d7154b0 --- /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 0000000..56a75c7 --- /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 0000000..fed07c3 --- /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 0000000..da68243 --- /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 0000000..56076cf --- /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 0000000..cb3bbe6 --- /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") + } +}