Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ 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, repoType, false)
}

// Both services embed the same BaseRepoService with the same target namespace,
Expand Down Expand Up @@ -145,7 +145,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, repoType, in.Attempted)
}

// finish applies the decision and consumes the force annotation when an attempt
Expand All @@ -155,6 +155,7 @@ func (r *Reconciler) finish(
ctx context.Context,
repo *helmv1alpha1.HelmClusterAddonRepository,
in Inputs,
repoType utils.InternalRepositoryType,
attempted bool,
) (reconcile.Result, error) {
decision := Evaluate(in)
Expand All @@ -172,6 +173,17 @@ func (r *Reconciler) finish(
}

if attempted {
// An oci:// repository has no internal source object of its own, so a force
// request reaches the artifacts only through the addons' OCIRepositories.
// This runs before the annotation is consumed: a failure leaves the request
// in place to be retried. The helm:// path needs no equivalent - there the
// internal HelmRepository carries the request.
if repoType == utils.InternalOCIRepository && repo.ForceReconcileRequired() {
if err := r.ociRepositoryService.ForceReconcileInternalRepositories(ctx, repo.Name); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to force reconcile internal oci repositories: %w", err)
}
}

if err := r.reconcileForceAnnotation(ctx, client.ObjectKeyFromObject(repo)); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to reconcile force annotation: %w", err)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"time"

"github.com/Masterminds/semver/v3"
"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"
Expand Down Expand Up @@ -82,6 +83,11 @@ func newReconciler(t *testing.T, stub *stubRepoClient, objects ...client.Object)
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()

factory := func(_ utils.InternalRepositoryType) (repoclient.ClientInterface, error) {
Expand Down Expand Up @@ -334,3 +340,98 @@ func TestReconcileDeleteCleansUpWhenURLNoLongerParses(t *testing.T) {
}
}
}

// TestReconcileForcedOCIRepositoryForcesAddonSources covers a force request on an
// oci:// repository. Unlike the helm:// path, where the internal HelmRepository
// carries the request and the HelmCharts follow it, an OCI repository has no
// internal source object of its own: the artifacts are pulled by the per-addon
// OCIRepositories, so the request must be pushed onto those.
func TestReconcileForcedOCIRepositoryForcesAddonSources(t *testing.T) {
repo := ociRepository()
addon := &helmv1alpha1.HelmClusterAddon{
ObjectMeta: metav1.ObjectMeta{Name: "consumer", Generation: 1},
Spec: helmv1alpha1.HelmClusterAddonSpec{
Namespace: "app",
Chart: helmv1alpha1.HelmClusterAddonChartRef{
HelmClusterAddonRepository: repo.Name,
HelmClusterAddonChartName: "podinfo",
Version: "6.7.1",
},
},
}
source := &sourcev1.OCIRepository{
ObjectMeta: metav1.ObjectMeta{
Name: utils.GetInternalOCIRepositoryName(addon.Name),
Namespace: helmv1alpha1.TargetNamespace,
},
}
stub := &stubRepoClient{charts: []repoclient.Chart{{
Name: "podinfo",
Versions: []repoclient.ChartVersion{{Version: semver.MustParse("6.7.1")}},
}}}

r, c := newReconciler(t, stub, repo, addon, source)
reconcileUntilStable(t, r, repo.Name)

stored := &helmv1alpha1.HelmClusterAddonRepository{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(repo), stored); err != nil {
t.Fatalf("getting repository: %v", err)
}
stored.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"}
if err := c.Update(context.Background(), stored); err != nil {
t.Fatalf("annotating repository: %v", err)
}

if _, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Name: repo.Name},
}); err != nil {
t.Fatalf("Reconcile returned %v", err)
}

forced := &sourcev1.OCIRepository{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(source), forced); err != nil {
t.Fatalf("getting internal oci repository: %v", err)
}
if forced.Annotations[meta.ReconcileRequestAnnotation] == "" {
t.Errorf("%s must be pushed onto the addon source by a forced repository", meta.ReconcileRequestAnnotation)
}
}

