3609 lines
100 KiB
Go
3609 lines
100 KiB
Go
/*
|
|
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"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
. "github.com/onsi/gomega"
|
|
"github.com/opencontainers/go-digest"
|
|
"helm.sh/helm/v3/pkg/chart"
|
|
helmrelease "helm.sh/helm/v3/pkg/release"
|
|
helmstorage "helm.sh/helm/v3/pkg/storage"
|
|
helmdriver "helm.sh/helm/v3/pkg/storage/driver"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/cli-runtime/pkg/genericclioptions"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/clientcmd"
|
|
"k8s.io/client-go/tools/record"
|
|
"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/reconcile"
|
|
"sigs.k8s.io/yaml"
|
|
|
|
"github.com/fluxcd/pkg/apis/acl"
|
|
aclv1 "github.com/fluxcd/pkg/apis/acl"
|
|
"github.com/fluxcd/pkg/apis/meta"
|
|
"github.com/fluxcd/pkg/runtime/conditions"
|
|
feathelper "github.com/fluxcd/pkg/runtime/features"
|
|
"github.com/fluxcd/pkg/runtime/patch"
|
|
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
|
sourcev1beta2 "github.com/fluxcd/source-controller/api/v1beta2"
|
|
|
|
v2 "github.com/fluxcd/helm-controller/api/v2"
|
|
intacl "github.com/fluxcd/helm-controller/internal/acl"
|
|
"github.com/fluxcd/helm-controller/internal/action"
|
|
"github.com/fluxcd/helm-controller/internal/chartutil"
|
|
"github.com/fluxcd/helm-controller/internal/features"
|
|
"github.com/fluxcd/helm-controller/internal/kube"
|
|
"github.com/fluxcd/helm-controller/internal/postrender"
|
|
intreconcile "github.com/fluxcd/helm-controller/internal/reconcile"
|
|
"github.com/fluxcd/helm-controller/internal/release"
|
|
"github.com/fluxcd/helm-controller/internal/testutil"
|
|
"github.com/fluxcd/pkg/apis/kustomize"
|
|
)
|
|
|
|
func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) {
|
|
t.Run("confirms dependencies are ready", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
dependency := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "dependency",
|
|
Namespace: "mock",
|
|
Generation: 1,
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.StalledCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
ObservedGeneration: 1,
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "dependant",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
DependsOn: []meta.NamespacedObjectReference{
|
|
{
|
|
Name: "dependency",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(dependency, obj).
|
|
Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
requeueDependency: 5 * time.Second,
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForDependency))
|
|
g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, meta.DependencyNotReadyReason, "dependency 'mock/dependency' is not ready"),
|
|
}))
|
|
})
|
|
|
|
t.Run("handles HelmChart get failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "mock/chart",
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(obj).
|
|
Build(),
|
|
}
|
|
|
|
_, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "Fulfilling prerequisites"),
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "could not get Source object"),
|
|
}))
|
|
})
|
|
|
|
t.Run("handles ACL error for HelmChart", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "other/chart",
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(obj).
|
|
Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(errors.Is(err, reconcile.TerminalError(nil))).To(BeTrue())
|
|
g.Expect(res.IsZero()).To(BeTrue())
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.StalledCondition, acl.AccessDeniedReason, "cross-namespace references are not allowed"),
|
|
*conditions.FalseCondition(meta.ReadyCondition, acl.AccessDeniedReason, "cross-namespace references are not allowed"),
|
|
}))
|
|
})
|
|
|
|
t.Run("waits for HelmChart to have an Artifact", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: sourcev1.GroupVersion.String(),
|
|
Kind: sourcev1.HelmChartKind,
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 2,
|
|
Artifact: nil,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "mock/chart",
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build(),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForChart))
|
|
g.Expect(res.RequeueAfter).To(Equal(obj.Spec.Interval.Duration))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, "SourceNotReady", "HelmChart 'mock/chart' is not ready"),
|
|
}))
|
|
})
|
|
|
|
t.Run("waits for HelmChart ObservedGeneration to equal Generation", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: sourcev1.GroupVersion.String(),
|
|
Kind: sourcev1.HelmChartKind,
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: &sourcev1.Artifact{},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "mock/chart",
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build(),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForChart))
|
|
g.Expect(res.RequeueAfter).To(Equal(obj.Spec.Interval.Duration))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, "SourceNotReady", "HelmChart 'mock/chart' is not ready"),
|
|
}))
|
|
})
|
|
|
|
t.Run("reports values composition failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Spec: sourcev1.HelmChartSpec{
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 2,
|
|
Artifact: &sourcev1.Artifact{},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ValuesFrom: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "missing",
|
|
},
|
|
},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "mock/chart",
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
_, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "Fulfilling prerequisites"),
|
|
*conditions.FalseCondition(meta.ReadyCondition, "ValuesError", "could not resolve Secret chart values reference 'mock/missing' with key 'values.yaml'"),
|
|
}))
|
|
})
|
|
|
|
t.Run("reports Helm chart load failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 1,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: &sourcev1.Artifact{
|
|
URL: testServer.URL() + "/does-not-exist",
|
|
},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "mock/chart",
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build(),
|
|
requeueDependency: 10 * time.Second,
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForDependency))
|
|
g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "Source not ready"),
|
|
}))
|
|
})
|
|
|
|
t.Run("attempts to adopt v2beta1 release state", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Initialize feature gates.
|
|
g.Expect((&feathelper.FeatureGates{}).SupportedFeatures(features.FeatureGates())).To(Succeed())
|
|
|
|
// Create a test namespace for storing the Helm release mock.
|
|
ns, err := testEnv.CreateNamespace(context.TODO(), "adopt-release")
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), ns)
|
|
})
|
|
|
|
// Create HelmChart mock.
|
|
chartMock := testutil.BuildChart()
|
|
chartArtifact, err := testutil.SaveChartAsArtifact(chartMock, digest.SHA256, testServer.URL(), testServer.Root())
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "adopt-release",
|
|
Namespace: ns.Name,
|
|
Generation: 1,
|
|
},
|
|
Spec: sourcev1.HelmChartSpec{
|
|
Chart: "testdata/test-helmrepo",
|
|
Version: "0.1.0",
|
|
SourceRef: sourcev1.LocalHelmChartSourceReference{
|
|
Kind: sourcev1.HelmRepositoryKind,
|
|
Name: "reconcile-delete",
|
|
},
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: chartArtifact,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a test Helm release storage mock.
|
|
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "adopt-release",
|
|
Namespace: ns.Name,
|
|
Version: 1,
|
|
Chart: chartMock,
|
|
Status: helmrelease.StatusDeployed,
|
|
}, testutil.ReleaseWithConfig(nil))
|
|
valChecksum := chartutil.DigestValues("sha1", rls.Config)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "adopt-release",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
StorageNamespace: ns.Name,
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: chart.Namespace + "/" + chart.Name,
|
|
LastReleaseRevision: rls.Version,
|
|
LastAttemptedValuesChecksum: valChecksum.Encoded(),
|
|
},
|
|
}
|
|
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build()
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
// Store the Helm release mock in the test namespace.
|
|
getter, err := r.buildRESTClientGetter(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.GetStorageNamespace()))
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
store := helmstorage.Init(cfg.Driver)
|
|
g.Expect(store.Create(rls)).To(Succeed())
|
|
|
|
// Reconcile the Helm release.
|
|
_, err = r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Assert that the Helm release has been adopted.
|
|
g.Expect(obj.Status.History).To(testutil.Equal(v2.Snapshots{
|
|
release.ObservedToSnapshot(release.ObserveRelease(rls)),
|
|
}))
|
|
g.Expect(obj.Status.StorageNamespace).To(Equal(ns.Name))
|
|
g.Expect(obj.Status.LastAttemptedConfigDigest).ToNot(BeEmpty())
|
|
g.Expect(obj.Status.LastReleaseRevision).To(Equal(0))
|
|
})
|
|
|
|
t.Run("uninstalls HelmRelease if target has changed", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chartMock := testutil.BuildChart()
|
|
chartArtifact, err := testutil.SaveChartAsArtifact(chartMock, digest.SHA256, testServer.URL(), testServer.Root())
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 1,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: chartArtifact,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
StorageNamespace: "other",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
History: v2.Snapshots{
|
|
{
|
|
Name: "mock",
|
|
Namespace: "mock",
|
|
},
|
|
},
|
|
HelmChart: "mock/chart",
|
|
StorageNamespace: "mock",
|
|
},
|
|
}
|
|
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build()
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, c), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(res.Requeue).To(BeTrue())
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.UninstallSucceededReason, "Release mock/mock.v0 was not found, assuming it is uninstalled"),
|
|
*conditions.FalseCondition(v2.ReleasedCondition, v2.UninstallSucceededReason, "Release mock/mock.v0 was not found, assuming it is uninstalled"),
|
|
}))
|
|
|
|
// Verify history and storage namespace are cleared.
|
|
g.Expect(obj.Status.History).To(BeNil())
|
|
g.Expect(obj.Status.StorageNamespace).To(BeEmpty())
|
|
})
|
|
|
|
t.Run("resets failure counts on configuration change", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chartMock := testutil.BuildChart()
|
|
chartArtifact, err := testutil.SaveChartAsArtifact(chartMock, digest.SHA256, testServer.URL(), testServer.Root())
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 1,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: chartArtifact,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
// Trigger a failure by setting an invalid storage namespace,
|
|
// preventing the release from actually being installed.
|
|
// This allows us to just test the failure count reset, without
|
|
// having to facilitate a full install.
|
|
StorageNamespace: "not-exist",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "mock/chart",
|
|
InstallFailures: 2,
|
|
UpgradeFailures: 3,
|
|
Failures: 5,
|
|
// Trigger actual failure reset due to change in spec.
|
|
LastAttemptedGeneration: 1,
|
|
},
|
|
}
|
|
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build()
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
_, err = r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, c), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring("namespaces \"not-exist\" not found"))
|
|
|
|
// Verify failure counts are reset.
|
|
g.Expect(obj.Status.InstallFailures).To(Equal(int64(0)))
|
|
g.Expect(obj.Status.UpgradeFailures).To(Equal(int64(0)))
|
|
g.Expect(obj.Status.Failures).To(Equal(int64(1)))
|
|
})
|
|
|
|
t.Run("sets last attempted values", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chartMock := testutil.BuildChart()
|
|
chartArtifact, err := testutil.SaveChartAsArtifact(chartMock, digest.SHA256, testServer.URL(), testServer.Root())
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 1,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: chartArtifact,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
// Trigger a failure by setting an invalid storage namespace,
|
|
// preventing the release from actually being installed.
|
|
// This allows us to just test the values being set, without
|
|
// having to facilitate a full install.
|
|
StorageNamespace: "not-exist",
|
|
Values: &apiextensionsv1.JSON{
|
|
Raw: []byte(`{"foo":"bar"}`),
|
|
},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "mock/chart",
|
|
ObservedGeneration: 2,
|
|
// Confirm deprecated value is cleared.
|
|
LastAttemptedValuesChecksum: "b5cbcf5c23cfd945d2cdf0ffaab387a46f2d054f",
|
|
},
|
|
}
|
|
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build()
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
_, err = r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, c), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring("namespaces \"not-exist\" not found"))
|
|
|
|
// Verify attempted values are set.
|
|
g.Expect(obj.Status.LastAttemptedGeneration).To(Equal(obj.Generation))
|
|
g.Expect(obj.Status.LastAttemptedRevision).To(Equal(chartMock.Metadata.Version))
|
|
g.Expect(obj.Status.LastAttemptedConfigDigest).To(Equal("sha256:1dabc4e3cbbd6a0818bd460f3a6c9855bfe95d506c74726bc0f2edb0aecb1f4e"))
|
|
g.Expect(obj.Status.LastAttemptedValuesChecksum).To(BeEmpty())
|
|
})
|
|
|
|
t.Run("error recovery updates ready condition", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Create a test namespace for storing the Helm release mock.
|
|
ns, err := testEnv.CreateNamespace(context.TODO(), "error-recovery")
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), ns)
|
|
})
|
|
|
|
// Create HelmChart mock.
|
|
chartMock := testutil.BuildChart()
|
|
chartArtifact, err := testutil.SaveChartAsArtifact(chartMock, digest.SHA256, testServer.URL(), testServer.Root())
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "error-recovery",
|
|
Namespace: ns.Name,
|
|
Generation: 1,
|
|
},
|
|
Spec: sourcev1.HelmChartSpec{
|
|
Chart: "testdata/test-helmrepo",
|
|
Version: "0.1.0",
|
|
SourceRef: sourcev1.LocalHelmChartSourceReference{
|
|
Kind: sourcev1.HelmRepositoryKind,
|
|
Name: "error-recovery",
|
|
},
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: chartArtifact,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a test Helm release storage mock.
|
|
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "error-recovery",
|
|
Namespace: ns.Name,
|
|
Version: 1,
|
|
Chart: chartMock,
|
|
Status: helmrelease.StatusDeployed,
|
|
}, testutil.ReleaseWithConfig(nil))
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "error-recovery",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
StorageNamespace: ns.Name,
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: chart.Namespace + "/" + chart.Name,
|
|
History: v2.Snapshots{
|
|
release.ObservedToSnapshot(release.ObserveRelease(rls)),
|
|
},
|
|
},
|
|
}
|
|
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build()
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
FieldManager: "test",
|
|
}
|
|
|
|
// Store the Helm release mock in the test namespace.
|
|
getter, err := r.buildRESTClientGetter(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.GetStorageNamespace()))
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
store := helmstorage.Init(cfg.Driver)
|
|
g.Expect(store.Create(rls)).To(Succeed())
|
|
|
|
sp := patch.NewSerialPatcher(obj, r.Client)
|
|
|
|
// List of failure reasons to test.
|
|
prereqFailures := []string{
|
|
v2.DependencyNotReadyReason,
|
|
aclv1.AccessDeniedReason,
|
|
v2.ArtifactFailedReason,
|
|
"SourceNotReady",
|
|
"ValuesError",
|
|
"RESTClientError",
|
|
"FactoryError",
|
|
}
|
|
|
|
// Update ready condition for each failure, reconcile and check if the
|
|
// stale failure condition gets updated.
|
|
for _, failReason := range prereqFailures {
|
|
conditions.MarkFalse(obj, meta.ReadyCondition, failReason, "foo")
|
|
err := sp.Patch(context.TODO(), obj,
|
|
patch.WithOwnedConditions{Conditions: intreconcile.OwnedConditions},
|
|
patch.WithFieldOwner(r.FieldManager),
|
|
)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
_, err = r.reconcileRelease(context.TODO(), sp, obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
ready := conditions.Get(obj, meta.ReadyCondition)
|
|
g.Expect(ready.Status).To(Equal(metav1.ConditionUnknown))
|
|
g.Expect(ready.Reason).To(Equal(meta.ProgressingReason))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_reconcileReleaseFromHelmChartSource(t *testing.T) {
|
|
t.Run("handles chartRef and Chart definition failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: sourcev1.HelmChartKind,
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
},
|
|
Chart: &v2.HelmChartTemplate{
|
|
Spec: v2.HelmChartTemplateSpec{
|
|
Chart: "mychart",
|
|
SourceRef: v2.CrossNamespaceObjectReference{
|
|
Name: "something",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(obj).
|
|
Build(),
|
|
}
|
|
|
|
res, err := r.Reconcile(context.TODO(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{
|
|
Namespace: obj.GetNamespace(),
|
|
Name: obj.GetName(),
|
|
},
|
|
})
|
|
|
|
// only chartRef or Chart must be set
|
|
g.Expect(errors.Is(err, reconcile.TerminalError(fmt.Errorf("invalid Chart reference")))).To(BeTrue())
|
|
g.Expect(res.IsZero()).To(BeTrue())
|
|
})
|
|
|
|
t.Run("handles ChartRef get failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: sourcev1.HelmChartKind,
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(obj).
|
|
Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
_, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "Fulfilling prerequisites"),
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "could not get Source object"),
|
|
}))
|
|
})
|
|
|
|
t.Run("handles ACL error for ChartRef", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: sourcev1.HelmChartKind,
|
|
Name: "chart",
|
|
Namespace: "mock-other",
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(obj).
|
|
Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(errors.Is(err, reconcile.TerminalError(nil))).To(BeTrue())
|
|
g.Expect(res.IsZero()).To(BeTrue())
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.StalledCondition, acl.AccessDeniedReason, "cross-namespace references are not allowed"),
|
|
*conditions.FalseCondition(meta.ReadyCondition, acl.AccessDeniedReason, "cross-namespace references are not allowed"),
|
|
}))
|
|
})
|
|
|
|
t.Run("waits for ChartRef to have an Artifact", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: sourcev1.GroupVersion.String(),
|
|
Kind: sourcev1.HelmChartKind,
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 2,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: sourcev1.HelmChartKind,
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
},
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build(),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForChart))
|
|
g.Expect(res.RequeueAfter).To(Equal(obj.Spec.Interval.Duration))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, "SourceNotReady", "HelmChart 'mock/chart' is not ready"),
|
|
}))
|
|
})
|
|
|
|
t.Run("reports Helm chart load failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Spec: sourcev1.HelmChartSpec{
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 2,
|
|
Artifact: &sourcev1.Artifact{
|
|
URL: testServer.URL() + "/does-not-exist",
|
|
},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: sourcev1.HelmChartKind,
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, obj).
|
|
Build(),
|
|
requeueDependency: 10 * time.Second,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForDependency))
|
|
g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "Source not ready"),
|
|
}))
|
|
})
|
|
t.Run("report helmChart load failure when switching from existing HelmChat to chartRef", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 1,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: &sourcev1.Artifact{},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
sharedChart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "sharedChart",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Spec: sourcev1.HelmChartSpec{
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 2,
|
|
Artifact: &sourcev1.Artifact{
|
|
URL: testServer.URL() + "/does-not-exist",
|
|
},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: sourcev1.HelmChartKind,
|
|
Name: "sharedChart",
|
|
Namespace: "mock",
|
|
},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "mock/chart",
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, sharedChart, obj).
|
|
Build(),
|
|
requeueDependency: 10 * time.Second,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForDependency))
|
|
g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "Source not ready"),
|
|
}))
|
|
})
|
|
|
|
t.Run("reports postrenderer changes", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
patches := `
|
|
- target:
|
|
version: v1
|
|
kind: ConfigMap
|
|
name: cm
|
|
patch: |
|
|
- op: add
|
|
path: /metadata/annotations/foo
|
|
value: bar
|
|
`
|
|
|
|
patches2 := `
|
|
- target:
|
|
version: v1
|
|
kind: ConfigMap
|
|
name: cm
|
|
patch: |
|
|
- op: add
|
|
path: /metadata/annotations/foo2
|
|
value: bar2
|
|
`
|
|
|
|
var targeted []kustomize.Patch
|
|
err := yaml.Unmarshal([]byte(patches), &targeted)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Create HelmChart mock.
|
|
chartMock := testutil.BuildChart()
|
|
chartArtifact, err := testutil.SaveChartAsArtifact(chartMock, digest.SHA256, testServer.URL(), testServer.Root())
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
ns, err := testEnv.CreateNamespace(context.TODO(), "mock")
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), ns)
|
|
})
|
|
|
|
hc := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: ns.Name,
|
|
Generation: 1,
|
|
},
|
|
Spec: sourcev1.HelmChartSpec{
|
|
Chart: "testdata/test-helmrepo",
|
|
Version: "0.1.0",
|
|
SourceRef: sourcev1.LocalHelmChartSourceReference{
|
|
Kind: sourcev1.HelmRepositoryKind,
|
|
Name: "test-helmrepo",
|
|
},
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: chartArtifact,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a test Helm release storage mock.
|
|
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "release",
|
|
Namespace: ns.Name,
|
|
Version: 1,
|
|
Chart: chartMock,
|
|
Status: helmrelease.StatusDeployed,
|
|
})
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: sourcev1.HelmChartKind,
|
|
Name: hc.Name,
|
|
},
|
|
PostRenderers: []v2.PostRenderer{
|
|
{
|
|
Kustomize: &v2.Kustomize{
|
|
Patches: targeted,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: ns.Name,
|
|
History: v2.Snapshots{
|
|
release.ObservedToSnapshot(release.ObserveRelease(rls)),
|
|
},
|
|
HelmChart: hc.Namespace + "/" + hc.Name,
|
|
},
|
|
}
|
|
|
|
obj.Status.ObservedPostRenderersDigest = postrender.Digest(digest.Canonical, obj.Spec.PostRenderers).String()
|
|
obj.Status.LastAttemptedConfigDigest = chartutil.DigestValues(digest.Canonical, chartMock.Values).String()
|
|
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(hc, obj).
|
|
Build()
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
//Store the Helm release mock in the test namespace.
|
|
getter, err := r.buildRESTClientGetter(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.Status.StorageNamespace))
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
store := helmstorage.Init(cfg.Driver)
|
|
g.Expect(store.Create(rls)).To(Succeed())
|
|
|
|
// update the postrenderers
|
|
err = yaml.Unmarshal([]byte(patches2), &targeted)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
obj.Spec.PostRenderers[0].Kustomize.Patches = targeted
|
|
|
|
_, err = r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify attempted values are set.
|
|
g.Expect(obj.Status.LastAttemptedGeneration).To(Equal(obj.Generation))
|
|
g.Expect(obj.Status.ObservedPostRenderersDigest).To(Equal(postrender.Digest(digest.Canonical, obj.Spec.PostRenderers).String()))
|
|
|
|
// verify upgrade succeeded
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReadyCondition, v2.UpgradeSucceededReason, "Helm upgrade succeeded for release %s with chart %s",
|
|
fmt.Sprintf("%s/%s.v%d", rls.Namespace, rls.Name, rls.Version+1), fmt.Sprintf("%s@%s", chartMock.Name(),
|
|
chartMock.Metadata.Version)),
|
|
*conditions.TrueCondition(v2.ReleasedCondition, v2.UpgradeSucceededReason, "Helm upgrade succeeded for release %s with chart %s",
|
|
fmt.Sprintf("%s/%s.v%d", rls.Namespace, rls.Name, rls.Version+1), fmt.Sprintf("%s@%s", chartMock.Name(),
|
|
chartMock.Metadata.Version)),
|
|
}))
|
|
|
|
})
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testing.T) {
|
|
|
|
t.Run("handles chartRef and Chart definition failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: "OCIRepository",
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
},
|
|
Chart: &v2.HelmChartTemplate{
|
|
Spec: v2.HelmChartTemplateSpec{
|
|
Chart: "mychart",
|
|
SourceRef: v2.CrossNamespaceObjectReference{
|
|
Name: "something",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(obj).
|
|
Build(),
|
|
}
|
|
|
|
res, err := r.Reconcile(context.TODO(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{
|
|
Namespace: obj.GetNamespace(),
|
|
Name: obj.GetName(),
|
|
},
|
|
})
|
|
|
|
// only chartRef or Chart must be set
|
|
g.Expect(errors.Is(err, reconcile.TerminalError(fmt.Errorf("invalid Chart reference")))).To(BeTrue())
|
|
g.Expect(res.IsZero()).To(BeTrue())
|
|
})
|
|
t.Run("handles ChartRef get failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: "OCIRepository",
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(obj).
|
|
Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
_, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "Fulfilling prerequisites"),
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "could not get Source object"),
|
|
}))
|
|
})
|
|
|
|
t.Run("handles ACL error for ChartRef", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: "OCIRepository",
|
|
Name: "ocirepo",
|
|
Namespace: "mock-other",
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(obj).
|
|
Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(errors.Is(err, reconcile.TerminalError(nil))).To(BeTrue())
|
|
g.Expect(res.IsZero()).To(BeTrue())
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.StalledCondition, acl.AccessDeniedReason, "cross-namespace references are not allowed"),
|
|
*conditions.FalseCondition(meta.ReadyCondition, acl.AccessDeniedReason, "cross-namespace references are not allowed"),
|
|
}))
|
|
})
|
|
|
|
t.Run("waits for ChartRef to have an Artifact", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
ocirepo := &sourcev1beta2.OCIRepository{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: sourcev1beta2.GroupVersion.String(),
|
|
Kind: sourcev1beta2.OCIRepositoryKind,
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Status: sourcev1beta2.OCIRepositoryStatus{
|
|
ObservedGeneration: 2,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: "OCIRepository",
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
},
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(ocirepo, obj).
|
|
Build(),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForChart))
|
|
g.Expect(res.RequeueAfter).To(Equal(obj.Spec.Interval.Duration))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, "SourceNotReady", "OCIRepository 'mock/ocirepo' is not ready"),
|
|
}))
|
|
})
|
|
|
|
t.Run("reports values composition failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
ocirepo := &sourcev1beta2.OCIRepository{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Spec: sourcev1beta2.OCIRepositorySpec{
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: sourcev1beta2.OCIRepositoryStatus{
|
|
ObservedGeneration: 2,
|
|
Artifact: &sourcev1.Artifact{},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: "OCIRepository",
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
},
|
|
ValuesFrom: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "missing",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(ocirepo, obj).
|
|
Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
_, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "Fulfilling prerequisites"),
|
|
*conditions.FalseCondition(meta.ReadyCondition, "ValuesError", "could not resolve Secret chart values reference 'mock/missing' with key 'values.yaml'"),
|
|
}))
|
|
})
|
|
|
|
t.Run("reports Helm chart load failure", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
ocirepo := &sourcev1beta2.OCIRepository{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Spec: sourcev1beta2.OCIRepositorySpec{
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: sourcev1beta2.OCIRepositoryStatus{
|
|
ObservedGeneration: 2,
|
|
Artifact: &sourcev1.Artifact{
|
|
URL: testServer.URL() + "/does-not-exist",
|
|
},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: "OCIRepository",
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(ocirepo, obj).
|
|
Build(),
|
|
requeueDependency: 10 * time.Second,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForDependency))
|
|
g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "Source not ready"),
|
|
}))
|
|
})
|
|
t.Run("report helmChart load failure when switching from existing HelmChat to chartRef", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: "mock",
|
|
Generation: 1,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: &sourcev1.Artifact{},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
ocirepo := &sourcev1beta2.OCIRepository{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
Generation: 2,
|
|
},
|
|
Spec: sourcev1beta2.OCIRepositorySpec{
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: sourcev1beta2.OCIRepositoryStatus{
|
|
ObservedGeneration: 2,
|
|
Artifact: &sourcev1.Artifact{
|
|
URL: testServer.URL() + "/does-not-exist",
|
|
},
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: "OCIRepository",
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "mock/chart",
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(chart, ocirepo, obj).
|
|
Build(),
|
|
requeueDependency: 10 * time.Second,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(Equal(errWaitForDependency))
|
|
g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency))
|
|
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""),
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "Source not ready"),
|
|
}))
|
|
})
|
|
|
|
t.Run("handle chartRef mutable tag", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Create HelmChart mock.
|
|
chartMock := testutil.BuildChart()
|
|
chartArtifact, err := testutil.SaveChartAsArtifact(chartMock, digest.SHA256, testServer.URL(), testServer.Root())
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
chartArtifact.Revision += "@" + chartArtifact.Digest
|
|
|
|
ocirepo := &sourcev1beta2.OCIRepository{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
Generation: 1,
|
|
},
|
|
Spec: sourcev1beta2.OCIRepositorySpec{
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: sourcev1beta2.OCIRepositoryStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: chartArtifact,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: "mock",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: "OCIRepository",
|
|
Name: "ocirepo",
|
|
Namespace: "mock",
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(ocirepo, obj).
|
|
Build(),
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
_, err = r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring("namespaces \"mock\" not found"))
|
|
|
|
// Verify attempted values are set.
|
|
g.Expect(obj.Status.LastAttemptedGeneration).To(Equal(obj.Generation))
|
|
dig := strings.Split(chartArtifact.Revision, ":")[1][0:12]
|
|
g.Expect(obj.Status.LastAttemptedRevision).To(Equal(chartMock.Metadata.Version + "+" + dig))
|
|
g.Expect(obj.Status.LastAttemptedConfigDigest).To(Equal("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
|
g.Expect(obj.Status.LastAttemptedValuesChecksum).To(BeEmpty())
|
|
|
|
// change the chart revision to simulate a new digest
|
|
chartArtifact.Revision = chartMock.Metadata.Version + "@" + "sha256:adebc5e3cbcd6a0918bd470f3a6c9855bfe95d506c74726bc0f2edb0aecb1f4e"
|
|
ocirepo.Status.Artifact = chartArtifact
|
|
r.Client.Update(context.Background(), ocirepo)
|
|
|
|
_, err = r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring("namespaces \"mock\" not found"))
|
|
|
|
// Verify attempted values are set.
|
|
g.Expect(obj.Status.LastAttemptedGeneration).To(Equal(obj.Generation))
|
|
dig = strings.Split(chartArtifact.Revision, ":")[1][0:12]
|
|
g.Expect(obj.Status.LastAttemptedRevision).To(Equal(chartMock.Metadata.Version + "+" + dig))
|
|
g.Expect(obj.Status.LastAttemptedConfigDigest).To(Equal("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
|
g.Expect(obj.Status.LastAttemptedValuesChecksum).To(BeEmpty())
|
|
})
|
|
|
|
t.Run("upgrade by switching from existing HelmChat to chartRef", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Create HelmChart mock.
|
|
chartMock := testutil.BuildChart()
|
|
chartArtifact, err := testutil.SaveChartAsArtifact(chartMock, digest.SHA256, testServer.URL(), testServer.Root())
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
// copy the artifact to mutate the revision
|
|
ociArtifact := chartArtifact.DeepCopy()
|
|
ociArtifact.Revision += "@" + chartArtifact.Digest
|
|
|
|
ns, err := testEnv.CreateNamespace(context.TODO(), "mock")
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), ns)
|
|
})
|
|
|
|
// hc is the HelmChart object created by the HelmRelease object.
|
|
hc := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "chart",
|
|
Namespace: ns.Name,
|
|
Generation: 1,
|
|
},
|
|
Spec: sourcev1.HelmChartSpec{
|
|
Chart: "testdata/test-helmrepo",
|
|
Version: "0.1.0",
|
|
SourceRef: sourcev1.LocalHelmChartSourceReference{
|
|
Kind: sourcev1.HelmRepositoryKind,
|
|
Name: "test-helmrepo",
|
|
},
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: chartArtifact,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// ocirepo is the chartRef object to switch to.
|
|
ocirepo := &sourcev1beta2.OCIRepository{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "ocirepo",
|
|
Namespace: ns.Name,
|
|
Generation: 1,
|
|
},
|
|
Spec: sourcev1beta2.OCIRepositorySpec{
|
|
URL: "oci://test-example.com",
|
|
Interval: metav1.Duration{Duration: 1 * time.Second},
|
|
},
|
|
Status: sourcev1beta2.OCIRepositoryStatus{
|
|
ObservedGeneration: 1,
|
|
Artifact: ociArtifact,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a test Helm release storage mock.
|
|
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "release",
|
|
Namespace: ns.Name,
|
|
Version: 1,
|
|
Chart: chartMock,
|
|
Status: helmrelease.StatusDeployed,
|
|
})
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "release",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ChartRef: &v2.CrossNamespaceSourceReference{
|
|
Kind: "OCIRepository",
|
|
Name: "ocirepo",
|
|
},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: ns.Name,
|
|
History: v2.Snapshots{
|
|
release.ObservedToSnapshot(release.ObserveRelease(rls)),
|
|
},
|
|
HelmChart: hc.Namespace + "/" + hc.Name,
|
|
},
|
|
}
|
|
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(NewTestScheme()).
|
|
WithStatusSubresource(&v2.HelmRelease{}).
|
|
WithObjects(hc, ocirepo, obj).
|
|
Build()
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
// Store the Helm release mock in the test namespace.
|
|
getter, err := r.buildRESTClientGetter(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.Status.StorageNamespace))
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
store := helmstorage.Init(cfg.Driver)
|
|
g.Expect(store.Create(rls)).To(Succeed())
|
|
|
|
_, err = r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify attempted values are set.
|
|
g.Expect(obj.Status.LastAttemptedGeneration).To(Equal(obj.Generation))
|
|
dig := strings.Split(ociArtifact.Revision, ":")[1][0:12]
|
|
g.Expect(obj.Status.LastAttemptedRevision).To(Equal(chartMock.Metadata.Version + "+" + dig))
|
|
g.Expect(obj.Status.LastAttemptedConfigDigest).To(Equal("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
|
g.Expect(obj.Status.LastAttemptedValuesChecksum).To(BeEmpty())
|
|
|
|
// verify upgrade succeeded
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.TrueCondition(meta.ReadyCondition, v2.UpgradeSucceededReason, "Helm upgrade succeeded for release %s with chart %s",
|
|
fmt.Sprintf("%s/%s.v%d", rls.Namespace, rls.Name, rls.Version+1), fmt.Sprintf("%s@%s", chartMock.Name(),
|
|
fmt.Sprintf("%s+%s", chartMock.Metadata.Version, dig))),
|
|
*conditions.TrueCondition(v2.ReleasedCondition, v2.UpgradeSucceededReason, "Helm upgrade succeeded for release %s with chart %s",
|
|
fmt.Sprintf("%s/%s.v%d", rls.Namespace, rls.Name, rls.Version+1), fmt.Sprintf("%s@%s", chartMock.Name(),
|
|
fmt.Sprintf("%s+%s", chartMock.Metadata.Version, dig))),
|
|
}))
|
|
})
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_reconcileDelete(t *testing.T) {
|
|
t.Run("uninstalls Helm release and removes chart", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Create a test namespace for storing the Helm release mock.
|
|
ns, err := testEnv.CreateNamespace(context.TODO(), "reconcile-delete")
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), ns)
|
|
})
|
|
|
|
// Create HelmChart mock.
|
|
hc := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: sourcev1.HelmChartSpec{
|
|
Chart: "testdata/test-helmrepo",
|
|
Version: "0.1.0",
|
|
SourceRef: sourcev1.LocalHelmChartSourceReference{
|
|
Kind: sourcev1.HelmRepositoryKind,
|
|
Name: "reconcile-delete",
|
|
},
|
|
},
|
|
}
|
|
g.Expect(testEnv.Create(context.TODO(), hc)).To(Succeed())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), hc)
|
|
})
|
|
|
|
// Create a test Helm release storage mock.
|
|
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "reconcile-delete",
|
|
Namespace: ns.Name,
|
|
Version: 1,
|
|
Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
|
|
Status: helmrelease.StatusDeployed,
|
|
})
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: ns.Name,
|
|
Finalizers: []string{v2.HelmReleaseFinalizer},
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: ns.Name,
|
|
History: v2.Snapshots{
|
|
release.ObservedToSnapshot(release.ObserveRelease(rls)),
|
|
},
|
|
HelmChart: hc.Namespace + "/" + hc.Name,
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: testEnv.Client,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
// Store the Helm release mock in the test namespace.
|
|
getter, err := r.buildRESTClientGetter(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.Status.StorageNamespace))
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
store := helmstorage.Init(cfg.Driver)
|
|
g.Expect(store.Create(rls)).To(Succeed())
|
|
|
|
// Reconcile the actual deletion of the Helm release.
|
|
res, err := r.reconcileDelete(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(res.IsZero()).To(BeTrue())
|
|
|
|
// Verify Helm release has been uninstalled.
|
|
_, err = store.History(rls.Name)
|
|
g.Expect(err).To(MatchError(helmdriver.ErrReleaseNotFound))
|
|
|
|
// Verify Helm chart has been removed.
|
|
g.Eventually(func(g Gomega) {
|
|
err = testEnv.Get(context.TODO(), client.ObjectKey{
|
|
Namespace: hc.Namespace,
|
|
Name: hc.Name,
|
|
}, &sourcev1.HelmChart{})
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
|
|
}).Should(Succeed())
|
|
})
|
|
|
|
t.Run("removes finalizer for suspended resource with DeletionTimestamp", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Finalizers: []string{v2.HelmReleaseFinalizer, "other-finalizer"},
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
Suspend: true,
|
|
},
|
|
}
|
|
|
|
res, err := (&HelmReleaseReconciler{}).reconcileDelete(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(res.IsZero()).To(BeTrue())
|
|
|
|
g.Expect(obj.GetFinalizers()).To(ConsistOf("other-finalizer"))
|
|
})
|
|
|
|
t.Run("does not remove finalizer when DeletionTimestamp is not set", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Finalizers: []string{v2.HelmReleaseFinalizer},
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
Suspend: true,
|
|
},
|
|
}
|
|
|
|
res, err := (&HelmReleaseReconciler{}).reconcileDelete(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(res.Requeue).To(BeTrue())
|
|
|
|
g.Expect(obj.GetFinalizers()).To(ConsistOf(v2.HelmReleaseFinalizer))
|
|
})
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_reconcileReleaseDeletion(t *testing.T) {
|
|
t.Run("uninstalls Helm release", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Create a test namespace for storing the Helm release mock.
|
|
ns, err := testEnv.CreateNamespace(context.TODO(), "reconcile-release-deletion")
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), ns)
|
|
})
|
|
|
|
// Create a test Helm release storage mock.
|
|
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "reconcile-delete",
|
|
Namespace: ns.Name,
|
|
Version: 1,
|
|
Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
|
|
Status: helmrelease.StatusDeployed,
|
|
})
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: ns.Name,
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: ns.Name,
|
|
History: v2.Snapshots{
|
|
release.ObservedToSnapshot(release.ObserveRelease(rls)),
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: testEnv.Client,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
// Store the Helm release mock in the test namespace.
|
|
getter, err := r.buildRESTClientGetter(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.Status.StorageNamespace))
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
store := helmstorage.Init(cfg.Driver)
|
|
g.Expect(store.Create(rls)).To(Succeed())
|
|
|
|
// Reconcile the actual deletion of the Helm release.
|
|
err = r.reconcileReleaseDeletion(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify status of Helm release has been updated.
|
|
g.Expect(obj.Status.StorageNamespace).To(BeEmpty())
|
|
g.Expect(obj.Status.History).To(BeNil())
|
|
|
|
// Verify Helm release has been uninstalled.
|
|
_, err = store.History(rls.Name)
|
|
g.Expect(err).To(MatchError(helmdriver.ErrReleaseNotFound))
|
|
})
|
|
|
|
t.Run("skip uninstalling Helm release when KubeConfig Secret is missing", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Create a test namespace for storing the Helm release mock.
|
|
ns, err := testEnv.CreateNamespace(context.TODO(), "reconcile-release-deletion")
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), ns)
|
|
})
|
|
|
|
// Create a test Helm release storage mock.
|
|
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "reconcile-delete",
|
|
Namespace: ns.Name,
|
|
Version: 1,
|
|
Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
|
|
Status: helmrelease.StatusDeployed,
|
|
})
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: ns.Name,
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: ns.Name,
|
|
History: v2.Snapshots{
|
|
release.ObservedToSnapshot(release.ObserveRelease(rls)),
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: testEnv.Client,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
}
|
|
|
|
// Store the Helm release mock in the test namespace.
|
|
getter, err := r.buildRESTClientGetter(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.Status.StorageNamespace))
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
store := helmstorage.Init(cfg.Driver)
|
|
g.Expect(store.Create(rls)).To(Succeed())
|
|
|
|
// Reconcile the actual deletion of the Helm release.
|
|
obj.Spec.KubeConfig = &meta.KubeConfigReference{
|
|
SecretRef: meta.SecretKeyReference{
|
|
Name: "missing-secret",
|
|
},
|
|
}
|
|
err = r.reconcileReleaseDeletion(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify status of Helm release has not been updated.
|
|
g.Expect(obj.Status.StorageNamespace).ToNot(BeEmpty())
|
|
g.Expect(obj.Status.History.Latest()).ToNot(BeNil())
|
|
|
|
// Verify Helm release has not been uninstalled.
|
|
_, err = store.History(rls.Name)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
t.Run("error when REST client getter construction fails", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
mockErr := errors.New("mock error")
|
|
r := &HelmReleaseReconciler{
|
|
Client: testEnv.Client,
|
|
GetClusterConfig: func() (*rest.Config, error) {
|
|
return nil, mockErr
|
|
},
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: "mock",
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: "mock",
|
|
},
|
|
}
|
|
|
|
// Reconcile the actual deletion of the Helm release.
|
|
err := r.reconcileReleaseDeletion(context.TODO(), obj)
|
|
g.Expect(errors.Is(err, mockErr)).To(BeTrue())
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.UninstallFailedReason,
|
|
"failed to build REST client getter to uninstall release"),
|
|
}))
|
|
|
|
// Verify status of Helm release has not been updated.
|
|
g.Expect(obj.Status.StorageNamespace).ToNot(BeEmpty())
|
|
})
|
|
|
|
t.Run("skip uninstalling Helm release when ServiceAccount is missing", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Create a test namespace for storing the Helm release mock.
|
|
ns, err := testEnv.CreateNamespace(context.TODO(), "reconcile-release-deletion")
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), ns)
|
|
})
|
|
|
|
// Create a test Helm release storage mock.
|
|
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "reconcile-delete",
|
|
Namespace: ns.Name,
|
|
Version: 1,
|
|
Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
|
|
Status: helmrelease.StatusDeployed,
|
|
})
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: ns.Name,
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: ns.Name,
|
|
History: v2.Snapshots{
|
|
release.ObservedToSnapshot(release.ObserveRelease(rls)),
|
|
},
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: testEnv.Client,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
}
|
|
|
|
// Store the Helm release mock in the test namespace.
|
|
getter, err := r.buildRESTClientGetter(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.Status.StorageNamespace))
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
store := helmstorage.Init(cfg.Driver)
|
|
g.Expect(store.Create(rls)).To(Succeed())
|
|
|
|
// Reconcile the actual deletion of the Helm release.
|
|
obj.Spec.ServiceAccountName = "missing-sa"
|
|
err = r.reconcileReleaseDeletion(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify status of Helm release has not been updated.
|
|
g.Expect(obj.Status.StorageNamespace).ToNot(BeEmpty())
|
|
g.Expect(obj.Status.History.Latest()).ToNot(BeNil())
|
|
|
|
// Verify Helm release has not been uninstalled.
|
|
_, err = store.History(rls.Name)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
t.Run("error when ServiceAccount existence check fails", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
var (
|
|
serviceAccount = "missing-sa"
|
|
namespace = "mock"
|
|
mockErr = errors.New("mock error")
|
|
)
|
|
|
|
c := fake.NewClientBuilder().WithInterceptorFuncs(interceptor.Funcs{
|
|
Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
|
|
if key.Name == serviceAccount && key.Namespace == namespace {
|
|
return mockErr
|
|
}
|
|
return client.Get(ctx, key, obj, opts...)
|
|
},
|
|
})
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c.Build(),
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: namespace,
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
ServiceAccountName: serviceAccount,
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: namespace,
|
|
},
|
|
}
|
|
|
|
// Reconcile the actual deletion of the Helm release.
|
|
err := r.reconcileReleaseDeletion(context.TODO(), obj)
|
|
g.Expect(errors.Is(err, mockErr)).To(BeTrue())
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.UninstallFailedReason,
|
|
"failed to confirm ServiceAccount '%s' can be used to uninstall release", serviceAccount),
|
|
}))
|
|
|
|
// Verify status of Helm release has not been updated.
|
|
g.Expect(obj.Status.StorageNamespace).ToNot(BeEmpty())
|
|
})
|
|
|
|
t.Run("error when Helm release uninstallation fails", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: testEnv.Client,
|
|
GetClusterConfig: func() (*rest.Config, error) {
|
|
return &rest.Config{
|
|
Host: "https://failing-mock.local",
|
|
}, nil
|
|
},
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: "mock",
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: "mock",
|
|
History: v2.Snapshots{
|
|
{},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := r.reconcileReleaseDeletion(context.TODO(), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{
|
|
*conditions.FalseCondition(meta.ReadyCondition, v2.UninstallFailedReason, "Kubernetes cluster unreachable"),
|
|
*conditions.FalseCondition(v2.ReleasedCondition, v2.UninstallFailedReason, "Kubernetes cluster unreachable"),
|
|
}))
|
|
})
|
|
|
|
t.Run("ignores ErrNoLatest when uninstalling Helm release", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: "mock",
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: "mock",
|
|
},
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: testEnv.Client,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
}
|
|
|
|
// Reconcile the actual deletion of the Helm release.
|
|
err := r.reconcileReleaseDeletion(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify status of Helm release been updated.
|
|
g.Expect(obj.Status.StorageNamespace).To(BeEmpty())
|
|
})
|
|
|
|
t.Run("error when DeletionTimestamp is not set", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: "mock",
|
|
},
|
|
}
|
|
|
|
err := (&HelmReleaseReconciler{}).reconcileReleaseDeletion(context.TODO(), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring("deletion timestamp is not set"))
|
|
})
|
|
|
|
t.Run("skip uninstalling Helm release when StorageNamespace is missing", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "reconcile-delete",
|
|
Namespace: "mock",
|
|
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
|
},
|
|
}
|
|
|
|
err := (&HelmReleaseReconciler{}).reconcileReleaseDeletion(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_reconcileChartTemplate(t *testing.T) {
|
|
t.Run("attempts to reconcile chart template", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: fake.NewClientBuilder().WithScheme(NewTestScheme()).Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
obj := &v2.HelmRelease{
|
|
Spec: v2.HelmReleaseSpec{
|
|
Chart: &v2.HelmChartTemplate{},
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: "default",
|
|
},
|
|
}
|
|
|
|
// We do not care about the result of the reconcile, only that it was attempted.
|
|
err := r.reconcileChartTemplate(context.TODO(), obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring("failed to run server-side apply"))
|
|
})
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_reconcileUninstall(t *testing.T) {
|
|
t.Run("attempts to uninstall release", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
getter := kube.NewMemoryRESTClientGetter(testEnv.GetConfig())
|
|
|
|
obj := &v2.HelmRelease{
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: "default",
|
|
},
|
|
}
|
|
|
|
// We do not care about the result of the uninstall, only that it was attempted.
|
|
err := (&HelmReleaseReconciler{}).reconcileUninstall(context.TODO(), getter, obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(errors.Is(err, intreconcile.ErrNoLatest)).To(BeTrue())
|
|
})
|
|
|
|
t.Run("error on empty storage namespace", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
obj := &v2.HelmRelease{
|
|
Status: v2.HelmReleaseStatus{
|
|
StorageNamespace: "",
|
|
},
|
|
}
|
|
|
|
err := (&HelmReleaseReconciler{}).reconcileUninstall(context.TODO(), nil, obj)
|
|
g.Expect(err).To(HaveOccurred())
|
|
|
|
g.Expect(conditions.IsFalse(obj, meta.ReadyCondition)).To(BeTrue())
|
|
g.Expect(conditions.GetReason(obj, meta.ReadyCondition)).To(Equal("ConfigFactoryErr"))
|
|
g.Expect(conditions.GetMessage(obj, meta.ReadyCondition)).To(ContainSubstring("no namespace provided"))
|
|
g.Expect(obj.GetConditions()).To(HaveLen(1))
|
|
})
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_checkDependencies(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
obj *v2.HelmRelease
|
|
objects []client.Object
|
|
expect func(g *WithT, err error)
|
|
}{
|
|
{
|
|
name: "all dependencies ready",
|
|
obj: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "dependant",
|
|
Namespace: "some-namespace",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
DependsOn: []meta.NamespacedObjectReference{
|
|
{
|
|
Name: "dependency-1",
|
|
},
|
|
{
|
|
Name: "dependency-2",
|
|
Namespace: "some-other-namespace",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
objects: []client.Object{
|
|
&v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Generation: 1,
|
|
Name: "dependency-1",
|
|
Namespace: "some-namespace",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
ObservedGeneration: 1,
|
|
Conditions: []metav1.Condition{
|
|
{Type: meta.ReadyCondition, Status: metav1.ConditionTrue},
|
|
},
|
|
},
|
|
},
|
|
&v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Generation: 2,
|
|
Name: "dependency-2",
|
|
Namespace: "some-other-namespace",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
ObservedGeneration: 2,
|
|
Conditions: []metav1.Condition{
|
|
{Type: meta.ReadyCondition, Status: metav1.ConditionTrue},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expect: func(g *WithT, err error) {
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
},
|
|
},
|
|
{
|
|
name: "error on dependency with ObservedGeneration < Generation",
|
|
obj: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "dependant",
|
|
Namespace: "some-namespace",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
DependsOn: []meta.NamespacedObjectReference{
|
|
{
|
|
Name: "dependency-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
objects: []client.Object{
|
|
&v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Generation: 2,
|
|
Name: "dependency-1",
|
|
Namespace: "some-namespace",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
ObservedGeneration: 1,
|
|
Conditions: []metav1.Condition{
|
|
{Type: meta.ReadyCondition, Status: metav1.ConditionTrue},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expect: func(g *WithT, err error) {
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring("is not ready"))
|
|
},
|
|
},
|
|
{
|
|
name: "error on dependency with ObservedGeneration = Generation and ReadyCondition = False",
|
|
obj: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "dependant",
|
|
Namespace: "some-namespace",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
DependsOn: []meta.NamespacedObjectReference{
|
|
{
|
|
Name: "dependency-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
objects: []client.Object{
|
|
&v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Generation: 1,
|
|
Name: "dependency-1",
|
|
Namespace: "some-namespace",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
ObservedGeneration: 1,
|
|
Conditions: []metav1.Condition{
|
|
{Type: meta.ReadyCondition, Status: metav1.ConditionFalse},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expect: func(g *WithT, err error) {
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring("is not ready"))
|
|
},
|
|
},
|
|
{
|
|
name: "error on dependency without conditions",
|
|
obj: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "dependant",
|
|
Namespace: "some-namespace",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
DependsOn: []meta.NamespacedObjectReference{
|
|
{
|
|
Name: "dependency-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
objects: []client.Object{
|
|
&v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Generation: 1,
|
|
Name: "dependency-1",
|
|
Namespace: "some-namespace",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
ObservedGeneration: 1,
|
|
},
|
|
},
|
|
},
|
|
expect: func(g *WithT, err error) {
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring("is not ready"))
|
|
},
|
|
},
|
|
{
|
|
name: "error on missing dependency",
|
|
obj: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "dependant",
|
|
Namespace: "some-namespace",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
DependsOn: []meta.NamespacedObjectReference{
|
|
{
|
|
Name: "dependency-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expect: func(g *WithT, err error) {
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
c := fake.NewClientBuilder().WithScheme(NewTestScheme())
|
|
if len(tt.objects) > 0 {
|
|
c.WithObjects(tt.objects...)
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c.Build(),
|
|
}
|
|
|
|
err := r.checkDependencies(context.TODO(), tt.obj)
|
|
tt.expect(g, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_adoptLegacyRelease(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
releases func(namespace string) []*helmrelease.Release
|
|
spec func(spec *v2.HelmReleaseSpec)
|
|
status v2.HelmReleaseStatus
|
|
expectHistory func(releases []*helmrelease.Release) v2.Snapshots
|
|
expectLastReleaseRevision int
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "adopts last release revision",
|
|
releases: func(namespace string) []*helmrelease.Release {
|
|
return []*helmrelease.Release{
|
|
testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "orphaned",
|
|
Namespace: namespace,
|
|
Version: 6,
|
|
Chart: testutil.BuildChart(),
|
|
Status: helmrelease.StatusDeployed,
|
|
}, testutil.ReleaseWithTestHook()),
|
|
}
|
|
},
|
|
spec: func(spec *v2.HelmReleaseSpec) {
|
|
spec.ReleaseName = "orphaned"
|
|
},
|
|
status: v2.HelmReleaseStatus{
|
|
LastReleaseRevision: 6,
|
|
},
|
|
expectHistory: func(releases []*helmrelease.Release) v2.Snapshots {
|
|
return v2.Snapshots{
|
|
release.ObservedToSnapshot(release.ObserveRelease(releases[0])),
|
|
}
|
|
},
|
|
expectLastReleaseRevision: 0,
|
|
},
|
|
{
|
|
name: "includes test hooks if enabled",
|
|
releases: func(namespace string) []*helmrelease.Release {
|
|
return []*helmrelease.Release{
|
|
testutil.BuildRelease(&helmrelease.MockReleaseOptions{
|
|
Name: "orphaned-with-hooks",
|
|
Namespace: namespace,
|
|
Version: 3,
|
|
Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
|
|
Status: helmrelease.StatusDeployed,
|
|
}, testutil.ReleaseWithTestHook()),
|
|
}
|
|
},
|
|
spec: func(spec *v2.HelmReleaseSpec) {
|
|
spec.ReleaseName = "orphaned-with-hooks"
|
|
spec.Test = &v2.Test{
|
|
Enable: true,
|
|
}
|
|
},
|
|
status: v2.HelmReleaseStatus{
|
|
LastReleaseRevision: 3,
|
|
},
|
|
expectHistory: func(releases []*helmrelease.Release) v2.Snapshots {
|
|
snap := release.ObservedToSnapshot(release.ObserveRelease(releases[0]))
|
|
snap.SetTestHooks(release.TestHooksFromRelease(releases[0]))
|
|
|
|
return v2.Snapshots{
|
|
snap,
|
|
}
|
|
},
|
|
expectLastReleaseRevision: 0,
|
|
},
|
|
{
|
|
name: "non-existing release",
|
|
spec: func(spec *v2.HelmReleaseSpec) {
|
|
spec.ReleaseName = "non-existing"
|
|
},
|
|
status: v2.HelmReleaseStatus{
|
|
LastReleaseRevision: 2,
|
|
},
|
|
expectLastReleaseRevision: 2,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "without last release revision",
|
|
status: v2.HelmReleaseStatus{
|
|
LastReleaseRevision: 0,
|
|
},
|
|
expectHistory: func(releases []*helmrelease.Release) v2.Snapshots {
|
|
return nil
|
|
},
|
|
expectLastReleaseRevision: 0,
|
|
},
|
|
{
|
|
name: "with existing history",
|
|
status: v2.HelmReleaseStatus{
|
|
History: v2.Snapshots{
|
|
{
|
|
Name: "something",
|
|
},
|
|
},
|
|
LastReleaseRevision: 5,
|
|
},
|
|
expectHistory: func(releases []*helmrelease.Release) v2.Snapshots {
|
|
return v2.Snapshots{
|
|
{
|
|
Name: "something",
|
|
},
|
|
}
|
|
},
|
|
expectLastReleaseRevision: 5,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Create a test namespace for storing the Helm release mock.
|
|
ns, err := testEnv.CreateNamespace(context.TODO(), "adopt-release")
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
t.Cleanup(func() {
|
|
_ = testEnv.Delete(context.TODO(), ns)
|
|
})
|
|
|
|
// Mock a HelmRelease object.
|
|
obj := &v2.HelmRelease{
|
|
Spec: v2.HelmReleaseSpec{
|
|
StorageNamespace: ns.Name,
|
|
},
|
|
Status: tt.status,
|
|
}
|
|
if tt.spec != nil {
|
|
tt.spec(&obj.Spec)
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: testEnv.Client,
|
|
GetClusterConfig: GetTestClusterConfig,
|
|
}
|
|
|
|
// Store the Helm release mock in the test namespace.
|
|
getter, err := r.buildRESTClientGetter(context.TODO(), obj)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.GetStorageNamespace()))
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
var releases []*helmrelease.Release
|
|
if tt.releases != nil {
|
|
releases = tt.releases(ns.Name)
|
|
}
|
|
store := helmstorage.Init(cfg.Driver)
|
|
for _, rls := range releases {
|
|
g.Expect(store.Create(rls)).To(Succeed())
|
|
}
|
|
|
|
// Adopt the Helm release mock.
|
|
err = r.adoptLegacyRelease(context.TODO(), getter, obj)
|
|
g.Expect(err != nil).To(Equal(tt.wantErr), "unexpected error: %s", err)
|
|
|
|
// Verify the Helm release mock has been adopted.
|
|
var expectHistory v2.Snapshots
|
|
if tt.expectHistory != nil {
|
|
expectHistory = tt.expectHistory(releases)
|
|
}
|
|
g.Expect(obj.Status.History).To(Equal(expectHistory))
|
|
g.Expect(obj.Status.LastReleaseRevision).To(Equal(tt.expectLastReleaseRevision))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_buildRESTClientGetter(t *testing.T) {
|
|
const (
|
|
namespace = "some-namespace"
|
|
kubeCfg = `apiVersion: v1
|
|
kind: Config
|
|
clusters:
|
|
- cluster:
|
|
insecure-skip-tls-verify: true
|
|
server: https://1.2.3.4
|
|
name: development
|
|
contexts:
|
|
- context:
|
|
cluster: development
|
|
namespace: frontend
|
|
user: developer
|
|
name: dev-frontend
|
|
current-context: dev-frontend
|
|
preferences: {}
|
|
users:
|
|
- name: developer
|
|
user:
|
|
password: some-password
|
|
username: exp`
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
env map[string]string
|
|
getConfig func() (*rest.Config, error)
|
|
spec v2.HelmReleaseSpec
|
|
secret *corev1.Secret
|
|
want genericclioptions.RESTClientGetter
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "builds in-cluster RESTClientGetter for HelmRelease",
|
|
getConfig: func() (*rest.Config, error) {
|
|
return clientcmd.RESTConfigFromKubeConfig([]byte(kubeCfg))
|
|
},
|
|
spec: v2.HelmReleaseSpec{},
|
|
want: &kube.MemoryRESTClientGetter{},
|
|
},
|
|
{
|
|
name: "returns error when in-cluster GetClusterConfig fails",
|
|
getConfig: func() (*rest.Config, error) {
|
|
return nil, errors.New("some-error")
|
|
},
|
|
wantErr: "some-error",
|
|
},
|
|
{
|
|
name: "builds RESTClientGetter from HelmRelease with KubeConfig",
|
|
spec: v2.HelmReleaseSpec{
|
|
KubeConfig: &meta.KubeConfigReference{
|
|
SecretRef: meta.SecretKeyReference{
|
|
Name: "kubeconfig",
|
|
},
|
|
},
|
|
},
|
|
secret: &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "kubeconfig",
|
|
Namespace: namespace,
|
|
},
|
|
Data: map[string][]byte{
|
|
kube.DefaultKubeConfigSecretKey: []byte(kubeCfg),
|
|
},
|
|
},
|
|
want: &kube.MemoryRESTClientGetter{},
|
|
},
|
|
{
|
|
name: "error on missing KubeConfig secret",
|
|
spec: v2.HelmReleaseSpec{
|
|
KubeConfig: &meta.KubeConfigReference{
|
|
SecretRef: meta.SecretKeyReference{
|
|
Name: "kubeconfig",
|
|
},
|
|
},
|
|
},
|
|
wantErr: "could not get KubeConfig secret",
|
|
},
|
|
{
|
|
name: "error on invalid KubeConfig secret",
|
|
spec: v2.HelmReleaseSpec{
|
|
KubeConfig: &meta.KubeConfigReference{
|
|
SecretRef: meta.SecretKeyReference{
|
|
Name: "kubeconfig",
|
|
Key: "invalid-key",
|
|
},
|
|
},
|
|
},
|
|
secret: &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "kubeconfig",
|
|
Namespace: namespace,
|
|
},
|
|
},
|
|
wantErr: "does not contain a 'invalid-key' key",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
for k, v := range tt.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
|
|
c := fake.NewClientBuilder()
|
|
if tt.secret != nil {
|
|
c.WithObjects(tt.secret)
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c.Build(),
|
|
GetClusterConfig: tt.getConfig,
|
|
}
|
|
|
|
getter, err := r.buildRESTClientGetter(context.Background(), &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "some-name",
|
|
Namespace: namespace,
|
|
},
|
|
Spec: tt.spec,
|
|
})
|
|
if len(tt.wantErr) > 0 {
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
|
|
} else {
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(getter).To(BeAssignableToTypeOf(tt.want))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHelmReleaseReconciler_getHelmChart(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
chart := &sourcev1.HelmChart{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Namespace: "some-namespace",
|
|
Name: "some-chart-name",
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
rel *v2.HelmRelease
|
|
chart *sourcev1.HelmChart
|
|
expectChart bool
|
|
wantErr bool
|
|
disallowCrossNS bool
|
|
}{
|
|
{
|
|
name: "retrieves HelmChart object from Status",
|
|
rel: &v2.HelmRelease{
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "some-namespace/some-chart-name",
|
|
},
|
|
},
|
|
chart: chart,
|
|
expectChart: true,
|
|
},
|
|
{
|
|
name: "no HelmChart found",
|
|
rel: &v2.HelmRelease{
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "some-namespace/some-chart-name",
|
|
},
|
|
},
|
|
chart: nil,
|
|
expectChart: false,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "no HelmChart in Status",
|
|
rel: &v2.HelmRelease{
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "",
|
|
},
|
|
},
|
|
chart: chart,
|
|
expectChart: false,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "ACL disallows cross namespace",
|
|
rel: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Namespace: "default",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
HelmChart: "some-namespace/some-chart-name",
|
|
},
|
|
},
|
|
chart: chart,
|
|
expectChart: false,
|
|
wantErr: true,
|
|
disallowCrossNS: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := fake.NewClientBuilder()
|
|
c.WithScheme(NewTestScheme())
|
|
if tt.chart != nil {
|
|
c.WithObjects(tt.chart)
|
|
}
|
|
|
|
r := &HelmReleaseReconciler{
|
|
Client: c.Build(),
|
|
EventRecorder: record.NewFakeRecorder(32),
|
|
}
|
|
|
|
curAllow := intacl.AllowCrossNamespaceRef
|
|
intacl.AllowCrossNamespaceRef = !tt.disallowCrossNS
|
|
t.Cleanup(func() { intacl.AllowCrossNamespaceRef = !curAllow })
|
|
|
|
got, err := r.getSource(context.TODO(), tt.rel)
|
|
if tt.wantErr {
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(got).To(BeNil())
|
|
return
|
|
}
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
hc, ok := got.(*sourcev1.HelmChart)
|
|
g.Expect(ok).To(BeTrue())
|
|
expect := g.Expect(hc.ObjectMeta)
|
|
if tt.expectChart {
|
|
expect.To(BeEquivalentTo(tt.chart.ObjectMeta))
|
|
} else {
|
|
expect.To(BeNil())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_waitForHistoryCacheSync(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rel *v2.HelmRelease
|
|
cacheRel *v2.HelmRelease
|
|
want bool
|
|
}{
|
|
{
|
|
name: "different history",
|
|
rel: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "some-name",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
History: v2.Snapshots{
|
|
{
|
|
Version: 2,
|
|
Status: "deployed",
|
|
},
|
|
{
|
|
Version: 1,
|
|
Status: "failed",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
cacheRel: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "some-name",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
History: v2.Snapshots{
|
|
{
|
|
Version: 1,
|
|
Status: "deployed",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "same history",
|
|
rel: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "some-name",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
History: v2.Snapshots{
|
|
{
|
|
Version: 2,
|
|
Status: "deployed",
|
|
},
|
|
{
|
|
Version: 1,
|
|
Status: "failed",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
cacheRel: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "some-name",
|
|
},
|
|
Status: v2.HelmReleaseStatus{
|
|
History: v2.Snapshots{
|
|
{
|
|
Version: 2,
|
|
Status: "deployed",
|
|
},
|
|
{
|
|
Version: 1,
|
|
Status: "failed",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "does not exist",
|
|
rel: &v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "some-name",
|
|
},
|
|
},
|
|
cacheRel: nil,
|
|
want: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
c := fake.NewClientBuilder()
|
|
c.WithScheme(NewTestScheme())
|
|
if tt.cacheRel != nil {
|
|
c.WithObjects(tt.cacheRel)
|
|
}
|
|
r := &HelmReleaseReconciler{
|
|
Client: c.Build(),
|
|
}
|
|
|
|
got, err := r.waitForHistoryCacheSync(tt.rel)(context.Background())
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(got == tt.want).To(BeTrue())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValuesReferenceValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
references []v2.ValuesReference
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid ValuesKey",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
ValuesKey: "any-key_na.me",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid ValuesKey: empty",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
ValuesKey: "",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid ValuesKey: long",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
ValuesKey: strings.Repeat("a", 253),
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid ValuesKey",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
ValuesKey: "a($&^%b",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid ValuesKey: too long",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
ValuesKey: strings.Repeat("a", 254),
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "valid target path: empty",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
TargetPath: "",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid target path",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
TargetPath: "list_with.nested-values.and.index[0]",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid target path: long",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
TargetPath: strings.Repeat("a", 250),
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid target path: too long",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
TargetPath: strings.Repeat("a", 251),
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid target path: opened index",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
ValuesKey: "single",
|
|
TargetPath: "a[",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid target path: incorrect index syntax",
|
|
references: []v2.ValuesReference{
|
|
{
|
|
Kind: "Secret",
|
|
Name: "values",
|
|
ValuesKey: "single",
|
|
TargetPath: "a]0[",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var values *apiextensionsv1.JSON
|
|
v, _ := yaml.YAMLToJSON([]byte("values"))
|
|
values = &apiextensionsv1.JSON{Raw: v}
|
|
|
|
hr := v2.HelmRelease{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test",
|
|
Namespace: "default",
|
|
},
|
|
Spec: v2.HelmReleaseSpec{
|
|
Interval: metav1.Duration{Duration: 5 * time.Minute},
|
|
Chart: &v2.HelmChartTemplate{
|
|
Spec: v2.HelmChartTemplateSpec{
|
|
Chart: "mychart",
|
|
SourceRef: v2.CrossNamespaceObjectReference{
|
|
Name: "something",
|
|
},
|
|
},
|
|
},
|
|
ValuesFrom: tt.references,
|
|
Values: values,
|
|
},
|
|
}
|
|
|
|
err := testEnv.Create(context.TODO(), &hr, client.DryRunAll)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("composeValues() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_isHelmChartReady(t *testing.T) {
|
|
mock := &sourcev1.HelmChart{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "HelmChart",
|
|
APIVersion: sourcev1.GroupVersion.String(),
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "mock",
|
|
Namespace: "default",
|
|
Generation: 2,
|
|
},
|
|
Status: sourcev1.HelmChartStatus{
|
|
ObservedGeneration: 2,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
Artifact: &sourcev1.Artifact{},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
obj *sourcev1.HelmChart
|
|
want bool
|
|
wantReason string
|
|
}{
|
|
{
|
|
name: "chart is ready",
|
|
obj: mock.DeepCopy(),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "chart generation differs from observed generation while Ready=True",
|
|
obj: func() *sourcev1.HelmChart {
|
|
m := mock.DeepCopy()
|
|
m.Generation = 3
|
|
return m
|
|
}(),
|
|
want: false,
|
|
wantReason: "HelmChart 'default/mock' is not ready: latest generation of object has not been reconciled",
|
|
},
|
|
{
|
|
name: "chart generation differs from observed generation while Ready=False",
|
|
obj: func() *sourcev1.HelmChart {
|
|
m := mock.DeepCopy()
|
|
m.Generation = 3
|
|
conditions.MarkFalse(m, meta.ReadyCondition, "Reason", "some reason")
|
|
return m
|
|
}(),
|
|
want: false,
|
|
wantReason: "HelmChart 'default/mock' is not ready: some reason",
|
|
},
|
|
{
|
|
name: "chart has Stalled=True",
|
|
obj: func() *sourcev1.HelmChart {
|
|
m := mock.DeepCopy()
|
|
conditions.MarkFalse(m, meta.ReadyCondition, "Reason", "some reason")
|
|
conditions.MarkStalled(m, "Reason", "some stalled reason")
|
|
return m
|
|
}(),
|
|
want: false,
|
|
wantReason: "HelmChart 'default/mock' is not ready: some stalled reason",
|
|
},
|
|
{
|
|
name: "chart does not have an Artifact",
|
|
obj: func() *sourcev1.HelmChart {
|
|
m := mock.DeepCopy()
|
|
m.Status.Artifact = nil
|
|
return m
|
|
}(),
|
|
want: false,
|
|
wantReason: "HelmChart 'default/mock' is not ready: does not have an artifact",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, gotReason := isReady(tt.obj, tt.obj.GetArtifact())
|
|
if got != tt.want {
|
|
t.Errorf("isHelmChartReady() got = %v, want %v", got, tt.want)
|
|
}
|
|
if gotReason != tt.wantReason {
|
|
t.Errorf("isHelmChartReady() reason = %v, want %v", gotReason, tt.wantReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_isOCIRepositoryReady(t *testing.T) {
|
|
mock := &sourcev1beta2.OCIRepository{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: sourcev1beta2.OCIRepositoryKind,
|
|
APIVersion: sourcev1beta2.GroupVersion.String(),
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "mock",
|
|
Namespace: "default",
|
|
Generation: 2,
|
|
},
|
|
Status: sourcev1beta2.OCIRepositoryStatus{
|
|
ObservedGeneration: 2,
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
},
|
|
},
|
|
Artifact: &sourcev1.Artifact{},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
obj *sourcev1beta2.OCIRepository
|
|
want bool
|
|
wantReason string
|
|
}{
|
|
{
|
|
name: "OCIRepository is ready",
|
|
obj: mock.DeepCopy(),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "OCIRepository generation differs from observed generation while Ready=True",
|
|
obj: func() *sourcev1beta2.OCIRepository {
|
|
m := mock.DeepCopy()
|
|
m.Generation = 3
|
|
return m
|
|
}(),
|
|
want: false,
|
|
wantReason: "OCIRepository 'default/mock' is not ready: latest generation of object has not been reconciled",
|
|
},
|
|
{
|
|
name: "OCIRepository generation differs from observed generation while Ready=False",
|
|
obj: func() *sourcev1beta2.OCIRepository {
|
|
m := mock.DeepCopy()
|
|
m.Generation = 3
|
|
conditions.MarkFalse(m, meta.ReadyCondition, "Reason", "some reason")
|
|
return m
|
|
}(),
|
|
want: false,
|
|
wantReason: "OCIRepository 'default/mock' is not ready: some reason",
|
|
},
|
|
{
|
|
name: "OCIRepository has Stalled=True",
|
|
obj: func() *sourcev1beta2.OCIRepository {
|
|
m := mock.DeepCopy()
|
|
conditions.MarkFalse(m, meta.ReadyCondition, "Reason", "some reason")
|
|
conditions.MarkStalled(m, "Reason", "some stalled reason")
|
|
return m
|
|
}(),
|
|
want: false,
|
|
wantReason: "OCIRepository 'default/mock' is not ready: some stalled reason",
|
|
},
|
|
{
|
|
name: "OCIRepository does not have an Artifact",
|
|
obj: func() *sourcev1beta2.OCIRepository {
|
|
m := mock.DeepCopy()
|
|
m.Status.Artifact = nil
|
|
return m
|
|
}(),
|
|
want: false,
|
|
wantReason: "OCIRepository 'default/mock' is not ready: does not have an artifact",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, gotReason := isReady(tt.obj, tt.obj.GetArtifact())
|
|
if got != tt.want {
|
|
t.Errorf("isOCIRepositoryReady() got = %v, want %v", got, tt.want)
|
|
}
|
|
if gotReason != tt.wantReason {
|
|
t.Errorf("isOCIRepositoryReady() reason = %v, want %v", gotReason, tt.wantReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_TryMutateChartWithSourceRevision(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
revision string
|
|
wantVersion string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid version and revision",
|
|
version: "1.2.3",
|
|
revision: "1.2.3@sha256:9933f58f8bf459eb199d59ebc8a05683f3944e1242d9f5467d99aa2cf08a5370",
|
|
wantVersion: "1.2.3+9933f58f8bf4",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid version and invalid revision",
|
|
version: "1.2.3",
|
|
revision: "1.2.4@sha256:9933f58f8bf459eb199d59ebc8a05683f3944e1242d9f5467d99aa2cf08a5370",
|
|
wantVersion: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "valid version and revision without version",
|
|
version: "1.2.3",
|
|
revision: "sha256:9933f58f8bf459eb199d59ebc8a05683f3944e1242d9f5467d99aa2cf08a5370",
|
|
wantVersion: "1.2.3+9933f58f8bf4",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid version",
|
|
version: "sha:123456",
|
|
revision: "1.2.3@sha:123456",
|
|
wantVersion: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
c := &chart.Chart{
|
|
Metadata: &chart.Metadata{
|
|
Version: tt.version,
|
|
},
|
|
}
|
|
|
|
s := &sourcev1beta2.OCIRepository{
|
|
Status: sourcev1beta2.OCIRepositoryStatus{
|
|
Artifact: &sourcev1.Artifact{
|
|
Revision: tt.revision,
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := mutateChartWithSourceRevision(c, s)
|
|
if tt.wantErr {
|
|
g.Expect(err).To(HaveOccurred())
|
|
} else {
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(c.Metadata.Version).To(Equal(tt.wantVersion))
|
|
}
|
|
})
|
|
}
|
|
|
|
}
|