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