...

Source file src/github.com/cert-manager/issuer-lib/controllers/issuer_controller_test.go

Documentation: github.com/cert-manager/issuer-lib/controllers

     1  /*
     2  Copyright 2023 The cert-manager 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 controllers
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"math/rand"
    23  	"testing"
    24  	"time"
    25  
    26  	cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
    27  	cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1"
    28  	logrtesting "github.com/go-logr/logr/testing"
    29  	"github.com/stretchr/testify/assert"
    30  	"github.com/stretchr/testify/require"
    31  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    32  	"k8s.io/apimachinery/pkg/runtime"
    33  	"k8s.io/apimachinery/pkg/runtime/schema"
    34  	"k8s.io/apimachinery/pkg/types"
    35  	"k8s.io/client-go/tools/record"
    36  	clocktesting "k8s.io/utils/clock/testing"
    37  	"k8s.io/utils/ptr"
    38  	"sigs.k8s.io/controller-runtime/pkg/client"
    39  	"sigs.k8s.io/controller-runtime/pkg/client/fake"
    40  	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    41  	"sigs.k8s.io/controller-runtime/pkg/source"
    42  
    43  	"github.com/cert-manager/issuer-lib/api/v1alpha1"
    44  	"github.com/cert-manager/issuer-lib/controllers/signer"
    45  	"github.com/cert-manager/issuer-lib/internal/testapi/api"
    46  	"github.com/cert-manager/issuer-lib/internal/testapi/testutil"
    47  	"github.com/cert-manager/issuer-lib/internal/tests/errormatch"
    48  )
    49  
    50  // We are using a random time generator to generate random times for the
    51  // fakeClock. This will result in different times for each test run and
    52  // should make sure we don't incorrectly rely on `time.Now()` in the code.
    53  // WARNING: This approach does not guarantee that incorrect use of `time.Now()`
    54  // is always detected, but after a few test runs it should be very unlikely.
    55  func randomTime() time.Time {
    56  	min := time.Date(1970, 1, 0, 0, 0, 0, 0, time.UTC).Unix()
    57  	max := time.Date(2070, 1, 0, 0, 0, 0, 0, time.UTC).Unix()
    58  	delta := max - min
    59  
    60  	sec := rand.Int63n(delta) + min
    61  	return time.Unix(sec, 0)
    62  }
    63  
    64  func TestTestIssuerReconcilerReconcile(t *testing.T) {
    65  	t.Parallel()
    66  
    67  	fieldOwner := "test-simple-issuer-reconciler-reconcile"
    68  
    69  	type testCase struct {
    70  		name                string
    71  		check               signer.Check
    72  		objects             []client.Object
    73  		eventSourceError    error
    74  		validateError       *errormatch.Matcher
    75  		expectedResult      reconcile.Result
    76  		expectedStatusPatch *v1alpha1.IssuerStatus
    77  		expectedEvents      []string
    78  	}
    79  
    80  	randTime := randomTime()
    81  
    82  	fakeTime1 := randTime.Truncate(time.Second)
    83  	fakeTimeObj1 := metav1.NewTime(fakeTime1)
    84  	fakeClock1 := clocktesting.NewFakeClock(fakeTime1)
    85  
    86  	fakeTime2 := randTime.Add(4 * time.Hour).Truncate(time.Second)
    87  	fakeTimeObj2 := metav1.NewTime(fakeTime2)
    88  	fakeClock2 := clocktesting.NewFakeClock(fakeTime2)
    89  
    90  	issuer1 := testutil.TestIssuer(
    91  		"issuer-1",
    92  		testutil.SetTestIssuerNamespace("ns1"),
    93  	)
    94  
    95  	staticChecker := func(err error) signer.Check {
    96  		return func(_ context.Context, _ v1alpha1.Issuer) error {
    97  			return err
    98  		}
    99  	}
   100  
   101  	tests := []testCase{
   102  		// Ignore if issuer not found
   103  		{
   104  			name:                "ignore-issuer-not-found",
   105  			check:               staticChecker(nil),
   106  			objects:             []client.Object{},
   107  			expectedStatusPatch: nil,
   108  		},
   109  
   110  		// Update status, even if already at Ready for observed generation
   111  		{
   112  			name:  "trigger-when-ready",
   113  			check: staticChecker(nil),
   114  			objects: []client.Object{
   115  				testutil.TestIssuerFrom(issuer1,
   116  					testutil.SetTestIssuerGeneration(80),
   117  					testutil.SetTestIssuerStatusCondition(
   118  						fakeClock1,
   119  						cmapi.IssuerConditionReady,
   120  						cmmeta.ConditionTrue,
   121  						v1alpha1.IssuerConditionReasonChecked,
   122  						"Succeeded checking the issuer",
   123  					),
   124  				),
   125  			},
   126  			expectedStatusPatch: &v1alpha1.IssuerStatus{
   127  				Conditions: []cmapi.IssuerCondition{
   128  					{
   129  						Type:               cmapi.IssuerConditionReady,
   130  						Status:             cmmeta.ConditionTrue,
   131  						Reason:             v1alpha1.IssuerConditionReasonChecked,
   132  						Message:            "Succeeded checking the issuer",
   133  						ObservedGeneration: 80,
   134  						LastTransitionTime: &fakeTimeObj1, // since the status is not updated, the LastTransitionTime is not updated either
   135  					},
   136  				},
   137  			},
   138  			expectedEvents: []string{
   139  				"Normal Checked Succeeded checking the issuer",
   140  			},
   141  		},
   142  
   143  		// Ignore if already at Failed for observed generation
   144  		{
   145  			name:  "ignore-failed",
   146  			check: staticChecker(nil),
   147  			objects: []client.Object{
   148  				testutil.TestIssuerFrom(issuer1,
   149  					testutil.SetTestIssuerGeneration(80),
   150  					testutil.SetTestIssuerStatusCondition(
   151  						fakeClock1,
   152  						cmapi.IssuerConditionReady,
   153  						cmmeta.ConditionFalse,
   154  						v1alpha1.IssuerConditionReasonFailed,
   155  						"[error message]",
   156  					),
   157  				),
   158  			},
   159  			expectedStatusPatch: nil,
   160  		},
   161  
   162  		// Ignore reported error if not ready
   163  		{
   164  			name:  "failed-ignore-reported-error",
   165  			check: staticChecker(nil),
   166  			objects: []client.Object{
   167  				testutil.TestIssuerFrom(issuer1,
   168  					testutil.SetTestIssuerGeneration(80),
   169  					testutil.SetTestIssuerStatusCondition(
   170  						fakeClock1,
   171  						cmapi.IssuerConditionReady,
   172  						cmmeta.ConditionFalse,
   173  						v1alpha1.IssuerConditionReasonFailed,
   174  						"[error message]",
   175  					),
   176  				),
   177  			},
   178  			eventSourceError:    fmt.Errorf("[specific error]"),
   179  			expectedStatusPatch: nil,
   180  		},
   181  
   182  		// Set error if the CertificateRequest controller reported error
   183  		{
   184  			name:  "ready-reported-error",
   185  			check: staticChecker(nil),
   186  			objects: []client.Object{
   187  				testutil.TestIssuerFrom(issuer1,
   188  					testutil.SetTestIssuerGeneration(80),
   189  					testutil.SetTestIssuerStatusCondition(
   190  						fakeClock1,
   191  						cmapi.IssuerConditionReady,
   192  						cmmeta.ConditionTrue,
   193  						v1alpha1.IssuerConditionReasonChecked,
   194  						"Succeeded checking the issuer",
   195  					),
   196  				),
   197  			},
   198  			eventSourceError: fmt.Errorf("[specific error]"),
   199  			expectedStatusPatch: &v1alpha1.IssuerStatus{
   200  				Conditions: []cmapi.IssuerCondition{
   201  					{
   202  						Type:               cmapi.IssuerConditionReady,
   203  						Status:             cmmeta.ConditionFalse,
   204  						Reason:             v1alpha1.IssuerConditionReasonPending,
   205  						Message:            "Not ready yet: [specific error]",
   206  						ObservedGeneration: 80,
   207  						LastTransitionTime: &fakeTimeObj2,
   208  					},
   209  				},
   210  			},
   211  			validateError: errormatch.ErrorContains("[specific error]"),
   212  			expectedEvents: []string{
   213  				"Warning RetryableError Not ready yet: [specific error]",
   214  			},
   215  		},
   216  
   217  		// Re-check if already at Ready for older observed generation
   218  		{
   219  			name:  "recheck-outdated-ready",
   220  			check: staticChecker(nil),
   221  			objects: []client.Object{
   222  				testutil.TestIssuerFrom(issuer1,
   223  					testutil.SetTestIssuerGeneration(80),
   224  					testutil.SetTestIssuerStatusCondition(
   225  						fakeClock1,
   226  						cmapi.IssuerConditionReady,
   227  						cmmeta.ConditionTrue,
   228  						v1alpha1.IssuerConditionReasonChecked,
   229  						"Succeeded checking the issuer",
   230  					),
   231  					testutil.SetTestIssuerGeneration(81),
   232  				),
   233  			},
   234  			expectedStatusPatch: &v1alpha1.IssuerStatus{
   235  				Conditions: []cmapi.IssuerCondition{
   236  					{
   237  						Type:               cmapi.IssuerConditionReady,
   238  						Status:             cmmeta.ConditionTrue,
   239  						Reason:             v1alpha1.IssuerConditionReasonChecked,
   240  						Message:            "Succeeded checking the issuer",
   241  						LastTransitionTime: &fakeTimeObj1, // since the status is not updated, the LastTransitionTime is not updated either
   242  						ObservedGeneration: 81,
   243  					},
   244  				},
   245  			},
   246  			expectedEvents: []string{
   247  				"Normal Checked Succeeded checking the issuer",
   248  			},
   249  		},
   250  
   251  		// Initialize the Issuer Ready condition if it is missing
   252  		{
   253  			name: "initialize-ready-condition",
   254  			objects: []client.Object{
   255  				issuer1,
   256  			},
   257  			expectedStatusPatch: &v1alpha1.IssuerStatus{
   258  				Conditions: []cmapi.IssuerCondition{
   259  					{
   260  						Type:               cmapi.IssuerConditionReady,
   261  						Status:             cmmeta.ConditionUnknown,
   262  						Reason:             v1alpha1.IssuerConditionReasonInitializing,
   263  						Message:            fieldOwner + " has started reconciling this Issuer",
   264  						LastTransitionTime: &fakeTimeObj2,
   265  					},
   266  				},
   267  			},
   268  		},
   269  
   270  		// Retry if the check function returns an error
   271  		{
   272  			name:  "retry-on-error",
   273  			check: staticChecker(fmt.Errorf("[specific error]")),
   274  			objects: []client.Object{
   275  				testutil.TestIssuerFrom(issuer1,
   276  					testutil.SetTestIssuerStatusCondition(
   277  						fakeClock1,
   278  						cmapi.IssuerConditionReady,
   279  						cmmeta.ConditionUnknown,
   280  						v1alpha1.IssuerConditionReasonInitializing,
   281  						fieldOwner+" has started reconciling this Issuer",
   282  					),
   283  				),
   284  			},
   285  			expectedStatusPatch: &v1alpha1.IssuerStatus{
   286  				Conditions: []cmapi.IssuerCondition{
   287  					{
   288  						Type:               cmapi.IssuerConditionReady,
   289  						Status:             cmmeta.ConditionFalse,
   290  						Reason:             v1alpha1.IssuerConditionReasonPending,
   291  						Message:            "Not ready yet: [specific error]",
   292  						LastTransitionTime: &fakeTimeObj2,
   293  					},
   294  				},
   295  			},
   296  			validateError: errormatch.ErrorContains("[specific error]"),
   297  			expectedEvents: []string{
   298  				"Warning RetryableError Not ready yet: [specific error]",
   299  			},
   300  		},
   301  
   302  		// Don't retry if the check function returns a permanent error
   303  		{
   304  			name:  "dont-retry-on-permanent-error",
   305  			check: staticChecker(signer.PermanentError{Err: fmt.Errorf("[specific error]")}),
   306  			objects: []client.Object{
   307  				testutil.TestIssuerFrom(issuer1,
   308  					testutil.SetTestIssuerStatusCondition(
   309  						fakeClock1,
   310  						cmapi.IssuerConditionReady,
   311  						cmmeta.ConditionUnknown,
   312  						v1alpha1.IssuerConditionReasonInitializing,
   313  						fieldOwner+" has started reconciling this Issuer",
   314  					),
   315  				),
   316  			},
   317  			expectedStatusPatch: &v1alpha1.IssuerStatus{
   318  				Conditions: []cmapi.IssuerCondition{
   319  					{
   320  						Type:               cmapi.IssuerConditionReady,
   321  						Status:             cmmeta.ConditionFalse,
   322  						Reason:             v1alpha1.IssuerConditionReasonFailed,
   323  						Message:            "Failed permanently: [specific error]",
   324  						LastTransitionTime: &fakeTimeObj2,
   325  					},
   326  				},
   327  			},
   328  			validateError: errormatch.ErrorContains("terminal error: [specific error]"),
   329  			expectedEvents: []string{
   330  				"Warning PermanentError Failed permanently: [specific error]",
   331  			},
   332  		},
   333  
   334  		// Retry if the check function returns a dependant resource error
   335  		// > see integration test
   336  
   337  		// Success if nothing is wrong
   338  		{
   339  			name:  "success-issuer",
   340  			check: staticChecker(nil),
   341  			objects: []client.Object{
   342  				testutil.TestIssuerFrom(issuer1,
   343  					testutil.SetTestIssuerStatusCondition(
   344  						fakeClock1,
   345  						cmapi.IssuerConditionReady,
   346  						cmmeta.ConditionUnknown,
   347  						v1alpha1.IssuerConditionReasonInitializing,
   348  						fieldOwner+" has started reconciling this Issuer",
   349  					),
   350  				),
   351  			},
   352  			expectedStatusPatch: &v1alpha1.IssuerStatus{
   353  				Conditions: []cmapi.IssuerCondition{
   354  					{
   355  						Type:               cmapi.IssuerConditionReady,
   356  						Status:             cmmeta.ConditionTrue,
   357  						Reason:             v1alpha1.IssuerConditionReasonChecked,
   358  						Message:            "Succeeded checking the issuer",
   359  						LastTransitionTime: &fakeTimeObj2,
   360  					},
   361  				},
   362  			},
   363  			expectedEvents: []string{
   364  				"Normal Checked Succeeded checking the issuer",
   365  			},
   366  		},
   367  
   368  		// Set the Ready condition to Ready if the check function returned a permanent error on a previous version
   369  		{
   370  			name:  "success-recover",
   371  			check: staticChecker(nil),
   372  			objects: []client.Object{
   373  				testutil.TestIssuerFrom(issuer1,
   374  					testutil.SetTestIssuerGeneration(80),
   375  					testutil.SetTestIssuerStatusCondition(
   376  						fakeClock1,
   377  						cmapi.IssuerConditionReady,
   378  						cmmeta.ConditionFalse,
   379  						v1alpha1.IssuerConditionReasonInitializing,
   380  						fieldOwner+" has started reconciling this Issuer",
   381  					),
   382  					testutil.SetTestIssuerGeneration(81),
   383  				),
   384  			},
   385  			expectedStatusPatch: &v1alpha1.IssuerStatus{
   386  				Conditions: []cmapi.IssuerCondition{
   387  					{
   388  						Type:               cmapi.IssuerConditionReady,
   389  						Status:             cmmeta.ConditionTrue,
   390  						Reason:             v1alpha1.IssuerConditionReasonChecked,
   391  						Message:            "Succeeded checking the issuer",
   392  						LastTransitionTime: &fakeTimeObj2,
   393  						ObservedGeneration: 81,
   394  					},
   395  				},
   396  			},
   397  			expectedEvents: []string{
   398  				"Normal Checked Succeeded checking the issuer",
   399  			},
   400  		},
   401  	}
   402  
   403  	for _, tc := range tests {
   404  		tc := tc
   405  		t.Run(tc.name, func(t *testing.T) {
   406  			t.Parallel()
   407  
   408  			scheme := runtime.NewScheme()
   409  			require.NoError(t, api.AddToScheme(scheme))
   410  			fakeClient := fake.NewClientBuilder().
   411  				WithScheme(scheme).
   412  				WithObjects(tc.objects...).
   413  				Build()
   414  
   415  			req := reconcile.Request{
   416  				NamespacedName: types.NamespacedName{
   417  					Name:      issuer1.Name,
   418  					Namespace: issuer1.Namespace,
   419  				},
   420  			}
   421  
   422  			var vciBefore api.TestIssuer
   423  			err := fakeClient.Get(context.TODO(), req.NamespacedName, &vciBefore)
   424  			require.NoError(t, client.IgnoreNotFound(err), "unexpected error from fake client")
   425  
   426  			logger := logrtesting.NewTestLoggerWithOptions(t, logrtesting.Options{LogTimestamp: true, Verbosity: 10})
   427  			fakeRecorder := record.NewFakeRecorder(100)
   428  
   429  			controller := IssuerReconciler{
   430  				ForObject:  &api.TestIssuer{},
   431  				FieldOwner: fieldOwner,
   432  				EventSource: fakeEventSource{
   433  					err: tc.eventSourceError,
   434  				},
   435  				Client:        fakeClient,
   436  				Check:         tc.check,
   437  				EventRecorder: fakeRecorder,
   438  				Clock:         fakeClock2,
   439  			}
   440  
   441  			res, issuerStatusPatch, reconcileErr := controller.reconcileStatusPatch(logger, context.TODO(), req)
   442  
   443  			assert.Equal(t, tc.expectedResult, res)
   444  			assert.Equal(t, tc.expectedStatusPatch, issuerStatusPatch)
   445  			ptr.Deref(tc.validateError, *errormatch.NoError())(t, reconcileErr)
   446  
   447  			allEvents := chanToSlice(fakeRecorder.Events)
   448  			if len(tc.expectedEvents) == 0 {
   449  				assert.Emptyf(t, allEvents, "expected no events to be recorded, but got: %#v", allEvents)
   450  			} else {
   451  				assert.Equal(t, tc.expectedEvents, allEvents)
   452  			}
   453  		})
   454  	}
   455  }
   456  
   457  type fakeEventSource struct {
   458  	err error
   459  }
   460  
   461  func (fakeEventSource) AddConsumer(gvk schema.GroupVersionKind) source.Source {
   462  	panic("not implemented")
   463  }
   464  func (fakeEventSource) ReportError(gvk schema.GroupVersionKind, namespacedName types.NamespacedName, err error) error {
   465  	panic("not implemented")
   466  }
   467  
   468  func (fes fakeEventSource) HasReportedError(gvk schema.GroupVersionKind, namespacedName types.NamespacedName) error {
   469  	return fes.err
   470  }
   471  

View as plain text