Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions pkg/operator/certrotation/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package certrotation

import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func ensureMetadataUpdate(secret *corev1.Secret, owner *metav1.OwnerReference, additionalAnnotations AdditionalAnnotations) bool {
needsMetadataUpdate := false
// no ownerReference set
if owner != nil {
needsMetadataUpdate = ensureOwnerReference(&secret.ObjectMeta, owner)
}
// ownership annotations not set
return additionalAnnotations.EnsureTLSMetadataUpdate(&secret.ObjectMeta) || needsMetadataUpdate
}

func ensureSecretTLSTypeSet(secret *corev1.Secret) bool {
// Existing secret not found - no need to update metadata (will be done by needNewSigningCertKeyPair / NeedNewTargetCertKeyPair)
if len(secret.ResourceVersion) == 0 {
return false
}

// convert outdated secret type (created by pre 4.7 installer)
if secret.Type != corev1.SecretTypeTLS {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should behave the same in practice, but since we're correcting for a specific mistake, how about:

Suggested change
if secret.Type != corev1.SecretTypeTLS {
if secret.Type == "SecretTypeTLS" {

And as an aside, can we have an issue to track removing this after N releases?

Copy link
Copy Markdown
Contributor Author

@vrutkovs vrutkovs Mar 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confusingly SecretTypeTLS is kubernetes.io/tls and not "SecretTypeTLS". So if secret.Type != corev1.SecretTypeTLS would make sure any non-kubernetes.io/tls secret would converted to kubernetes.io/tls (clearing up data if necessary)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I understand, but my point is that we expect any secret managed by this controller will either have type kubernetes.io/tls or, if it was created pre-4.7, SecretTypeTLS. If we discover that there is some instance of a managed secret with a completely unexpected type, I would rather understand why our expectation was wrong than stomp it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its perfectly possible for users to find a way to replace these certificates behind cert-syncer back and replace it with invalid contents. This code will ensure that we attempt to convert cases like pre-4.7 if possible, but also prioritize availability - invalid content would be stomped for cluster to continue functioning. Why the bad secret was stomped can be found out via audit logs (or etcd contents).
This change will also protects us from other similar cases like "old/misconfigured installer has generated certificates which we no longer expect"

secret.Type = corev1.SecretTypeTLS
// wipe secret contents if tls.crt and tls.key are missing
_, certExists := secret.Data[corev1.TLSCertKey]
_, keyExists := secret.Data[corev1.TLSPrivateKeyKey]
if !certExists || !keyExists {
secret.Data = map[string][]byte{}
}
return true
}
return false
}
26 changes: 13 additions & 13 deletions pkg/operator/certrotation/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,24 +62,24 @@ func (c RotatedSigningCASecret) EnsureSigningCertKeyPair(ctx context.Context) (*
signingCertKeyPairSecret := originalSigningCertKeyPairSecret.DeepCopy()
if apierrors.IsNotFound(err) {
// create an empty one
signingCertKeyPairSecret = &corev1.Secret{ObjectMeta: NewTLSArtifactObjectMeta(
c.Name,
c.Namespace,
c.AdditionalAnnotations,
)}
signingCertKeyPairSecret = &corev1.Secret{
ObjectMeta: NewTLSArtifactObjectMeta(
c.Name,
c.Namespace,
c.AdditionalAnnotations,
),
Type: corev1.SecretTypeTLS,
}
}
signingCertKeyPairSecret.Type = corev1.SecretTypeTLS

needsMetadataUpdate := false
if c.Owner != nil {
needsMetadataUpdate = ensureOwnerReference(&signingCertKeyPairSecret.ObjectMeta, c.Owner)
}
needsMetadataUpdate = c.AdditionalAnnotations.EnsureTLSMetadataUpdate(&signingCertKeyPairSecret.ObjectMeta) || needsMetadataUpdate
if needsMetadataUpdate && len(signingCertKeyPairSecret.ResourceVersion) > 0 {
_, _, err := resourceapply.ApplySecret(ctx, c.Client, c.EventRecorder, signingCertKeyPairSecret)
// apply necessary metadata (possibly via delete+recreate) if secret exists
// this is done before content update to prevent unexpected rollouts
if ensureMetadataUpdate(signingCertKeyPairSecret, c.Owner, c.AdditionalAnnotations) && ensureSecretTLSTypeSet(signingCertKeyPairSecret) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this && and not ||?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to update labels before content changes only when:

  • secret type won't change (ensureSecretTLSTypeSet returns true)
  • metadata (annotations, ownership) needs updating (ensureMetadataUpdate returns true)

The function are also mutating signingCertKeyPairSecret, changing it type and clearing data, so the secret would be deleted + created during if needed, reason := needNewSigningCertKeyPair clause. It needs to be done once to avoid updating it twice for metadata and content ("update no annotations" and "update SecretTLSType secrets" unit test cases)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll want to make sure your secret always passes both functions, right?

Either operator might short-circuit in a different scenario.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll want to make sure your secret always passes both functions, right?

correct, if either doesn't pass possible updates will be applied by ApplySecret in needNewSigningCertKeyPair

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the secret would be deleted + created during if needed, reason := needNewSigningCertKeyPair clause

Thanks, this is the part that I was missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would stuck on invalid secret type with valid metadata, #1687 fixes this case

actualSigningCertKeyPairSecret, _, err := resourceapply.ApplySecret(ctx, c.Client, c.EventRecorder, signingCertKeyPairSecret)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the following seems as a bit of a cleaner method to achieve the same

var err error
signingCertKeyPairSecret, _, err = resourceapply.ApplySecret(ctx, c.Client, c.EventRecorder, signingCertKeyPairSecret)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following the pattern as in if needed, reason := needNewSigningCertKeyPair

if err != nil {
return nil, err
}
signingCertKeyPairSecret = actualSigningCertKeyPairSecret
}

if needed, reason := needNewSigningCertKeyPair(signingCertKeyPairSecret.Annotations, c.Refresh, c.RefreshOnlyWhenExpired); needed {
Expand Down
143 changes: 141 additions & 2 deletions pkg/operator/certrotation/signer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
clienttesting "k8s.io/client-go/testing"
"k8s.io/client-go/tools/cache"

"github.com/openshift/api/annotations"
"github.com/openshift/library-go/pkg/operator/events"
)

Expand Down Expand Up @@ -50,13 +51,30 @@ func TestEnsureSigningCertKeyPair(t *testing.T) {
if len(actual.Data["tls.crt"]) == 0 || len(actual.Data["tls.key"]) == 0 {
t.Error(actual.Data)
}
if len(actual.Annotations) == 0 {
t.Errorf("expected certificates to be annotated")
}
Comment on lines +54 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks redundant, any value for annotations that would fail here is also going to fail below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I'd prefer to a clear error message

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine with me given that nothing out of the control of this test should be touching annotations.

ownershipValue, found := actual.Annotations[annotations.OpenShiftComponent]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to avoid sharing values like this between tests and implementation, since it creates the opportunity to accidentally make a breaking change without seeing a test failure.

Feel free to ignore if you disagree or are comfortable with it in this instance because it is declared in openshift/api.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it creates the opportunity to accidentally make a breaking change without seeing a test failure

We explicitly set AdditionalAnnotations in https://github.com/openshift/library-go/pull/1681/files#diff-d7a4e30c6635930d8721bf060fe2803587326298e0eace9bada256f1b7377459R228-R230

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That link didn't work for me. In this case I'm specifically referring to a change to the constant OpenShiftComponent, which would be a breaking change but would not cause this test to fail.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, hmm, JiraComponent and OpenshiftComponent are linked in https://github.com/openshift/library-go/blob/master/pkg/operator/certrotation/annotations.go#L27-L30, not sure how to make sure unit tests don't depend on it though

if !found {
t.Errorf("expected secret to have ownership annotations, got: %v", actual.Annotations)
}
if ownershipValue != "test" {
t.Errorf("expected ownership annotation to be 'test', got: %v", ownershipValue)
}
if len(actual.OwnerReferences) != 1 {
t.Errorf("expected to have exactly one owner reference")
}
if actual.OwnerReferences[0].Name != "operator" {
t.Errorf("expected owner reference to be 'operator', got %v", actual.OwnerReferences[0].Name)
}
},
},
{
name: "update no annotations",
initialSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "signer"},
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "signer", ResourceVersion: "10"},
Type: corev1.SecretTypeTLS,
Data: map[string][]byte{"tls.crt": {}, "tls.key": {}},
},
verifyActions: func(t *testing.T, client *kubefake.Clientset) {
t.Helper()
Expand All @@ -65,27 +83,46 @@ func TestEnsureSigningCertKeyPair(t *testing.T) {
t.Fatal(spew.Sdump(actions))
}

if !actions[0].Matches("get", "secrets") {
t.Error(actions[0])
}
if !actions[1].Matches("update", "secrets") {
t.Error(actions[1])
}

actual := actions[1].(clienttesting.UpdateAction).GetObject().(*corev1.Secret)
if certType, _ := CertificateTypeFromObject(actual); certType != CertificateTypeSigner {
t.Errorf("expected certificate type 'signer', got: %v", certType)
}
if len(actual.Data["tls.crt"]) == 0 || len(actual.Data["tls.key"]) == 0 {
t.Error(actual.Data)
}
ownershipValue, found := actual.Annotations[annotations.OpenShiftComponent]
if !found {
t.Errorf("expected secret to have ownership annotations, got: %v", actual.Annotations)
}
if ownershipValue != "test" {
t.Errorf("expected ownership annotation to be 'test', got: %v", ownershipValue)
}
if len(actual.OwnerReferences) != 1 {
t.Errorf("expected to have exactly one owner reference")
}
if actual.OwnerReferences[0].Name != "operator" {
t.Errorf("expected owner reference to be 'operator', got %v", actual.OwnerReferences[0].Name)
}
},
},
{
name: "update no work",
initialSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "signer",
ResourceVersion: "10",
Annotations: map[string]string{
"auth.openshift.io/certificate-not-after": "2108-09-08T22:47:31-07:00",
"auth.openshift.io/certificate-not-before": "2108-09-08T20:47:31-07:00",
annotations.OpenShiftComponent: "test",
}},
Type: corev1.SecretTypeTLS,
Data: map[string][]byte{"tls.crt": {}, "tls.key": {}},
},
verifyActions: func(t *testing.T, client *kubefake.Clientset) {
t.Helper()
Expand All @@ -96,6 +133,102 @@ func TestEnsureSigningCertKeyPair(t *testing.T) {
},
expectedError: "certFile missing", // this means we tried to read the cert from the existing secret. If we created one, we fail in the client check
},
{
name: "update SecretTLSType secrets",
initialSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "signer",
ResourceVersion: "10",
Annotations: map[string]string{
"auth.openshift.io/certificate-not-after": "2108-09-08T22:47:31-07:00",
"auth.openshift.io/certificate-not-before": "2108-09-08T20:47:31-07:00",
}},
Type: "SecretTypeTLS",
Data: map[string][]byte{"tls.crt": {}, "tls.key": {}},
},
verifyActions: func(t *testing.T, client *kubefake.Clientset) {
t.Helper()
actions := client.Actions()
if len(actions) != 3 {
t.Fatal(spew.Sdump(actions))
}

if !actions[0].Matches("get", "secrets") {
t.Error(actions[0])
}
if !actions[1].Matches("delete", "secrets") {
t.Error(actions[1])
}
if !actions[2].Matches("create", "secrets") {
t.Error(actions[2])
}
actual := actions[2].(clienttesting.UpdateAction).GetObject().(*corev1.Secret)
if actual.Type != corev1.SecretTypeTLS {
t.Errorf("expected secret type to be kubernetes.io/tls, got: %v", actual.Type)
}
cert, found := actual.Data["tls.crt"]
if !found {
t.Errorf("expected to have tls.crt key")
}
if len(cert) != 0 {
t.Errorf("expected tls.crt to be empty, got %v", cert)
}
key, found := actual.Data["tls.key"]
if !found {
t.Errorf("expected to have tls.key key")
}
if len(key) != 0 {
t.Errorf("expected tls.key to be empty, got %v", key)
}
if len(actual.OwnerReferences) != 1 {
t.Errorf("expected to have exactly one owner reference")
}
if actual.OwnerReferences[0].Name != "operator" {
t.Errorf("expected owner reference to be 'operator', got %v", actual.OwnerReferences[0].Name)
}
},
expectedError: "certFile missing", // this means we tried to read the cert from the existing secret. If we created one, we fail in the client check
},
{
name: "recreate invalid type secrets",
initialSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "signer",
ResourceVersion: "10",
Annotations: map[string]string{
"auth.openshift.io/certificate-not-after": "2108-09-08T22:47:31-07:00",
"auth.openshift.io/certificate-not-before": "2108-09-08T20:47:31-07:00",
}},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{"foo": {}, "bar": {}},
},
verifyActions: func(t *testing.T, client *kubefake.Clientset) {
t.Helper()
actions := client.Actions()
if len(actions) != 3 {
t.Fatal(spew.Sdump(actions))
}

if !actions[0].Matches("get", "secrets") {
t.Error(actions[0])
}
if !actions[1].Matches("delete", "secrets") {
t.Error(actions[1])
}
if !actions[2].Matches("create", "secrets") {
t.Error(actions[2])
}
actual := actions[2].(clienttesting.UpdateAction).GetObject().(*corev1.Secret)
if actual.Type != corev1.SecretTypeTLS {
t.Errorf("expected secret type to be kubernetes.io/tls, got: %v", actual.Type)
}
if len(actual.OwnerReferences) != 1 {
t.Errorf("expected to have exactly one owner reference")
}
if actual.OwnerReferences[0].Name != "operator" {
t.Errorf("expected owner reference to be 'operator', got %v", actual.OwnerReferences[0].Name)
}
},
expectedError: "certFile missing", // this means we tried to read the cert from the existing secret. If we created one, we fail in the client check
},
}

