Skip to content

Commit 5c4de14

Browse files
Re-register CRDs when CA changes, and use intermediate issuer
1 parent f9ff591 commit 5c4de14

6 files changed

Lines changed: 377 additions & 17 deletions

File tree

misc/helm-charts/operator/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ The following table lists the configurable parameters of the Materialize operato
144144
| `operator.args.enableInternalStatementLogging` | | ``true`` |
145145
| `operator.args.enableLicenseKeyChecks` | | ``false`` |
146146
| `operator.args.startupLogFilter` | Log filtering settings for startup logs | ``"INFO,mz_orchestratord=TRACE"`` |
147+
| `operator.args.webhookCertReloadInterval` | How often orchestratord reloads its webhook TLS certificate from disk and, when the CA changes, refreshes the conversion webhook's CA bundle. Must be shorter than the certificate's lifetime. Accepts a humantime duration (e.g. "1h", "30m"). Leave null to use the binary default. | ``nil`` |
148+
| `operator.certificate.caDuration` | Lifetime of the root CA that signs the webhook serving certificate, when `source` is "cert-manager". The serving certificate is signed by this CA, so the CA outlives individual serving-certificate rotations. | ``"87600h"`` |
149+
| `operator.certificate.caRenewBefore` | How long before the root CA expires to renew it. Must be less than `caDuration`. | ``"8760h"`` |
147150
| `operator.certificate.secretName` | Name of a secret in the operator's namespace containing ca.crt, tls.crt, and tls.key entries. Only used if `source` is "secret". | ``nil`` |
148151
| `operator.certificate.source` | Where to obtain the certificate for orchestratord. Valid values are 'cert-manager' and 'secret'. | ``"cert-manager"`` |
149152
| `operator.cloudProvider.providers.aws.accountID` | When using AWS, accountID is required | ``""`` |

misc/helm-charts/operator/templates/certificate.yaml

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
88
# by the Apache License, Version 2.0.
99

1010
{{- if eq .Values.operator.certificate.source "cert-manager" -}}
11+
# We provision the webhook serving certificate from a stable root CA rather
12+
# than as a bare self-signed certificate. The serving certificate rotates
13+
# frequently, but it is always signed by the same long-lived CA, so the
14+
# `ca.crt` that orchestratord registers as the conversion webhook's caBundle
15+
# stays valid across serving-certificate rotations. orchestratord refreshes the
16+
# caBundle if the CA itself ever changes, e.g. on the rare root CA renewal, so a
17+
# routine serving-certificate rotation leaves the webhook undisturbed.
1118
---
19+
# Bootstrap issuer used only to sign the root CA below.
1220
apiVersion: cert-manager.io/v1
1321
kind: Issuer
1422
metadata:
@@ -20,10 +28,49 @@ spec:
2028
selfSigned: {}
2129

2230
---
31+
# Long-lived root CA. Stays stable across serving-certificate rotations so the
32+
# conversion webhook's caBundle remains valid without changing every rotation.
2333
apiVersion: cert-manager.io/v1
2434
kind: Certificate
2535
metadata:
26-
name: {{ include "materialize-operator.fullname" . }}-self-signed
36+
name: {{ include "materialize-operator.fullname" . }}-ca
37+
namespace: {{ .Release.Namespace }}
38+
labels:
39+
{{- include "materialize-operator.labels" . | nindent 4 }}
40+
spec:
41+
isCA: true
42+
commonName: {{ include "materialize-operator.fullname" . }}-ca
43+
secretName: {{ include "materialize-operator.fullname" . }}-ca
44+
duration: {{ .Values.operator.certificate.caDuration }}
45+
renewBefore: {{ .Values.operator.certificate.caRenewBefore }}
46+
privateKey:
47+
algorithm: ECDSA
48+
rotationPolicy: Always
49+
issuerRef:
50+
name: {{ include "materialize-operator.fullname" . }}-self-signed
51+
kind: Issuer
52+
group: cert-manager.io
53+
54+
---
55+
# CA issuer that signs the serving certificate using the stable root CA.
56+
apiVersion: cert-manager.io/v1
57+
kind: Issuer
58+
metadata:
59+
name: {{ include "materialize-operator.fullname" . }}-ca
60+
namespace: {{ .Release.Namespace }}
61+
labels:
62+
{{- include "materialize-operator.labels" . | nindent 4 }}
63+
spec:
64+
ca:
65+
secretName: {{ include "materialize-operator.fullname" . }}-ca
66+
67+
---
68+
# Webhook serving certificate. Rotates frequently; its `ca.crt` is the stable
69+
# root CA above.
70+
apiVersion: cert-manager.io/v1
71+
kind: Certificate
72+
metadata:
73+
name: {{ include "materialize-operator.fullname" . }}-cert
2774
namespace: {{ .Release.Namespace }}
2875
labels:
2976
{{- include "materialize-operator.labels" . | nindent 4 }}
@@ -35,7 +82,7 @@ spec:
3582
algorithm: ECDSA
3683
rotationPolicy: Always
3784
issuerRef:
38-
name: {{ include "materialize-operator.fullname" . }}-self-signed
85+
name: {{ include "materialize-operator.fullname" . }}-ca
3986
kind: Issuer
4087
group: cert-manager.io
4188
{{- end -}}

