From cfc838686a72894ddadb0d2586b9761977f15f93 Mon Sep 17 00:00:00 2001 From: Kirsten Laskoski Date: Fri, 6 Dec 2024 12:20:09 -0500 Subject: [PATCH] certificate: add new package for CSR resource * Adds the CertificateSigningRequest resource and creates a new certificate package for it. * Adds listing functions, along with a method to wait until all CSRs are approved. * Unit tests are provided for all exported functions. --- pkg/certificate/signingrequest.go | 176 ++++++++++++++ pkg/certificate/signingrequest_test.go | 259 +++++++++++++++++++++ pkg/certificate/signingrequestlist.go | 123 ++++++++++ pkg/certificate/signingrequestlist_test.go | 137 +++++++++++ 4 files changed, 695 insertions(+) create mode 100644 pkg/certificate/signingrequest.go create mode 100644 pkg/certificate/signingrequest_test.go create mode 100644 pkg/certificate/signingrequestlist.go create mode 100644 pkg/certificate/signingrequestlist_test.go diff --git a/pkg/certificate/signingrequest.go b/pkg/certificate/signingrequest.go new file mode 100644 index 000000000..ff5c58ae3 --- /dev/null +++ b/pkg/certificate/signingrequest.go @@ -0,0 +1,176 @@ +package certificate + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/openshift-kni/eco-goinfra/pkg/clients" + "github.com/openshift-kni/eco-goinfra/pkg/msg" + certificatesv1 "k8s.io/api/certificates/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// SigningRequestBuilder provides a struct for CertificateSigningRequest resource containing a connection to the cluster +// and the CertificateSigningRequest definition. +type SigningRequestBuilder struct { + // SigningRequest definition, used to create the signing request object. + Definition *certificatesv1.CertificateSigningRequest + // Created signing request object on cluster. + Object *certificatesv1.CertificateSigningRequest + // apiClient to interact with the cluster. + apiClient runtimeclient.Client +} + +// PullSigningRequest loads an existing signing request into SigningRequestBuilder struct. +func PullSigningRequest(apiClient *clients.Settings, name string) (*SigningRequestBuilder, error) { + glog.V(100).Infof("Pulling existing CertificateSigningRequest with name %s", name) + + if apiClient == nil { + glog.V(100).Infof("CertificateSigningRequest apiClient cannot be nil") + + return nil, fmt.Errorf("certificateSigniingRequest apiClient cannot be nil") + } + + err := apiClient.AttachScheme(certificatesv1.AddToScheme) + if err != nil { + glog.V(100).Infof("Failed to add certificates v1 scheme to client schemes") + + return nil, err + } + + builder := &SigningRequestBuilder{ + apiClient: apiClient.Client, + Definition: &certificatesv1.CertificateSigningRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + }, + } + + if name == "" { + glog.V(100).Infof("The name of the CertificateSigningRequest is empty") + + return nil, fmt.Errorf("certificateSigningRequest 'name' cannot be empty") + } + + if !builder.Exists() { + glog.V(100).Infof("CertificateSigningRequest %s does not exist", name) + + return nil, fmt.Errorf("certificateSigningRequest %s does not exist", name) + } + + builder.Definition = builder.Object + + return builder, nil +} + +// Get returns the CertificateSigningRequest object if found. +func (builder *SigningRequestBuilder) Get() (*certificatesv1.CertificateSigningRequest, error) { + if valid, err := builder.validate(); !valid { + return nil, err + } + + glog.V(100).Infof("Collecting CertificateSigningRequest object %s", builder.Definition.Name) + + signingRequest := &certificatesv1.CertificateSigningRequest{} + err := builder.apiClient.Get(context.TODO(), runtimeclient.ObjectKey{ + Name: builder.Definition.Name, + }, signingRequest) + + if err != nil { + glog.V(100).Infof("Failed to get CertificateSigningRequest object %s: %v", builder.Definition.Name, err) + + return nil, err + } + + return signingRequest, nil +} + +// Exists checks whether the given CertificateSigningRequest object exists. +func (builder *SigningRequestBuilder) Exists() bool { + if valid, _ := builder.validate(); !valid { + return false + } + + glog.V(100).Infof("Checking if CertificateSigningRequest %s exists", builder.Definition.Name) + + var err error + builder.Object, err = builder.Get() + + return err == nil || !k8serrors.IsNotFound(err) +} + +// Create creates a new CertificateSigningRequest object if it does not exist. +func (builder *SigningRequestBuilder) Create() (*SigningRequestBuilder, error) { + if valid, err := builder.validate(); !valid { + return builder, err + } + + glog.V(100).Infof("Creating CertificateSigningRequest %s", builder.Definition.Name) + + if builder.Exists() { + return builder, nil + } + + err := builder.apiClient.Create(context.TODO(), builder.Definition) + if err != nil { + return builder, err + } + + builder.Object = builder.Definition + + return builder, nil +} + +// Delete removes a CertificateSigningRequest object from the cluster if it exists. +func (builder *SigningRequestBuilder) Delete() error { + if valid, err := builder.validate(); !valid { + return err + } + + glog.V(100).Infof("Deleting CertificateSigningRequest %s", builder.Definition.Name) + + if !builder.Exists() { + glog.V(100).Infof("CertificateSigningRequest %s does not exist", builder.Definition.Name) + + builder.Object = nil + + return nil + } + + err := builder.apiClient.Delete(context.TODO(), builder.Definition) + if err != nil { + return err + } + + builder.Object = nil + + return nil +} + +func (builder *SigningRequestBuilder) validate() (bool, error) { + resourceCRD := "certificateSigningRequest" + + if builder == nil { + glog.V(100).Infof("The %s builder is uninitialized", resourceCRD) + + return false, fmt.Errorf("error: received nil %s builder", resourceCRD) + } + + if builder.Definition == nil { + glog.V(100).Infof("The %s is undefined", resourceCRD) + + return false, fmt.Errorf(msg.UndefinedCrdObjectErrString(resourceCRD)) + } + + if builder.apiClient == nil { + glog.V(100).Infof("The %s builder apiclient is nil", resourceCRD) + + return false, fmt.Errorf("%s builder cannot have nil apiClient", resourceCRD) + } + + return true, nil +} diff --git a/pkg/certificate/signingrequest_test.go b/pkg/certificate/signingrequest_test.go new file mode 100644 index 000000000..410a2af80 --- /dev/null +++ b/pkg/certificate/signingrequest_test.go @@ -0,0 +1,259 @@ +package certificate + +import ( + "fmt" + "testing" + + "github.com/openshift-kni/eco-goinfra/pkg/clients" + "github.com/stretchr/testify/assert" + certificatesv1 "k8s.io/api/certificates/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var testSchemes = []clients.SchemeAttacher{ + certificatesv1.AddToScheme, +} + +const ( + defaultSigningRequestName = "test-signing-request" +) + +func TestPullSigningRequest(t *testing.T) { + testCases := []struct { + name string + addToRuntimeObjects bool + client bool + expectedError error + }{ + { + name: defaultSigningRequestName, + addToRuntimeObjects: true, + client: true, + expectedError: nil, + }, + { + name: "", + addToRuntimeObjects: true, + client: true, + expectedError: fmt.Errorf("certificateSigningRequest 'name' cannot be empty"), + }, + { + name: defaultSigningRequestName, + addToRuntimeObjects: false, + client: true, + expectedError: fmt.Errorf("certificateSigningRequest %s does not exist", defaultSigningRequestName), + }, + { + name: defaultSigningRequestName, + addToRuntimeObjects: true, + client: false, + expectedError: fmt.Errorf("certificateSigniingRequest apiClient cannot be nil"), + }, + } + + for _, testCase := range testCases { + var ( + runtimeObjects []runtime.Object + testSettings *clients.Settings + ) + + if testCase.addToRuntimeObjects { + runtimeObjects = append(runtimeObjects, buildDummySigningRequest(testCase.name)) + } + + if testCase.client { + testSettings = clients.GetTestClients(clients.TestClientParams{ + K8sMockObjects: runtimeObjects, + SchemeAttachers: testSchemes, + }) + } + + signingRequestBuilder, err := PullSigningRequest(testSettings, testCase.name) + assert.Equal(t, testCase.expectedError, err) + + if testCase.expectedError == nil { + assert.Equal(t, testCase.name, signingRequestBuilder.Object.Name) + } + } +} + +func TestSigningRequestGet(t *testing.T) { + testCases := []struct { + testBuilder *SigningRequestBuilder + expectedError string + }{ + { + testBuilder: newSigningRequestBuilder(buildTestClientWithDummySigningRequest()), + expectedError: "", + }, + { + testBuilder: newSigningRequestBuilder(clients.GetTestClients(clients.TestClientParams{})), + expectedError: fmt.Sprintf("certificatesigningrequests.certificates.k8s.io \"%s\" not found", + defaultSigningRequestName), + }, + } + + for _, testCase := range testCases { + signingRequest, err := testCase.testBuilder.Get() + + if testCase.expectedError == "" { + assert.Nil(t, err) + assert.Equal(t, testCase.testBuilder.Definition.Name, signingRequest.Name) + } else { + assert.EqualError(t, err, testCase.expectedError) + } + } +} + +func TestSigningRequestExists(t *testing.T) { + testCases := []struct { + testBuilder *SigningRequestBuilder + exists bool + }{ + { + testBuilder: newSigningRequestBuilder(buildTestClientWithDummySigningRequest()), + exists: true, + }, + { + testBuilder: newSigningRequestBuilder(clients.GetTestClients(clients.TestClientParams{})), + exists: false, + }, + } + + for _, testCase := range testCases { + exists := testCase.testBuilder.Exists() + assert.Equal(t, testCase.exists, exists) + } +} + +func TestSigningRequestCreate(t *testing.T) { + testCases := []struct { + testBuilder *SigningRequestBuilder + expectedError error + }{ + { + testBuilder: newSigningRequestBuilder(buildTestClientWithDummySigningRequest()), + expectedError: nil, + }, + { + testBuilder: newSigningRequestBuilder(clients.GetTestClients(clients.TestClientParams{})), + expectedError: nil, + }, + } + + for _, testCase := range testCases { + signingRequestBuilder, err := testCase.testBuilder.Create() + assert.Equal(t, testCase.expectedError, err) + + if testCase.expectedError == nil { + assert.Equal(t, signingRequestBuilder.Definition.Name, signingRequestBuilder.Object.Name) + } + } +} + +func TestSigningRequestDelete(t *testing.T) { + testCases := []struct { + testBuilder *SigningRequestBuilder + expectedError error + }{ + { + testBuilder: newSigningRequestBuilder(buildTestClientWithDummySigningRequest()), + expectedError: nil, + }, + { + testBuilder: newSigningRequestBuilder(clients.GetTestClients(clients.TestClientParams{})), + expectedError: nil, + }, + } + + for _, testCase := range testCases { + err := testCase.testBuilder.Delete() + assert.Equal(t, testCase.expectedError, err) + + if testCase.expectedError == nil { + assert.Nil(t, testCase.testBuilder.Object) + } + } +} + +func TestSigningRequestValidate(t *testing.T) { + testCases := []struct { + builderNil bool + definitionNil bool + apiClientNil bool + expectedError error + }{ + { + builderNil: false, + definitionNil: false, + apiClientNil: false, + expectedError: nil, + }, + { + builderNil: true, + definitionNil: false, + apiClientNil: false, + expectedError: fmt.Errorf("error: received nil certificateSigningRequest builder"), + }, + { + builderNil: false, + definitionNil: true, + apiClientNil: false, + expectedError: fmt.Errorf("can not redefine the undefined certificateSigningRequest"), + }, + { + builderNil: false, + definitionNil: false, + apiClientNil: true, + expectedError: fmt.Errorf("certificateSigningRequest builder cannot have nil apiClient"), + }, + } + + for _, testCase := range testCases { + signingRequestBuilder := newSigningRequestBuilder(buildTestClientWithDummySigningRequest()) + + if testCase.builderNil { + signingRequestBuilder = nil + } + + if testCase.definitionNil { + signingRequestBuilder.Definition = nil + } + + if testCase.apiClientNil { + signingRequestBuilder.apiClient = nil + } + + valid, err := signingRequestBuilder.validate() + assert.Equal(t, testCase.expectedError, err) + assert.Equal(t, testCase.expectedError == nil, valid) + } +} + +// buildDummySigningRequest returns a dummy CertificateSigningRequest object with the given name. +func buildDummySigningRequest(name string) *certificatesv1.CertificateSigningRequest { + return &certificatesv1.CertificateSigningRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } +} + +// buildTestClientWithDummySigningRequest returns a clients.Settings object with a dummy CertificateSigningRequest +// object using the default name. +func buildTestClientWithDummySigningRequest() *clients.Settings { + return clients.GetTestClients(clients.TestClientParams{ + K8sMockObjects: []runtime.Object{ + buildDummySigningRequest(defaultSigningRequestName), + }, + SchemeAttachers: testSchemes, + }) +} + +func newSigningRequestBuilder(apiClient *clients.Settings) *SigningRequestBuilder { + return &SigningRequestBuilder{ + Definition: buildDummySigningRequest(defaultSigningRequestName), + apiClient: apiClient.Client, + } +} diff --git a/pkg/certificate/signingrequestlist.go b/pkg/certificate/signingrequestlist.go new file mode 100644 index 000000000..39f442a17 --- /dev/null +++ b/pkg/certificate/signingrequestlist.go @@ -0,0 +1,123 @@ +package certificate + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/golang/glog" + "github.com/openshift-kni/eco-goinfra/pkg/clients" + certificatesv1 "k8s.io/api/certificates/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/wait" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ListSigningRequests returns a list of all CertificateSigningRequest objects in the cluster with the provided options. +func ListSigningRequests( + apiClient *clients.Settings, options ...runtimeclient.ListOptions) ([]*SigningRequestBuilder, error) { + if apiClient == nil { + glog.V(100).Infof("CertificateSigningRequest 'apiClient' cannot be nil") + + return nil, fmt.Errorf("certificateSigningRequest 'apiClient' cannot be nil") + } + + err := apiClient.AttachScheme(certificatesv1.AddToScheme) + if err != nil { + glog.V(100).Infof("Failed to add certificates v1 scheme to client schemes") + + return nil, err + } + + if len(options) > 1 { + glog.V(100).Infof("Only one ListOptions object can be provided to ListSigningRequests") + + return nil, fmt.Errorf("error: more than one ListOptions was passed") + } + + logMessage := "Listing all CertificateSigningRequests" + passedOptions := runtimeclient.ListOptions{} + + if len(options) == 1 { + passedOptions = options[0] + logMessage += fmt.Sprintf(" with options: %v", passedOptions) + } + + glog.V(100).Info(logMessage) + + csrList := new(certificatesv1.CertificateSigningRequestList) + err = apiClient.Client.List(context.TODO(), csrList, &passedOptions) + + if err != nil { + glog.V(100).Infof("Failed to list CertificateSigningRequests: %v", err) + + return nil, err + } + + var signingRequestBuilders []*SigningRequestBuilder + + for _, csr := range csrList.Items { + copiedCSR := csr + signingRequestBuilder := &SigningRequestBuilder{ + apiClient: apiClient.Client, + Definition: &copiedCSR, + Object: &copiedCSR, + } + + signingRequestBuilders = append(signingRequestBuilders, signingRequestBuilder) + } + + return signingRequestBuilders, nil +} + +// WaitUntilSigningRequestsApproved polls the cluster for all CertificateSigningRequests with the provided options every +// 3 seconds for up to the timeout duration or until all CertificateSigningRequests are approved. +func WaitUntilSigningRequestsApproved( + apiClient *clients.Settings, timeout time.Duration, options ...runtimeclient.ListOptions) error { + if apiClient == nil { + glog.V(100).Infof("CertificateSigningRequest 'apiClient' cannot be nil") + + return fmt.Errorf("certificateSigningRequest 'apiClient' cannot be nil") + } + + if len(options) > 1 { + glog.V(100).Infof("Only one ListOptions object can be provided to WaitUntilSigningRequestsApproved") + + return fmt.Errorf("error: more than one ListOptions was passed") + } + + logMessage := "Waiting for all CertificateSigningRequests to be approved" + passedOptions := runtimeclient.ListOptions{} + + if len(options) == 1 { + passedOptions = options[0] + logMessage += fmt.Sprintf(" with options: %v", passedOptions) + } + + glog.V(100).Info(logMessage) + + return wait.PollUntilContextTimeout( + context.TODO(), 3*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + signingRequests, err := ListSigningRequests(apiClient, passedOptions) + if err != nil { + glog.V(100).Infof("Failed to list CertificateSigningRequests: %v", err) + + return false, nil + } + + for _, signingRequest := range signingRequests { + if !slices.ContainsFunc(signingRequest.Object.Status.Conditions, approvedCondition) { + glog.V(100).Infof("CertificateSigningRequest %s is not approved yet", signingRequest.Object.Name) + + return false, nil + } + } + + return true, nil + }) +} + +func approvedCondition(cond certificatesv1.CertificateSigningRequestCondition) bool { + return cond.Type == certificatesv1.CertificateApproved && cond.Status == corev1.ConditionTrue +} diff --git a/pkg/certificate/signingrequestlist_test.go b/pkg/certificate/signingrequestlist_test.go new file mode 100644 index 000000000..00ab3646c --- /dev/null +++ b/pkg/certificate/signingrequestlist_test.go @@ -0,0 +1,137 @@ +package certificate + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/openshift-kni/eco-goinfra/pkg/clients" + "github.com/stretchr/testify/assert" + certificatesv1 "k8s.io/api/certificates/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestListSigningRequests(t *testing.T) { + testCases := []struct { + signingRequests []*SigningRequestBuilder + listOptions []runtimeclient.ListOptions + client bool + expectedError error + }{ + { + signingRequests: []*SigningRequestBuilder{newSigningRequestBuilder(buildTestClientWithDummySigningRequest())}, + listOptions: nil, + client: true, + expectedError: nil, + }, + { + signingRequests: []*SigningRequestBuilder{newSigningRequestBuilder(buildTestClientWithDummySigningRequest())}, + listOptions: []runtimeclient.ListOptions{{LabelSelector: labels.NewSelector()}}, + client: true, + expectedError: nil, + }, + { + signingRequests: []*SigningRequestBuilder{newSigningRequestBuilder(buildTestClientWithDummySigningRequest())}, + listOptions: []runtimeclient.ListOptions{{}, {}}, + client: true, + expectedError: fmt.Errorf("error: more than one ListOptions was passed"), + }, + { + signingRequests: []*SigningRequestBuilder{newSigningRequestBuilder(buildTestClientWithDummySigningRequest())}, + listOptions: nil, + client: false, + expectedError: fmt.Errorf("certificateSigningRequest 'apiClient' cannot be nil"), + }, + } + + for _, testCase := range testCases { + var testSettings *clients.Settings + + if testCase.client { + testSettings = buildTestClientWithDummySigningRequest() + } + + builders, err := ListSigningRequests(testSettings, testCase.listOptions...) + assert.Equal(t, testCase.expectedError, err) + + if testCase.expectedError == nil && len(testCase.listOptions) == 0 { + assert.Equal(t, len(testCase.signingRequests), len(builders)) + } + } +} + +func TestWaitUntilSigningRequestsApproved(t *testing.T) { + testcases := []struct { + listOptions []runtimeclient.ListOptions + client bool + approved bool + expectedError error + }{ + { + listOptions: nil, + client: true, + approved: true, + expectedError: nil, + }, + { + listOptions: []runtimeclient.ListOptions{{}, {}}, + client: true, + approved: true, + expectedError: fmt.Errorf("error: more than one ListOptions was passed"), + }, + { + listOptions: nil, + client: false, + approved: true, + expectedError: fmt.Errorf("certificateSigningRequest 'apiClient' cannot be nil"), + }, + { + listOptions: nil, + client: true, + approved: false, + expectedError: context.DeadlineExceeded, + }, + } + + for _, testCase := range testcases { + var ( + runtimeObjects []runtime.Object + testSettings *clients.Settings + ) + + if testCase.approved { + runtimeObjects = append(runtimeObjects, buildDummyApprovedSigningRequest()) + } else { + runtimeObjects = append(runtimeObjects, buildDummySigningRequest(defaultSigningRequestName)) + } + + if testCase.client { + testSettings = clients.GetTestClients(clients.TestClientParams{ + K8sMockObjects: runtimeObjects, + SchemeAttachers: testSchemes, + }) + } + + err := WaitUntilSigningRequestsApproved(testSettings, time.Second, testCase.listOptions...) + assert.Equal(t, testCase.expectedError, err) + } +} + +func buildDummyApprovedSigningRequest() *certificatesv1.CertificateSigningRequest { + return &certificatesv1.CertificateSigningRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultSigningRequestName, + }, + Status: certificatesv1.CertificateSigningRequestStatus{ + Conditions: []certificatesv1.CertificateSigningRequestCondition{{ + Type: certificatesv1.CertificateApproved, + Status: corev1.ConditionTrue, + }}, + }, + } +}