Repository: gardener/cert-management
Pull request #489: feat: Add `IssuanceDate` to `Certificate` status
Author: login: marc1404
Labels: name: kind/api-change
description: API change with impact on API users
name: kind/task
description: General task
name: needs/ok-to-test
description: Needs approval for testing (check PR in detail before setting this label because PR is run on CI/CD)
name: reviewed/ok-to-test
description: Has approval for testing (check PR in detail before setting this label because PR is run on CI/CD)
name: size/L
description: Denotes a PR that changes 100-499 lines, ignoring generated files.
name: cla: yes
description: Indicates the PR's author has signed the cla-assistant.io CLA.
name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: lgtm
description: Indicates that a PR is ready to be merged.
Created: 2025-05-02T11:42:28Z | Merged: 2025-05-05T08:08:55Z

**How to categorize this PR?**
<!--
Please select a kind for this pull request. This helps the community categorizing it.
Replace the below TODO or exchange the existing identifiers with those that fit best in your opinion.
If multiple identifiers make sense you can also state the command multiple times, e.g.
  /kind api-change
  /kind cleanup
  ...

"/kind" identifiers:     api-change|bug|cleanup|discussion|enhancement|epic|flake|impediment|poc|post-mortem|question|regression|task|technical-debt|test
-->
/kind api-change
/kind task

**What this PR does / why we need it**:

This PR adds a `cert.gardener.cloud/not-before` annotation and a new field `issuanceDate` to the `Certificate` status.
The change is analogous to the `cert.gardener.cloud/not-after` annotation and `expirationDate` field.