// TestReconcileUnforcedOCIRepositoryLeavesAddonSources is the complement: a
// scheduled synchronization must not stamp the addon sources, or every pass would
// make the source controller re-pull every artifact of the repository.
func TestReconcileUnforcedOCIRepositoryLeavesAddonSources(t *testing.T) {
repo := ociRepository()
addon := &helmv1alpha1.HelmClusterAddon{
ObjectMeta: metav1.ObjectMeta{Name: "consumer", Generation: 1},
Spec: helmv1alpha1.HelmClusterAddonSpec{
Namespace: "app",
Chart: helmv1alpha1.HelmClusterAddonChartRef{
HelmClusterAddonRepository: repo.Name,
HelmClusterAddonChartName: "podinfo",
Version: "6.7.1",
},
},
}
source := &sourcev1.OCIRepository{
ObjectMeta: metav1.ObjectMeta{
Name: utils.GetInternalOCIRepositoryName(addon.Name),
Namespace: helmv1alpha1.TargetNamespace,
},
}
stub := &stubRepoClient{charts: []repoclient.Chart{{
Name: "podinfo",
Versions: []repoclient.ChartVersion{{Version: semver.MustParse("6.7.1")}},
}}}

r, c := newReconciler(t, stub, repo, addon, source)
reconcileUntilStable(t, r, repo.Name)

untouched := &sourcev1.OCIRepository{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(source), untouched); err != nil {
t.Fatalf("getting internal oci repository: %v", err)
}
if _, found := untouched.Annotations[meta.ReconcileRequestAnnotation]; found {
t.Errorf("%s must not be pushed onto the addon source by a scheduled synchronization", meta.ReconcileRequestAnnotation)
}
}
20 changes: 20 additions & 0 deletions images/operator-helm-controller/internal/services/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ package services
import (
"context"
"fmt"
"time"

"github.com/werf/3p-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"
Expand Down Expand Up @@ -267,3 +269,21 @@ func (s *BaseRepoService) reconcileTLSSecret(ctx context.Context, repo *helmv1al

return nil
}

// setReconcileRequestAnnotations stamps the flux reconcile/force request
// annotations so the controller owning obj reconciles it immediately instead of
// waiting for its next interval. Both are stamped with the same timestamp:
// ForceRequestAnnotation is only honoured when it matches
// ReconcileRequestAnnotation.
func setReconcileRequestAnnotations(obj metav1.Object) {
annotations := obj.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}

ts := time.Now().UTC().Format(time.RFC3339)
annotations[meta.ForceRequestAnnotation] = ts
annotations[meta.ReconcileRequestAnnotation] = ts

obj.SetAnnotations(annotations)
}
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ func (s *ChartService) CleanupHelmChart(ctx context.Context, addon *helmv1alpha1
}

