Skip to content

Commit 996d5d4

Browse files
authored
Merge pull request #168 from Fedosin/applied_spec_hash
🐛 Update provider components if (and only if) its spec has been changed
2 parents 73389c1 + fbe6ef4 commit 996d5d4

File tree

3 files changed

+229
-43
lines changed

3 files changed

+229
-43
lines changed

internal/controller/genericprovider_controller.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ package controller
1818

1919
import (
2020
"context"
21+
"crypto/sha256"
22+
"encoding/json"
2123
"errors"
2224
"fmt"
2325
"reflect"
@@ -44,6 +46,10 @@ type GenericProviderReconciler struct {
4446
Config *rest.Config
4547
}
4648

49+
const (
50+
appliedSpecHashAnnotation = "operator.cluster.x-k8s.io/applied-spec-hash"
51+
)
52+
4753
func (r *GenericProviderReconciler) SetupWithManager(mgr ctrl.Manager, options controller.Options) error {
4854
return ctrl.NewControllerManagedBy(mgr).
4955
For(r.Provider).
@@ -106,7 +112,35 @@ func (r *GenericProviderReconciler) Reconcile(ctx context.Context, req reconcile
106112
return r.reconcileDelete(ctx, typedProvider)
107113
}
108114

109-
return r.reconcile(ctx, typedProvider, typedProviderList)
115+
// Check if spec hash stays the same and don't go further in this case.
116+
specHash, err := calculateHash(typedProvider.GetSpec())
117+
if err != nil {
118+
return ctrl.Result{}, err
119+
}
120+
121+
if typedProvider.GetAnnotations()[appliedSpecHashAnnotation] == specHash {
122+
log.Info("No changes detected, skipping further steps")
123+
124+
return ctrl.Result{}, nil
125+
}
126+
127+
res, err := r.reconcile(ctx, typedProvider, typedProviderList)
128+
129+
annotations := typedProvider.GetAnnotations()
130+
if annotations == nil {
131+
annotations = map[string]string{}
132+
}
133+
134+
// Set the spec hash annotation if reconciliation was successful or reset it otherwise.
135+
if res.IsZero() && err == nil {
136+
annotations[appliedSpecHashAnnotation] = specHash
137+
} else {
138+
annotations[appliedSpecHashAnnotation] = ""
139+
}
140+
141+
typedProvider.SetAnnotations(annotations)
142+
143+
return res, err
110144
}
111145

112146
func patchProvider(ctx context.Context, provider genericprovider.GenericProvider, patchHelper *patch.Helper, options ...patch.Option) error {
@@ -227,3 +261,18 @@ func (r *GenericProviderReconciler) newGenericProviderList() (genericprovider.Ge
227261
return nil, failedToCastInterfaceErr
228262
}
229263
}
264+
265+
func calculateHash(object interface{}) (string, error) {
266+
jsonData, err := json.Marshal(object)
267+
if err != nil {
268+
return "", fmt.Errorf("cannot parse provider spec: %w", err)
269+
}
270+
271+
hash := sha256.New()
272+
273+
if _, err = hash.Write(jsonData); err != nil {
274+
return "", fmt.Errorf("cannot calculate provider spec hash: %w", err)
275+
}
276+
277+
return fmt.Sprintf("%x", hash.Sum(nil)), nil
278+
}

internal/controller/genericprovider_controller_test.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2626
"k8s.io/apimachinery/pkg/runtime"
2727
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
28+
"k8s.io/utils/pointer"
2829
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
2930
clusterctlv1 "sigs.k8s.io/cluster-api/cmd/clusterctl/api/v1alpha3"
3031
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -462,6 +463,181 @@ func TestNewGenericProviderList(t *testing.T) {
462463
}
463464
}
464465

