1use anyhow::bail;
11use k8s_controller::{Outcome, TraceMetadata};
12use k8s_openapi::{
13 api::{
14 apps::v1::{Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment},
15 core::v1::{
16 Affinity, Capabilities, ConfigMapVolumeSource, Container, ContainerPort, HTTPGetAction,
17 KeyToPath, PodSecurityContext, PodSpec, PodTemplateSpec, Probe, ResourceRequirements,
18 SeccompProfile, SecretVolumeSource, SecurityContext, Service, ServicePort, ServiceSpec,
19 Toleration, Volume, VolumeMount,
20 },
21 policy::v1::PodDisruptionBudget,
22 },
23 apimachinery::pkg::{
24 apis::meta::v1::{Condition, LabelSelector, Time},
25 util::intstr::IntOrString,
26 },
27 jiff::Timestamp,
28};
29use kube::{
30 Api, Client, Resource, ResourceExt,
31 api::{DeleteParams, ObjectMeta, Patch, PatchParams, PostParams},
32 runtime::{conditions::is_deployment_completed, controller::Action, wait::await_condition},
33};
34use maplit::btreemap;
35use tracing::{trace, warn};
36
37use crate::{
38 Error,
39 k8s::{
40 FIELD_MANAGER, apply_resource, get_resource, pod_disruption_budget, recommended_k8s_labels,
41 replace_resource,
42 },
43 tls::{DefaultCertificateSpecs, create_certificate, issuer_ref_defined},
44};
45use mz_cloud_resources::crd::{
46 ManagedResource,
47 balancer::v1alpha1::{Balancer, Routing},
48 generated::cert_manager::certificates::{Certificate, CertificatePrivateKeyAlgorithm},
49};
50use mz_orchestrator_kubernetes::KubernetesImagePullPolicy;
51use mz_ore::{cli::KeyValueArg, instrument};
52
53pub const CONTROLLER_NAME: &str = "balancer";
56
57#[derive(Clone)]
58pub struct Config {
59 pub enable_security_context: bool,
60 pub enable_user_namespaces: bool,
62 pub enable_prometheus_scrape_annotations: bool,
63
64 pub image_pull_policy: KubernetesImagePullPolicy,
65 pub scheduler_name: Option<String>,
66 pub balancerd_node_selector: Vec<KeyValueArg<String, String>>,
67 pub balancerd_affinity: Option<Affinity>,
68 pub balancerd_tolerations: Option<Vec<Toleration>>,
69 pub balancerd_default_resources: Option<ResourceRequirements>,
70
71 pub default_certificate_specs: DefaultCertificateSpecs,
72
73 pub environmentd_sql_port: u16,
74 pub environmentd_http_port: u16,
75 pub balancerd_sql_port: u16,
76 pub balancerd_http_port: u16,
77 pub balancerd_internal_http_port: u16,
78 pub balancerd_readiness_probe_path: String,
82 pub balancerd_termination_grace_period_seconds: i64,
83}
84
85pub struct Context {
86 config: Config,
87}
88
89impl Context {
90 pub fn new(config: Config) -> Self {
91 Self { config }
92 }
93
94 async fn sync_deployment_status(
95 &self,
96 client: &Client,
97 balancer: &Balancer,
98 ) -> Result<(), Error> {
99 let namespace = balancer.namespace();
100 let balancer_api: Api<Balancer> = Api::namespaced(client.clone(), &namespace);
101 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
102
103 let Some(deployment) = get_resource(&deployment_api, &balancer.deployment_name()).await?
104 else {
105 return Ok(());
106 };
107
108 let Some(deployment_conditions) = &deployment
109 .status
110 .as_ref()
111 .and_then(|status| status.conditions.as_ref())
112 else {
113 return Ok(());
116 };
117
118 let ready = deployment_conditions
119 .iter()
120 .any(|condition| condition.type_ == "Available" && condition.status == "True");
121 let ready_str = if ready { "True" } else { "False" };
122
123 let mut status = balancer.status.clone().unwrap();
124 if status
125 .conditions
126 .iter()
127 .any(|condition| condition.type_ == "Ready" && condition.status == ready_str)
128 {
129 return Ok(());
133 }
134
135 status.conditions = vec![Condition {
136 type_: "Ready".to_string(),
137 status: ready_str.to_string(),
138 last_transition_time: Time(Timestamp::now()),
139 message: format!(
140 "balancerd deployment is{} ready",
141 if ready { "" } else { " not" }
142 ),
143 observed_generation: None,
144 reason: "DeploymentStatus".to_string(),
145 }];
146 let mut new_balancer = balancer.clone();
147 new_balancer.status = Some(status);
148
149 balancer_api
150 .replace_status(
151 &balancer.name_unchecked(),
152 &PostParams::default(),
153 &new_balancer,
154 )
155 .await?;
156
157 Ok(())
158 }
159
160 fn create_external_certificate_object(
161 &self,
162 balancer: &Balancer,
163 ) -> Result<Option<Certificate>, anyhow::Error> {
164 create_certificate(
165 self.config
166 .default_certificate_specs
167 .balancerd_external
168 .clone(),
169 balancer,
170 balancer.spec.external_certificate_spec.clone(),
171 balancer.external_certificate_name(),
172 balancer.external_certificate_secret_name(),
173 None,
174 CertificatePrivateKeyAlgorithm::Ecdsa,
175 Some(256),
176 )
177 }
178
179 fn create_deployment_object(&self, balancer: &Balancer) -> anyhow::Result<Deployment> {
180 let security_context = if self.config.enable_security_context {
181 Some(SecurityContext {
185 run_as_non_root: Some(true),
186 capabilities: Some(Capabilities {
187 drop: Some(vec!["ALL".to_string()]),
188 ..Default::default()
189 }),
190 seccomp_profile: Some(SeccompProfile {
191 type_: "RuntimeDefault".to_string(),
192 ..Default::default()
193 }),
194 allow_privilege_escalation: Some(false),
195 ..Default::default()
196 })
197 } else {
198 None
199 };
200
201 let pod_template_annotations = if self.config.enable_prometheus_scrape_annotations {
202 Some(btreemap! {
203 "prometheus.io/scrape".to_owned() => "true".to_string(),
204 "prometheus.io/port".to_owned() => self.config.balancerd_internal_http_port.to_string(),
205 "prometheus.io/path".to_owned() => "/metrics".to_string(),
206 "prometheus.io/scheme".to_owned() => "http".to_string(),
207 })
208 } else {
209 None
210 };
211 let mut pod_template_labels = balancer.default_labels();
212 pod_template_labels.insert(
213 "materialize.cloud/name".to_owned(),
214 balancer.deployment_name(),
215 );
216 pod_template_labels.insert("app".to_owned(), "balancerd".to_string());
217 pod_template_labels.insert("materialize.cloud/app".to_owned(), balancer.app_name());
218
219 let ports = vec![
220 ContainerPort {
221 container_port: self.config.balancerd_sql_port.into(),
222 name: Some("pgwire".into()),
223 protocol: Some("TCP".into()),
224 ..Default::default()
225 },
226 ContainerPort {
227 container_port: self.config.balancerd_http_port.into(),
228 name: Some("http".into()),
229 protocol: Some("TCP".into()),
230 ..Default::default()
231 },
232 ContainerPort {
233 container_port: self.config.balancerd_internal_http_port.into(),
234 name: Some("internal-http".into()),
235 protocol: Some("TCP".into()),
236 ..Default::default()
237 },
238 ];
239
240 let mut args = vec![
241 "service".to_string(),
242 format!(
243 "--pgwire-listen-addr=0.0.0.0:{}",
244 self.config.balancerd_sql_port
245 ),
246 format!(
247 "--https-listen-addr=0.0.0.0:{}",
248 self.config.balancerd_http_port
249 ),
250 format!(
251 "--internal-http-listen-addr=0.0.0.0:{}",
252 self.config.balancerd_internal_http_port
253 ),
254 ];
255 match balancer.routing()? {
256 Routing::Static(static_routing_config) => {
257 args.extend([
258 format!(
259 "--https-resolver-template={}.{}.svc.cluster.local:{}",
260 static_routing_config.environmentd_service_name,
261 static_routing_config.environmentd_namespace,
262 self.config.environmentd_http_port
263 ),
264 format!(
265 "--static-resolver-addr={}.{}.svc.cluster.local:{}",
266 static_routing_config.environmentd_service_name,
267 static_routing_config.environmentd_namespace,
268 self.config.environmentd_sql_port
269 ),
270 ]);
271 }
272 Routing::Frontegg(_frontegg_routing_config) => {
273 bail!("frontegg routing is not yet implemented");
274 }
275 }
276
277 if issuer_ref_defined(
278 &self.config.default_certificate_specs.internal,
279 &balancer.spec.internal_certificate_spec,
280 ) {
281 args.push("--internal-tls".to_owned())
282 }
283
284 let mut volumes = Vec::new();
285 let mut volume_mounts = Vec::new();
286 if let Some(name) = &balancer.spec.configmap_name {
287 volumes.push(Volume {
288 name: "dynamic-config".to_owned(),
289 config_map: Some(ConfigMapVolumeSource {
290 name: name.clone(),
291 optional: Some(false),
293 items: Some(vec![KeyToPath {
294 key: "config.json".to_owned(),
295 path: "config.json".to_owned(),
296 ..Default::default()
297 }]),
298 ..Default::default()
299 }),
300 ..Default::default()
301 });
302 volume_mounts.push(VolumeMount {
303 name: "dynamic-config".to_owned(),
304 mount_path: "/etc/balancerd".to_owned(),
306 read_only: Some(true),
307 ..Default::default()
308 });
309 args.extend([
310 "--config-sync-file-path=/etc/balancerd/config.json".to_owned(),
311 "--config-sync-loop-interval=1s".to_owned(),
312 ]);
313 }
314 if issuer_ref_defined(
315 &self.config.default_certificate_specs.balancerd_external,
316 &balancer.spec.external_certificate_spec,
317 ) {
318 volumes.push(Volume {
319 name: "external-certificate".to_owned(),
320 secret: Some(SecretVolumeSource {
321 default_mode: Some(0o400),
322 secret_name: Some(balancer.external_certificate_secret_name()),
323 items: None,
324 optional: Some(false),
325 }),
326 ..Default::default()
327 });
328 volume_mounts.push(VolumeMount {
329 name: "external-certificate".to_owned(),
330 mount_path: "/etc/external_tls".to_owned(),
331 read_only: Some(true),
332 ..Default::default()
333 });
334 args.extend([
335 "--tls-mode=require".into(),
336 "--tls-cert=/etc/external_tls/tls.crt".into(),
337 "--tls-key=/etc/external_tls/tls.key".into(),
338 ]);
339 } else {
340 args.push("--tls-mode=disable".to_string());
341 }
342
343 let startup_probe = Probe {
344 http_get: Some(HTTPGetAction {
345 port: IntOrString::Int(self.config.balancerd_internal_http_port.into()),
346 path: Some("/api/readyz".into()),
347 ..Default::default()
348 }),
349 failure_threshold: Some(20),
350 initial_delay_seconds: Some(3),
351 period_seconds: Some(3),
352 success_threshold: Some(1),
353 timeout_seconds: Some(1),
354 ..Default::default()
355 };
356 let readiness_probe = Probe {
359 http_get: Some(HTTPGetAction {
360 port: IntOrString::Int(self.config.balancerd_internal_http_port.into()),
361 path: Some(self.config.balancerd_readiness_probe_path.clone()),
362 ..Default::default()
363 }),
364 failure_threshold: Some(2),
365 period_seconds: Some(2),
366 success_threshold: Some(1),
367 timeout_seconds: Some(1),
368 ..Default::default()
369 };
370 let liveness_probe = Probe {
371 http_get: Some(HTTPGetAction {
372 port: IntOrString::Int(self.config.balancerd_internal_http_port.into()),
373 path: Some("/api/livez".into()),
374 ..Default::default()
375 }),
376 failure_threshold: Some(3),
377 initial_delay_seconds: Some(8),
378 period_seconds: Some(10),
379 success_threshold: Some(1),
380 timeout_seconds: Some(1),
381 ..Default::default()
382 };
383
384 let container = Container {
385 name: "balancerd".to_owned(),
386 image: Some(balancer.spec.balancerd_image_ref.clone()),
387 image_pull_policy: Some(self.config.image_pull_policy.to_string()),
388 ports: Some(ports),
389 args: Some(args),
390 startup_probe: Some(startup_probe),
391 readiness_probe: Some(readiness_probe),
392 liveness_probe: Some(liveness_probe),
393 resources: balancer
394 .spec
395 .resource_requirements
396 .clone()
397 .or_else(|| self.config.balancerd_default_resources.clone()),
398 security_context: security_context.clone(),
399 volume_mounts: Some(volume_mounts),
400 ..Default::default()
401 };
402
403 let match_labels = pod_template_labels.clone();
404 pod_template_labels.extend(recommended_k8s_labels(balancer.app_name()));
405
406 let deployment_spec = DeploymentSpec {
407 replicas: balancer.replicas(),
408 selector: LabelSelector {
409 match_labels: Some(match_labels),
410 ..Default::default()
411 },
412 strategy: Some(DeploymentStrategy {
413 rolling_update: Some(RollingUpdateDeployment {
414 max_surge: Some(IntOrString::String("100%".into())),
418 ..Default::default()
419 }),
420 ..Default::default()
421 }),
422 template: PodTemplateSpec {
423 metadata: Some(ObjectMeta {
426 annotations: pod_template_annotations,
427 labels: Some(pod_template_labels),
428 ..Default::default()
429 }),
430 spec: Some(PodSpec {
431 containers: vec![container],
432 node_selector: Some(
433 self.config
434 .balancerd_node_selector
435 .iter()
436 .map(|selector| (selector.key.clone(), selector.value.clone()))
437 .collect(),
438 ),
439 affinity: self.config.balancerd_affinity.clone(),
440 tolerations: self.config.balancerd_tolerations.clone(),
441 security_context: Some(PodSecurityContext {
442 fs_group: Some(999),
443 run_as_user: Some(999),
444 run_as_group: Some(999),
445 ..Default::default()
446 }),
447 scheduler_name: self.config.scheduler_name.clone(),
448 termination_grace_period_seconds: Some(
449 self.config.balancerd_termination_grace_period_seconds,
450 ),
451 volumes: Some(volumes),
452 host_users: self.config.enable_user_namespaces.then_some(false),
455 ..Default::default()
456 }),
457 },
458 ..Default::default()
459 };
460
461 Ok(Deployment {
462 metadata: balancer.managed_resource_meta(balancer.deployment_name()),
463 spec: Some(deployment_spec),
464 status: None,
465 })
466 }
467
468 fn create_service_object(&self, balancer: &Balancer) -> Service {
469 let selector =
470 btreemap! {"materialize.cloud/name".to_string() => balancer.deployment_name()};
471
472 let ports = vec![
473 ServicePort {
474 name: Some("http".to_string()),
475 protocol: Some("TCP".to_string()),
476 port: self.config.balancerd_http_port.into(),
477 target_port: Some(IntOrString::Int(self.config.balancerd_http_port.into())),
478 ..Default::default()
479 },
480 ServicePort {
481 name: Some("pgwire".to_string()),
482 protocol: Some("TCP".to_string()),
483 port: self.config.balancerd_sql_port.into(),
484 target_port: Some(IntOrString::Int(self.config.balancerd_sql_port.into())),
485 ..Default::default()
486 },
487 ];
488
489 let spec = ServiceSpec {
490 type_: Some("ClusterIP".to_string()),
491 cluster_ip: Some("None".to_string()),
492 selector: Some(selector),
493 ports: Some(ports),
494 ..Default::default()
495 };
496
497 Service {
498 metadata: balancer.managed_resource_meta(balancer.service_name()),
499 spec: Some(spec),
500 status: None,
501 }
502 }
503
504 async fn release_replicas(
515 &self,
516 deployment_api: &Api<Deployment>,
517 deployment: &Deployment,
518 initial_replicas: i32,
519 ) -> Result<(), Error> {
520 let name = deployment.name_unchecked();
521 let existing = match get_resource(deployment_api, &name).await? {
522 Some(existing) => existing,
523 None => {
524 let mut seeded = deployment.clone();
528 seeded.spec.as_mut().unwrap().replicas = Some(initial_replicas);
529 apply_resource(deployment_api, &seeded).await?
530 }
531 };
532 if !owns_replicas(&existing) {
533 return Ok(());
534 }
535 let replicas = existing.spec.and_then(|spec| spec.replicas);
536 deployment_api
537 .patch(
538 &name,
539 &PatchParams::apply(REPLICAS_HANDOVER_MANAGER),
540 &Patch::Apply(serde_json::json!({
541 "apiVersion": "apps/v1",
542 "kind": "Deployment",
543 "metadata": { "name": name },
544 "spec": { "replicas": replicas },
545 })),
546 )
547 .await?;
548 Ok(())
549 }
550
551 async fn fix_deployment(
554 &self,
555 deployment_api: &Api<Deployment>,
556 new_deployment: &Deployment,
557 ) -> Result<(), Error> {
558 let Some(mut existing_deployment) =
559 get_resource(deployment_api, &new_deployment.name_unchecked()).await?
560 else {
561 return Ok(());
562 };
563
564 if existing_deployment.spec.as_ref().unwrap().selector
565 == new_deployment.spec.as_ref().unwrap().selector
566 {
567 return Ok(());
568 }
569
570 warn!("found existing deployment with old label selector, fixing");
571
572 existing_deployment
575 .spec
576 .as_mut()
577 .unwrap()
578 .template
579 .metadata
580 .as_mut()
581 .unwrap()
582 .labels = new_deployment
583 .spec
584 .as_ref()
585 .unwrap()
586 .template
587 .metadata
588 .as_ref()
589 .unwrap()
590 .labels
591 .clone();
592
593 replace_resource(deployment_api, &existing_deployment).await?;
597 await_condition(
598 deployment_api.clone(),
599 &existing_deployment.name_unchecked(),
600 |deployment: Option<&Deployment>| {
601 let observed_generation = deployment
602 .and_then(|deployment| deployment.status.as_ref())
603 .and_then(|status| status.observed_generation)
604 .unwrap_or(0);
605 let current_generation = deployment
606 .and_then(|deployment| deployment.meta().generation)
607 .unwrap_or(0);
608 let previous_generation = existing_deployment.meta().generation.unwrap_or(0);
609 observed_generation == current_generation
610 && current_generation > previous_generation
611 },
612 )
613 .await
614 .map_err(|e| anyhow::anyhow!(e))?;
615 await_condition(
616 deployment_api.clone(),
617 &existing_deployment.name_unchecked(),
618 is_deployment_completed(),
619 )
620 .await
621 .map_err(|e| anyhow::anyhow!(e))?;
622
623 match kube::runtime::wait::delete::delete_and_finalize(
626 deployment_api.clone(),
627 &existing_deployment.name_unchecked(),
628 &DeleteParams::orphan(),
629 )
630 .await
631 {
632 Ok(_) => {}
633 Err(kube::runtime::wait::delete::Error::Delete(kube::Error::Api(e)))
634 if e.code == 404 =>
635 {
636 }
638 Err(e) => return Err(anyhow::anyhow!(e).into()),
639 }
640
641 Ok(())
647 }
648}
649
650#[async_trait::async_trait]
651impl k8s_controller::Context for Context {
652 type Resource = Balancer;
653 type Error = Error;
654
655 #[instrument(fields())]
656 async fn apply(
657 &self,
658 client: Client,
659 balancer: &Self::Resource,
660 metadata: &mut TraceMetadata,
661 ) -> Result<Option<Action>, Self::Error> {
662 if balancer.status.is_none() {
663 let step = metadata.step("initialize_status");
664 let balancer_api: Api<Balancer> =
665 Api::namespaced(client.clone(), &balancer.meta().namespace.clone().unwrap());
666 let mut new_balancer = balancer.clone();
667 new_balancer.status = Some(balancer.status());
668 balancer_api
669 .replace_status(
670 &balancer.name_unchecked(),
671 &PostParams::default(),
672 &new_balancer,
673 )
674 .await?;
675 step.finish(Outcome::Completed);
676 return Ok(None);
679 }
680
681 let namespace = balancer.namespace();
682 let certificate_api: Api<Certificate> = Api::namespaced(client.clone(), &namespace);
683 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
684 let service_api: Api<Service> = Api::namespaced(client.clone(), &namespace);
685 let pdb_api: Api<PodDisruptionBudget> = Api::namespaced(client.clone(), &namespace);
686
687 let step = metadata.step("certificate");
688 if let Some(external_certificate) = self.create_external_certificate_object(balancer)? {
689 trace!("creating new balancerd external certificate");
690 apply_resource(&certificate_api, &external_certificate).await?;
691 step.finish(Outcome::Completed);
692 } else {
693 step.finish(Outcome::Skipped);
694 }
695
696 let step = metadata.step("deployment");
697 let deployment = self.create_deployment_object(balancer)?;
698 self.fix_deployment(&deployment_api, &deployment).await?;
699 if balancer.replicas().is_none() {
701 self.release_replicas(&deployment_api, &deployment, balancer.initial_replicas())
702 .await?;
703 }
704 trace!("creating new balancerd deployment");
705 apply_resource(&deployment_api, &deployment).await?;
706 step.finish(Outcome::Completed);
707
708 let step = metadata.step("pod_disruption_budget");
709 let pdb = pod_disruption_budget(
710 balancer.managed_resource_meta(balancer.deployment_name()),
711 &deployment,
712 );
713 apply_resource(&pdb_api, &pdb).await?;
714 step.finish(Outcome::Completed);
715
716 let step = metadata.step("service");
717 let service = self.create_service_object(balancer);
718 trace!("creating new balancerd service");
719 apply_resource(&service_api, &service).await?;
720 step.finish(Outcome::Completed);
721
722 let step = metadata.step("sync_status");
723 self.sync_deployment_status(&client, balancer).await?;
724 step.finish(Outcome::Completed);
725
726 Ok(None)
727 }
728}
729
730const REPLICAS_HANDOVER_MANAGER: &str = "orchestratord.materialize.cloud/replicas-handover";
731
732fn owns_replicas(deployment: &Deployment) -> bool {
733 deployment
734 .metadata
735 .managed_fields
736 .iter()
737 .flatten()
738 .filter(|entry| entry.manager.as_deref() == Some(FIELD_MANAGER))
739 .filter_map(|entry| entry.fields_v1.as_ref())
740 .any(|fields| fields.0.pointer("/f:spec/f:replicas").is_some())
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746 use mz_cloud_resources::crd::balancer::v1alpha1::{BalancerSpec, StaticRoutingConfig};
747
748 fn test_context() -> Context {
749 Context::new(Config {
750 enable_security_context: false,
751 enable_user_namespaces: false,
752 enable_prometheus_scrape_annotations: false,
753 image_pull_policy: KubernetesImagePullPolicy::IfNotPresent,
754 scheduler_name: None,
755 balancerd_node_selector: vec![],
756 balancerd_affinity: None,
757 balancerd_tolerations: None,
758 balancerd_default_resources: None,
759 default_certificate_specs: DefaultCertificateSpecs::default(),
760 environmentd_sql_port: 6875,
761 environmentd_http_port: 6876,
762 balancerd_sql_port: 6875,
763 balancerd_http_port: 6876,
764 balancerd_internal_http_port: 6878,
765 balancerd_readiness_probe_path: "/api/readyz".to_owned(),
766 balancerd_termination_grace_period_seconds: 600,
767 })
768 }
769
770 fn test_balancer(spec: BalancerSpec) -> Balancer {
771 let mut balancer = Balancer::new(
772 "test",
773 BalancerSpec {
774 static_routing: Some(StaticRoutingConfig {
775 environmentd_namespace: "test".to_owned(),
776 environmentd_service_name: "environmentd".to_owned(),
777 }),
778 ..spec
779 },
780 );
781 balancer.metadata.namespace = Some("test".to_owned());
782 balancer.metadata.uid = Some("test".to_owned());
783 balancer.status = Some(balancer.status());
784 balancer
785 }
786
787 #[mz_ore::test]
788 fn externally_scaled_omits_replicas() {
789 let context = test_context();
790 for (externally_scaled, expected) in [(None, Some(3)), (Some(true), None)] {
791 let balancer = test_balancer(BalancerSpec {
792 replicas: Some(3),
793 externally_scaled,
794 ..Default::default()
795 });
796 let deployment = context.create_deployment_object(&balancer).unwrap();
797 assert_eq!(deployment.spec.unwrap().replicas, expected);
798 assert_eq!(balancer.initial_replicas(), 3);
799 }
800 }
801
802 #[mz_ore::test]
803 fn user_namespaces_set_host_users() {
804 let balancer = test_balancer(BalancerSpec::default());
805 for (enabled, expected) in [(false, None), (true, Some(false))] {
806 let mut context = test_context();
807 context.config.enable_user_namespaces = enabled;
808 let pod = context
809 .create_deployment_object(&balancer)
810 .unwrap()
811 .spec
812 .unwrap()
813 .template
814 .spec
815 .unwrap();
816 assert_eq!(pod.host_users, expected);
817 }
818 }
819
820 #[mz_ore::test]
821 fn pdb_selects_deployment_pods() {
822 let balancer = test_balancer(BalancerSpec::default());
823 let deployment = test_context().create_deployment_object(&balancer).unwrap();
824 let pdb = pod_disruption_budget(
825 balancer.managed_resource_meta(balancer.deployment_name()),
826 &deployment,
827 );
828 let spec = pdb.spec.unwrap();
829 assert_eq!(spec.max_unavailable, Some(IntOrString::Int(1)));
830 assert_eq!(
831 spec.unhealthy_pod_eviction_policy.as_deref(),
832 Some("AlwaysAllow")
833 );
834 let match_labels = spec.selector.unwrap().match_labels.unwrap();
835 let pod_labels = deployment
836 .spec
837 .unwrap()
838 .template
839 .metadata
840 .unwrap()
841 .labels
842 .unwrap();
843 assert!(!match_labels.is_empty());
844 assert!(
845 match_labels
846 .iter()
847 .all(|(k, v)| pod_labels.get(k) == Some(v)),
848 "PDB selector {match_labels:?} does not match pod labels {pod_labels:?}",
849 );
850 }
851
852 #[mz_ore::test]
853 fn owns_replicas_reads_managed_fields() {
854 use k8s_openapi::apimachinery::pkg::apis::meta::v1::{FieldsV1, ManagedFieldsEntry};
855 let deployment = |manager: &str| Deployment {
856 metadata: ObjectMeta {
857 managed_fields: Some(vec![ManagedFieldsEntry {
858 manager: Some(manager.to_owned()),
859 fields_v1: Some(FieldsV1(
860 serde_json::json!({ "f:spec": { "f:replicas": {} } }),
861 )),
862 ..Default::default()
863 }]),
864 ..Default::default()
865 },
866 ..Default::default()
867 };
868 assert!(owns_replicas(&deployment(FIELD_MANAGER)));
869 assert!(!owns_replicas(&deployment(REPLICAS_HANDOVER_MANAGER)));
870 assert!(!owns_replicas(&Deployment::default()));
871 }
872
873 #[mz_ore::test]
874 fn configmap_reference_controls_file_sync() {
875 let context = test_context();
876 for configmap_name in [None, Some("balancerd-settings".to_owned())] {
877 let balancer = test_balancer(BalancerSpec {
878 configmap_name: configmap_name.clone(),
879 ..Default::default()
880 });
881 let pod = context
882 .create_deployment_object(&balancer)
883 .unwrap()
884 .spec
885 .unwrap()
886 .template
887 .spec
888 .unwrap();
889 let container = &pod.containers[0];
890 let args = container.args.as_ref().unwrap();
891 let volumes = pod.volumes.as_ref().unwrap();
892 let mounts = container.volume_mounts.as_ref().unwrap();
893 if let Some(name) = configmap_name {
894 let source = volumes[0].config_map.as_ref().unwrap();
895 assert_eq!(source.name, name);
896 assert_eq!(source.optional, Some(false));
897 assert_eq!(source.items.as_ref().unwrap()[0].key, "config.json");
898 assert_eq!(mounts[0].read_only, Some(true));
899 assert!(mounts[0].sub_path.is_none());
900 assert!(args.contains(&format!(
901 "--config-sync-file-path={}/config.json",
902 mounts[0].mount_path
903 )));
904 assert!(args.contains(&"--config-sync-loop-interval=1s".to_owned()));
905 } else {
906 assert!(volumes.is_empty());
907 assert!(mounts.is_empty());
908 assert!(!args.iter().any(|arg| arg.starts_with("--config-sync-")));
909 }
910 }
911 }
912}