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