As the name indicates, the annotation and field capture the `NotBefore` field of an [x509.Certificate](https://github.com/golang/go/blob/1cc624fd6265abe16de78e9cd84272435156aa72/src/crypto/x509/x509.go#L703).

Example output of `kubectl get -owide`:
```terminal
k get cert cert-selfsigned -n default -owide
NAME              COMMON NAME        ISSUER              STATUS   ISSUANCE_DATE          EXPIRATION_DATE        DNS_NAMES   AGE
cert-selfsigned   ca1.mydomain.com   issuer-selfsigned   Ready    2025-05-02T09:35:17Z   2025-07-31T09:35:17Z               121m
```

<details>
<summary>
Example YAML output

`k get cert cert-selfsigned -n default -oyaml`

</summary>

```terminal
apiVersion: cert.gardener.cloud/v1alpha1
kind: Certificate
metadata:
  annotations:
    cert.gardener.cloud/not-after: "2025-07-31T09:35:17Z"
    cert.gardener.cloud/not-before: "2025-05-02T09:35:17Z"
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"cert.gardener.cloud/v1alpha1","kind":"Certificate","metadata":{"annotations":{},"name":"cert-selfsigned","namespace":"default"},"spec":{"commonName":"ca1.mydomain.com","isCA":true,"issuerRef":{"name":"issuer-selfsigned","namespace":"default"}}}
  creationTimestamp: "2025-05-02T09:35:17Z"
  generation: 2
  labels:
    cert.gardener.cloud/hash: ee276e3074c86e90723ba41ebd22069741948eb0e409bbb1ce52048f
  name: cert-selfsigned
  namespace: default
  resourceVersion: "4451"
  uid: 40a049f8-1852-45c8-b29d-79f4cda80e9e
spec:
  commonName: ca1.mydomain.com
  isCA: true
  issuerRef:
    name: issuer-selfsigned
    namespace: default
  secretRef:
    name: cert-selfsigned-s6nsw
    namespace: default
status:
  commonName: ca1.mydomain.com
  conditions:
  - lastTransitionTime: "2025-05-02T09:35:17Z"
    message: ""
    observedGeneration: 2
    reason: Ready
    status: "True"
    type: Ready
  expirationDate: "2025-07-31T09:35:17Z"
  issuanceDate: "2025-05-02T09:35:17Z" # <-- New!
  issuerRef:
    cluster: default
    name: issuer-selfsigned
    namespace: default
  message: certificate (SN 1) valid from 2025-05-02 09:35:17 +0000 UTC to 2025-07-31
    09:35:17 +0000 UTC
  observedGeneration: 2
  state: Ready
```

</details>

**Which issue(s) this PR fixes**:

Fixes #454

**Special notes for your reviewer**:

/cc @MartinWeindel 

**Release note**:
<!--  Write your release note:
1. Enter your release note in the below block.
2. If no release note is required, just write "NONE" within the block.

Format of block header: <category> <target_group>
Possible values:
- category:       breaking|feature|bugfix|doc|other
- target_group:   user|operator|developer|dependency
-->
```feature user
Added `cert.gardener.cloud/not-before` annotation and `IssuanceDate` field to `Certificate`.
```


Linked issue #454: Add `notBefore` Timestamp to Certificate API
**What would you like to be added**:
Currently, for the self-signed certs, the `notAfter` timestamp is available in the following places in the Certificate resource:
1. The annotation `cert.gardener.cloud/not-after`
2. The `.status.exipirationDate` timestamp field

However, the `notBefore` timestamp does not have a dedicated field in the Certificate resource. This timestamp is only present in the `.status.message` string field of the Certificate resource; this is not a reliable source since the string format is not a fixed contract. Furthermore, it is might result in extra compute in downstream to decode the PEM of the cert for getting the validity. Since, the Gardener CM already decodes the PEM to get the validity for writting in the message field, it is already available in codebase and it would be beneficial to persist this information.

**Why is this needed**:
It is a nice-to-have, so downstream processes do not have to decode the generated certificate to get the validity information.
Linked issue comments: user: login: MartinWeindel
created_at: 2025-04-08T14:22:57Z
body: Thanks for reporting and explaining the background of the request!
We will add it to our backlog, sounds like a minor change.
author_association: MEMBER

Diff:
diff --git a/charts/cert-management/templates/cert.gardener.cloud_certificates.yaml b/charts/cert-management/templates/cert.gardener.cloud_certificates.yaml
index bee4ee313..6b504ea4b 100644
--- a/charts/cert-management/templates/cert.gardener.cloud_certificates.yaml
+++ b/charts/cert-management/templates/cert.gardener.cloud_certificates.yaml
@@ -35,6 +35,11 @@ spec:
       jsonPath: .status.state
       name: STATUS
       type: string
+    - description: Issuance date (not valid before this date)
+      jsonPath: .status.issuanceDate
+      name: ISSUANCE_DATE
+      priority: 500
+      type: string
     - description: Expiration date (not valid anymore after this date)
       jsonPath: .status.expirationDate
       name: EXPIRATION_DATE
@@ -358,6 +363,9 @@ spec:
               expirationDate:
                 description: ExpirationDate shows the notAfter validity date.
                 type: string
+              issuanceDate:
+                description: IssuanceDate shows the notBefore validity date.
+                type: string
               issuerRef:
                 description: IssuerRef is the used issuer.
                 properties:
diff --git a/pkg/apis/cert/crds/cert.gardener.cloud_certificates.yaml b/pkg/apis/cert/crds/cert.gardener.cloud_certificates.yaml
index d9760a094..a3b86fdeb 100644
--- a/pkg/apis/cert/crds/cert.gardener.cloud_certificates.yaml
+++ b/pkg/apis/cert/crds/cert.gardener.cloud_certificates.yaml
@@ -29,6 +29,11 @@ spec:
       jsonPath: .status.state
       name: STATUS
       type: string
+    - description: Issuance date (not valid before this date)
+      jsonPath: .status.issuanceDate
+      name: ISSUANCE_DATE
+      priority: 500
+      type: string
     - description: Expiration date (not valid anymore after this date)
       jsonPath: .status.expirationDate
       name: EXPIRATION_DATE
@@ -352,6 +357,9 @@ spec:
               expirationDate:
                 description: ExpirationDate shows the notAfter validity date.
                 type: string
+              issuanceDate:
+                description: IssuanceDate shows the notBefore validity date.
+                type: string
               issuerRef:
                 description: IssuerRef is the used issuer.
                 properties:
diff --git a/pkg/apis/cert/crds/zz_generated_crds.go b/pkg/apis/cert/crds/zz_generated_crds.go
index fea85c347..cd85723fb 100644
--- a/pkg/apis/cert/crds/zz_generated_crds.go
+++ b/pkg/apis/cert/crds/zz_generated_crds.go
@@ -333,6 +333,11 @@ spec:
       jsonPath: .status.state
       name: STATUS
       type: string
+    - description: Issuance date (not valid before this date)
+      jsonPath: .status.issuanceDate
+      name: ISSUANCE_DATE
+      priority: 500
+      type: string
     - description: Expiration date (not valid anymore after this date)
       jsonPath: .status.expirationDate
       name: EXPIRATION_DATE
@@ -656,6 +661,9 @@ spec:
               expirationDate:
                 description: ExpirationDate shows the notAfter validity date.
                 type: string
+              issuanceDate:
+                description: IssuanceDate shows the notBefore validity date.
+                type: string
               issuerRef:
                 description: IssuerRef is the used issuer.
                 properties:
diff --git a/pkg/apis/cert/v1alpha1/types.go b/pkg/apis/cert/v1alpha1/types.go
index 7f8ad0e2f..d1d406283 100644
--- a/pkg/apis/cert/v1alpha1/types.go
+++ b/pkg/apis/cert/v1alpha1/types.go
@@ -30,6 +30,7 @@ type CertificateList struct {
 // +kubebuilder:printcolumn:name=COMMON NAME,description="Subject domain name of certificate",JSONPath=".status.commonName",type=string
 // +kubebuilder:printcolumn:name=ISSUER,description="Issuer name",JSONPath=".status.issuerRef.name",type=string
 // +kubebuilder:printcolumn:name=STATUS,JSONPath=".status.state",type=string,description="Status of registration"
+// +kubebuilder:printcolumn:name=ISSUANCE_DATE,JSONPath=".status.issuanceDate",priority=500,type=string,description="Issuance date (not valid before this date)"
 // +kubebuilder:printcolumn:name=EXPIRATION_DATE,JSONPath=".status.expirationDate",priority=500,type=string,description="Expiration date (not valid anymore after this date)"
 // +kubebuilder:printcolumn:name=DNS_NAMES,JSONPath=".status.dnsNames",priority=2000,type=string,description="Domains names in subject alternative names"
 // +kubebuilder:printcolumn:name=AGE,JSONPath=".metadata.creationTimestamp",type=date,description="object creation timestamp"
@@ -180,6 +181,9 @@ type CertificateStatus struct {
 	// IssuerRef is the used issuer.
 	// +optional
 	IssuerRef *QualifiedIssuerRef `json:"issuerRef,omitempty"`
+	// IssuanceDate shows the notBefore validity date.
+	// +optional
+	IssuanceDate *string `json:"issuanceDate,omitempty"`
 	// ExpirationDate shows the notAfter validity date.
 	// +optional
 	ExpirationDate *string `json:"expirationDate,omitempty"`
diff --git a/pkg/apis/cert/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/cert/v1alpha1/zz_generated.deepcopy.go
index 618b3df6e..95daf723a 100644
--- a/pkg/apis/cert/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/cert/v1alpha1/zz_generated.deepcopy.go
@@ -503,6 +503,11 @@ func (in *CertificateStatus) DeepCopyInto(out *CertificateStatus) {
 		*out = new(QualifiedIssuerRef)
 		**out = **in
 	}
+	if in.IssuanceDate != nil {
+		in, out := &in.IssuanceDate, &out.IssuanceDate
+		*out = new(string)
+		**out = **in
+	}
 	if in.ExpirationDate != nil {
 		in, out := &in.ExpirationDate, &out.ExpirationDate
 		*out = new(string)
diff --git a/pkg/certman2/apis/cert/crd-cert.gardener.cloud_certificates.yaml b/pkg/certman2/apis/cert/crd-cert.gardener.cloud_certificates.yaml
index d9760a094..a3b86fdeb 100644
--- a/pkg/certman2/apis/cert/crd-cert.gardener.cloud_certificates.yaml
+++ b/pkg/certman2/apis/cert/crd-cert.gardener.cloud_certificates.yaml
@@ -29,6 +29,11 @@ spec:
       jsonPath: .status.state
       name: STATUS
       type: string
+    - description: Issuance date (not valid before this date)
+      jsonPath: .status.issuanceDate
+      name: ISSUANCE_DATE
+      priority: 500
+      type: string
     - description: Expiration date (not valid anymore after this date)
       jsonPath: .status.expirationDate
       name: EXPIRATION_DATE
@@ -352,6 +357,9 @@ spec:
               expirationDate:
                 description: ExpirationDate shows the notAfter validity date.
                 type: string
+              issuanceDate:
+                description: IssuanceDate shows the notBefore validity date.
+                type: string
               issuerRef:
                 description: IssuerRef is the used issuer.
                 properties:
diff --git a/pkg/controller/issuer/certificate/reconciler.go b/pkg/controller/issuer/certificate/reconciler.go
index 45723f6cd..90593a7a8 100644
--- a/pkg/controller/issuer/certificate/reconciler.go
+++ b/pkg/controller/issuer/certificate/reconciler.go
@@ -54,6 +54,8 @@ const (
 	LabelCertificateBackup = api.GroupName + "/backup"
 	// LabelCertificateSerialNumber is the label for the certificate serial number
 	LabelCertificateSerialNumber = api.GroupName + "/certificate-serialnumber"
+	// AnnotationNotBefore is the annotation for storing the not-before timestamp
+	AnnotationNotBefore = api.GroupName + "/not-before"
 	// AnnotationNotAfter is the annotation for storing the not-after timestamp
 	AnnotationNotAfter = api.GroupName + "/not-after"
 	// AnnotationRevoked is the label for marking revoked secrets
@@ -70,6 +72,13 @@ const (
 	boStop
 )
 
+type secretRecord struct {
+	ref       *corev1.SecretReference
+	specHash  string
+	notBefore *time.Time
+	notAfter  *time.Time
+}
+
 // CertReconciler creates a certReconciler.
 func CertReconciler(c controller.Interface, support *core.Support) (reconcile.Interface, error) {
 	targetCluster := c.GetCluster(ctrl.TargetCluster)
@@ -372,13 +381,17 @@ func (r *certReconciler) handleObtainOutput(logctx logger.LogContext, obj resour
 	}
 	logctx.Infof("certificate written in secret %s/%s", secretRef.Namespace, secretRef.Name)
 
-	var notAfter *time.Time
+	var (
+		notBefore *time.Time
+		notAfter  *time.Time
+	)
 	x509cert, err := legobridge.DecodeCertificate(result.Certificates.Certificate)
 	if err == nil {
+		notBefore = &x509cert.NotBefore
 		notAfter = &x509cert.NotAfter
 	}
 
-	status := r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notAfter)
+	status := r.updateSecretRefAndSucceeded(logctx, obj, &secretRecord{secretRef, specHash, notBefore, notAfter})
 	return status, status.Error == nil
 }
 
@@ -472,14 +485,19 @@ func (r *certReconciler) obtainCertificateAndPendingACME(logctx logger.LogContex
 		return r.failedStop(logctx, obj, api.StateError, err)
 	}
 
-	if secretRef, specHash, notAfter := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secretRef != nil {
+	secret, err := r.findSecretByHashLabel(cert.Namespace, &cert.Spec)
+	if err != nil {
+		return r.failedStop(logctx, obj, api.StateError, err)
+	}
+	if secret != nil {
 		// reuse found certificate
 		issuerInfo := shared.NewACMEIssuerInfo(issuerKey)
-		secretRef, err := r.copySecretIfNeeded(logctx, issuerInfo, cert.ObjectMeta, secretRef, specHash, &cert.Spec)
+		secretRef, err := r.copySecretIfNeeded(logctx, issuerInfo, cert.ObjectMeta, secret.ref, secret.specHash, &cert.Spec)
 		if err != nil {
 			return r.failed(logctx, obj, api.StateError, err)
 		}
-		return r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notAfter)
+		secret.ref = secretRef
+		return r.updateSecretRefAndSucceeded(logctx, obj, secret)
 	}
 
 	preflightCheck := func() error {
@@ -644,14 +662,19 @@ func (r *certReconciler) obtainCertificateSelfSigned(logctx logger.LogContext, o
 		return r.failedStop(logctx, obj, api.StateError, err)
 	}
 
-	if secretRef, specHash, notAfter := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secretRef != nil {
+	secret, err := r.findSecretByHashLabel(cert.Namespace, &cert.Spec)
+	if err != nil {
+		return r.failedStop(logctx, obj, api.StateError, err)
+	}
+	if secret != nil {
 		// reuse found certificate
 		issuerInfo := shared.NewSelfSignedIssuerInfo(issuerKey)
-		secretRef, err := r.copySecretIfNeeded(logctx, issuerInfo, cert.ObjectMeta, secretRef, specHash, &cert.Spec)
+		secretRef, err := r.copySecretIfNeeded(logctx, issuerInfo, cert.ObjectMeta, secret.ref, secret.specHash, &cert.Spec)
 		if err != nil {
 			return r.failed(logctx, obj, api.StateError, err)
 		}
-		return r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notAfter)
+		secret.ref = secretRef
+		return r.updateSecretRefAndSucceeded(logctx, obj, secret)
 	}
 
 	objectKey := client.ObjectKeyFromObject(cert)
@@ -722,14 +745,19 @@ func (r *certReconciler) obtainCertificateCA(logctx logger.LogContext, obj resou
 		return r.failedStop(logctx, obj, api.StateError, err)
 	}
 
-	if secretRef, specHash, notAfter := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secretRef != nil {
+	secret, err := r.findSecretByHashLabel(cert.Namespace, &cert.Spec)
+	if err != nil {
+		return r.failedStop(logctx, obj, api.StateError, err)
+	}
+	if secret != nil {
 		// reuse found certificate
 		issuerInfo := shared.NewCAIssuerInfo(issuerKey)
-		secretRef, err := r.copySecretIfNeeded(logctx, issuerInfo, cert.ObjectMeta, secretRef, specHash, &cert.Spec)
+		secretRef, err := r.copySecretIfNeeded(logctx, issuerInfo, cert.ObjectMeta, secret.ref, secret.specHash, &cert.Spec)
 		if err != nil {
 			return r.failed(logctx, obj, api.StateError, err)
 		}
-		return r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notAfter)
+		secret.ref = secretRef
+		return r.updateSecretRefAndSucceeded(logctx, obj, secret)
 	}
 
 	objectKey := client.ObjectKeyFromObject(cert)
@@ -1004,17 +1032,20 @@ func (r *certReconciler) determineSecretRef(namespace string, spec *api.Certific
 	return nil, nil
 }
 
-func (r *certReconciler) findSecretByHashLabel(namespace string, spec *api.CertificateSpec) (*corev1.SecretReference, string, *time.Time) {
+func (r *certReconciler) findSecretByHashLabel(namespace string, spec *api.CertificateSpec) (*secretRecord, error) {
 	issuerKey := r.support.IssuerClusterObjectKey(namespace, spec)
 	specHash := r.buildSpecNewHash(spec, issuerKey)
 	objs, err := FindAllCertificateSecretsByNewHashLabel(r.certSecretResources, specHash)
 	if err != nil {
-		return nil, "", nil
+		return nil, fmt.Errorf("failed to find all certificate secrets by new hash label: %w", err)
 	}
 
 	secretRef, _ := r.determineSecretRef(namespace, spec)
 	var best resources.Object
-	var bestNotAfter time.Time
+	var (
+		notBefore    time.Time
+		bestNotAfter time.Time
+	)
 	for _, obj := range objs {
 		if obj.GetAnnotation(AnnotationRevoked) != "" {
 			continue
@@ -1031,15 +1062,16 @@ func (r *certReconciler) findSecretByHashLabel(namespace string, spec *api.Certi
 				bestNotAfter.Before(cert.NotAfter) ||
 				secretRef != nil && bestNotAfter.Equal(cert.NotAfter) && obj.GetName() == secretRef.Name && core.NormalizeNamespace(obj.GetNamespace()) == core.NormalizeNamespace(secretRef.Namespace) {
 				best = obj
+				notBefore = cert.NotBefore
 				bestNotAfter = cert.NotAfter
 			}
 		}
 	}
 	if best == nil {
-		return nil, "", nil
+		return nil, nil
 	}
 	ref := &corev1.SecretReference{Namespace: best.GetNamespace(), Name: best.GetName()}
-	return ref, specHash, &bestNotAfter
+	return &secretRecord{ref, specHash, &notBefore, &bestNotAfter}, nil
 }
 
 func (r *certReconciler) copySecretIfNeeded(logctx logger.LogContext, issuerInfo shared.IssuerInfo,
@@ -1139,16 +1171,20 @@ func (r *certReconciler) updateKeystoresIfSpecChanged(logctx logger.LogContext,
 	return nil
 }
 
-func (r *certReconciler) updateSecretRefAndSucceeded(logctx logger.LogContext, obj resources.Object,
-	secretRef *corev1.SecretReference, specHash string, notAfter *time.Time) reconcile.Status {
+func (r *certReconciler) updateSecretRefAndSucceeded(logctx logger.LogContext, obj resources.Object, secret *secretRecord) reconcile.Status {
 	crt := obj.Data().(*api.Certificate)
-	crt.Spec.SecretRef = secretRef
+	crt.Spec.SecretRef = secret.ref
 	if crt.Labels == nil {
 		crt.Labels = map[string]string{}
 	}
-	crt.Labels[LabelCertificateNewHashKey] = specHash
-	if notAfter != nil {
-		resources.SetAnnotation(crt, AnnotationNotAfter, notAfter.Format(time.RFC3339))
+	crt.Labels[LabelCertificateNewHashKey] = secret.specHash
+	if secret.notBefore != nil {
+		resources.SetAnnotation(crt, AnnotationNotBefore, secret.notBefore.Format(time.RFC3339))
+	} else {
+		resources.RemoveAnnotation(crt, AnnotationNotBefore)
+	}
+	if secret.notAfter != nil {
+		resources.SetAnnotation(crt, AnnotationNotAfter, secret.notAfter.Format(time.RFC3339))
 	} else {
 		resources.RemoveAnnotation(crt, AnnotationNotAfter)
 	}
@@ -1216,6 +1252,13 @@ func (r *certReconciler) prepareUpdateStatus(obj resources.Object, state string,
 	mod.AssureStringPtrPtr(&status.CommonName, cn)
 	utils.AssureStringSlice(mod.ModificationState, &status.DNSNames, dnsNames)
 
+	var issuanceDate *string
+	notBefore, ok := resources.GetAnnotation(crt, AnnotationNotBefore)
+	if ok {
+		issuanceDate = &notBefore
+	}
+	mod.AssureStringPtrPtr(&status.IssuanceDate, issuanceDate)
+
 	var expirationDate *string
 	notAfter, ok := resources.GetAnnotation(crt, AnnotationNotAfter)
 	if ok {
diff --git a/test/integration/controller/issuer/certificate_test.go b/test/integration/controller/issuer/certificate_test.go
index 6a4faf371..c6d8cd35e 100644
--- a/test/integration/controller/issuer/certificate_test.go
+++ b/test/integration/controller/issuer/certificate_test.go
@@ -19,6 +19,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	certv1alpha1 "github.com/gardener/cert-management/pkg/apis/cert/v1alpha1"
+	certctrl "github.com/gardener/cert-management/pkg/controller/issuer/certificate"
 )
 
 var _ = Describe("Certificate controller tests", func() {
@@ -56,6 +57,74 @@ var _ = Describe("Certificate controller tests", func() {
 		})
 	})
 
+	Context("Self-signed certificates", func() {
+		var selfSignedIssuer *certv1alpha1.Issuer
+
+		BeforeEach(func() {
+			selfSignedIssuer = &certv1alpha1.Issuer{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace:    testNamespace.Name,
+					GenerateName: "self-signed-issuer-",
+				},
+				Spec: certv1alpha1.IssuerSpec{
+					SelfSigned: &certv1alpha1.SelfSignedSpec{},
+				},
+			}
+			Expect(testClient.Create(ctx, selfSignedIssuer)).To(Succeed())
+			DeferCleanup(func() {
+				Expect(testClient.Delete(ctx, selfSignedIssuer)).To(Succeed())
+			})
+			Eventually(func(g Gomega) {
+				g.Expect(testClient.Get(ctx, client.ObjectKeyFromObject(selfSignedIssuer), selfSignedIssuer)).To(Succeed())
+				g.Expect(selfSignedIssuer.Status.State).To(Equal("Ready"))
+			}).Should(Succeed())
+		})
+
+		It("should have appropriate not-before and not-after timestamps", func() {
+			By("Create self-signed certificate")
+			certificate := &certv1alpha1.Certificate{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace:    testNamespace.Name,
+					GenerateName: "self-signed-certificate-",
+				},
+				Spec: certv1alpha1.CertificateSpec{
+					IssuerRef: &certv1alpha1.IssuerRef{
+						Namespace: selfSignedIssuer.Namespace,
+						Name:      selfSignedIssuer.Name,
+					},
+					IsCA:       ptr.To(true),
+					CommonName: ptr.To("example.com"),
+				},
+			}
+			Expect(testClient.Create(ctx, certificate)).To(Succeed())
+			DeferCleanup(func() {
+				Expect(testClient.Delete(ctx, certificate)).To(Succeed())
+			})
+			Eventually(func(g Gomega) {
+				g.Expect(testClient.Get(ctx, client.ObjectKeyFromObject(certificate), certificate)).To(Succeed())
+				g.Expect(certificate.Status.State).To(Equal("Ready"))
+			}).WithTimeout(10 * time.Second).Should(Succeed())
+
+			By("Read not-before and not-after timestamps")
+			var (
+				notBefore      = certificate.Annotations[certctrl.AnnotationNotBefore]
+				notAfter       = certificate.Annotations[certctrl.AnnotationNotAfter]
+				issuanceDate   = certificate.Status.IssuanceDate
+				expirationDate = certificate.Status.ExpirationDate
+			)
+			Expect(notBefore).ToNot(BeEmpty())
+			Expect(notAfter).ToNot(BeEmpty())
+			Expect(issuanceDate).NotTo(BeNil())
+			Expect(expirationDate).NotTo(BeNil())
+			Expect(notBefore).To(Equal(*issuanceDate))
+			Expect(notAfter).To(Equal(*expirationDate))
+
+			issued, _ := time.Parse(time.RFC3339, *issuanceDate)
+			expired, _ := time.Parse(time.RFC3339, *expirationDate)
+			Expect(issued).To(BeTemporally("<", expired))
+		})
+	})
+
 	Context("Self-signed certificates from CA issuer", func() {
 		var caIssuer *certv1alpha1.Issuer
 


Reviews:
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T11:44:03Z
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T11:44:55Z
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T11:46:11Z
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T11:47:04Z
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T11:48:55Z
user: login: MartinWeindel
body: The reconciler method [updateNotAfterAnnotation(...)](https://github.com/gardener/cert-management/blob/master/pkg/controller/issuer/certificate/reconciler.go#L906) should be extended, too. 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-02T14:36:06Z
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T07:30:07Z
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T07:31:26Z
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T07:36:52Z
user: login: MartinWeindel
body: /lgtm

Thanks for this nice addition! 🙏

Feel free to deal as you think on my nit remark.

state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-05T08:08:04Z
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T08:16:40Z
user: login: marc1404
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T10:23:16Z

Review comments:
diff_hunk: @@ -70,6 +72,13 @@ const (
 	boStop
 )
 
+type secretLookupResult struct {
+	ref       *corev1.SecretReference
+	specHash  string
+	notBefore *time.Time
+	notAfter  *time.Time
+}
path: pkg/controller/issuer/certificate/reconciler.go
user: login: marc1404
body: Used by `findSecretByHashLabel` ([ref](https://github.com/gardener/cert-management/pull/489/files#diff-b9949c0cbf865b5be064d5e33a2214dcab98c9e2fd6dac60b8ecd92bba725fd1R1020)) as the new return value.
created_at: 2025-05-02T11:44:03Z
author_association: MEMBER
diff_hunk: @@ -372,13 +381,17 @@ func (r *certReconciler) handleObtainOutput(logctx logger.LogContext, obj resour
 	}
 	logctx.Infof("certificate written in secret %s/%s", secretRef.Namespace, secretRef.Name)
 
-	var notAfter *time.Time
+	var (
+		notBefore *time.Time
+		notAfter  *time.Time
+	)
 	x509cert, err := legobridge.DecodeCertificate(result.Certificates.Certificate)
 	if err == nil {
+		notBefore = &x509cert.NotBefore
 		notAfter = &x509cert.NotAfter
 	}
 
-	status := r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notAfter)
+	status := r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notBefore, notAfter)
path: pkg/controller/issuer/certificate/reconciler.go
user: login: marc1404
body: I've also considered changing the function signature to accept a struct, but decided against it. Let me know what you think.
created_at: 2025-05-02T11:44:54Z
author_association: MEMBER
diff_hunk: @@ -472,14 +485,14 @@ func (r *certReconciler) obtainCertificateAndPendingACME(logctx logger.LogContex
 		return r.failedStop(logctx, obj, api.StateError, err)
 	}
 
-	if secretRef, specHash, notAfter := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secretRef != nil {
+	if result, _ := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); result != nil {
path: pkg/controller/issuer/certificate/reconciler.go
user: login: marc1404
body: I let `findSecretByHashLabel` return an error to follow idiomatic Go, but I ignore it here since the unhappy case wasn't handled before anyway.
created_at: 2025-05-02T11:46:11Z
author_association: MEMBER
diff_hunk: @@ -1004,17 +1017,20 @@ func (r *certReconciler) determineSecretRef(namespace string, spec *api.Certific
 	return nil, nil
 }
 
-func (r *certReconciler) findSecretByHashLabel(namespace string, spec *api.CertificateSpec) (*corev1.SecretReference, string, *time.Time) {
+func (r *certReconciler) findSecretByHashLabel(namespace string, spec *api.CertificateSpec) (*secretLookupResult, error) {
 	issuerKey := r.support.IssuerClusterObjectKey(namespace, spec)
 	specHash := r.buildSpecNewHash(spec, issuerKey)
 	objs, err := FindAllCertificateSecretsByNewHashLabel(r.certSecretResources, specHash)
 	if err != nil {
-		return nil, "", nil
+		return nil, fmt.Errorf("failed to find all certificate secrets by new hash label: %w", err)
path: pkg/controller/issuer/certificate/reconciler.go
user: login: marc1404
body: As stated above, I introduced the secondary error return value to follow idiomatic Go, but we can remove it if it doesn't serve any purpose.
created_at: 2025-05-02T11:47:03Z
author_association: MEMBER
diff_hunk: @@ -56,6 +57,74 @@ var _ = Describe("Certificate controller tests", func() {
 		})
 	})
 
+	Context("Self-signed certificates", func() {
+		var selfSignedIssuer *certv1alpha1.Issuer
+
+		BeforeEach(func() {
+			selfSignedIssuer = &certv1alpha1.Issuer{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace:    testNamespace.Name,
+					GenerateName: "self-signed-issuer-",
+				},
+				Spec: certv1alpha1.IssuerSpec{
+					SelfSigned: &certv1alpha1.SelfSignedSpec{},
+				},
+			}
+			Expect(testClient.Create(ctx, selfSignedIssuer)).To(Succeed())
+			DeferCleanup(func() {
+				Expect(testClient.Delete(ctx, selfSignedIssuer)).To(Succeed())
+			})
+			Eventually(func(g Gomega) {
+				g.Expect(testClient.Get(ctx, client.ObjectKeyFromObject(selfSignedIssuer), selfSignedIssuer)).To(Succeed())
+				g.Expect(selfSignedIssuer.Status.State).To(Equal("Ready"))
+			}).Should(Succeed())
+		})
+
+		It("should have appropriate not-before and not-after timestamps", func() {
+			By("Create self-signed certificate")
+			certificate := &certv1alpha1.Certificate{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace:    testNamespace.Name,
+					GenerateName: "self-signed-certificate-",
+				},
+				Spec: certv1alpha1.CertificateSpec{
+					IssuerRef: &certv1alpha1.IssuerRef{
+						Namespace: selfSignedIssuer.Namespace,
+						Name:      selfSignedIssuer.Name,
+					},
+					IsCA:       ptr.To(true),
+					CommonName: ptr.To("example.com"),
+				},
+			}
+			Expect(testClient.Create(ctx, certificate)).To(Succeed())
+			DeferCleanup(func() {
+				Expect(testClient.Delete(ctx, certificate)).To(Succeed())
+			})
+			Eventually(func(g Gomega) {
+				g.Expect(testClient.Get(ctx, client.ObjectKeyFromObject(certificate), certificate)).To(Succeed())
+				g.Expect(certificate.Status.State).To(Equal("Ready"))
+			}).WithTimeout(10 * time.Second).Should(Succeed())
+
+			By("Read not-before and not-after timestamps")
+			var (
+				notBefore      = certificate.Annotations[certctrl.AnnotationNotBefore]
+				notAfter       = certificate.Annotations[certctrl.AnnotationNotAfter]
+				issuanceDate   = certificate.Status.IssuanceDate
+				expirationDate = certificate.Status.ExpirationDate
+			)
+			Expect(notBefore).ToNot(BeEmpty())
+			Expect(notAfter).ToNot(BeEmpty())
+			Expect(issuanceDate).NotTo(BeNil())
+			Expect(expirationDate).NotTo(BeNil())
+			Expect(notBefore).To(Equal(*issuanceDate))
+			Expect(notAfter).To(Equal(*expirationDate))
+
+			issued, _ := time.Parse(time.RFC3339, *issuanceDate)
+			expired, _ := time.Parse(time.RFC3339, *expirationDate)
+			Expect(issued).To(BeTemporally("<", expired))
path: test/integration/controller/issuer/certificate_test.go
user: login: marc1404
body: This could probably be more in-depth.
It only tests whether:
* timestamps and fields are present.
* timestamp and field match each other respectively.
* not-before precedes not-after.
created_at: 2025-05-02T11:48:54Z
author_association: MEMBER
diff_hunk: @@ -1004,17 +1017,20 @@ func (r *certReconciler) determineSecretRef(namespace string, spec *api.Certific
 	return nil, nil
 }
 
-func (r *certReconciler) findSecretByHashLabel(namespace string, spec *api.CertificateSpec) (*corev1.SecretReference, string, *time.Time) {
+func (r *certReconciler) findSecretByHashLabel(namespace string, spec *api.CertificateSpec) (*secretLookupResult, error) {
 	issuerKey := r.support.IssuerClusterObjectKey(namespace, spec)
 	specHash := r.buildSpecNewHash(spec, issuerKey)
 	objs, err := FindAllCertificateSecretsByNewHashLabel(r.certSecretResources, specHash)
 	if err != nil {
-		return nil, "", nil
+		return nil, fmt.Errorf("failed to find all certificate secrets by new hash label: %w", err)
path: pkg/controller/issuer/certificate/reconciler.go
user: login: MartinWeindel
body: Nice, it improves error handling, so keep it. 
created_at: 2025-05-02T14:05:30Z
author_association: MEMBER
diff_hunk: @@ -1031,15 +1047,16 @@ func (r *certReconciler) findSecretByHashLabel(namespace string, spec *api.Certi
 				bestNotAfter.Before(cert.NotAfter) ||
 				secretRef != nil && bestNotAfter.Equal(cert.NotAfter) && obj.GetName() == secretRef.Name && core.NormalizeNamespace(obj.GetNamespace()) == core.NormalizeNamespace(secretRef.Namespace) {
 				best = obj
+				notBefore = cert.NotBefore
 				bestNotAfter = cert.NotAfter
 			}
 		}
 	}
 	if best == nil {
-		return nil, "", nil
+		return nil, fmt.Errorf("could not determine a best match in the certificate secrets with the hash: %s", specHash)
path: pkg/controller/issuer/certificate/reconciler.go
user: login: MartinWeindel
body: Nothing found is a valid outcome. Should not result in an error for a find method.
```suggestion
		return nil, nil
```
created_at: 2025-05-02T14:08:06Z
author_association: MEMBER
diff_hunk: @@ -472,14 +485,14 @@ func (r *certReconciler) obtainCertificateAndPendingACME(logctx logger.LogContex
 		return r.failedStop(logctx, obj, api.StateError, err)
 	}
 
-	if secretRef, specHash, notAfter := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secretRef != nil {
+	if result, _ := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); result != nil {
path: pkg/controller/issuer/certificate/reconciler.go
user: login: MartinWeindel
body: If you introduce an error - which makes sense to me - you should also handle the error, here return the error
created_at: 2025-05-02T14:10:48Z
author_association: MEMBER
diff_hunk: @@ -372,13 +381,17 @@ func (r *certReconciler) handleObtainOutput(logctx logger.LogContext, obj resour
 	}
 	logctx.Infof("certificate written in secret %s/%s", secretRef.Namespace, secretRef.Name)
 
-	var notAfter *time.Time
+	var (
+		notBefore *time.Time
+		notAfter  *time.Time
+	)
 	x509cert, err := legobridge.DecodeCertificate(result.Certificates.Certificate)
 	if err == nil {
+		notBefore = &x509cert.NotBefore
 		notAfter = &x509cert.NotAfter
 	}
 
-	status := r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notAfter)
+	status := r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notBefore, notAfter)
path: pkg/controller/issuer/certificate/reconciler.go
user: login: MartinWeindel
body: My vote for changing the function signature to accept the struct. 3 of 4 calls already have the struct ready. 
created_at: 2025-05-02T14:13:55Z
author_association: MEMBER
diff_hunk: @@ -1031,15 +1047,16 @@ func (r *certReconciler) findSecretByHashLabel(namespace string, spec *api.Certi
 				bestNotAfter.Before(cert.NotAfter) ||
 				secretRef != nil && bestNotAfter.Equal(cert.NotAfter) && obj.GetName() == secretRef.Name && core.NormalizeNamespace(obj.GetNamespace()) == core.NormalizeNamespace(secretRef.Namespace) {
 				best = obj
+				notBefore = cert.NotBefore
 				bestNotAfter = cert.NotAfter
 			}
 		}
 	}
 	if best == nil {
-		return nil, "", nil
+		return nil, fmt.Errorf("could not determine a best match in the certificate secrets with the hash: %s", specHash)
path: pkg/controller/issuer/certificate/reconciler.go
user: login: marc1404
body: Makes sense, thanks! :) 
Fixed via: [`a13a23b` (#489)](https://github.com/gardener/cert-management/pull/489/commits/a13a23b6ddbb97ae3a18092cfeacb3da5d63cc9e)
created_at: 2025-05-05T07:30:07Z
author_association: MEMBER
diff_hunk: @@ -372,13 +381,17 @@ func (r *certReconciler) handleObtainOutput(logctx logger.LogContext, obj resour
 	}
 	logctx.Infof("certificate written in secret %s/%s", secretRef.Namespace, secretRef.Name)
 
-	var notAfter *time.Time
+	var (
+		notBefore *time.Time
+		notAfter  *time.Time
+	)
 	x509cert, err := legobridge.DecodeCertificate(result.Certificates.Certificate)
 	if err == nil {
+		notBefore = &x509cert.NotBefore
 		notAfter = &x509cert.NotAfter
 	}
 
-	status := r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notAfter)
+	status := r.updateSecretRefAndSucceeded(logctx, obj, secretRef, specHash, notBefore, notAfter)
path: pkg/controller/issuer/certificate/reconciler.go
user: login: marc1404
body: Nice, it looks much better now :) 
I was unsure about the name of the struct, but since it's a local type, I settled for `secretRecord`.

Fixed via: [`bb7f571` (#489)](https://github.com/gardener/cert-management/pull/489/commits/bb7f5710fc5e3a1587002ddcf700c2a021b13d72)
created_at: 2025-05-05T07:31:26Z
author_association: MEMBER
diff_hunk: @@ -472,14 +485,14 @@ func (r *certReconciler) obtainCertificateAndPendingACME(logctx logger.LogContex
 		return r.failedStop(logctx, obj, api.StateError, err)
 	}
 
-	if secretRef, specHash, notAfter := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secretRef != nil {
+	if result, _ := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); result != nil {
path: pkg/controller/issuer/certificate/reconciler.go
user: login: marc1404
body: Makes sense :) Fixed via: [`6479a39` (#489)](https://github.com/gardener/cert-management/pull/489/commits/6479a39e9827b33d6e97b590d476dfdf4ae876f8)
created_at: 2025-05-05T07:36:52Z
author_association: MEMBER
diff_hunk: @@ -485,14 +485,15 @@ func (r *certReconciler) obtainCertificateAndPendingACME(logctx logger.LogContex
 		return r.failedStop(logctx, obj, api.StateError, err)
 	}
 
-	if result, _ := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); result != nil {
+	if secret, _ := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secret != nil {
path: pkg/controller/issuer/certificate/reconciler.go
user: login: MartinWeindel
body: NIT: The variable name `secret` is a little bit misleading. Maybe use `secretRecord` instead?
created_at: 2025-05-05T08:03:49Z
author_association: MEMBER
diff_hunk: @@ -485,14 +485,15 @@ func (r *certReconciler) obtainCertificateAndPendingACME(logctx logger.LogContex
 		return r.failedStop(logctx, obj, api.StateError, err)
 	}
 
-	if result, _ := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); result != nil {
+	if secret, _ := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secret != nil {
path: pkg/controller/issuer/certificate/reconciler.go
user: login: marc1404
body: Thanks for pointing it out, I'm also not happy with it! 🙃 `secretRecord` shadows the type name itself, which is why I didn't use it, but I guess `secretRec` would work.
created_at: 2025-05-05T08:16:40Z
author_association: MEMBER
diff_hunk: @@ -485,14 +485,15 @@ func (r *certReconciler) obtainCertificateAndPendingACME(logctx logger.LogContex
 		return r.failedStop(logctx, obj, api.StateError, err)
 	}
 
-	if result, _ := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); result != nil {
+	if secret, _ := r.findSecretByHashLabel(cert.Namespace, &cert.Spec); secret != nil {
path: pkg/controller/issuer/certificate/reconciler.go
user: login: marc1404
body: https://github.com/gardener/cert-management/pull/491
created_at: 2025-05-05T10:23:16Z
author_association: MEMBER

PR comments:
user: login: gardener-prow[bot]
created_at: 2025-05-05T08:08:08Z
body: LGTM label has been added.  <details>Git tree hash: 74a4102ebbcbfefee919fe7a44e5b9ec921c995c</details>
author_association: NONE
user: login: gardener-prow[bot]
created_at: 2025-05-05T08:08:12Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/gardener/cert-management/pull/489#pullrequestreview-2814080548" title="LGTM">MartinWeindel</a>*

The full list of commands accepted by this bot can be found [here](https://prow.gardener.cloud/command-help?repo=gardener%2Fcert-management).

The pull request process is described [here](https://gardener.cloud/docs/contribute/#pull-request-checklist)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/gardener/cert-management/blob/master/OWNERS)~~ [MartinWeindel]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: NONE