/* Copyright 2020 The Flux authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package controller import ( "context" "crypto/tls" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "strings" "testing" "time" . "github.com/onsi/gomega" "github.com/opencontainers/go-digest" helmgetter "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/repo" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" kstatus "github.com/fluxcd/cli-utils/pkg/kstatus/status" "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/helmtestserver" "github.com/fluxcd/pkg/runtime/conditions" conditionscheck "github.com/fluxcd/pkg/runtime/conditions/check" "github.com/fluxcd/pkg/runtime/patch" sourcev1 "github.com/fluxcd/source-controller/api/v1" helmv1 "github.com/fluxcd/source-controller/api/v1beta2" "github.com/fluxcd/source-controller/internal/cache" intdigest "github.com/fluxcd/source-controller/internal/digest" "github.com/fluxcd/source-controller/internal/helm/getter" "github.com/fluxcd/source-controller/internal/helm/repository" intpredicates "github.com/fluxcd/source-controller/internal/predicates" sreconcile "github.com/fluxcd/source-controller/internal/reconcile" "github.com/fluxcd/source-controller/internal/reconcile/summarize" stls "github.com/fluxcd/source-controller/internal/tls" ) func TestHelmRepositoryReconciler_deleteBeforeFinalizer(t *testing.T) { g := NewWithT(t) namespaceName := "helmrepo-" + randStringRunes(5) namespace := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{Name: namespaceName}, } g.Expect(k8sClient.Create(ctx, namespace)).ToNot(HaveOccurred()) t.Cleanup(func() { g.Expect(k8sClient.Delete(ctx, namespace)).NotTo(HaveOccurred()) }) helmrepo := &helmv1.HelmRepository{} helmrepo.Name = "test-helmrepo" helmrepo.Namespace = namespaceName helmrepo.Spec = helmv1.HelmRepositorySpec{ Interval: metav1.Duration{Duration: interval}, URL: "https://example.com", } // Add a test finalizer to prevent the object from getting deleted. helmrepo.SetFinalizers([]string{"test-finalizer"}) g.Expect(k8sClient.Create(ctx, helmrepo)).NotTo(HaveOccurred()) // Add deletion timestamp by deleting the object. g.Expect(k8sClient.Delete(ctx, helmrepo)).NotTo(HaveOccurred()) r := &HelmRepositoryReconciler{ Client: k8sClient, EventRecorder: record.NewFakeRecorder(32), Storage: testStorage, } // NOTE: Only a real API server responds with an error in this scenario. _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(helmrepo)}) g.Expect(err).NotTo(HaveOccurred()) } func TestHelmRepositoryReconciler_Reconcile(t *testing.T) { g := NewWithT(t) testServer, err := helmtestserver.NewTempHelmServer() g.Expect(err).NotTo(HaveOccurred()) defer os.RemoveAll(testServer.Root()) g.Expect(testServer.PackageChart("testdata/charts/helmchart")).To(Succeed()) g.Expect(testServer.GenerateIndex()).To(Succeed()) testServer.Start() defer testServer.Stop() origObj := &helmv1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "helmrepository-reconcile-", Namespace: "default", }, Spec: helmv1.HelmRepositorySpec{ Interval: metav1.Duration{Duration: interval}, URL: testServer.URL(), }, } obj := origObj.DeepCopy() g.Expect(testEnv.Create(ctx, obj)).To(Succeed()) key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} // Wait for finalizer to be set g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return false } return len(obj.Finalizers) > 0 }, timeout).Should(BeTrue()) // Wait for HelmRepository to be Ready waitForSourceReadyWithArtifact(ctx, g, obj) // Check if the object status is valid. condns := &conditionscheck.Conditions{NegativePolarity: helmRepositoryReadyCondition.NegativePolarity} checker := conditionscheck.NewChecker(testEnv.Client, condns) checker.WithT(g).CheckErr(ctx, obj) // kstatus client conformance check. u, err := patch.ToUnstructured(obj) g.Expect(err).ToNot(HaveOccurred()) res, err := kstatus.Compute(u) g.Expect(err).ToNot(HaveOccurred()) g.Expect(res.Status).To(Equal(kstatus.CurrentStatus)) // Patch the object with reconcile request annotation. patchHelper, err := patch.NewHelper(obj, testEnv.Client) g.Expect(err).ToNot(HaveOccurred()) annotations := map[string]string{ meta.ReconcileRequestAnnotation: "now", } obj.SetAnnotations(annotations) g.Expect(patchHelper.Patch(ctx, obj)).ToNot(HaveOccurred()) g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return false } return obj.Status.LastHandledReconcileAt == "now" }, timeout).Should(BeTrue()) g.Expect(testEnv.Delete(ctx, obj)).To(Succeed()) // Wait for HelmRepository to be deleted waitForSourceDeletion(ctx, g, obj) // Check if a suspended object gets deleted. obj = origObj.DeepCopy() testSuspendedObjectDeleteWithArtifact(ctx, g, obj) } func TestHelmRepositoryReconciler_reconcileStorage(t *testing.T) { tests := []struct { name string beforeFunc func(obj *helmv1.HelmRepository, storage *Storage) error want sreconcile.Result wantErr bool assertArtifact *sourcev1.Artifact assertConditions []metav1.Condition assertPaths []string }{ { name: "garbage collects", beforeFunc: func(obj *helmv1.HelmRepository, storage *Storage) error { revisions := []string{"a", "b", "c", "d"} for n := range revisions { v := revisions[n] obj.Status.Artifact = &sourcev1.Artifact{ Path: fmt.Sprintf("/reconcile-storage/%s.txt", v), Revision: v, } if err := storage.MkdirAll(*obj.Status.Artifact); err != nil { return err } if err := storage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader(v), 0o640); err != nil { return err } if n != len(revisions)-1 { time.Sleep(time.Second * 1) } } storage.SetArtifactURL(obj.Status.Artifact) conditions.MarkTrue(obj, meta.ReadyCondition, "foo", "bar") return nil }, assertArtifact: &sourcev1.Artifact{ Path: "/reconcile-storage/d.txt", Revision: "d", Digest: "sha256:18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4", URL: testStorage.Hostname + "/reconcile-storage/d.txt", Size: int64p(int64(len("d"))), }, assertPaths: []string{ "/reconcile-storage/d.txt", "/reconcile-storage/c.txt", "!/reconcile-storage/b.txt", "!/reconcile-storage/a.txt", }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReadyCondition, "foo", "bar"), }, }, { name: "build artifact first time", want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact"), }, }, { name: "notices missing artifact in storage", beforeFunc: func(obj *helmv1.HelmRepository, storage *Storage) error { obj.Status.Artifact = &sourcev1.Artifact{ Path: "/reconcile-storage/invalid.txt", Revision: "d", } storage.SetArtifactURL(obj.Status.Artifact) return nil }, want: sreconcile.ResultSuccess, assertPaths: []string{ "!/reconcile-storage/invalid.txt", }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: disappeared from storage"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: disappeared from storage"), }, }, { name: "notices empty artifact digest", beforeFunc: func(obj *helmv1.HelmRepository, storage *Storage) error { f := "empty-digest.txt" obj.Status.Artifact = &sourcev1.Artifact{ Path: fmt.Sprintf("/reconcile-storage/%s.txt", f), Revision: "fake", } if err := storage.MkdirAll(*obj.Status.Artifact); err != nil { return err } if err := storage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader(f), 0o600); err != nil { return err } // Overwrite with a different digest obj.Status.Artifact.Digest = "" return nil }, want: sreconcile.ResultSuccess, assertPaths: []string{ "!/reconcile-storage/empty-digest.txt", }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: disappeared from storage"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: disappeared from storage"), }, }, { name: "notices artifact digest mismatch", beforeFunc: func(obj *helmv1.HelmRepository, storage *Storage) error { f := "digest-mismatch.txt" obj.Status.Artifact = &sourcev1.Artifact{ Path: fmt.Sprintf("/reconcile-storage/%s.txt", f), Revision: "fake", } if err := storage.MkdirAll(*obj.Status.Artifact); err != nil { return err } if err := storage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader(f), 0o600); err != nil { return err } // Overwrite with a different digest obj.Status.Artifact.Digest = "sha256:6c329d5322473f904e2f908a51c12efa0ca8aa4201dd84f2c9d203a6ab3e9023" return nil }, want: sreconcile.ResultSuccess, assertPaths: []string{ "!/reconcile-storage/digest-mismatch.txt", }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: disappeared from storage"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: disappeared from storage"), }, }, { name: "updates hostname on diff from current", beforeFunc: func(obj *helmv1.HelmRepository, storage *Storage) error { obj.Status.Artifact = &sourcev1.Artifact{ Path: "/reconcile-storage/hostname.txt", Revision: "f", Digest: "sha256:3b9c358f36f0a31b6ad3e14f309c7cf198ac9246e8316f9ce543d5b19ac02b80", URL: "http://outdated.com/reconcile-storage/hostname.txt", } if err := storage.MkdirAll(*obj.Status.Artifact); err != nil { return err } if err := storage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader("file"), 0o640); err != nil { return err } conditions.MarkTrue(obj, meta.ReadyCondition, "foo", "bar") return nil }, want: sreconcile.ResultSuccess, assertPaths: []string{ "/reconcile-storage/hostname.txt", }, assertArtifact: &sourcev1.Artifact{ Path: "/reconcile-storage/hostname.txt", Revision: "f", Digest: "sha256:3b9c358f36f0a31b6ad3e14f309c7cf198ac9246e8316f9ce543d5b19ac02b80", URL: testStorage.Hostname + "/reconcile-storage/hostname.txt", Size: int64p(int64(len("file"))), }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReadyCondition, "foo", "bar"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) r := &HelmRepositoryReconciler{ Client: fakeclient.NewClientBuilder(). WithScheme(testEnv.GetScheme()). WithStatusSubresource(&helmv1.HelmRepository{}). Build(), EventRecorder: record.NewFakeRecorder(32), Storage: testStorage, patchOptions: getPatchOptions(helmRepositoryReadyCondition.Owned, "sc"), } obj := &helmv1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", Generation: 1, }, } if tt.beforeFunc != nil { g.Expect(tt.beforeFunc(obj, testStorage)).To(Succeed()) } g.Expect(r.Client.Create(context.TODO(), obj)).ToNot(HaveOccurred()) defer func() { g.Expect(r.Client.Delete(context.TODO(), obj)).ToNot(HaveOccurred()) }() var chartRepo repository.ChartRepository var artifact sourcev1.Artifact sp := patch.NewSerialPatcher(obj, r.Client) got, err := r.reconcileStorage(context.TODO(), sp, obj, &artifact, &chartRepo) g.Expect(err != nil).To(Equal(tt.wantErr)) g.Expect(got).To(Equal(tt.want)) g.Expect(obj.Status.Artifact).To(MatchArtifact(tt.assertArtifact)) if tt.assertArtifact != nil && tt.assertArtifact.URL != "" { g.Expect(obj.Status.Artifact.URL).To(Equal(tt.assertArtifact.URL)) } g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions)) for _, p := range tt.assertPaths { absoluteP := filepath.Join(testStorage.BasePath, p) if !strings.HasPrefix(p, "!") { g.Expect(absoluteP).To(BeAnExistingFile()) continue } g.Expect(absoluteP).NotTo(BeAnExistingFile()) } // In-progress status condition validity. checker := conditionscheck.NewInProgressChecker(r.Client) checker.WithT(g).CheckErr(ctx, obj) }) } } func TestHelmRepositoryReconciler_reconcileSource(t *testing.T) { type options struct { username string password string publicKey []byte privateKey []byte ca []byte } tests := []struct { name string protocol string server options url string secret *corev1.Secret beforeFunc func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) afterFunc func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) want sreconcile.Result wantErr bool assertConditions []metav1.Condition }{ { name: "HTTPS with certSecretRef pointing to CA cert but public repo URL succeeds", protocol: "http", url: "https://stefanprodan.github.io/podinfo", want: sreconcile.ResultSuccess, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "ca-file", }, Data: map[string][]byte{ "ca.crt": tlsCA, }, }, beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.CertSecretRef = &meta.LocalObjectReference{Name: "ca-file"} }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), }, }, { name: "HTTPS with certSecretRef makes ArtifactOutdated=True", protocol: "https", server: options{ publicKey: tlsPublicKey, privateKey: tlsPrivateKey, ca: tlsCA, }, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "ca-file", }, Data: map[string][]byte{ "ca.crt": tlsCA, }, }, beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.CertSecretRef = &meta.LocalObjectReference{Name: "ca-file"} }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { t.Expect(chartRepo.Path).ToNot(BeEmpty()) t.Expect(chartRepo.Index).ToNot(BeNil()) t.Expect(artifact.Revision).ToNot(BeEmpty()) }, }, { name: "HTTPS with secretRef and caFile key makes ArtifactOutdated=True", protocol: "https", server: options{ publicKey: tlsPublicKey, privateKey: tlsPrivateKey, ca: tlsCA, }, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "ca-file", }, Data: map[string][]byte{ "caFile": tlsCA, }, }, beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "ca-file"} }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { t.Expect(chartRepo.Path).ToNot(BeEmpty()) t.Expect(chartRepo.Index).ToNot(BeNil()) t.Expect(artifact.Revision).ToNot(BeEmpty()) }, }, { // Regression test for: https://github.com/fluxcd/source-controller/issues/1218 name: "HTTPS with docker config secretRef and caFile key makes ArtifactOutdated=True", protocol: "https", server: options{ publicKey: tlsPublicKey, privateKey: tlsPrivateKey, ca: tlsCA, }, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "ca-file", }, Data: map[string][]byte{ "caFile": tlsCA, }, Type: corev1.SecretTypeDockerConfigJson, }, beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "ca-file"} }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { t.Expect(chartRepo.Path).ToNot(BeEmpty()) t.Expect(chartRepo.Index).ToNot(BeNil()) t.Expect(artifact.Revision).ToNot(BeEmpty()) }, }, { name: "HTTP without secretRef makes ArtifactOutdated=True", protocol: "http", want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { t.Expect(chartRepo.Path).ToNot(BeEmpty()) t.Expect(chartRepo.Index).ToNot(BeNil()) t.Expect(artifact.Revision).ToNot(BeEmpty()) }, }, { name: "HTTP with Basic Auth secret makes ArtifactOutdated=True", protocol: "http", server: options{ username: "git", password: "1234", }, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-auth", }, Data: map[string][]byte{ "username": []byte("git"), "password": []byte("1234"), }, }, beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "basic-auth"} }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { t.Expect(chartRepo.Path).ToNot(BeEmpty()) t.Expect(chartRepo.Index).ToNot(BeNil()) t.Expect(artifact.Revision).ToNot(BeEmpty()) }, }, { // Regression test for: https://github.com/fluxcd/source-controller/issues/1218 name: "HTTP with docker config secretRef sets Reconciling=True", protocol: "http", server: options{ username: "git", password: "1234", }, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-auth", }, Data: map[string][]byte{ "username": []byte("git"), "password": []byte("1234"), }, Type: corev1.SecretTypeDockerConfigJson, }, beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "basic-auth"} }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { t.Expect(chartRepo.Path).ToNot(BeEmpty()) t.Expect(chartRepo.Index).ToNot(BeNil()) t.Expect(artifact.Revision).ToNot(BeEmpty()) }, }, { name: "HTTPS with invalid CAFile in certSecretRef makes FetchFailed=True and returns error", protocol: "https", server: options{ publicKey: tlsPublicKey, privateKey: tlsPrivateKey, ca: tlsCA, }, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "invalid-ca", }, Data: map[string][]byte{ "ca.crt": []byte("invalid"), }, }, beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.CertSecretRef = &meta.LocalObjectReference{Name: "invalid-ca"} conditions.MarkReconciling(obj, meta.ProgressingReason, "foo") conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar") }, wantErr: true, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "cannot append certificate into certificate pool: invalid CA certificate"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"), *conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { // No repo index due to fetch fail. t.Expect(chartRepo.Path).To(BeEmpty()) t.Expect(chartRepo.Index).To(BeNil()) t.Expect(artifact.Revision).To(BeEmpty()) }, }, { name: "Invalid URL makes FetchFailed=True and returns stalling error", protocol: "http", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.URL = strings.ReplaceAll(obj.Spec.URL, "http://", "") conditions.MarkReconciling(obj, meta.ProgressingReason, "foo") conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar") }, want: sreconcile.ResultEmpty, wantErr: true, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.URLInvalidReason, "first path segment in URL cannot contain colon"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"), *conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { // No repo index due to fetch fail. t.Expect(chartRepo.Path).To(BeEmpty()) t.Expect(chartRepo.Index).To(BeNil()) t.Expect(artifact.Revision).To(BeEmpty()) }, }, { name: "Unsupported scheme makes FetchFailed=True and returns stalling error", protocol: "http", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.URL = strings.ReplaceAll(obj.Spec.URL, "http://", "ftp://") conditions.MarkReconciling(obj, meta.ProgressingReason, "foo") conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar") }, want: sreconcile.ResultEmpty, wantErr: true, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.FetchFailedCondition, meta.FailedReason, "scheme \"ftp\" not supported"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"), *conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { // No repo index due to fetch fail. t.Expect(chartRepo.Path).To(BeEmpty()) t.Expect(chartRepo.Index).To(BeNil()) t.Expect(artifact.Revision).To(BeEmpty()) }, }, { name: "Missing secret returns FetchFailed=True and returns error", protocol: "http", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "non-existing"} conditions.MarkReconciling(obj, meta.ProgressingReason, "foo") conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar") }, wantErr: true, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "secrets \"non-existing\" not found"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"), *conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { // No repo index due to fetch fail. t.Expect(chartRepo.Path).To(BeEmpty()) t.Expect(chartRepo.Index).To(BeNil()) t.Expect(artifact.Revision).To(BeEmpty()) }, }, { name: "Malformed secret returns FetchFailed=True and returns error", protocol: "http", secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "malformed-basic-auth", }, Data: map[string][]byte{ "username": []byte("git"), }, }, beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "malformed-basic-auth"} conditions.MarkReconciling(obj, meta.ProgressingReason, "foo") conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar") }, wantErr: true, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "required fields 'username' and 'password"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"), *conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { // No repo index due to fetch fail. t.Expect(chartRepo.Path).To(BeEmpty()) t.Expect(chartRepo.Index).To(BeNil()) t.Expect(artifact.Revision).To(BeEmpty()) }, }, { name: "Stored index with same revision", protocol: "http", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Status.Artifact = &sourcev1.Artifact{ Revision: rev.String(), } conditions.MarkReconciling(obj, meta.ProgressingReason, "foo") conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar") conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, "foo", "bar") }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"), *conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { t.Expect(chartRepo.Path).ToNot(BeEmpty()) t.Expect(chartRepo.Index).To(BeNil()) t.Expect(&artifact).To(BeEquivalentTo(obj.Status.Artifact)) }, want: sreconcile.ResultSuccess, }, { name: "Stored index with different revision", protocol: "http", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Status.Artifact = &sourcev1.Artifact{ Revision: "80bb3dd67c63095d985850459834ea727603727a370079de90d221191d375a86", } conditions.MarkReconciling(obj, meta.ProgressingReason, "foo") conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar") conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, "foo", "bar") }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewRevision", "new index revision"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { t.Expect(chartRepo.Path).ToNot(BeEmpty()) t.Expect(chartRepo.Index).ToNot(BeNil()) t.Expect(artifact.Path).To(Not(BeEmpty())) t.Expect(artifact.Revision).ToNot(Equal(obj.Status.Artifact.Revision)) }, want: sreconcile.ResultSuccess, }, { name: "Existing artifact makes ArtifactOutdated=True", protocol: "http", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev digest.Digest) { obj.Status.Artifact = &sourcev1.Artifact{ Path: "some-path", Revision: "some-rev", } }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewRevision", "new index revision"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), }, }, } for _, tt := range tests { obj := &helmv1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "auth-strategy-", Generation: 1, }, Spec: helmv1.HelmRepositorySpec{ Interval: metav1.Duration{Duration: interval}, Timeout: &metav1.Duration{Duration: timeout}, }, } t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) server, err := helmtestserver.NewTempHelmServer() g.Expect(err).NotTo(HaveOccurred()) defer os.RemoveAll(server.Root()) g.Expect(server.PackageChart("testdata/charts/helmchart")).To(Succeed()) g.Expect(server.GenerateIndex()).To(Succeed()) if len(tt.server.username+tt.server.password) > 0 { server.WithMiddleware(func(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() if !ok || u != tt.server.username || p != tt.server.password { w.WriteHeader(401) return } handler.ServeHTTP(w, r) }) }) } secret := tt.secret.DeepCopy() switch tt.protocol { case "http": server.Start() defer server.Stop() obj.Spec.URL = server.URL() if tt.url != "" { obj.Spec.URL = tt.url } case "https": g.Expect(server.StartTLS(tt.server.publicKey, tt.server.privateKey, tt.server.ca, "example.com")).To(Succeed()) defer server.Stop() obj.Spec.URL = server.URL() if tt.url != "" { obj.Spec.URL = tt.url } default: t.Fatalf("unsupported protocol %q", tt.protocol) } clientBuilder := fakeclient.NewClientBuilder(). WithScheme(testEnv.GetScheme()). WithStatusSubresource(&helmv1.HelmRepository{}) if secret != nil { clientBuilder.WithObjects(secret.DeepCopy()) } // Calculate the artifact digest for valid repos configurations. getterOpts := []helmgetter.Option{ helmgetter.WithURL(server.URL()), } var newChartRepo *repository.ChartRepository var tlsConf *tls.Config validSecret := true if secret != nil { // Extract the client options from secret, ignoring any invalid // value. validSecret is used to determine if the index digest // should be calculated below. var gOpts []helmgetter.Option var serr error gOpts, serr = getter.GetterOptionsFromSecret(*secret) if serr != nil { validSecret = false } getterOpts = append(getterOpts, gOpts...) repoURL := server.URL() if tt.url != "" { repoURL = tt.url } tlsConf, _, serr = stls.KubeTLSClientConfigFromSecret(*secret, repoURL) if serr != nil { validSecret = false } if tlsConf == nil { tlsConf, _, serr = stls.TLSClientConfigFromSecret(*secret, repoURL) if serr != nil { validSecret = false } } newChartRepo, err = repository.NewChartRepository(obj.Spec.URL, "", testGetters, tlsConf, getterOpts...) } else { newChartRepo, err = repository.NewChartRepository(obj.Spec.URL, "", testGetters, nil) } g.Expect(err).ToNot(HaveOccurred()) var rev digest.Digest if validSecret { g.Expect(newChartRepo.CacheIndex()).To(Succeed()) rev = newChartRepo.Digest(intdigest.Canonical) } r := &HelmRepositoryReconciler{ EventRecorder: record.NewFakeRecorder(32), Client: clientBuilder.Build(), Storage: testStorage, Getters: testGetters, patchOptions: getPatchOptions(helmRepositoryReadyCondition.Owned, "sc"), } if tt.beforeFunc != nil { tt.beforeFunc(g, obj, rev) } g.Expect(r.Client.Create(context.TODO(), obj)).ToNot(HaveOccurred()) defer func() { g.Expect(r.Client.Delete(context.TODO(), obj)).ToNot(HaveOccurred()) }() var chartRepo repository.ChartRepository var artifact sourcev1.Artifact sp := patch.NewSerialPatcher(obj, r.Client) got, err := r.reconcileSource(context.TODO(), sp, obj, &artifact, &chartRepo) defer os.Remove(chartRepo.Path) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions)) g.Expect(err != nil).To(Equal(tt.wantErr)) g.Expect(got).To(Equal(tt.want)) if tt.afterFunc != nil { tt.afterFunc(g, obj, artifact, &chartRepo) } // In-progress status condition validity. checker := conditionscheck.NewInProgressChecker(r.Client) checker.WithT(g).CheckErr(ctx, obj) }) } } func TestHelmRepositoryReconciler_reconcileArtifact(t *testing.T) { tests := []struct { name string cache *cache.Cache beforeFunc func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, index *repository.ChartRepository) afterFunc func(t *WithT, obj *helmv1.HelmRepository, cache *cache.Cache) want sreconcile.Result wantErr bool assertConditions []metav1.Condition }{ { name: "Archiving artifact to storage makes ArtifactInStorage=True and artifact is stored as JSON", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, index *repository.ChartRepository) { obj.Spec.Interval = metav1.Duration{Duration: interval} }, want: sreconcile.ResultSuccess, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, cache *cache.Cache) { localPath := testStorage.LocalPath(*obj.GetArtifact()) b, err := os.ReadFile(localPath) t.Expect(err).To(Not(HaveOccurred())) t.Expect(json.Valid(b)).To(BeTrue()) }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.ArtifactInStorageCondition, meta.SucceededReason, "stored artifact: revision 'existing'"), }, }, { name: "Archiving (loaded) artifact to storage adds to cache", cache: cache.New(10, time.Minute), beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, index *repository.ChartRepository) { index.Index = &repo.IndexFile{ APIVersion: "v1", Generated: time.Now(), } obj.Spec.Interval = metav1.Duration{Duration: interval} }, want: sreconcile.ResultSuccess, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, cache *cache.Cache) { i, ok := cache.Get(obj.GetArtifact().Path) t.Expect(ok).To(BeTrue()) t.Expect(i).To(BeAssignableToTypeOf(&repo.IndexFile{})) }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.ArtifactInStorageCondition, meta.SucceededReason, "stored artifact: revision 'existing'"), }, }, { name: "Up-to-date artifact should not update status", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, index *repository.ChartRepository) { obj.Spec.Interval = metav1.Duration{Duration: interval} obj.Status.Artifact = artifact.DeepCopy() }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, _ *cache.Cache) { t.Expect(obj.Status.URL).To(BeEmpty()) }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.ArtifactInStorageCondition, meta.SucceededReason, "stored artifact: revision 'existing'"), }, }, { name: "Removes ArtifactOutdatedCondition after creating a new artifact", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, index *repository.ChartRepository) { obj.Spec.Interval = metav1.Duration{Duration: interval} conditions.MarkTrue(obj, sourcev1.ArtifactOutdatedCondition, "Foo", "") }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.ArtifactInStorageCondition, meta.SucceededReason, "stored artifact: revision 'existing'"), }, }, { name: "Creates latest symlink to the created artifact", beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, index *repository.ChartRepository) { obj.Spec.Interval = metav1.Duration{Duration: interval} }, afterFunc: func(t *WithT, obj *helmv1.HelmRepository, _ *cache.Cache) { localPath := testStorage.LocalPath(*obj.GetArtifact()) symlinkPath := filepath.Join(filepath.Dir(localPath), "index.yaml") targetFile, err := os.Readlink(symlinkPath) t.Expect(err).NotTo(HaveOccurred()) t.Expect(localPath).To(Equal(targetFile)) }, want: sreconcile.ResultSuccess, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.ArtifactInStorageCondition, meta.SucceededReason, "stored artifact: revision 'existing'"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) r := &HelmRepositoryReconciler{ Client: fakeclient.NewClientBuilder(). WithScheme(testEnv.GetScheme()). WithStatusSubresource(&helmv1.HelmRepository{}). Build(), EventRecorder: record.NewFakeRecorder(32), Storage: testStorage, Cache: tt.cache, TTL: 1 * time.Minute, patchOptions: getPatchOptions(helmRepositoryReadyCondition.Owned, "sc"), } obj := &helmv1.HelmRepository{ TypeMeta: metav1.TypeMeta{ Kind: helmv1.HelmRepositoryKind, }, ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-bucket-", Generation: 1, Namespace: "default", }, Spec: helmv1.HelmRepositorySpec{ Timeout: &metav1.Duration{Duration: timeout}, URL: "https://example.com/index.yaml", }, } chartRepo, err := repository.NewChartRepository(obj.Spec.URL, "", testGetters, nil) g.Expect(err).ToNot(HaveOccurred()) chartRepo.Index = &repo.IndexFile{} artifact := testStorage.NewArtifactFor(obj.Kind, obj, "existing", "foo.tar.gz") // Digest of the index file calculated by the ChartRepository. artifact.Digest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" if tt.beforeFunc != nil { tt.beforeFunc(g, obj, artifact, chartRepo) } sp := patch.NewSerialPatcher(obj, r.Client) got, err := r.reconcileArtifact(context.TODO(), sp, obj, &artifact, chartRepo) g.Expect(err != nil).To(Equal(tt.wantErr)) g.Expect(got).To(Equal(tt.want)) // On error, artifact is empty. Check artifacts only on successful // reconcile. if !tt.wantErr { g.Expect(obj.Status.Artifact).To(MatchArtifact(artifact.DeepCopy())) } g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions)) if tt.afterFunc != nil { tt.afterFunc(g, obj, tt.cache) } }) } } func TestHelmRepositoryReconciler_reconcileSubRecs(t *testing.T) { // Helper to build simple helmRepositoryReconcileFunc with result and error. buildReconcileFuncs := func(r sreconcile.Result, e error) helmRepositoryReconcileFunc { return func(ctx context.Context, sp *patch.SerialPatcher, obj *helmv1.HelmRepository, artifact *sourcev1.Artifact, repo *repository.ChartRepository) (sreconcile.Result, error) { return r, e } } tests := []struct { name string generation int64 observedGeneration int64 reconcileFuncs []helmRepositoryReconcileFunc wantResult sreconcile.Result wantErr bool assertConditions []metav1.Condition }{ { name: "successful reconciliations", reconcileFuncs: []helmRepositoryReconcileFunc{ buildReconcileFuncs(sreconcile.ResultSuccess, nil), }, wantResult: sreconcile.ResultSuccess, wantErr: false, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "reconciliation in progress"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress"), }, }, { name: "successful reconciliation with generation difference", generation: 3, observedGeneration: 2, reconcileFuncs: []helmRepositoryReconcileFunc{ buildReconcileFuncs(sreconcile.ResultSuccess, nil), }, wantResult: sreconcile.ResultSuccess, wantErr: false, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "processing object: new generation 2 -> 3"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "processing object: new generation 2 -> 3"), }, }, { name: "failed reconciliation", reconcileFuncs: []helmRepositoryReconcileFunc{ buildReconcileFuncs(sreconcile.ResultEmpty, fmt.Errorf("some error")), }, wantResult: sreconcile.ResultEmpty, wantErr: true, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "reconciliation in progress"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress"), }, }, { name: "multiple object status conditions mutations", reconcileFuncs: []helmRepositoryReconcileFunc{ func(ctx context.Context, sp *patch.SerialPatcher, obj *helmv1.HelmRepository, artifact *sourcev1.Artifact, repo *repository.ChartRepository) (sreconcile.Result, error) { conditions.MarkTrue(obj, sourcev1.ArtifactOutdatedCondition, "NewRevision", "new index revision") return sreconcile.ResultSuccess, nil }, func(ctx context.Context, sp *patch.SerialPatcher, obj *helmv1.HelmRepository, artifact *sourcev1.Artifact, repo *repository.ChartRepository) (sreconcile.Result, error) { conditions.MarkTrue(obj, meta.ReconcilingCondition, meta.ProgressingReason, "creating artifact") return sreconcile.ResultSuccess, nil }, }, wantResult: sreconcile.ResultSuccess, wantErr: false, assertConditions: []metav1.Condition{ *conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewRevision", "new index revision"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "creating artifact"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress"), }, }, { name: "subrecs with one result=Requeue, no error", reconcileFuncs: []helmRepositoryReconcileFunc{ buildReconcileFuncs(sreconcile.ResultSuccess, nil), buildReconcileFuncs(sreconcile.ResultRequeue, nil), buildReconcileFuncs(sreconcile.ResultSuccess, nil), }, wantResult: sreconcile.ResultRequeue, wantErr: false, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "reconciliation in progress"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress"), }, }, { name: "subrecs with error before result=Requeue", reconcileFuncs: []helmRepositoryReconcileFunc{ buildReconcileFuncs(sreconcile.ResultSuccess, nil), buildReconcileFuncs(sreconcile.ResultEmpty, fmt.Errorf("some error")), buildReconcileFuncs(sreconcile.ResultRequeue, nil), }, wantResult: sreconcile.ResultEmpty, wantErr: true, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "reconciliation in progress"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) r := &HelmRepositoryReconciler{ Client: fakeclient.NewClientBuilder(). WithScheme(testEnv.GetScheme()). WithStatusSubresource(&helmv1.HelmRepository{}). Build(), patchOptions: getPatchOptions(helmRepositoryReadyCondition.Owned, "sc"), } obj := &helmv1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", Generation: tt.generation, }, Status: helmv1.HelmRepositoryStatus{ ObservedGeneration: tt.observedGeneration, }, } g.Expect(r.Client.Create(context.TODO(), obj)).ToNot(HaveOccurred()) defer func() { g.Expect(r.Client.Delete(context.TODO(), obj)).ToNot(HaveOccurred()) }() ctx := context.TODO() sp := patch.NewSerialPatcher(obj, r.Client) gotRes, gotErr := r.reconcile(ctx, sp, obj, tt.reconcileFuncs) g.Expect(gotErr != nil).To(Equal(tt.wantErr)) g.Expect(gotRes).To(Equal(tt.wantResult)) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions)) }) } } func TestHelmRepositoryReconciler_statusConditions(t *testing.T) { tests := []struct { name string beforeFunc func(obj *helmv1.HelmRepository) assertConditions []metav1.Condition wantErr bool }{ { name: "positive conditions only", beforeFunc: func(obj *helmv1.HelmRepository) { conditions.MarkTrue(obj, sourcev1.ArtifactInStorageCondition, meta.SucceededReason, "stored artifact for revision") }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, "stored artifact for revision"), *conditions.TrueCondition(sourcev1.ArtifactInStorageCondition, meta.SucceededReason, "stored artifact for revision"), }, wantErr: false, }, { name: "multiple failures", beforeFunc: func(obj *helmv1.HelmRepository) { conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get secret") conditions.MarkTrue(obj, sourcev1.StorageOperationFailedCondition, sourcev1.DirCreationFailedReason, "failed to create directory") conditions.MarkTrue(obj, sourcev1.ArtifactOutdatedCondition, "NewRevision", "some error") }, assertConditions: []metav1.Condition{ *conditions.FalseCondition(meta.ReadyCondition, sourcev1.DirCreationFailedReason, "failed to create directory"), *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get secret"), *conditions.TrueCondition(sourcev1.StorageOperationFailedCondition, sourcev1.DirCreationFailedReason, "failed to create directory"), *conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewRevision", "some error"), }, wantErr: true, }, { name: "mixed positive and negative conditions", beforeFunc: func(obj *helmv1.HelmRepository) { conditions.MarkTrue(obj, sourcev1.ArtifactInStorageCondition, meta.SucceededReason, "stored artifact for revision") conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get secret") }, assertConditions: []metav1.Condition{ *conditions.FalseCondition(meta.ReadyCondition, sourcev1.AuthenticationFailedReason, "failed to get secret"), *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get secret"), *conditions.TrueCondition(sourcev1.ArtifactInStorageCondition, meta.SucceededReason, "stored artifact for revision"), }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) obj := &helmv1.HelmRepository{ TypeMeta: metav1.TypeMeta{ Kind: helmv1.HelmRepositoryKind, APIVersion: helmv1.GroupVersion.String(), }, ObjectMeta: metav1.ObjectMeta{ Name: "helmrepo", Namespace: "foo", }, } clientBuilder := fakeclient.NewClientBuilder(). WithObjects(obj). WithStatusSubresource(&helmv1.HelmRepository{}) c := clientBuilder.Build() serialPatcher := patch.NewSerialPatcher(obj, c) if tt.beforeFunc != nil { tt.beforeFunc(obj) } ctx := context.TODO() summarizeHelper := summarize.NewHelper(record.NewFakeRecorder(32), serialPatcher) summarizeOpts := []summarize.Option{ summarize.WithConditions(helmRepositoryReadyCondition), summarize.WithReconcileResult(sreconcile.ResultSuccess), summarize.WithIgnoreNotFound(), summarize.WithResultBuilder(sreconcile.AlwaysRequeueResultBuilder{RequeueAfter: obj.GetRequeueAfter()}), summarize.WithPatchFieldOwner("source-controller"), } _, err := summarizeHelper.SummarizeAndPatch(ctx, obj, summarizeOpts...) g.Expect(err != nil).To(Equal(tt.wantErr)) key := client.ObjectKeyFromObject(obj) g.Expect(c.Get(ctx, key, obj)).ToNot(HaveOccurred()) g.Expect(obj.GetConditions()).To(conditions.MatchConditions(tt.assertConditions)) }) } } func TestHelmRepositoryReconciler_notify(t *testing.T) { var aSize int64 = 30000 tests := []struct { name string res sreconcile.Result resErr error oldObjBeforeFunc func(obj *helmv1.HelmRepository) newObjBeforeFunc func(obj *helmv1.HelmRepository) wantEvent string }{ { name: "error - no event", res: sreconcile.ResultEmpty, resErr: errors.New("some error"), }, { name: "new artifact with nil size", res: sreconcile.ResultSuccess, resErr: nil, newObjBeforeFunc: func(obj *helmv1.HelmRepository) { obj.Status.Artifact = &sourcev1.Artifact{Revision: "xxx", Digest: "yyy", Size: nil} }, wantEvent: "Normal NewArtifact stored fetched index of unknown size", }, { name: "new artifact", res: sreconcile.ResultSuccess, resErr: nil, newObjBeforeFunc: func(obj *helmv1.HelmRepository) { obj.Status.Artifact = &sourcev1.Artifact{Revision: "xxx", Digest: "yyy", Size: &aSize} }, wantEvent: "Normal NewArtifact stored fetched index of size", }, { name: "recovery from failure", res: sreconcile.ResultSuccess, resErr: nil, oldObjBeforeFunc: func(obj *helmv1.HelmRepository) { obj.Status.Artifact = &sourcev1.Artifact{Revision: "xxx", Digest: "yyy", Size: &aSize} conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, sourcev1.GitOperationFailedReason, "fail") conditions.MarkFalse(obj, meta.ReadyCondition, meta.FailedReason, "foo") }, newObjBeforeFunc: func(obj *helmv1.HelmRepository) { obj.Status.Artifact = &sourcev1.Artifact{Revision: "xxx", Digest: "yyy", Size: &aSize} conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, "ready") }, wantEvent: "Normal Succeeded stored fetched index of size", }, { name: "recovery and new artifact", res: sreconcile.ResultSuccess, resErr: nil, oldObjBeforeFunc: func(obj *helmv1.HelmRepository) { obj.Status.Artifact = &sourcev1.Artifact{Revision: "xxx", Digest: "yyy", Size: &aSize} conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, sourcev1.GitOperationFailedReason, "fail") conditions.MarkFalse(obj, meta.ReadyCondition, meta.FailedReason, "foo") }, newObjBeforeFunc: func(obj *helmv1.HelmRepository) { obj.Status.Artifact = &sourcev1.Artifact{Revision: "aaa", Digest: "bbb", Size: &aSize} conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, "ready") }, wantEvent: "Normal NewArtifact stored fetched index of size", }, { name: "no updates", res: sreconcile.ResultSuccess, resErr: nil, oldObjBeforeFunc: func(obj *helmv1.HelmRepository) { obj.Status.Artifact = &sourcev1.Artifact{Revision: "xxx", Digest: "yyy", Size: &aSize} conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, "ready") }, newObjBeforeFunc: func(obj *helmv1.HelmRepository) { obj.Status.Artifact = &sourcev1.Artifact{Revision: "xxx", Digest: "yyy", Size: &aSize} conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, "ready") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) recorder := record.NewFakeRecorder(32) oldObj := &helmv1.HelmRepository{} newObj := oldObj.DeepCopy() if tt.oldObjBeforeFunc != nil { tt.oldObjBeforeFunc(oldObj) } if tt.newObjBeforeFunc != nil { tt.newObjBeforeFunc(newObj) } reconciler := &HelmRepositoryReconciler{ EventRecorder: recorder, patchOptions: getPatchOptions(helmRepositoryReadyCondition.Owned, "sc"), } chartRepo := repository.ChartRepository{ URL: "some-address", } reconciler.notify(ctx, oldObj, newObj, &chartRepo, tt.res, tt.resErr) select { case x, ok := <-recorder.Events: g.Expect(ok).To(Equal(tt.wantEvent != ""), "unexpected event received") if tt.wantEvent != "" { g.Expect(x).To(ContainSubstring(tt.wantEvent)) } default: if tt.wantEvent != "" { t.Errorf("expected some event to be emitted") } } }) } } func TestHelmRepositoryReconciler_ReconcileTypeUpdatePredicateFilter(t *testing.T) { g := NewWithT(t) testServer, err := helmtestserver.NewTempHelmServer() g.Expect(err).NotTo(HaveOccurred()) defer os.RemoveAll(testServer.Root()) g.Expect(testServer.PackageChart("testdata/charts/helmchart")).To(Succeed()) g.Expect(testServer.GenerateIndex()).To(Succeed()) testServer.Start() defer testServer.Stop() obj := &helmv1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "helmrepository-reconcile-", Namespace: "default", }, Spec: helmv1.HelmRepositorySpec{ Interval: metav1.Duration{Duration: interval}, URL: testServer.URL(), }, } g.Expect(testEnv.CreateAndWait(ctx, obj)).To(Succeed()) key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} // Wait for finalizer to be set g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return false } return len(obj.Finalizers) > 0 }, timeout).Should(BeTrue()) // Wait for HelmRepository to be Ready g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return false } if !conditions.IsReady(obj) && obj.Status.Artifact == nil { return false } readyCondition := conditions.Get(obj, meta.ReadyCondition) return readyCondition.Status == metav1.ConditionTrue && obj.Generation == readyCondition.ObservedGeneration && obj.Generation == obj.Status.ObservedGeneration }, timeout).Should(BeTrue()) // Check if the object status is valid. condns := &conditionscheck.Conditions{NegativePolarity: helmRepositoryReadyCondition.NegativePolarity} checker := conditionscheck.NewChecker(testEnv.Client, condns) checker.WithT(g).CheckErr(ctx, obj) // kstatus client conformance check. u, err := patch.ToUnstructured(obj) g.Expect(err).ToNot(HaveOccurred()) res, err := kstatus.Compute(u) g.Expect(err).ToNot(HaveOccurred()) g.Expect(res.Status).To(Equal(kstatus.CurrentStatus)) // Switch to a OCI helm repository type obj.Spec.Type = helmv1.HelmRepositoryTypeOCI obj.Spec.URL = fmt.Sprintf("oci://%s", testRegistryServer.registryHost) oldGen := obj.GetGeneration() g.Expect(testEnv.Update(ctx, obj)).To(Succeed()) newGen := oldGen + 1 // Wait for HelmRepository to become static for new generation. g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return false } return newGen == obj.Generation && !intpredicates.HelmRepositoryOCIRequireMigration(obj) }, timeout).Should(BeTrue()) g.Expect(testEnv.Delete(ctx, obj)).To(Succeed()) // Wait for HelmRepository to be deleted g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return apierrors.IsNotFound(err) } return false }, timeout).Should(BeTrue()) } func TestHelmRepositoryReconciler_ReconcileSpecUpdatePredicateFilter(t *testing.T) { g := NewWithT(t) testServer, err := helmtestserver.NewTempHelmServer() g.Expect(err).NotTo(HaveOccurred()) defer os.RemoveAll(testServer.Root()) g.Expect(testServer.PackageChart("testdata/charts/helmchart")).To(Succeed()) g.Expect(testServer.GenerateIndex()).To(Succeed()) testServer.Start() defer testServer.Stop() obj := &helmv1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "helmrepository-reconcile-", Namespace: "default", }, Spec: helmv1.HelmRepositorySpec{ Interval: metav1.Duration{Duration: interval}, URL: testServer.URL(), }, } g.Expect(testEnv.CreateAndWait(ctx, obj)).To(Succeed()) key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} // Wait for finalizer to be set g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return false } return len(obj.Finalizers) > 0 }, timeout).Should(BeTrue()) // Wait for HelmRepository to be Ready g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return false } if !conditions.IsReady(obj) && obj.Status.Artifact == nil { return false } readyCondition := conditions.Get(obj, meta.ReadyCondition) return readyCondition.Status == metav1.ConditionTrue && obj.Generation == readyCondition.ObservedGeneration && obj.Generation == obj.Status.ObservedGeneration }, timeout).Should(BeTrue()) // Check if the object status is valid. condns := &conditionscheck.Conditions{NegativePolarity: helmRepositoryReadyCondition.NegativePolarity} checker := conditionscheck.NewChecker(testEnv.Client, condns) checker.WithT(g).CheckErr(ctx, obj) // kstatus client conformance check. u, err := patch.ToUnstructured(obj) g.Expect(err).ToNot(HaveOccurred()) res, err := kstatus.Compute(u) g.Expect(err).ToNot(HaveOccurred()) g.Expect(res.Status).To(Equal(kstatus.CurrentStatus)) // Change spec Interval to validate spec update obj.Spec.Interval = metav1.Duration{Duration: interval + time.Second} oldGen := obj.GetGeneration() g.Expect(testEnv.Update(ctx, obj)).To(Succeed()) newGen := oldGen + 1 // Wait for HelmRepository to be Ready g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return false } if !conditions.IsReady(obj) && obj.Status.Artifact == nil { return false } readyCondition := conditions.Get(obj, meta.ReadyCondition) return readyCondition.Status == metav1.ConditionTrue && newGen == readyCondition.ObservedGeneration && newGen == obj.Status.ObservedGeneration }, timeout).Should(BeTrue()) // Check if the object status is valid. condns = &conditionscheck.Conditions{NegativePolarity: helmRepositoryReadyCondition.NegativePolarity} checker = conditionscheck.NewChecker(testEnv.Client, condns) checker.WithT(g).CheckErr(ctx, obj) g.Expect(testEnv.Delete(ctx, obj)).To(Succeed()) // Wait for HelmRepository to be deleted g.Eventually(func() bool { if err := testEnv.Get(ctx, key, obj); err != nil { return apierrors.IsNotFound(err) } return false }, timeout).Should(BeTrue()) } func TestHelmRepositoryReconciler_InMemoryCaching(t *testing.T) { g := NewWithT(t) testCache.Clear() testServer, err := helmtestserver.NewTempHelmServer() g.Expect(err).NotTo(HaveOccurred()) defer os.RemoveAll(testServer.Root()) g.Expect(testServer.PackageChartWithVersion("testdata/charts/helmchart", "0.1.0")).To(Succeed()) g.Expect(testServer.GenerateIndex()).To(Succeed()) testServer.Start() defer testServer.Stop() ns, err := testEnv.CreateNamespace(ctx, "helmrepository") g.Expect(err).ToNot(HaveOccurred()) defer func() { g.Expect(testEnv.Delete(ctx, ns)).To(Succeed()) }() helmRepo := &helmv1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "helmrepository-", Namespace: ns.Name, }, Spec: helmv1.HelmRepositorySpec{ URL: testServer.URL(), }, } g.Expect(testEnv.CreateAndWait(ctx, helmRepo)).To(Succeed()) key := client.ObjectKey{Name: helmRepo.Name, Namespace: helmRepo.Namespace} // Wait for finalizer to be set g.Eventually(func() bool { if err := testEnv.Get(ctx, key, helmRepo); err != nil { return false } return len(helmRepo.Finalizers) > 0 }, timeout).Should(BeTrue()) // Wait for HelmRepository to be Ready g.Eventually(func() bool { if err := testEnv.Get(ctx, key, helmRepo); err != nil { return false } if !conditions.IsReady(helmRepo) || helmRepo.Status.Artifact == nil { return false } readyCondition := conditions.Get(helmRepo, meta.ReadyCondition) return helmRepo.Generation == readyCondition.ObservedGeneration && helmRepo.Generation == helmRepo.Status.ObservedGeneration }, timeout).Should(BeTrue()) err = testEnv.Get(ctx, key, helmRepo) g.Expect(err).ToNot(HaveOccurred()) _, cacheHit := testCache.Get(helmRepo.GetArtifact().Path) g.Expect(cacheHit).To(BeTrue()) } func TestHelmRepositoryReconciler_ociMigration(t *testing.T) { g := NewWithT(t) testns, err := testEnv.CreateNamespace(ctx, "hr-oci-migration-test") g.Expect(err).ToNot(HaveOccurred()) t.Cleanup(func() { g.Expect(testEnv.Cleanup(ctx, testns)).ToNot(HaveOccurred()) }) hr := &helmv1.HelmRepository{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("hr-%s", randStringRunes(5)), Namespace: testns.Name, }, } hrKey := client.ObjectKeyFromObject(hr) // Migrates newly created object with finalizer. hr.ObjectMeta.Finalizers = append(hr.ObjectMeta.Finalizers, "foo.bar", sourcev1.SourceFinalizer) hr.Spec = helmv1.HelmRepositorySpec{ Type: helmv1.HelmRepositoryTypeOCI, URL: "oci://foo/bar", Interval: metav1.Duration{Duration: interval}, } g.Expect(testEnv.Create(ctx, hr)).ToNot(HaveOccurred()) g.Eventually(func() bool { _ = testEnv.Get(ctx, hrKey, hr) return !intpredicates.HelmRepositoryOCIRequireMigration(hr) }, timeout, time.Second).Should(BeTrue()) // Migrates updated object with finalizer. patchHelper, err := patch.NewHelper(hr, testEnv.Client) g.Expect(err).ToNot(HaveOccurred()) hr.ObjectMeta.Finalizers = append(hr.ObjectMeta.Finalizers, sourcev1.SourceFinalizer) hr.Spec.URL = "oci://foo/baz" g.Expect(patchHelper.Patch(ctx, hr)).ToNot(HaveOccurred()) g.Eventually(func() bool { _ = testEnv.Get(ctx, hrKey, hr) return !intpredicates.HelmRepositoryOCIRequireMigration(hr) }, timeout, time.Second).Should(BeTrue()) // Migrates deleted object with finalizer. patchHelper, err = patch.NewHelper(hr, testEnv.Client) g.Expect(err).ToNot(HaveOccurred()) // Suspend the object to prevent finalizer from getting removed. // Ensure only flux finalizer is set to allow the object to be garbage // collected at the end. // NOTE: Suspending and updating finalizers are done separately here as // doing them in a single patch results in flaky test where the finalizer // update doesn't gets registered with the kube-apiserver, resulting in // timeout waiting for finalizer to appear on the object below. hr.Spec.Suspend = true g.Expect(patchHelper.Patch(ctx, hr)).ToNot(HaveOccurred()) g.Eventually(func() bool { _ = k8sClient.Get(ctx, hrKey, hr) return hr.Spec.Suspend == true }, timeout).Should(BeTrue()) patchHelper, err = patch.NewHelper(hr, testEnv.Client) g.Expect(err).ToNot(HaveOccurred()) // Add finalizer and verify that finalizer exists on the object using a live // client. hr.ObjectMeta.Finalizers = []string{sourcev1.SourceFinalizer} g.Expect(patchHelper.Patch(ctx, hr)).ToNot(HaveOccurred()) g.Eventually(func() bool { _ = k8sClient.Get(ctx, hrKey, hr) return controllerutil.ContainsFinalizer(hr, sourcev1.SourceFinalizer) }, timeout).Should(BeTrue()) // Delete the object and verify. g.Expect(testEnv.Delete(ctx, hr)).ToNot(HaveOccurred()) g.Eventually(func() bool { if err := testEnv.Get(ctx, hrKey, hr); err != nil { return apierrors.IsNotFound(err) } return false }, timeout).Should(BeTrue()) }