Skip to content

Commit 3f63163

Browse files
committed
Add tests for ExternalArtifact reconciliation
Signed-off-by: Stefan Prodan <[email protected]>
1 parent cb86e4a commit 3f63163

File tree

3 files changed

+343
-1
lines changed

3 files changed

+343
-1
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ require (
3939
github.com/opencontainers/go-digest/blake3 v0.0.0-20250116041648-1e56c6daea3b
4040
github.com/spf13/pflag v1.0.7
4141
github.com/wI2L/jsondiff v0.7.0
42+
go.uber.org/zap v1.27.0
4243
golang.org/x/text v0.28.0
4344
helm.sh/helm/v3 v3.18.6
4445
k8s.io/api v0.34.0
@@ -201,7 +202,6 @@ require (
201202
go.opentelemetry.io/otel/metric v1.38.0 // indirect
202203
go.opentelemetry.io/otel/trace v1.38.0 // indirect
203204
go.uber.org/multierr v1.11.0 // indirect
204-
go.uber.org/zap v1.27.0 // indirect
205205
go.yaml.in/yaml/v2 v2.4.2 // indirect
206206
go.yaml.in/yaml/v3 v3.0.4 // indirect
207207
golang.org/x/crypto v0.41.0 // indirect
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package test
18+
19+
import (
20+
"context"
21+
"testing"
22+
"time"
23+
24+
"github.com/fluxcd/pkg/apis/meta"
25+
sourcev1 "github.com/fluxcd/source-controller/api/v1"
26+
. "github.com/onsi/gomega"
27+
"github.com/opencontainers/go-digest"
28+
v1 "k8s.io/api/core/v1"
29+
apimeta "k8s.io/apimachinery/pkg/api/meta"
30+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
31+
"sigs.k8s.io/controller-runtime/pkg/client"
32+
33+
v2 "github.com/fluxcd/helm-controller/api/v2"
34+
"github.com/fluxcd/helm-controller/internal/testutil"
35+
)
36+
37+
func TestExternalArtifact_LifeCycle(t *testing.T) {
38+
g := NewWithT(t)
39+
40+
// Create a namespace for the test
41+
ns := v1.Namespace{
42+
ObjectMeta: metav1.ObjectMeta{
43+
Name: "external-artifact-test",
44+
},
45+
}
46+
err := k8sClient.Create(context.Background(), &ns)
47+
g.Expect(err).ToNot(HaveOccurred())
48+
49+
// Create an ExternalArtifact containing a Helm chart
50+
revision := "1.0.0"
51+
eaKey := client.ObjectKey{
52+
Namespace: ns.Name,
53+
Name: "test-ea",
54+
}
55+
ea, err := applyExternalArtifact(eaKey, revision)
56+
g.Expect(err).ToNot(HaveOccurred(), "failed to create ExternalArtifact")
57+
58+
// Create a HelmRelease that references the ExternalArtifact
59+
hr := &v2.HelmRelease{
60+
TypeMeta: metav1.TypeMeta{
61+
APIVersion: v2.GroupVersion.String(),
62+
Kind: v2.HelmReleaseKind,
63+
},
64+
ObjectMeta: metav1.ObjectMeta{
65+
Name: "test",
66+
Namespace: ns.Name,
67+
},
68+
Spec: v2.HelmReleaseSpec{
69+
ChartRef: &v2.CrossNamespaceSourceReference{
70+
Kind: sourcev1.ExternalArtifactKind,
71+
Name: ea.Name,
72+
},
73+
Interval: metav1.Duration{Duration: time.Hour},
74+
},
75+
}
76+
77+
err = k8sClient.Create(context.Background(), hr)
78+
g.Expect(err).ToNot(HaveOccurred())
79+
80+
t.Run("installs from external artifact", func(t *testing.T) {
81+
g.Eventually(func() bool {
82+
err = testEnv.Get(context.Background(), client.ObjectKeyFromObject(hr), hr)
83+
if err != nil {
84+
return false
85+
}
86+
return apimeta.IsStatusConditionTrue(hr.Status.Conditions, meta.ReadyCondition)
87+
}, 5*time.Second, time.Second).Should(BeTrue(), "HelmRelease did not become ready")
88+
89+
g.Expect(hr.Status.LastAttemptedRevision).To(Equal(revision))
90+
g.Expect(hr.Status.LastAttemptedReleaseAction).To(Equal(v2.ReleaseActionInstall))
91+
})
92+
93+
t.Run("upgrades at external artifact revision change", func(t *testing.T) {
94+
newRevision := "2.0.0"
95+
ea, err = applyExternalArtifact(eaKey, newRevision)
96+
g.Expect(err).ToNot(HaveOccurred())
97+
98+
g.Eventually(func() bool {
99+
err = testEnv.Get(context.Background(), client.ObjectKeyFromObject(hr), hr)
100+
if err != nil {
101+
return false
102+
}
103+
return apimeta.IsStatusConditionTrue(hr.Status.Conditions, meta.ReadyCondition) &&
104+
hr.Status.LastAttemptedRevision == newRevision
105+
}, 5*time.Second, time.Second).Should(BeTrue(), "HelmRelease did not upgrade")
106+
107+
g.Expect(hr.Status.LastAttemptedReleaseAction).To(Equal(v2.ReleaseActionUpgrade))
108+
})
109+
110+
t.Run("uninstalls successfully", func(t *testing.T) {
111+
err = k8sClient.Delete(context.Background(), hr)
112+
g.Expect(err).ToNot(HaveOccurred())
113+
114+
g.Eventually(func() bool {
115+
err = testEnv.Get(context.Background(), client.ObjectKeyFromObject(hr), hr)
116+
return err != nil && client.IgnoreNotFound(err) == nil
117+
}, 5*time.Second, time.Second).Should(BeTrue(), "HelmRelease was not deleted")
118+
})
119+
}
120+
121+
func applyExternalArtifact(objKey client.ObjectKey, revision string) (*sourcev1.ExternalArtifact, error) {
122+
chart := testutil.BuildChart(testutil.ChartWithVersion(revision))
123+
artifact, err := testutil.SaveChartAsArtifact(chart, digest.SHA256, testServer.URL(), testServer.Root())
124+
if err != nil {
125+
return nil, err
126+
}
127+
128+
ea := &sourcev1.ExternalArtifact{
129+
TypeMeta: metav1.TypeMeta{
130+
APIVersion: sourcev1.GroupVersion.String(),
131+
Kind: sourcev1.ExternalArtifactKind,
132+
},
133+
ObjectMeta: metav1.ObjectMeta{
134+
Name: objKey.Name,
135+
Namespace: objKey.Namespace,
136+
},
137+
}
138+
139+
patchOpts := []client.PatchOption{
140+
client.ForceOwnership,
141+
client.FieldOwner("kustomize-controller"),
142+
}
143+
144+
err = k8sClient.Patch(context.Background(), ea, client.Apply, patchOpts...)
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
ea.ManagedFields = nil
150+
ea.Status = sourcev1.ExternalArtifactStatus{
151+
Artifact: artifact,
152+
Conditions: []metav1.Condition{
153+
{
154+
Type: meta.ReadyCondition,
155+
Status: metav1.ConditionTrue,
156+
LastTransitionTime: metav1.Now(),
157+
Reason: meta.SucceededReason,
158+
ObservedGeneration: 1,
159+
},
160+
},
161+
}
162+
163+
statusOpts := &client.SubResourcePatchOptions{
164+
PatchOptions: client.PatchOptions{
165+
FieldManager: "source-controller",
166+
},
167+
}
168+
169+
err = k8sClient.Status().Patch(context.Background(), ea, client.Apply, statusOpts)
170+
if err != nil {
171+
return nil, err
172+
}
173+
return ea, nil
174+
}

internal/test/suite_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package test
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
"testing"
24+
"time"
25+
26+
rclient "github.com/fluxcd/pkg/runtime/client"
27+
helper "github.com/fluxcd/pkg/runtime/controller"
28+
"github.com/fluxcd/pkg/runtime/testenv"
29+
"github.com/fluxcd/pkg/testserver"
30+
sourcev1 "github.com/fluxcd/source-controller/api/v1"
31+
"go.uber.org/zap/zapcore"
32+
"helm.sh/helm/v3/pkg/kube"
33+
corev1 "k8s.io/api/core/v1"
34+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
35+
"k8s.io/apimachinery/pkg/runtime"
36+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
37+
"k8s.io/client-go/rest"
38+
ctrl "sigs.k8s.io/controller-runtime"
39+
"sigs.k8s.io/controller-runtime/pkg/client"
40+
controllerLog "sigs.k8s.io/controller-runtime/pkg/log"
41+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
42+
43+
v2 "github.com/fluxcd/helm-controller/api/v2"
44+
"github.com/fluxcd/helm-controller/internal/controller"
45+
// +kubebuilder:scaffold:imports
46+
)
47+
48+
var (
49+
controllerName = "helm-controller"
50+
testEnv *testenv.Environment
51+
testServer *testserver.HTTPServer
52+
k8sClient client.Client
53+
54+
testCtx = ctrl.SetupSignalHandler()
55+
)
56+
57+
func NewTestScheme() *runtime.Scheme {
58+
s := runtime.NewScheme()
59+
utilruntime.Must(corev1.AddToScheme(s))
60+
utilruntime.Must(apiextensionsv1.AddToScheme(s))
61+
utilruntime.Must(sourcev1.AddToScheme(s))
62+
utilruntime.Must(v2.AddToScheme(s))
63+
return s
64+
}
65+
66+
func TestMain(m *testing.M) {
67+
// Set logs on development mode and debug level.
68+
controllerLog.SetLogger(zap.New(zap.WriteTo(os.Stderr), zap.UseDevMode(true), zap.Level(zapcore.DebugLevel)))
69+
70+
// Set the managedFields owner for resources reconciled from Helm charts.
71+
kube.ManagedFieldsManager = controllerName
72+
73+
// Initialize the test environment with source and helm controller CRDs.
74+
testEnv = testenv.New(
75+
testenv.WithCRDPath(
76+
filepath.Join("..", "..", "build", "config", "crd", "bases"),
77+
filepath.Join("..", "..", "config", "crd", "bases"),
78+
),
79+
testenv.WithScheme(NewTestScheme()),
80+
)
81+
82+
// Start a local test HTTP server to serve chart and artifact files.
83+
var err error
84+
if testServer, err = testserver.NewTempHTTPServer(); err != nil {
85+
panic(fmt.Sprintf("Failed to create a temporary storage server: %v", err))
86+
}
87+
fmt.Println("Starting the test storage server")
88+
testServer.Start()
89+
90+
// Start the test environment.
91+
go func() {
92+
fmt.Println("Starting the test environment")
93+
if err := testEnv.Start(testCtx); err != nil {
94+
panic(fmt.Sprintf("Failed to start the test environment manager: %v", err))
95+
}
96+
}()
97+
<-testEnv.Manager.Elected()
98+
99+
// Client with caching disabled.
100+
k8sClient, err = client.New(testEnv.Config, client.Options{Scheme: NewTestScheme()})
101+
if err != nil {
102+
panic(fmt.Sprintf("Failed to create client: %v", err))
103+
}
104+
105+
// Start the HelmRelease controller.
106+
if err := StartController(); err != nil {
107+
panic(fmt.Sprintf("Failed to start HelmRelease controller: %v", err))
108+
}
109+
110+
code := m.Run()
111+
112+
fmt.Println("Stopping the test environment")
113+
if err := testEnv.Stop(); err != nil {
114+
panic(fmt.Sprintf("Failed to stop the test environment: %v", err))
115+
}
116+
117+
fmt.Println("Stopping the test storage server")
118+
testServer.Stop()
119+
if err := os.RemoveAll(testServer.Root()); err != nil {
120+
panic(fmt.Sprintf("Failed to remove storage server dir: %v", err))
121+
}
122+
123+
os.Exit(code)
124+
}
125+
126+
// GetTestClusterConfig returns a copy of the test cluster config.
127+
func GetTestClusterConfig() (*rest.Config, error) {
128+
return rest.CopyConfig(testEnv.GetConfig()), nil
129+
}
130+
131+
// StartController adds the HelmReleaseReconciler to the test controller manager
132+
// and starts the reconciliation loops.
133+
func StartController() error {
134+
timeout := time.Second * 10
135+
reconciler := &controller.HelmReleaseReconciler{
136+
Client: testEnv,
137+
EventRecorder: testEnv.GetEventRecorderFor(controllerName),
138+
Metrics: helper.Metrics{},
139+
GetClusterConfig: GetTestClusterConfig,
140+
ClientOpts: rclient.Options{
141+
QPS: 50.0,
142+
Burst: 300,
143+
},
144+
KubeConfigOpts: rclient.KubeConfigOptions{
145+
InsecureExecProvider: false,
146+
InsecureTLS: false,
147+
UserAgent: controllerName,
148+
Timeout: &timeout,
149+
},
150+
APIReader: testEnv,
151+
TokenCache: nil,
152+
FieldManager: controllerName,
153+
DefaultServiceAccount: "",
154+
DisableChartDigestTracking: false,
155+
AdditiveCELDependencyCheck: false,
156+
}
157+
158+
watchConfigsPredicate, err := helper.GetWatchConfigsPredicate(helper.WatchOptions{})
159+
if err != nil {
160+
return err
161+
}
162+
163+
return (reconciler).SetupWithManager(testCtx, testEnv, controller.HelmReleaseReconcilerOptions{
164+
WatchConfigsPredicate: watchConfigsPredicate,
165+
DependencyRequeueInterval: 5 * time.Second,
166+
HTTPRetry: 1,
167+
})
168+
}

0 commit comments

Comments
 (0)