1use anyhow::bail;
11use k8s_controller::TraceMetadata;
12use k8s_openapi::{
13 api::{
14 apps::v1::{Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment},
15 core::v1::{
16 Affinity, Capabilities, Container, ContainerPort, HTTPGetAction, PodSecurityContext,
17 PodSpec, PodTemplateSpec, Probe, ResourceRequirements, SeccompProfile,
18 SecretVolumeSource, SecurityContext, Service, ServicePort, ServiceSpec, Toleration,
19 Volume, VolumeMount,
20 },
21 },
22 apimachinery::pkg::{
23 apis::meta::v1::{Condition, LabelSelector, Time},
24 util::intstr::IntOrString,
25 },
26 jiff::Timestamp,
27};
28use kube::{
29 Api, Client, Resource, ResourceExt,
30 api::{DeleteParams, ObjectMeta, PostParams},
31 runtime::{conditions::is_deployment_completed, controller::Action, wait::await_condition},
32};
33use maplit::btreemap;
34use tracing::{trace, warn};
35
36use crate::{
37 Error,
38 k8s::{apply_resource, get_resource, recommended_k8s_labels, replace_resource},
39 tls::{DefaultCertificateSpecs, create_certificate, issuer_ref_defined},
40};
41use mz_cloud_resources::crd::{
42 ManagedResource,
43 balancer::v1alpha1::{Balancer, Routing},
44 generated::cert_manager::certificates::{Certificate, CertificatePrivateKeyAlgorithm},
45};
46use mz_orchestrator_kubernetes::KubernetesImagePullPolicy;
47use mz_ore::{cli::KeyValueArg, instrument};
48
49#[derive(Clone)]
50pub struct Config {
51 pub enable_security_context: bool,
52 pub enable_prometheus_scrape_annotations: bool,
53
54 pub image_pull_policy: KubernetesImagePullPolicy,
55 pub scheduler_name: Option<String>,
56 pub balancerd_node_selector: Vec<KeyValueArg<String, String>>,
57 pub balancerd_affinity: Option<Affinity>,
58 pub balancerd_tolerations: Option<Vec<Toleration>>,
59 pub balancerd_default_resources: Option<ResourceRequirements>,
60
61 pub default_certificate_specs: DefaultCertificateSpecs,
62
63 pub environmentd_sql_port: u16,
64 pub environmentd_http_port: u16,
65 pub balancerd_sql_port: u16,
66 pub balancerd_http_port: u16,
67 pub balancerd_internal_http_port: u16,
68}
69
70pub struct Context {
71 config: Config,
72}
73
74impl Context {
75 pub fn new(config: Config) -> Self {
76 Self { config }
77 }
78
79 async fn sync_deployment_status(
80 &self,
81 client: &Client,
82 balancer: &Balancer,
83 ) -> Result<(), Error> {
84 let namespace = balancer.namespace();
85 let balancer_api: Api<Balancer> = Api::namespaced(client.clone(), &namespace);
86 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
87
88 let Some(deployment) = get_resource(&deployment_api, &balancer.deployment_name()).await?
89 else {
90 return Ok(());
91 };
92
93 let Some(deployment_conditions) = &deployment
94 .status
95 .as_ref()
96 .and_then(|status| status.conditions.as_ref())
97 else {
98 return Ok(());
101 };
102
103 let ready = deployment_conditions
104 .iter()
105 .any(|condition| condition.type_ == "Available" && condition.status == "True");
106 let ready_str = if ready { "True" } else { "False" };
107
108 let mut status = balancer.status.clone().unwrap();
109 if status
110 .conditions
111 .iter()
112 .any(|condition| condition.type_ == "Ready" && condition.status == ready_str)
113 {
114 return Ok(());
118 }
119
120 status.conditions = vec![Condition {
121 type_: "Ready".to_string(),
122 status: ready_str.to_string(),
123 last_transition_time: Time(Timestamp::now()),
124 message: format!(
125 "balancerd deployment is{} ready",
126 if ready { "" } else { " not" }
127 ),
128 observed_generation: None,
129 reason: "DeploymentStatus".to_string(),
130 }];
131 let mut new_balancer = balancer.clone();
132 new_balancer.status = Some(status);
133
134 balancer_api
135 .replace_status(
136 &balancer.name_unchecked(),
137 &PostParams::default(),
138 &new_balancer,
139 )
140 .await?;
141
142 Ok(())
143 }
144
145 fn create_external_certificate_object(
146 &self,
147 balancer: &Balancer,
148 ) -> Result<Option<Certificate>, anyhow::Error> {
149 create_certificate(
150 self.config
151 .default_certificate_specs
152 .balancerd_external
153 .clone(),
154 balancer,
155 balancer.spec.external_certificate_spec.clone(),
156 balancer.external_certificate_name(),
157 balancer.external_certificate_secret_name(),
158 None,
159 CertificatePrivateKeyAlgorithm::Ecdsa,
160 Some(256),
161 )
162 }
163
164 fn create_deployment_object(&self, balancer: &Balancer) -> anyhow::Result<Deployment> {
165 let security_context = if self.config.enable_security_context {
166 Some(SecurityContext {
170 run_as_non_root: Some(true),
171 capabilities: Some(Capabilities {
172 drop: Some(vec!["ALL".to_string()]),
173 ..Default::default()
174 }),
175 seccomp_profile: Some(SeccompProfile {
176 type_: "RuntimeDefault".to_string(),
177 ..Default::default()
178 }),
179 allow_privilege_escalation: Some(false),
180 ..Default::default()
181 })
182 } else {
183 None
184 };
185
186 let pod_template_annotations = if self.config.enable_prometheus_scrape_annotations {
187 Some(btreemap! {
188 "prometheus.io/scrape".to_owned() => "true".to_string(),
189 "prometheus.io/port".to_owned() => self.config.balancerd_internal_http_port.to_string(),
190 "prometheus.io/path".to_owned() => "/metrics".to_string(),
191 "prometheus.io/scheme".to_owned() => "http".to_string(),
192 })
193 } else {
194 None
195 };
196 let mut pod_template_labels = balancer.default_labels();
197 pod_template_labels.insert(
198 "materialize.cloud/name".to_owned(),
199 balancer.deployment_name(),
200 );
201 pod_template_labels.insert("app".to_owned(), "balancerd".to_string());
202 pod_template_labels.insert("materialize.cloud/app".to_owned(), balancer.app_name());
203
204 let ports = vec![
205 ContainerPort {
206 container_port: self.config.balancerd_sql_port.into(),
207 name: Some("pgwire".into()),
208 protocol: Some("TCP".into()),
209 ..Default::default()
210 },
211 ContainerPort {
212 container_port: self.config.balancerd_http_port.into(),
213 name: Some("http".into()),
214 protocol: Some("TCP".into()),
215 ..Default::default()
216 },
217 ContainerPort {
218 container_port: self.config.balancerd_internal_http_port.into(),
219 name: Some("internal-http".into()),
220 protocol: Some("TCP".into()),
221 ..Default::default()
222 },
223 ];
224
225 let mut args = vec![
226 "service".to_string(),
227 format!(
228 "--pgwire-listen-addr=0.0.0.0:{}",
229 self.config.balancerd_sql_port
230 ),
231 format!(
232 "--https-listen-addr=0.0.0.0:{}",
233 self.config.balancerd_http_port
234 ),
235 format!(
236 "--internal-http-listen-addr=0.0.0.0:{}",
237 self.config.balancerd_internal_http_port
238 ),
239 ];
240 match balancer.routing()? {
241 Routing::Static(static_routing_config) => {
242 args.extend([
243 format!(
244 "--https-resolver-template={}.{}.svc.cluster.local:{}",
245 static_routing_config.environmentd_service_name,
246 static_routing_config.environmentd_namespace,
247 self.config.environmentd_http_port
248 ),
249 format!(
250 "--static-resolver-addr={}.{}.svc.cluster.local:{}",
251 static_routing_config.environmentd_service_name,
252 static_routing_config.environmentd_namespace,
253 self.config.environmentd_sql_port
254 ),
255 ]);
256 }
257 Routing::Frontegg(_frontegg_routing_config) => {
258 bail!("frontegg routing is not yet implemented");
259 }
260 }
261
262 if issuer_ref_defined(
263 &self.config.default_certificate_specs.internal,
264 &balancer.spec.internal_certificate_spec,
265 ) {
266 args.push("--internal-tls".to_owned())
267 }
268
269 let mut volumes = Vec::new();
270 let mut volume_mounts = Vec::new();
271 if issuer_ref_defined(
272 &self.config.default_certificate_specs.balancerd_external,
273 &balancer.spec.external_certificate_spec,
274 ) {
275 volumes.push(Volume {
276 name: "external-certificate".to_owned(),
277 secret: Some(SecretVolumeSource {
278 default_mode: Some(0o400),
279 secret_name: Some(balancer.external_certificate_secret_name()),
280 items: None,
281 optional: Some(false),
282 }),
283 ..Default::default()
284 });
285 volume_mounts.push(VolumeMount {
286 name: "external-certificate".to_owned(),
287 mount_path: "/etc/external_tls".to_owned(),
288 read_only: Some(true),
289 ..Default::default()
290 });
291 args.extend([
292 "--tls-mode=require".into(),
293 "--tls-cert=/etc/external_tls/tls.crt".into(),
294 "--tls-key=/etc/external_tls/tls.key".into(),
295 ]);
296 } else {
297 args.push("--tls-mode=disable".to_string());
298 }
299
300 let startup_probe = Probe {
301 http_get: Some(HTTPGetAction {
302 port: IntOrString::Int(self.config.balancerd_internal_http_port.into()),
303 path: Some("/api/readyz".into()),
304 ..Default::default()
305 }),
306 failure_threshold: Some(20),
307 initial_delay_seconds: Some(3),
308 period_seconds: Some(3),
309 success_threshold: Some(1),
310 timeout_seconds: Some(1),
311 ..Default::default()
312 };
313 let readiness_probe = Probe {
314 http_get: Some(HTTPGetAction {
315 port: IntOrString::Int(self.config.balancerd_internal_http_port.into()),
316 path: Some("/api/readyz".into()),
317 ..Default::default()
318 }),
319 failure_threshold: Some(3),
320 period_seconds: Some(10),
321 success_threshold: Some(1),
322 timeout_seconds: Some(1),
323 ..Default::default()
324 };
325 let liveness_probe = Probe {
326 http_get: Some(HTTPGetAction {
327 port: IntOrString::Int(self.config.balancerd_internal_http_port.into()),
328 path: Some("/api/livez".into()),
329 ..Default::default()
330 }),
331 failure_threshold: Some(3),
332 initial_delay_seconds: Some(8),
333 period_seconds: Some(10),
334 success_threshold: Some(1),
335 timeout_seconds: Some(1),
336 ..Default::default()
337 };
338
339 let container = Container {
340 name: "balancerd".to_owned(),
341 image: Some(balancer.spec.balancerd_image_ref.clone()),
342 image_pull_policy: Some(self.config.image_pull_policy.to_string()),
343 ports: Some(ports),
344 args: Some(args),
345 startup_probe: Some(startup_probe),
346 readiness_probe: Some(readiness_probe),
347 liveness_probe: Some(liveness_probe),
348 resources: balancer
349 .spec
350 .resource_requirements
351 .clone()
352 .or_else(|| self.config.balancerd_default_resources.clone()),
353 security_context: security_context.clone(),
354 volume_mounts: Some(volume_mounts),
355 ..Default::default()
356 };
357
358 let match_labels = pod_template_labels.clone();
359 pod_template_labels.extend(recommended_k8s_labels(balancer.app_name()));
360
361 let deployment_spec = DeploymentSpec {
362 replicas: Some(balancer.replicas()),
363 selector: LabelSelector {
364 match_labels: Some(match_labels),
365 ..Default::default()
366 },
367 strategy: Some(DeploymentStrategy {
368 rolling_update: Some(RollingUpdateDeployment {
369 max_surge: Some(IntOrString::String("100%".into())),
373 ..Default::default()
374 }),
375 ..Default::default()
376 }),
377 template: PodTemplateSpec {
378 metadata: Some(ObjectMeta {
381 annotations: pod_template_annotations,
382 labels: Some(pod_template_labels),
383 ..Default::default()
384 }),
385 spec: Some(PodSpec {
386 containers: vec![container],
387 node_selector: Some(
388 self.config
389 .balancerd_node_selector
390 .iter()
391 .map(|selector| (selector.key.clone(), selector.value.clone()))
392 .collect(),
393 ),
394 affinity: self.config.balancerd_affinity.clone(),
395 tolerations: self.config.balancerd_tolerations.clone(),
396 security_context: Some(PodSecurityContext {
397 fs_group: Some(999),
398 run_as_user: Some(999),
399 run_as_group: Some(999),
400 ..Default::default()
401 }),
402 scheduler_name: self.config.scheduler_name.clone(),
403 volumes: Some(volumes),
404 ..Default::default()
405 }),
406 },
407 ..Default::default()
408 };
409
410 Ok(Deployment {
411 metadata: balancer.managed_resource_meta(balancer.deployment_name()),
412 spec: Some(deployment_spec),
413 status: None,
414 })
415 }
416
417 fn create_service_object(&self, balancer: &Balancer) -> Service {
418 let selector =
419 btreemap! {"materialize.cloud/name".to_string() => balancer.deployment_name()};
420
421 let ports = vec![
422 ServicePort {
423 name: Some("http".to_string()),
424 protocol: Some("TCP".to_string()),
425 port: self.config.balancerd_http_port.into(),
426 target_port: Some(IntOrString::Int(self.config.balancerd_http_port.into())),
427 ..Default::default()
428 },
429 ServicePort {
430 name: Some("pgwire".to_string()),
431 protocol: Some("TCP".to_string()),
432 port: self.config.balancerd_sql_port.into(),
433 target_port: Some(IntOrString::Int(self.config.balancerd_sql_port.into())),
434 ..Default::default()
435 },
436 ];
437
438 let spec = ServiceSpec {
439 type_: Some("ClusterIP".to_string()),
440 cluster_ip: Some("None".to_string()),
441 selector: Some(selector),
442 ports: Some(ports),
443 ..Default::default()
444 };
445
446 Service {
447 metadata: balancer.managed_resource_meta(balancer.service_name()),
448 spec: Some(spec),
449 status: None,
450 }
451 }
452
453 async fn fix_deployment(
456 &self,
457 deployment_api: &Api<Deployment>,
458 new_deployment: &Deployment,
459 ) -> Result<(), Error> {
460 let Some(mut existing_deployment) =
461 get_resource(deployment_api, &new_deployment.name_unchecked()).await?
462 else {
463 return Ok(());
464 };
465
466 if existing_deployment.spec.as_ref().unwrap().selector
467 == new_deployment.spec.as_ref().unwrap().selector
468 {
469 return Ok(());
470 }
471
472 warn!("found existing deployment with old label selector, fixing");
473
474 existing_deployment
477 .spec
478 .as_mut()
479 .unwrap()
480 .template
481 .metadata
482 .as_mut()
483 .unwrap()
484 .labels = new_deployment
485 .spec
486 .as_ref()
487 .unwrap()
488 .template
489 .metadata
490 .as_ref()
491 .unwrap()
492 .labels
493 .clone();
494
495 replace_resource(deployment_api, &existing_deployment).await?;
499 await_condition(
500 deployment_api.clone(),
501 &existing_deployment.name_unchecked(),
502 |deployment: Option<&Deployment>| {
503 let observed_generation = deployment
504 .and_then(|deployment| deployment.status.as_ref())
505 .and_then(|status| status.observed_generation)
506 .unwrap_or(0);
507 let current_generation = deployment
508 .and_then(|deployment| deployment.meta().generation)
509 .unwrap_or(0);
510 let previous_generation = existing_deployment.meta().generation.unwrap_or(0);
511 observed_generation == current_generation
512 && current_generation > previous_generation
513 },
514 )
515 .await
516 .map_err(|e| anyhow::anyhow!(e))?;
517 await_condition(
518 deployment_api.clone(),
519 &existing_deployment.name_unchecked(),
520 is_deployment_completed(),
521 )
522 .await
523 .map_err(|e| anyhow::anyhow!(e))?;
524
525 match kube::runtime::wait::delete::delete_and_finalize(
528 deployment_api.clone(),
529 &existing_deployment.name_unchecked(),
530 &DeleteParams::orphan(),
531 )
532 .await
533 {
534 Ok(_) => {}
535 Err(kube::runtime::wait::delete::Error::Delete(kube::Error::Api(e)))
536 if e.code == 404 =>
537 {
538 }
540 Err(e) => return Err(anyhow::anyhow!(e).into()),
541 }
542
543 Ok(())
549 }
550}
551
552#[async_trait::async_trait]
553impl k8s_controller::Context for Context {
554 type Resource = Balancer;
555 type Error = Error;
556
557 #[instrument(fields())]
558 async fn apply(
559 &self,
560 client: Client,
561 balancer: &Self::Resource,
562 _metadata: &mut TraceMetadata,
563 ) -> Result<Option<Action>, Self::Error> {
564 if balancer.status.is_none() {
565 let balancer_api: Api<Balancer> =
566 Api::namespaced(client.clone(), &balancer.meta().namespace.clone().unwrap());
567 let mut new_balancer = balancer.clone();
568 new_balancer.status = Some(balancer.status());
569 balancer_api
570 .replace_status(
571 &balancer.name_unchecked(),
572 &PostParams::default(),
573 &new_balancer,
574 )
575 .await?;
576 return Ok(None);
579 }
580
581 let namespace = balancer.namespace();
582 let certificate_api: Api<Certificate> = Api::namespaced(client.clone(), &namespace);
583 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
584 let service_api: Api<Service> = Api::namespaced(client.clone(), &namespace);
585
586 if let Some(external_certificate) = self.create_external_certificate_object(balancer)? {
587 trace!("creating new balancerd external certificate");
588 apply_resource(&certificate_api, &external_certificate).await?;
589 }
590
591 let deployment = self.create_deployment_object(balancer)?;
592 self.fix_deployment(&deployment_api, &deployment).await?;
593 trace!("creating new balancerd deployment");
594 apply_resource(&deployment_api, &deployment).await?;
595
596 let service = self.create_service_object(balancer);
597 trace!("creating new balancerd service");
598 apply_resource(&service_api, &service).await?;
599
600 self.sync_deployment_status(&client, balancer).await?;
601
602 Ok(None)
603 }
604}