466+
func TestProviderSpecChanges(t *testing.T) {
467+
testCases := []struct {
468+
name string
469+
spec operatorv1.ProviderSpec
470+
updatedSpec operatorv1.ProviderSpec
471+
expectError bool
472+
}{
473+
{
474+
name: "same spec, hash annotation doesn't change",
475+
spec: operatorv1.ProviderSpec{
476+
Version: testCurrentVersion,
477+
FetchConfig: &operatorv1.FetchConfiguration{
478+
Selector: &metav1.LabelSelector{
479+
MatchLabels: map[string]string{
480+
"test": "dummy-config",
481+
},
482+
},
483+
},
484+
},
485+
updatedSpec: operatorv1.ProviderSpec{
486+
Version: testCurrentVersion,
487+
FetchConfig: &operatorv1.FetchConfiguration{
488+
Selector: &metav1.LabelSelector{
489+
MatchLabels: map[string]string{
490+
"test": "dummy-config",
491+
},
492+
},
493+
},
494+
},
495+
},
496+
{
497+
name: "add more replicas, hash annotation is updated",
498+
spec: operatorv1.ProviderSpec{
499+
Version: testCurrentVersion,
500+
FetchConfig: &operatorv1.FetchConfiguration{
501+
Selector: &metav1.LabelSelector{
502+
MatchLabels: map[string]string{
503+
"test": "dummy-config",
504+
},
505+
},
506+
},
507+
},
508+
updatedSpec: operatorv1.ProviderSpec{
509+
Version: testCurrentVersion,
510+
Deployment: &operatorv1.DeploymentSpec{
511+
Replicas: pointer.Int(2),
512+
},
513+
FetchConfig: &operatorv1.FetchConfiguration{
514+
Selector: &metav1.LabelSelector{
515+
MatchLabels: map[string]string{
516+
"test": "dummy-config",
517+
},
518+
},
519+
},
520+
},
521+
},
522+
{
523+
name: "upgrade to a non-existent version, hash annotation is empty",
524+
expectError: true,
525+
spec: operatorv1.ProviderSpec{
526+
Version: testCurrentVersion,
527+
FetchConfig: &operatorv1.FetchConfiguration{
528+
Selector: &metav1.LabelSelector{
529+
MatchLabels: map[string]string{
530+
"test": "dummy-config",
531+
},
532+
},
533+
},
534+
},
535+
updatedSpec: operatorv1.ProviderSpec{
536+
Version: "10000.0.0-NONEXISTENT",
537+
Deployment: &operatorv1.DeploymentSpec{
538+
Replicas: pointer.Int(2),
539+
},
540+
FetchConfig: &operatorv1.FetchConfiguration{
541+
Selector: &metav1.LabelSelector{
542+
MatchLabels: map[string]string{
543+
"test": "dummy-config",
544+
},
545+
},
546+
},
547+
},
548+
},
549+
}
550+
551+
for _, tc := range testCases {
552+
t.Run(tc.name, func(t *testing.T) {
553+
g := NewWithT(t)
554+
555+
specHash, err := calculateHash(tc.spec)
556+
g.Expect(err).ToNot(HaveOccurred())
557+
558+
updatedSpecHash, err := calculateHash(tc.spec)
559+
g.Expect(err).ToNot(HaveOccurred())
560+
561+
provider := &genericprovider.CoreProviderWrapper{
562+
CoreProvider: &operatorv1.CoreProvider{
563+
ObjectMeta: metav1.ObjectMeta{
564+
Name: "cluster-api",
565+
},
566+
Spec: operatorv1.CoreProviderSpec{
567+
ProviderSpec: tc.spec,
568+
},
569+
},
570+
}
571+
572+
namespace := "test-provider-spec-changes"
573+
574+
t.Log("Ensure namespace exists", namespace)
575+
g.Expect(env.EnsureNamespaceExists(ctx, namespace)).To(Succeed())
576+
577+
g.Expect(env.CreateAndWait(ctx, dummyConfigMap(namespace, testCurrentVersion))).To(Succeed())
578+
579+
provider.SetNamespace(namespace)
580+
t.Log("creating test provider", provider.GetName())
581+
g.Expect(env.CreateAndWait(ctx, provider.GetObject())).To(Succeed())
582+
583+
g.Eventually(generateExpectedResultChecker(provider, specHash, corev1.ConditionTrue), timeout).Should(BeEquivalentTo(true))
584+
585+
// Change provider spec
586+
provider.SetSpec(tc.updatedSpec)
587+
588+
// Set a label to ensure that provider was changed
589+
labels := provider.GetLabels()
590+
if labels == nil {
591+
labels = map[string]string{}
592+
}
593+
labels["my-label"] = "some-value"
594+
provider.SetLabels(labels)
595+
596+
g.Expect(env.Client.Update(ctx, provider.GetObject())).To(Succeed())
597+
598+
if !tc.expectError {
599+
g.Eventually(generateExpectedResultChecker(provider, updatedSpecHash, corev1.ConditionTrue), timeout).Should(BeEquivalentTo(true))
600+
} else {
601+
g.Eventually(generateExpectedResultChecker(provider, "", corev1.ConditionFalse), timeout).Should(BeEquivalentTo(true))
602+
}
603+
604+
// Clean up
605+
objs := []client.Object{provider.GetObject()}
606+
objs = append(objs, &corev1.ConfigMap{
607+
ObjectMeta: metav1.ObjectMeta{
608+
Name: testCurrentVersion,
609+
Namespace: namespace,
610+
},
611+
})
612+
613+
g.Expect(env.CleanupAndWait(ctx, objs...)).To(Succeed())
614+
})
615+
}
616+
}
617+
618+
func generateExpectedResultChecker(provider *genericprovider.CoreProviderWrapper, specHash string, condStatus corev1.ConditionStatus) func() bool {
619+
return func() bool {
620+
if err := env.Get(ctx, client.ObjectKeyFromObject(provider.GetObject()), provider.GetObject()); err != nil {
621+
return false
622+
}
623+
624+
// In case of error we don't want the spec annotation to be updated
625+
if provider.GetAnnotations()[appliedSpecHashAnnotation] != specHash {
626+
return false
627+
}
628+
629+
for _, cond := range provider.GetStatus().Conditions {
630+
if cond.Type == operatorv1.ProviderInstalledCondition {
631+
if cond.Status == condStatus {
632+
return true
633+
}
634+
}
635+
}
636+
637+
return false
638+
}
639+
}
640+
465641
func setupScheme() *runtime.Scheme {
466642
scheme := runtime.NewScheme()
467643
utilruntime.Must(corev1.AddToScheme(scheme))

internal/controller/phases.go

Lines changed: 3 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -353,59 +353,20 @@ func (p *phaseReconciler) fetch(ctx context.Context) (reconcile.Result, error) {
353353
func (p *phaseReconciler) preInstall(ctx context.Context) (reconcile.Result, error) {
354354
log := ctrl.LoggerFrom(ctx)
355355

356-
needPreDelete, err := p.versionChanged()
357-
if err != nil {
358-
return reconcile.Result{}, wrapPhaseError(err, "failed getting clusterctl Provider version")
359-
}
360-
361-
// we need to delete existing components only if their version changes and something has already been installed.
362-
if !needPreDelete || p.provider.GetStatus().InstalledVersion == nil {
356+
// Nothing to do if it's a fresh installation.
357+
if p.provider.GetStatus().InstalledVersion == nil {
363358
return reconcile.Result{}, nil
364359
}
365360

366-
log.Info("Upgrade detected, deleting existing components")
361+
log.Info("Changes detected, deleting existing components")
367362

368363
return p.delete(ctx)
369364
}
370365

371-
// versionChanged try to get installed version from provider status and decide if it has changed.
372-
func (p *phaseReconciler) versionChanged() (bool, error) {
373-
installedVersion := p.provider.GetStatus().InstalledVersion
374-
if installedVersion == nil {
375-
return true, nil
376-
}
377-
378-
currentVersion, err := versionutil.ParseSemantic(*installedVersion)
379-
if err != nil {
380-
return false, err
381-
}
382-
383-
res, err := currentVersion.Compare(p.components.Version())
384-
if err != nil {
385-
return false, err
386-
}
387-
388-
// we need to delete installed components if versions are different
389-
versionChanged := res != 0
390-
391-
return versionChanged, nil
392-
}
393-
394366
// install installs the provider components using clusterctl library.
395367
func (p *phaseReconciler) install(ctx context.Context) (reconcile.Result, error) {
396368
log := ctrl.LoggerFrom(ctx)
397369

398-
versionChanged, err := p.versionChanged()
399-
if err != nil {
400-
return reconcile.Result{}, wrapPhaseError(err, "failed getting clusterctl Provider version")
401-
}
402-
403-
// skip installation if the version hasn't changed.
404-
if !versionChanged {
405-
log.Info("Provider already installed")
406-
return reconcile.Result{}, nil
407-
}
408-
409370
clusterClient := p.newClusterClient()
410371

411372
log.Info("Installing provider")

0 commit comments

Comments
 (0)