func applyHelmChartSpec(addon *helmv1alpha1.HelmClusterAddon, existing *sourcev1.HelmChart) {
if addon.ForceReconcileRequired() {
setReconcileRequestAnnotations(existing)
}

if existing.Labels == nil {
existing.Labels = map[string]string{}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
Copyright 2026 Flant JSC.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES 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"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1"
"github.com/deckhouse/operator-helm/internal/utils"
)

func newChartService(t *testing.T, objects ...client.Object) (*ChartService, client.Client) {
t.Helper()

scheme := testScheme(t)
if err := sourcev1.AddToScheme(scheme); err != nil {
t.Fatalf("registering source scheme: %v", err)
}

c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()

return NewChartService(c, scheme, testNamespace), c
}

// 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
// instead of only nudging the HelmRelease.
func TestEnsureHelmChartForcesReconcileFromAddon(t *testing.T) {
addon := testAddon()
addon.Annotations = map[string]string{helmv1alpha1.AnnotationForceReconcile: "2026-01-01T00:00:00Z"}
service, c := newChartService(t, addon)

service.EnsureHelmChart(context.Background(), addon)

chart := &sourcev1.HelmChart{}
key := client.ObjectKey{Name: utils.GetInternalHelmChartName(addon.Name), Namespace: testNamespace}
if err := c.Get(context.Background(), key, chart); err != nil {
t.Fatalf("helm chart was not created: %v", err)
}

if chart.Annotations[meta.ReconcileRequestAnnotation] == "" {
t.Errorf("%s must be stamped on the helm chart", meta.ReconcileRequestAnnotation)
}
if chart.Annotations[meta.ForceRequestAnnotation] == "" {
t.Errorf("%s must be stamped on the helm chart", meta.ForceRequestAnnotation)
}
}

// TestEnsureHelmChartDoesNotForceReconcileWithoutAnnotation is the complement:
// an unannotated addon must not stamp a fresh timestamp on every pass, which
// would make the source controller re-reconcile continuously.
func TestEnsureHelmChartDoesNotForceReconcileWithoutAnnotation(t *testing.T) {
addon := testAddon()
service, c := newChartService(t, addon)

service.EnsureHelmChart(context.Background(), addon)

chart := &sourcev1.HelmChart{}
key := client.ObjectKey{Name: utils.GetInternalHelmChartName(addon.Name), Namespace: testNamespace}
if err := c.Get(context.Background(), key, chart); err != nil {
t.Fatalf("helm chart was not created: %v", err)
}

if _, found := chart.Annotations[meta.ReconcileRequestAnnotation]; found {
t.Errorf("%s must not be stamped without a force request", meta.ReconcileRequestAnnotation)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ package services
import (
"context"
"fmt"
"time"

"github.com/werf/3p-fluxcd-pkg/apis/meta"
sourcev1 "github.com/werf/nelm-source-controller/api/v1"
corev1 "k8s.io/api/core/v1"
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"
Expand All @@ -32,6 +32,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"

helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1"
"github.com/deckhouse/operator-helm/internal/index"
"github.com/deckhouse/operator-helm/internal/manager/status"
"github.com/deckhouse/operator-helm/internal/utils"
)
Expand Down Expand Up @@ -140,6 +141,50 @@ func (s *OCIRepoService) EnsureInternalOCIRepository(
}
}

// ForceReconcileInternalRepositories stamps the reconcile request annotations on
// the internal OCIRepository of every addon that references repoName.
//
// An oci:// repository has no internal source object of its own: the artifact is
// pulled per addon, so a force request on the repository reaches the artifacts
// only through its addons' OCIRepositories. The helm:// path needs no equivalent -
// there the internal HelmRepository carries the request and its HelmCharts follow
// the re-indexed source on their own.
//
// An addon whose internal OCIRepository does not exist yet is skipped: the force
// request must not be blocked by an addon that has not reached the point of
// building one.
func (s *OCIRepoService) ForceReconcileInternalRepositories(ctx context.Context, repoName string) error {
addons := &helmv1alpha1.HelmClusterAddonList{}
if err := s.Client.List(ctx, addons, client.MatchingFields{index.AddonRepository: repoName}); err != nil {
return fmt.Errorf("listing addons of repository %s: %w", repoName, err)
}

for i := range addons.Items {
name := utils.GetInternalOCIRepositoryName(addons.Items[i].Name)
nn := types.NamespacedName{Name: name, Namespace: s.TargetNamespace}

ociRepo := &sourcev1.OCIRepository{}
if err := s.Client.Get(ctx, nn, ociRepo); err != nil {
if apierrors.IsNotFound(err) {
continue
}

return fmt.Errorf("getting internal oci repository %s: %w", name, err)
}

base := ociRepo.DeepCopy()
setReconcileRequestAnnotations(ociRepo)

// The internal repository may be removed between the get and the patch,
// which is the same case as the one skipped above.
if err := s.Client.Patch(ctx, ociRepo, client.MergeFrom(base)); client.IgnoreNotFound(err) != nil {
return fmt.Errorf("requesting reconciliation of internal oci repository %s: %w", name, err)
}
}

return nil
}

func (s *OCIRepoService) CleanupOCIRepository(ctx context.Context, repoName string) error {
resources := []struct {
name string
Expand Down Expand Up @@ -190,13 +235,8 @@ func applyOCIRepositorySpec(
mediaType string,
existing *sourcev1.OCIRepository,
) {
if repo.ForceReconcileRequired() {
if existing.Annotations == nil {
existing.Annotations = map[string]string{}
}
ts := time.Now().UTC().Format(time.RFC3339)
existing.Annotations[meta.ForceRequestAnnotation] = ts
existing.Annotations[meta.ReconcileRequestAnnotation] = ts
if addon.ForceReconcileRequired() {
setReconcileRequestAnnotations(existing)
}

existing.Spec.URL = repo.Spec.URL
Expand Down
Loading
Loading