From 9a0d9e4febdee8fc458919b9112918382d8f6db1 Mon Sep 17 00:00:00 2001 From: Andrew Stucki Date: Tue, 3 Dec 2024 11:23:55 -0500 Subject: [PATCH] Add extended cluster license info (#331) * Add extended cluster license info * Add additional information and change timestamp type * Always show inUseFeatures and violations * Fix up test expectations --- .../api/redpanda/v1alpha2/redpanda_types.go | 33 ++++++++++ .../v1alpha2/zz_generated.deepcopy.go | 44 +++++++++++++ .../bases/cluster.redpanda.com_redpandas.yaml | 48 ++++++++++++++ .../redpanda/redpanda_controller.go | 51 +++++++++++++++ .../redpanda/redpanda_controller_test.go | 64 +++++++++++++++++-- 5 files changed, 235 insertions(+), 5 deletions(-) diff --git a/operator/api/redpanda/v1alpha2/redpanda_types.go b/operator/api/redpanda/v1alpha2/redpanda_types.go index 6bbb45630..d1440bbed 100644 --- a/operator/api/redpanda/v1alpha2/redpanda_types.go +++ b/operator/api/redpanda/v1alpha2/redpanda_types.go @@ -12,6 +12,8 @@ package v1alpha2 import ( "encoding/json" "fmt" + "strconv" + "strings" "github.com/cockroachdb/errors" helmv2beta2 "github.com/fluxcd/helm-controller/api/v2beta2" @@ -143,6 +145,37 @@ type RedpandaStatus struct { // decommissioned from the cluster and provides its ordinal number. // +optional ManagedDecommissioningNode *int32 `json:"decommissioningNode,omitempty"` + + // LicenseStatus contains information about the current state of any + // installed license in the Redpanda cluster. + // +optional + LicenseStatus *RedpandaLicenseStatus `json:"license,omitempty"` +} + +type RedpandaLicenseStatus struct { + Violation bool `json:"violation"` + InUseFeatures []string `json:"inUseFeatures"` + // +optional + Expired *bool `json:"expired,omitempty"` + // +optional + Type *string `json:"type,omitempty"` + // +optional + Organization *string `json:"organization,omitempty"` + // +optional + Expiration *metav1.Time `json:"expiration,omitempty"` +} + +func (s *RedpandaLicenseStatus) String() string { + expired := "nil" + expiration := "nil" + if s.Expired != nil { + expired = strconv.FormatBool(*s.Expired) + } + if s.Expiration != nil { + expiration = s.Expiration.UTC().Format("Jan 2 2006 MST") + } + + return fmt.Sprintf("License Status: Expired(%s), Expiration(%s), Features([%s])", expired, expiration, strings.Join(s.InUseFeatures, ", ")) } type RemediationStrategy string diff --git a/operator/api/redpanda/v1alpha2/zz_generated.deepcopy.go b/operator/api/redpanda/v1alpha2/zz_generated.deepcopy.go index 00490b011..dfc9ba79a 100644 --- a/operator/api/redpanda/v1alpha2/zz_generated.deepcopy.go +++ b/operator/api/redpanda/v1alpha2/zz_generated.deepcopy.go @@ -2799,6 +2799,45 @@ func (in *RedpandaImage) DeepCopy() *RedpandaImage { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedpandaLicenseStatus) DeepCopyInto(out *RedpandaLicenseStatus) { + *out = *in + if in.InUseFeatures != nil { + in, out := &in.InUseFeatures, &out.InUseFeatures + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Expired != nil { + in, out := &in.Expired, &out.Expired + *out = new(bool) + **out = **in + } + if in.Type != nil { + in, out := &in.Type, &out.Type + *out = new(string) + **out = **in + } + if in.Organization != nil { + in, out := &in.Organization, &out.Organization + *out = new(string) + **out = **in + } + if in.Expiration != nil { + in, out := &in.Expiration, &out.Expiration + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedpandaLicenseStatus. +func (in *RedpandaLicenseStatus) DeepCopy() *RedpandaLicenseStatus { + if in == nil { + return nil + } + out := new(RedpandaLicenseStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RedpandaList) DeepCopyInto(out *RedpandaList) { *out = *in @@ -2908,6 +2947,11 @@ func (in *RedpandaStatus) DeepCopyInto(out *RedpandaStatus) { *out = new(int32) **out = **in } + if in.LicenseStatus != nil { + in, out := &in.LicenseStatus, &out.LicenseStatus + *out = new(RedpandaLicenseStatus) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedpandaStatus. diff --git a/operator/config/crd/bases/cluster.redpanda.com_redpandas.yaml b/operator/config/crd/bases/cluster.redpanda.com_redpandas.yaml index 77a29426b..ff1d7d28a 100644 --- a/operator/config/crd/bases/cluster.redpanda.com_redpandas.yaml +++ b/operator/config/crd/bases/cluster.redpanda.com_redpandas.yaml @@ -9790,6 +9790,30 @@ spec: reconcile request value, so a change of the annotation value can be detected. type: string + license: + description: |- + LicenseStatus contains information about the current state of any + installed license in the Redpanda cluster. + properties: + expiration: + format: date-time + type: string + expired: + type: boolean + inUseFeatures: + items: + type: string + type: array + organization: + type: string + type: + type: string + violation: + type: boolean + required: + - inUseFeatures + - violation + type: object observedGeneration: description: Specifies the last observed generation. format: int64 @@ -19580,6 +19604,30 @@ spec: reconcile request value, so a change of the annotation value can be detected. type: string + license: + description: |- + LicenseStatus contains information about the current state of any + installed license in the Redpanda cluster. + properties: + expiration: + format: date-time + type: string + expired: + type: boolean + inUseFeatures: + items: + type: string + type: array + organization: + type: string + type: + type: string + violation: + type: boolean + required: + - inUseFeatures + - violation + type: object observedGeneration: description: Specifies the last observed generation. format: int64 diff --git a/operator/internal/controller/redpanda/redpanda_controller.go b/operator/internal/controller/redpanda/redpanda_controller.go index 6d611d575..d6a69c4ae 100644 --- a/operator/internal/controller/redpanda/redpanda_controller.go +++ b/operator/internal/controller/redpanda/redpanda_controller.go @@ -489,6 +489,22 @@ func (r *RedpandaReconciler) reconcileLicense(ctx context.Context, rp *v1alpha2. return err } + licenseInfo, err := client.GetLicenseInfo(ctx) + if err != nil { + if internalclient.IsTerminalClientError(err) { + apimeta.SetStatusCondition(rp.GetConditions(), metav1.Condition{ + Type: v1alpha2.ClusterLicenseValid, + Status: metav1.ConditionUnknown, + ObservedGeneration: rp.Generation, + Reason: "TerminalError", + Message: err.Error(), + }) + + return nil + } + return err + } + var message string var reason string status := metav1.ConditionUnknown @@ -516,6 +532,41 @@ func (r *RedpandaReconciler) reconcileLicense(ctx context.Context, rp *v1alpha2. Message: message, }) + licenseStatus := func() *v1alpha2.RedpandaLicenseStatus { + inUseFeatures := []string{} + for _, feature := range features.Features { + if feature.Enabled { + inUseFeatures = append(inUseFeatures, feature.Name) + } + } + + status := &v1alpha2.RedpandaLicenseStatus{ + InUseFeatures: inUseFeatures, + Violation: features.Violation, + } + + // make sure we can actually format the extend license properties + if !licenseInfo.Loaded { + return status + } + + status.Organization = ptr.To(licenseInfo.Properties.Organization) + status.Type = ptr.To(licenseInfo.Properties.Type) + expirationTime := time.Unix(licenseInfo.Properties.Expires, 0) + + // if we have an expiration that is below 0 we are already expired + // so no need to set the expiration time + status.Expired = ptr.To(licenseInfo.Properties.Expires <= 0 || expirationTime.Before(time.Now())) + + if !*status.Expired { + status.Expiration = &metav1.Time{Time: expirationTime.UTC()} + } + + return status + } + + rp.Status.LicenseStatus = licenseStatus() + return nil } diff --git a/operator/internal/controller/redpanda/redpanda_controller_test.go b/operator/internal/controller/redpanda/redpanda_controller_test.go index 8b5a4b2b0..13c7ada78 100644 --- a/operator/internal/controller/redpanda/redpanda_controller_test.go +++ b/operator/internal/controller/redpanda/redpanda_controller_test.go @@ -441,9 +441,10 @@ func (s *RedpandaControllerSuite) TestLicense() { } cases := []struct { - image image - license bool - expected string + image image + license bool + expected string + expectedLicenseStatus *redpandav1alpha2.RedpandaLicenseStatus }{{ image: image{ repository: "redpandadata/redpanda-unstable", @@ -451,13 +452,29 @@ func (s *RedpandaControllerSuite) TestLicense() { }, license: false, expected: "Expired", + expectedLicenseStatus: &redpandav1alpha2.RedpandaLicenseStatus{ + Violation: false, + InUseFeatures: []string{}, + Expired: ptr.To(true), + Type: ptr.To("free_trial"), + Organization: ptr.To("Redpanda Built-In Evaluation Period"), + }, }, { image: image{ repository: "redpandadata/redpanda-unstable", - tag: "v24.3.1-rc4", + tag: "v24.3.1-rc8", }, license: true, expected: "Valid", + expectedLicenseStatus: &redpandav1alpha2.RedpandaLicenseStatus{ + Violation: false, + InUseFeatures: []string{}, + Expired: ptr.To(false), + // add a 30 day expiration, which is how we handle trial licenses + Expiration: &metav1.Time{Time: time.Now().Add(30 * 24 * time.Hour).UTC()}, + Type: ptr.To("free_trial"), + Organization: ptr.To("Redpanda Built-In Evaluation Period"), + }, }, { image: image{ repository: "redpandadata/redpanda", @@ -465,6 +482,10 @@ func (s *RedpandaControllerSuite) TestLicense() { }, license: false, expected: "Not Present", + expectedLicenseStatus: &redpandav1alpha2.RedpandaLicenseStatus{ + Violation: false, + InUseFeatures: []string{}, + }, }, { image: image{ repository: "redpandadata/redpanda", @@ -472,6 +493,10 @@ func (s *RedpandaControllerSuite) TestLicense() { }, license: true, expected: "Not Present", + expectedLicenseStatus: &redpandav1alpha2.RedpandaLicenseStatus{ + Violation: false, + InUseFeatures: []string{}, + }, }} for _, c := range cases { @@ -492,6 +517,7 @@ func (s *RedpandaControllerSuite) TestLicense() { } var condition metav1.Condition + var licenseStatus *redpandav1alpha2.RedpandaLicenseStatus s.applyAndWaitFor(func(o client.Object) bool { rp := o.(*redpandav1alpha2.Redpanda) @@ -500,6 +526,7 @@ func (s *RedpandaControllerSuite) TestLicense() { // grab the first non-unknown status if cond.Status != metav1.ConditionUnknown { condition = cond + licenseStatus = rp.Status.LicenseStatus return true } return false @@ -512,6 +539,33 @@ func (s *RedpandaControllerSuite) TestLicense() { message := fmt.Sprintf("%s - %s != %s", name, c.expected, condition.Message) s.Require().Equal(c.expected, condition.Message, message) + if c.expectedLicenseStatus == nil && licenseStatus != nil { + s.T().Fatalf("%s does not have a nil license %s", name, licenseStatus.String()) + } + + if c.expectedLicenseStatus != nil { + s.Require().NotNil(licenseStatus, "%s does has a nil license", name) + s.Require().Equal(licenseStatus.Expired, c.expectedLicenseStatus.Expired, "%s license expired field does not match", name) + s.Require().EqualValues(licenseStatus.InUseFeatures, c.expectedLicenseStatus.InUseFeatures, "%s license valid features do not match", name) + s.Require().Equal(licenseStatus.Organization, c.expectedLicenseStatus.Organization, "%s license organization field does not match", name) + s.Require().Equal(licenseStatus.Type, c.expectedLicenseStatus.Type, "%s license type field does not match", name) + s.Require().Equal(licenseStatus.Violation, c.expectedLicenseStatus.Violation, "%s license violation field does not match", name) + + // only do the expiration check if the license isn't already expired + if licenseStatus.Expired != nil && !*licenseStatus.Expired { + expectedExpiration := c.expectedLicenseStatus.Expiration.UTC() + actualExpiration := licenseStatus.Expiration.UTC() + + rangeFactor := 5 * time.Minute + // add some fudge factor so that we don't fail with flakiness due to tests being run at + // the change of a couple of minutes that causes the date to be rolled over by some factor + if !(expectedExpiration.Add(rangeFactor).After(actualExpiration) && + expectedExpiration.Add(-rangeFactor).Before(actualExpiration)) { + s.T().Fatalf("%s does not match expected expiration: %s != %s", name, actualExpiration, expectedExpiration) + } + } + } + s.deleteAndWait(rp) } } @@ -749,7 +803,7 @@ func (s *RedpandaControllerSuite) randString(length int) string { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" name := "" - for i := 0; i < 6; i++ { + for i := 0; i < length; i++ { //nolint:gosec // not meant to be a secure random string. name += string(alphabet[rand.Intn(len(alphabet))]) }