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::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            // if the deployment doesn't have any conditions set yet, there
98            // is nothing to sync
99            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            // if the deployment status is already set correctly, we don't
114            // need to set it again (this prevents us from getting stuck in
115            // a reconcile loop)
116            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            // Since we want to adhere to the most restrictive security context, all
166            // of these fields have to be set how they are.
167            // See https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
168            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                    // Allow a complete set of new pods at once, to minimize the
369                    // chances of a new connection going to a pod that will be
370                    // immediately drained
371                    max_surge: Some(IntOrString::String("100%".into())),
372                    ..Default::default()
373                }),
374                ..Default::default()
375            }),
376            template: PodTemplateSpec {
377                // not using managed_resource_meta because the pod should be
378                // owned by the deployment, not the materialize instance
379                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    // TODO: remove this once everyone is upgraded to an orchestratord
453    // version with the separate balancer operator
454    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        // this is sufficient because the new labels are a superset of the
474        // old labels, so the existing label selector should still be valid
475        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        // using await_condition is not ideal in a controller loop, but this
495        // is very temporary and will only ever happen once, so this feels
496        // simpler than trying to introduce an entire state machine here
497        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        // delete the deployment but leave the pods around (via
525        // DeleteParams::orphan)
526        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                // the resource already doesn't exist
538            }
539            Err(e) => return Err(anyhow::anyhow!(e).into()),
540        }
541
542        // now, the normal apply of the new deployment (in the main loop)
543        // will take over the existing pods from the old deployment we just
544        // deleted, since we already updated the pod labels to be the same as
545        // the new label selector
546
547        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            // Updating the status should trigger a reconciliation
576            // which will include a status this time.
577            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}