misc/helm-charts/operator/templates/deployment.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ spec:
6666
{{- if not .Values.operator.args.enableLicenseKeyChecks }}
6767
- "--disable-license-key-checks"
6868
{{- end }}
69+
{{- if .Values.operator.args.webhookCertReloadInterval }}
70+
- "--webhook-cert-reload-interval={{ .Values.operator.args.webhookCertReloadInterval }}"
71+
{{- end }}
6972

7073
{{/* AWS Configuration */}}
7174
{{- if eq .Values.operator.cloudProvider.type "aws" }}

misc/helm-charts/operator/values.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ operator:
2424
enableInternalStatementLogging: true
2525
# Newer versions ignore this setting and always enforce license key checks.
2626
enableLicenseKeyChecks: false
27+
# -- (string) How often orchestratord reloads its webhook TLS certificate
28+
# from disk and, when the CA changes, refreshes the conversion webhook's CA
29+
# bundle. Must be shorter than the certificate's lifetime. Accepts a
30+
# humantime duration (e.g. "1h", "30m"). Leave null to use the binary
31+
# default.
32+
webhookCertReloadInterval: null
2733

2834
# -- Additional columns to display when printing the Materialize CRD in table format.
2935
additionalMaterializeCRDColumns: {}
@@ -39,6 +45,14 @@ operator:
3945
source: cert-manager
4046
# -- (string) Name of a secret in the operator's namespace containing ca.crt, tls.crt, and tls.key entries. Only used if `source` is "secret".
4147
secretName: null
48+
# -- (string) Lifetime of the root CA that signs the webhook serving
49+
# certificate, when `source` is "cert-manager". The serving certificate is
50+
# signed by this CA, so the CA outlives individual serving-certificate
51+
# rotations.
52+
caDuration: 87600h
53+
# -- (string) How long before the root CA expires to renew it. Must be less
54+
# than `caDuration`.
55+
caRenewBefore: 8760h
4256

4357

4458
# Cloud provider configuration

