Skip to main content

mz_orchestratord/controller/
balancer.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use 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
53/// The name identifying this controller in its reconciliation metrics and in
54/// the reporter of the events it publishes.
55pub const CONTROLLER_NAME: &str = "balancer";
56
57#[derive(Clone)]
58pub struct Config {
59    pub enable_security_context: bool,
60    /// Whether to run pods in a Kubernetes user namespace (`hostUsers: false`).
61    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    /// `/api/readyz` takes a balancerd at its connection high watermark out of
79    /// the Service endpoints. `/api/livez` keeps it in, for load balancers that
80    /// read `/api/readyz` in their own health check instead.
81    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            // if the deployment doesn't have any conditions set yet, there
114            // is nothing to sync
115            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            // if the deployment status is already set correctly, we don't
130            // need to set it again (this prevents us from getting stuck in
131            // a reconcile loop)
132            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            // Since we want to adhere to the most restrictive security context, all
182            // of these fields have to be set how they are.
183            // See https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
184            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                    // The file must exist at startup or balancerd skips the sync loop.
292                    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 the directory: subPath mounts do not receive ConfigMap updates.
305                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        // Tighter than liveness so that, on `/api/readyz`, a balancerd at its
357        // connection high watermark leaves the Service endpoints within seconds.
358        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                    // Allow a complete set of new pods at once, to minimize the
415                    // chances of a new connection going to a pod that will be
416                    // immediately drained
417                    max_surge: Some(IntOrString::String("100%".into())),
418                    ..Default::default()
419                }),
420                ..Default::default()
421            }),
422            template: PodTemplateSpec {
423                // not using managed_resource_meta because the pod should be
424                // owned by the deployment, not the materialize instance
425                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                    // `None` rather than `Some(true)` when disabled, so the
453                    // pod spec is unchanged from before user namespace support.
454                    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    /// Hands `spec.replicas` to [`REPLICAS_HANDOVER_MANAGER`] at its live value
505    /// while [`FIELD_MANAGER`] still owns it, creating the Deployment with
506    /// `initial_replicas` first if it does not exist yet.
507    ///
508    /// NOTE: Dropping a field from an apply when no other manager owns it
509    /// deletes it, which defaults the Deployment to 1 replica. An autoscaler
510    /// cannot become a co-owner first, because each of its writes triggers a
511    /// reconcile whose forced apply reclaims the field. The handover apply is
512    /// not forced, so a concurrent scale by the autoscaler conflicts and the
513    /// reconcile retries.
514    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                // The handover apply carries no selector or template, so it
525                // cannot create the Deployment. Create it with the initial
526                // count under FIELD_MANAGER, then hand that count over below.
527                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    // TODO: remove this once everyone is upgraded to an orchestratord
552    // version with the separate balancer operator
553    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        // this is sufficient because the new labels are a superset of the
573        // old labels, so the existing label selector should still be valid
574        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        // using await_condition is not ideal in a controller loop, but this
594        // is very temporary and will only ever happen once, so this feels
595        // simpler than trying to introduce an entire state machine here
596        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        // delete the deployment but leave the pods around (via
624        // DeleteParams::orphan)
625        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                // the resource already doesn't exist
637            }
638            Err(e) => return Err(anyhow::anyhow!(e).into()),
639        }
640
641        // now, the normal apply of the new deployment (in the main loop)
642        // will take over the existing pods from the old deployment we just
643        // deleted, since we already updated the pod labels to be the same as
644        // the new label selector
645
646        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            // Updating the status should trigger a reconciliation
677            // which will include a status this time.
678            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        // Must precede the apply that omits `replicas`; see `release_replicas`.
700        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}