for _, test := range tests {
Expand All @@ -116,6 +249,12 @@ func TestEnsureSigningCertKeyPair(t *testing.T) {
Client: client.CoreV1(),
Lister: corev1listers.NewSecretLister(indexer),
EventRecorder: events.NewInMemoryRecorder("test"),
AdditionalAnnotations: AdditionalAnnotations{
JiraComponent: "test",
},
Owner: &metav1.OwnerReference{
Name: "operator",
},
}

_, err := c.EnsureSigningCertKeyPair(context.TODO())
Expand Down
26 changes: 13 additions & 13 deletions pkg/operator/certrotation/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,24 +97,24 @@ func (c RotatedSelfSignedCertKeySecret) EnsureTargetCertKeyPair(ctx context.Cont
targetCertKeyPairSecret := originalTargetCertKeyPairSecret.DeepCopy()
if apierrors.IsNotFound(err) {
// create an empty one
targetCertKeyPairSecret = &corev1.Secret{ObjectMeta: NewTLSArtifactObjectMeta(
c.Name,
c.Namespace,
c.AdditionalAnnotations,
)}
targetCertKeyPairSecret = &corev1.Secret{
ObjectMeta: NewTLSArtifactObjectMeta(
c.Name,
c.Namespace,
c.AdditionalAnnotations,
),
Type: corev1.SecretTypeTLS,
}
}
targetCertKeyPairSecret.Type = corev1.SecretTypeTLS

needsMetadataUpdate := false
if c.Owner != nil {
needsMetadataUpdate = ensureOwnerReference(&targetCertKeyPairSecret.ObjectMeta, c.Owner)
}
needsMetadataUpdate = c.AdditionalAnnotations.EnsureTLSMetadataUpdate(&targetCertKeyPairSecret.ObjectMeta) || needsMetadataUpdate
if needsMetadataUpdate && len(targetCertKeyPairSecret.ResourceVersion) > 0 {
_, _, err := resourceapply.ApplySecret(ctx, c.Client, c.EventRecorder, targetCertKeyPairSecret)
// apply necessary metadata (possibly via delete+recreate) if secret exists
// this is done before content update to prevent unexpected rollouts
if ensureMetadataUpdate(targetCertKeyPairSecret, c.Owner, c.AdditionalAnnotations) && ensureSecretTLSTypeSet(targetCertKeyPairSecret) {
actualTargetCertKeyPairSecret, _, err := resourceapply.ApplySecret(ctx, c.Client, c.EventRecorder, targetCertKeyPairSecret)
if err != nil {
return nil, err
}
targetCertKeyPairSecret = actualTargetCertKeyPairSecret
}

if reason := c.CertCreator.NeedNewTargetCertKeyPair(targetCertKeyPairSecret.Annotations, signingCertKeyPair, caBundleCerts, c.Refresh, c.RefreshOnlyWhenExpired); len(reason) > 0 {
Expand Down
Loading