src/orchestratord/src/bin/orchestratord.rs

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ pub struct Args {
7878
tls_cert: String,
7979
#[clap(long, default_value = "/etc/tls/tls.key")]
8080
tls_key: String,
81+
/// How often to reload the webhook TLS serving certificate from disk and,
82+
/// when the CA changes, refresh the conversion webhook's CA bundle. The
83+
/// certificate is rotated out-of-band (e.g. by cert-manager), so this must
84+
/// be short enough that rotations are picked up before the old certificate
85+
/// expires.
86+
#[clap(long, default_value = "1h", value_parser = humantime::parse_duration)]
87+
webhook_cert_reload_interval: Duration,
8188

8289
#[clap(long)]
8390
cloud_provider: CloudProvider,
@@ -283,9 +290,10 @@ async fn run(args: Args) -> Result<(), anyhow::Error> {
283290

284291
let metrics = Arc::new(Metrics::register_into(&metrics_registry));
285292

286-
{
287-
let tls_cert = args.tls_cert;
288-
let tls_key = args.tls_key;
293+
let tls_cert = args.tls_cert;
294+
let tls_key = args.tls_key;
295+
let tls_ca = args.tls_ca;
296+
let reload_config = {
289297
let config = OpenSSLConfig::from_pem_file(&tls_cert, &tls_key).unwrap();
290298
let reload_config = config.clone();
291299
let webhook_listen_address = args.webhook_listen_address;
@@ -299,28 +307,89 @@ async fn run(args: Args) -> Result<(), anyhow::Error> {
299307
}
300308
});
301309

310+
reload_config
311+
};
312+
313+
let (client, namespace) = create_client(args.kubernetes_context.clone()).await?;
314+
let additional_crd_columns = args.additional_crd_columns.unwrap_or_default();
315+
let webhook_service_name = args.webhook_service_name;
316+
let webhook_service_namespace = args.webhook_service_namespace;
317+
let webhook_service_port = args.webhook_service_port;
318+
register_crds(
319+
client.clone(),
320+
additional_crd_columns.clone(),
321+
webhook_service_name.clone(),
322+
webhook_service_namespace.clone(),
323+
webhook_service_port,
324+
tls_ca.clone(),
325+
)
326+
.await?;
327+
328+
// Periodically reload the webhook serving certificate from disk, and
329+
// refresh the conversion webhook's CA bundle whenever the CA changes.
330+
//
331+
// The certificate is rotated out-of-band (e.g. by cert-manager). The
332+
// serving certificate is signed by a stable root CA, so routine rotations
333+
// reuse the same CA and the CA bundle registered into the CRD at startup
334+
// keeps working. But if the CA itself rotates (e.g. on root CA renewal),
335+
// that startup CA bundle would not trust the served certificate, and the
336+
// Kubernetes API server would reject every conversion request. Refreshing
337+
// the CA bundle when the CA changes keeps the webhook working across CA
338+
// rotations.
339+
{
340+
let reload_interval = args.webhook_cert_reload_interval;
341+
let client = client.clone();
302342
mz_ore::task::spawn(|| "webhook certificate reload", async move {
303-
let mut interval = tokio::time::interval(Duration::from_secs(60 * 60));
343+
let mut last_ca = tokio::fs::read(&tls_ca).await.ok();
344+
let mut interval = tokio::time::interval(reload_interval);
345+
// The first tick completes immediately; skip it so we don't
346+
// re-register the CRDs we just registered above.
347+
interval.tick().await;
304348
loop {
305349
interval.tick().await;
306350
if let Err(err) = reload_config.reload_from_pem_file(&tls_cert, &tls_key) {
307351
tracing::error!("failed to reload webhook TLS certificate: {err}");
352+
continue;
353+
}
354+
let current_ca = match tokio::fs::read(&tls_ca).await {
355+
Ok(ca) => ca,
356+
Err(err) => {
357+
tracing::error!("failed to read webhook CA certificate: {err}");
358+
continue;
359+
}
360+
};
361+
if last_ca.as_deref() == Some(current_ca.as_slice()) {
362+
continue;
363+
}
364+
// The CA changed, meaning the certificate was rotated. Re-register
365+
// the CRDs so the conversion webhook's caBundle matches the
366+
// newly-served certificate.
367+
match register_crds(
368+
client.clone(),
369+
additional_crd_columns.clone(),
370+
webhook_service_name.clone(),
371+
webhook_service_namespace.clone(),
372+
webhook_service_port,
373+
tls_ca.clone(),
374+
)
375+
.await
376+
{
377+
Ok(()) => {
378+
tracing::info!(
379+
"refreshed conversion webhook CA bundle after certificate rotation"
380+
);
381+
last_ca = Some(current_ca);
382+
}
383+
Err(err) => {
384+
tracing::error!(
385+
"failed to refresh conversion webhook CA bundle after rotation: {err}"
386+
);
387+
}
308388
}
309389
}
310390
});
311391
}
312392

313-
let (client, namespace) = create_client(args.kubernetes_context.clone()).await?;
314-
register_crds(
315-
client.clone(),
316-
args.additional_crd_columns.unwrap_or_default(),
317-
args.webhook_service_name,
318-
args.webhook_service_namespace,
319-
args.webhook_service_port,
320-
args.tls_ca,
321-
)
322-
.await?;
323-
324393
let crd_api: Api<CustomResourceDefinition> = Api::all(client.clone());
325394
let crds = crd_api.list(&ListParams::default()).await?;
326395
let has_cert_manager = crds

0 commit comments

Comments
 (0)