Skip to content

Commit 526bb1f

Browse files
feat: support rotationPolicy=Never for not rotating the private key (#189)
Signed-off-by: Sebastian Gaiser <sebastiangaiser@users.noreply.github.com>
1 parent 35f50ef commit 526bb1f

File tree

5 files changed

+164
-11
lines changed

5 files changed

+164
-11
lines changed

.github/workflows/e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
4949
- name: Install cert-manager
5050
run: |
51-
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml
51+
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.20.0/cert-manager.yaml
5252
kubectl wait --for=condition=Available deployment/cert-manager -n cert-manager --timeout=120s
5353
kubectl wait --for=condition=Available deployment/cert-manager-webhook -n cert-manager --timeout=120s
5454
kubectl wait --for=condition=Available deployment/cert-manager-cainjector -n cert-manager --timeout=120s

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,42 @@ controller can be used...
1212
Please check the `examples/example-ca.yaml` how to use the controller after deploying it and using it with cert-manager but
1313
it also works with normal Kubernetes secrets of type TLS.
1414

15+
## Configuration
16+
17+
### Required annotations
18+
19+
The controller watches secrets of type `kubernetes.io/tls` that carry the following annotations. Set them via cert-manager's `secretTemplate` or directly on a hand-crafted secret.
20+
21+
| Annotation | Description |
22+
|---|---|
23+
| `sebastian.gaiser.bayern/tls-strimzi-ca: "reconcile"` | Opt the secret into reconciliation |
24+
| `sebastian.gaiser.bayern/target-cluster-name` | Name of the Strimzi Kafka cluster |
25+
| `sebastian.gaiser.bayern/target-secret-name` | Name of the target certificate secret (receives `ca.crt` and `tls.crt`) |
26+
| `sebastian.gaiser.bayern/target-secret-key-name` | Name of the target private key secret (receives `ca.key`) |
27+
28+
### Private key rotation policy
29+
30+
By default the controller keeps the private key secret in sync with the source secret on every reconciliation. If you configure cert-manager to never rotate the private key (`rotationPolicy: Never`), you should tell the controller the same so it does not overwrite the key secret on certificate renewals.
31+
32+
Add the annotation `sebastian.gaiser.bayern/rotation-policy: "Never"` to the cert-manager `secretTemplate`. Use a YAML anchor to reference the value from `privateKey.rotationPolicy` directly so both fields are always in sync:
33+
34+
```yaml
35+
spec:
36+
privateKey:
37+
rotationPolicy: &rotationPolicy Never
38+
secretTemplate:
39+
annotations:
40+
sebastian.gaiser.bayern/rotation-policy: *rotationPolicy
41+
```
42+
43+
With this annotation set the controller will:
44+
45+
- **Create** the private key secret on the first reconciliation (Strimzi requires it to exist).
46+
- **Skip updating** the private key secret on subsequent reconciliations, even when the certificate is renewed.
47+
- **Skip creating historical snapshots** of the private key secret.
48+
49+
The certificate secret (`ca.crt`, `tls.crt`) is always kept up to date regardless of this setting.
50+
1551
## Install the controller via Helm
1652

1753
```shell
@@ -26,7 +62,7 @@ helm upgrade --install -n kafka ca-controller-for-strimzi sebastiangaiser-ca-con
2662
# create cluster
2763
kind create cluster
2864
# install cert-manager
29-
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.5/cert-manager.yaml
65+
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.20.0/cert-manager.yaml
3066
# bootstrap strimzi in kafka namespace
3167
kubectl create namespace kafka
3268
helm install -n kafka strimzi-cluster-operator oci://quay.io/strimzi-helm/strimzi-kafka-operator

examples/example-ca.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ spec:
4343
privateKey:
4444
algorithm: ECDSA
4545
size: 256
46+
rotationPolicy: &rotationPolicy Never
4647
issuerRef:
4748
name: my-ca
4849
kind: ClusterIssuer
@@ -53,6 +54,7 @@ spec:
5354
sebastian.gaiser.bayern/target-cluster-name: "my-cluster"
5455
sebastian.gaiser.bayern/target-secret-name: "my-cluster-clients-ca-cert"
5556
sebastian.gaiser.bayern/target-secret-key-name: "my-cluster-clients-ca"
57+
sebastian.gaiser.bayern/rotation-policy: *rotationPolicy
5658
---
5759
apiVersion: cert-manager.io/v1
5860
kind: Certificate
@@ -66,6 +68,7 @@ spec:
6668
privateKey:
6769
algorithm: ECDSA
6870
size: 256
71+
rotationPolicy: &rotationPolicy Never
6972
issuerRef:
7073
group: cert-manager.io
7174
kind: ClusterIssuer
@@ -76,3 +79,4 @@ spec:
7679
sebastian.gaiser.bayern/target-cluster-name: "my-cluster"
7780
sebastian.gaiser.bayern/target-secret-name: "my-cluster-cluster-ca-cert"
7881
sebastian.gaiser.bayern/target-secret-key-name: "my-cluster-cluster-ca"
82+
sebastian.gaiser.bayern/rotation-policy: *rotationPolicy

hack/e2e-test.sh

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,14 @@ cleanup_test_resources() {
102102
log_info "Cleaning up test resources..."
103103
kubectl delete certificate my-cluster-clients-ca-cert-tls -n "$NAMESPACE" --ignore-not-found
104104
kubectl delete certificate my-cluster-cluster-ca-cert-tls -n "$NAMESPACE" --ignore-not-found
105+
kubectl delete certificate rotation-policy-never-ca-cert-tls -n "$NAMESPACE" --ignore-not-found
105106
kubectl delete secret my-cluster-clients-ca-cert -n "$NAMESPACE" --ignore-not-found
106107
kubectl delete secret my-cluster-clients-ca -n "$NAMESPACE" --ignore-not-found
107108
kubectl delete secret my-cluster-cluster-ca-cert -n "$NAMESPACE" --ignore-not-found
108109
kubectl delete secret my-cluster-cluster-ca -n "$NAMESPACE" --ignore-not-found
110+
kubectl delete secret rotation-policy-never-ca-cert -n "$NAMESPACE" --ignore-not-found
111+
kubectl delete secret rotation-policy-never-ca -n "$NAMESPACE" --ignore-not-found
112+
kubectl delete secret rotation-policy-never-ca-cert-tls -n "$NAMESPACE" --ignore-not-found
109113
# Clean up any historical secrets
110114
kubectl delete secrets -n "$NAMESPACE" -l sebastian.gaiser.bayern/historical=true --ignore-not-found
111115
sleep 2
@@ -295,6 +299,105 @@ test_certificate_rotation() {
295299
fi
296300
}
297301

302+
# ==============================================================================
303+
# Test: rotationPolicy Never creates key secret initially but skips updates
304+
# ==============================================================================
305+
test_rotation_policy_never() {
306+
log_info "=== Test: rotationPolicy Never creates key secret initially but skips updates ==="
307+
308+
# Create a certificate with rotationPolicy: Never
309+
log_info "Creating certificate with rotationPolicy: Never..."
310+
cat <<EOF | kubectl apply -f -
311+
apiVersion: cert-manager.io/v1
312+
kind: Certificate
313+
metadata:
314+
name: rotation-policy-never-ca-cert-tls
315+
namespace: $NAMESPACE
316+
spec:
317+
isCA: true
318+
commonName: rotation-policy-never-ca-cert-tls
319+
secretName: rotation-policy-never-ca-cert-tls
320+
privateKey:
321+
algorithm: ECDSA
322+
size: 256
323+
rotationPolicy: &rotationPolicy Never
324+
issuerRef:
325+
name: my-ca
326+
kind: ClusterIssuer
327+
group: cert-manager.io
328+
secretTemplate:
329+
annotations:
330+
sebastian.gaiser.bayern/tls-strimzi-ca: "reconcile"
331+
sebastian.gaiser.bayern/target-cluster-name: "my-cluster"
332+
sebastian.gaiser.bayern/target-secret-name: "rotation-policy-never-ca-cert"
333+
sebastian.gaiser.bayern/target-secret-key-name: "rotation-policy-never-ca"
334+
sebastian.gaiser.bayern/rotation-policy: *rotationPolicy
335+
EOF
336+
337+
# Wait for certificate to be ready
338+
kubectl wait --for=condition=Ready certificate/rotation-policy-never-ca-cert-tls -n "$NAMESPACE" --timeout=60s
339+
340+
# Wait for controller to create the cert and key target secrets
341+
wait_for_secret "rotation-policy-never-ca-cert" "$NAMESPACE" 30
342+
wait_for_secret "rotation-policy-never-ca" "$NAMESPACE" 30
343+
344+
# Verify both secrets exist after initial creation
345+
assert_secret_exists "rotation-policy-never-ca-cert" "$NAMESPACE"
346+
assert_secret_exists "rotation-policy-never-ca" "$NAMESPACE"
347+
348+
# Verify cert secret has correct data
349+
local ca_crt
350+
ca_crt=$(kubectl get secret rotation-policy-never-ca-cert -n "$NAMESPACE" -o jsonpath='{.data.ca\.crt}')
351+
assert_not_empty "$ca_crt" "Cert secret has ca.crt data"
352+
353+
# Verify key secret has correct data
354+
local ca_key
355+
ca_key=$(kubectl get secret rotation-policy-never-ca -n "$NAMESPACE" -o jsonpath='{.data.ca\.key}')
356+
assert_not_empty "$ca_key" "Key secret has ca.key data"
357+
358+
# Record key and cert hashes before rotation
359+
local key_hash_before cert_hash_before
360+
key_hash_before=$(kubectl get secret rotation-policy-never-ca -n "$NAMESPACE" -o jsonpath='{.metadata.labels.sebastian\.gaiser\.bayern/hash}')
361+
cert_hash_before=$(kubectl get secret rotation-policy-never-ca-cert -n "$NAMESPACE" -o jsonpath='{.metadata.labels.sebastian\.gaiser\.bayern/hash}')
362+
log_info "Key hash before rotation: $key_hash_before"
363+
log_info "Cert hash before rotation: $cert_hash_before"
364+
365+
# Trigger rotation by deleting the source secret
366+
log_info "Triggering certificate rotation..."
367+
kubectl delete secret rotation-policy-never-ca-cert-tls -n "$NAMESPACE"
368+
369+
# Wait for cert-manager to recreate the secret
370+
sleep 5
371+
kubectl wait --for=condition=Ready certificate/rotation-policy-never-ca-cert-tls -n "$NAMESPACE" --timeout=60s
372+
373+
# Wait for controller to process the change
374+
sleep 10
375+
376+
# Verify key secret was NOT updated (hash unchanged)
377+
local key_hash_after
378+
key_hash_after=$(kubectl get secret rotation-policy-never-ca -n "$NAMESPACE" -o jsonpath='{.metadata.labels.sebastian\.gaiser\.bayern/hash}')
379+
log_info "Key hash after rotation: $key_hash_after"
380+
381+
assert_equals "$key_hash_before" "$key_hash_after" "Key secret hash unchanged after rotation (rotationPolicy: Never)"
382+
383+
# Verify no historical key secret was created
384+
local historical_key_secret="rotation-policy-never-ca-generation-0"
385+
assert_secret_not_exists "$historical_key_secret" "$NAMESPACE"
386+
387+
# Verify cert secret was updated (hash changed)
388+
local cert_hash_after
389+
cert_hash_after=$(kubectl get secret rotation-policy-never-ca-cert -n "$NAMESPACE" -o jsonpath='{.metadata.labels.sebastian\.gaiser\.bayern/hash}')
390+
log_info "Cert hash after rotation: $cert_hash_after"
391+
392+
if [[ "$cert_hash_before" != "$cert_hash_after" ]]; then
393+
log_info "PASS: Cert secret hash changed after rotation"
394+
((TEST_PASSED++)) || true
395+
else
396+
log_error "FAIL: Cert secret hash did not change after rotation"
397+
((TEST_FAILED++)) || true
398+
fi
399+
}
400+
298401
# ==============================================================================
299402
# Main
300403
# ==============================================================================
@@ -309,6 +412,7 @@ main() {
309412
test_controller_running
310413
test_certificate_creation
311414
test_certificate_rotation
415+
test_rotation_policy_never
312416

313417
# Cleanup after tests
314418
cleanup_test_resources

main.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ var (
4545
strimziKindValue = "Kafka"
4646
strimziCaCertGeneration = "strimzi.io/ca-cert-generation"
4747
strimziCaKeyGeneration = "strimzi.io/ca-key-generation"
48+
rotationPolicyAnnotationKey = "sebastian.gaiser.bayern/rotation-policy"
49+
rotationPolicyNever = "Never"
4850
)
4951

5052
func init() {
@@ -191,6 +193,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrlRuntime.Request) (ct
191193
tlsCrt := string(tlsSecret.Data["tls.crt"])
192194
tlsKey := string(tlsSecret.Data["tls.key"])
193195

196+
rotationPolicyIsNever := tlsSecretAnnotations[rotationPolicyAnnotationKey] == rotationPolicyNever
197+
if rotationPolicyIsNever {
198+
ctrlRuntime.Log.Info(fmt.Sprintf("Secret with name '%s' has rotationPolicy 'Never', skipping private key secret operations", tlsSecret.Name))
199+
}
200+
194201
// check if target secrets are existing
195202
targetSecret := &corev1.Secret{}
196203
targetSecretExists := false
@@ -226,15 +233,17 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrlRuntime.Request) (ct
226233
ctrlRuntime.Log.Info(fmt.Sprintf("Secret %s already exists", tlsSecretAnnotations[targetSecretAnnotationKeyNameKey]))
227234
targetSecretKeyExists = true
228235

229-
// check if target secret need an update
230-
// but first check if the hash label exists...
231-
targetSecretKeyHash, ok := targetSecretKey.Labels[hashLabelKey]
232-
if !ok {
233-
return ctrlRuntime.Result{}, fmt.Errorf("label '%s' not found for target secret '%s'", targetSecretKey, hashLabelKey)
234-
}
235-
// now check if it needs an update
236-
if tlsSecretHash != targetSecretKeyHash {
237-
targetSecretKeyNeedsUpdate = true
236+
if !rotationPolicyIsNever {
237+
// check if target secret need an update
238+
// but first check if the hash label exists...
239+
targetSecretKeyHash, ok := targetSecretKey.Labels[hashLabelKey]
240+
if !ok {
241+
return ctrlRuntime.Result{}, fmt.Errorf("label '%s' not found for target secret '%s'", targetSecretKey, hashLabelKey)
242+
}
243+
// now check if it needs an update
244+
if tlsSecretHash != targetSecretKeyHash {
245+
targetSecretKeyNeedsUpdate = true
246+
}
238247
}
239248
}
240249

0 commit comments

Comments
 (0)