Skip to main content

mz_orchestratord/controller/
console.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 k8s_controller::TraceMetadata;
11use k8s_openapi::{
12    api::{
13        apps::v1::{Deployment, DeploymentSpec},
14        core::v1::{
15            Affinity, Capabilities, ConfigMap, ConfigMapVolumeSource, Container, ContainerPort,
16            EnvVar, HTTPGetAction, KeyToPath, PodSecurityContext, PodSpec, PodTemplateSpec, Probe,
17            ResourceRequirements, SeccompProfile, SecretVolumeSource, SecurityContext, Service,
18            ServicePort, ServiceSpec, Toleration, Volume, VolumeMount,
19        },
20        networking::v1::{
21            IPBlock, NetworkPolicy, NetworkPolicyIngressRule, NetworkPolicyPeer, NetworkPolicyPort,
22            NetworkPolicySpec,
23        },
24    },
25    apimachinery::pkg::{
26        apis::meta::v1::{Condition, LabelSelector, Time},
27        util::intstr::IntOrString,
28    },
29    jiff::Timestamp,
30};
31use kube::{
32    Api, Client, Resource, ResourceExt,
33    api::{DeleteParams, ObjectMeta, PostParams},
34    runtime::{conditions::is_deployment_completed, controller::Action, wait::await_condition},
35};
36use maplit::btreemap;
37use serde::Serialize;
38use tracing::{trace, warn};
39
40use crate::{
41    Error,
42    k8s::{apply_resource, get_resource, recommended_k8s_labels, replace_resource},
43    tls::{DefaultCertificateSpecs, create_certificate, issuer_ref_defined},
44};
45use mz_cloud_resources::crd::{
46    ManagedResource,
47    console::v1alpha1::{Console, HttpConnectionScheme},
48    generated::cert_manager::certificates::{Certificate, CertificatePrivateKeyAlgorithm},
49};
50use mz_orchestrator_kubernetes::KubernetesImagePullPolicy;
51use mz_ore::{cli::KeyValueArg, instrument};
52use mz_server_core::listeners::AuthenticatorKind;
53
54#[derive(Clone)]
55pub struct Config {
56    pub enable_security_context: bool,
57    pub enable_prometheus_scrape_annotations: bool,
58
59    pub image_pull_policy: KubernetesImagePullPolicy,
60    pub scheduler_name: Option<String>,
61    pub console_node_selector: Vec<KeyValueArg<String, String>>,
62    pub console_affinity: Option<Affinity>,
63    pub console_tolerations: Option<Vec<Toleration>>,
64    pub console_default_resources: Option<ResourceRequirements>,
65    pub network_policies_ingress_enabled: bool,
66    pub network_policies_ingress_cidrs: Vec<String>,
67
68    pub default_certificate_specs: DefaultCertificateSpecs,
69
70    pub console_http_port: u16,
71    pub balancerd_http_port: u16,
72}
73
74#[derive(Serialize)]
75struct AppConfig {
76    version: String,
77    auth: AppConfigAuth,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    balancerd_dns_names: Option<Vec<String>>,
80}
81
82#[derive(Serialize)]
83struct AppConfigAuth {
84    mode: AuthenticatorKind,
85}
86
87pub struct Context {
88    config: Config,
89}
90
91impl Context {
92    pub fn new(config: Config) -> Self {
93        Self { config }
94    }
95
96    async fn sync_deployment_status(
97        &self,
98        client: &Client,
99        console: &Console,
100    ) -> Result<(), Error> {
101        let namespace = console.namespace();
102        let console_api: Api<Console> = Api::namespaced(client.clone(), &namespace);
103        let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
104
105        let Some(deployment) = get_resource(&deployment_api, &console.deployment_name()).await?
106        else {
107            return Ok(());
108        };
109
110        let Some(deployment_conditions) = &deployment
111            .status
112            .as_ref()
113            .and_then(|status| status.conditions.as_ref())
114        else {
115            // if the deployment doesn't have any conditions set yet, there
116            // is nothing to sync
117            return Ok(());
118        };
119
120        let ready = deployment_conditions
121            .iter()
122            .any(|condition| condition.type_ == "Available" && condition.status == "True");
123        let ready_str = if ready { "True" } else { "False" };
124
125        let mut status = console.status.clone().unwrap();
126        if status
127            .conditions
128            .iter()
129            .any(|condition| condition.type_ == "Ready" && condition.status == ready_str)
130        {
131            // if the deployment status is already set correctly, we don't
132            // need to set it again (this prevents us from getting stuck in
133            // a reconcile loop)
134            return Ok(());
135        }
136
137        status.conditions = vec![Condition {
138            type_: "Ready".to_string(),
139            status: ready_str.to_string(),
140            last_transition_time: Time(Timestamp::now()),
141            message: format!(
142                "console deployment is{} ready",
143                if ready { "" } else { " not" }
144            ),
145            observed_generation: None,
146            reason: "DeploymentStatus".to_string(),
147        }];
148        let mut new_console = console.clone();
149        new_console.status = Some(status);
150
151        console_api
152            .replace_status(
153                &console.name_unchecked(),
154                &PostParams::default(),
155                &new_console,
156            )
157            .await?;
158
159        Ok(())
160    }
161
162    fn create_network_policies(&self, console: &Console) -> Vec<NetworkPolicy> {
163        let mut network_policies = Vec::new();
164        if self.config.network_policies_ingress_enabled {
165            let console_label_selector = LabelSelector {
166                match_labels: Some(
167                    console
168                        .default_labels()
169                        .into_iter()
170                        .chain([("materialize.cloud/app".to_owned(), console.app_name())])
171                        .collect(),
172                ),
173                ..Default::default()
174            };
175            network_policies.extend([NetworkPolicy {
176                metadata: console.managed_resource_meta(console.name_prefixed("console-ingress")),
177                spec: Some(NetworkPolicySpec {
178                    ingress: Some(vec![NetworkPolicyIngressRule {
179                        from: Some(
180                            self.config
181                                .network_policies_ingress_cidrs
182                                .iter()
183                                .map(|cidr| NetworkPolicyPeer {
184                                    ip_block: Some(IPBlock {
185                                        cidr: cidr.to_owned(),
186                                        except: None,
187                                    }),
188                                    ..Default::default()
189                                })
190                                .collect(),
191                        ),
192                        ports: Some(vec![NetworkPolicyPort {
193                            port: Some(IntOrString::Int(self.config.console_http_port.into())),
194                            protocol: Some("TCP".to_string()),
195                            ..Default::default()
196                        }]),
197                        ..Default::default()
198                    }]),
199                    pod_selector: Some(console_label_selector),
200                    policy_types: Some(vec!["Ingress".to_owned()]),
201                    ..Default::default()
202                }),
203            }]);
204        }
205        network_policies
206    }
207
208    fn create_console_external_certificate(
209        &self,
210        console: &Console,
211    ) -> anyhow::Result<Option<Certificate>> {
212        create_certificate(
213            self.config
214                .default_certificate_specs
215                .console_external
216                .clone(),
217            console,
218            console.spec.external_certificate_spec.clone(),
219            console.external_certificate_name(),
220            console.external_certificate_secret_name(),
221            None,
222            CertificatePrivateKeyAlgorithm::Ecdsa,
223            Some(256),
224        )
225    }
226
227    fn create_console_app_configmap_object(&self, console: &Console) -> ConfigMap {
228        let balancerd_dns_names = console.spec.balancerd.dns_names.clone();
229        let version: String = console
230            .spec
231            .console_image_ref
232            .rsplitn(2, ':')
233            .next()
234            .expect("at least one chunk, even if empty")
235            .to_owned();
236        let app_config_json = serde_json::to_string(&AppConfig {
237            version,
238            balancerd_dns_names,
239            auth: AppConfigAuth {
240                mode: console.spec.authenticator_kind,
241            },
242        })
243        .expect("known valid");
244        ConfigMap {
245            binary_data: None,
246            data: Some(btreemap! {
247                "app-config.json".to_owned() => app_config_json,
248            }),
249            immutable: None,
250            metadata: console.managed_resource_meta(console.configmap_name()),
251        }
252    }
253
254    fn create_console_deployment_object(&self, console: &Console) -> Deployment {
255        let mut pod_template_labels = console.default_labels();
256        pod_template_labels.insert(
257            "materialize.cloud/name".to_owned(),
258            console.deployment_name(),
259        );
260        pod_template_labels.insert("app".to_owned(), "console".to_string());
261        pod_template_labels.insert("materialize.cloud/app".to_owned(), console.app_name());
262
263        let ports = vec![ContainerPort {
264            container_port: self.config.console_http_port.into(),
265            name: Some("http".into()),
266            protocol: Some("TCP".into()),
267            ..Default::default()
268        }];
269
270        let scheme = match console.spec.balancerd.scheme {
271            HttpConnectionScheme::Http => "http",
272            HttpConnectionScheme::Https => "https",
273        };
274        let mut env = vec![EnvVar {
275            name: "MZ_ENDPOINT".to_string(),
276            value: Some(format!(
277                "{}://{}.{}.svc.cluster.local:{}",
278                scheme,
279                console.spec.balancerd.service_name,
280                console.spec.balancerd.namespace,
281                self.config.balancerd_http_port,
282            )),
283            ..Default::default()
284        }];
285        let mut volumes = vec![Volume {
286            name: "app-config".to_string(),
287            config_map: Some(ConfigMapVolumeSource {
288                name: console.configmap_name(),
289                default_mode: Some(256),
290                optional: Some(false),
291                items: Some(vec![KeyToPath {
292                    key: "app-config.json".to_string(),
293                    path: "app-config.json".to_string(),
294                    ..Default::default()
295                }]),
296            }),
297            ..Default::default()
298        }];
299        let mut volume_mounts = vec![VolumeMount {
300            name: "app-config".to_string(),
301            mount_path: "/usr/share/nginx/html/app-config".to_string(),
302            ..Default::default()
303        }];
304
305        let scheme = if issuer_ref_defined(
306            &self.config.default_certificate_specs.console_external,
307            &console.spec.external_certificate_spec,
308        ) {
309            volumes.push(Volume {
310                name: "external-certificate".to_owned(),
311                secret: Some(SecretVolumeSource {
312                    default_mode: Some(0o400),
313                    secret_name: Some(console.external_certificate_secret_name()),
314                    items: None,
315                    optional: Some(false),
316                }),
317                ..Default::default()
318            });
319            volume_mounts.push(VolumeMount {
320                name: "external-certificate".to_owned(),
321                mount_path: "/nginx/tls".to_owned(),
322                read_only: Some(true),
323                ..Default::default()
324            });
325            env.push(EnvVar {
326                name: "MZ_NGINX_LISTENER_CONFIG".to_string(),
327                value: Some(format!(
328                    "listen {} ssl;
329ssl_certificate /nginx/tls/tls.crt;
330ssl_certificate_key /nginx/tls/tls.key;",
331                    self.config.console_http_port
332                )),
333                ..Default::default()
334            });
335            Some("HTTPS".to_owned())
336        } else {
337            env.push(EnvVar {
338                name: "MZ_NGINX_LISTENER_CONFIG".to_string(),
339                value: Some(format!("listen {};", self.config.console_http_port)),
340                ..Default::default()
341            });
342            Some("HTTP".to_owned())
343        };
344
345        let probe = Probe {
346            http_get: Some(HTTPGetAction {
347                path: Some("/".to_string()),
348                port: IntOrString::Int(self.config.console_http_port.into()),
349                scheme,
350                ..Default::default()
351            }),
352            ..Default::default()
353        };
354
355        let security_context = if self.config.enable_security_context {
356            // Since we want to adhere to the most restrictive security context, all
357            // of these fields have to be set how they are.
358            // See https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
359            Some(SecurityContext {
360                run_as_non_root: Some(true),
361                capabilities: Some(Capabilities {
362                    drop: Some(vec!["ALL".to_string()]),
363                    ..Default::default()
364                }),
365                seccomp_profile: Some(SeccompProfile {
366                    type_: "RuntimeDefault".to_string(),
367                    ..Default::default()
368                }),
369                allow_privilege_escalation: Some(false),
370                ..Default::default()
371            })
372        } else {
373            None
374        };
375
376        let container = Container {
377            name: "console".to_owned(),
378            image: Some(console.spec.console_image_ref.clone()),
379            image_pull_policy: Some(self.config.image_pull_policy.to_string()),
380            ports: Some(ports),
381            env: Some(env),
382            startup_probe: Some(Probe {
383                period_seconds: Some(1),
384                failure_threshold: Some(10),
385                ..probe.clone()
386            }),
387            readiness_probe: Some(Probe {
388                period_seconds: Some(30),
389                failure_threshold: Some(1),
390                ..probe.clone()
391            }),
392            liveness_probe: Some(Probe {
393                period_seconds: Some(30),
394                ..probe.clone()
395            }),
396            resources: console
397                .spec
398                .resource_requirements
399                .clone()
400                .or_else(|| self.config.console_default_resources.clone()),
401            security_context,
402            volume_mounts: Some(volume_mounts),
403            ..Default::default()
404        };
405
406        let match_labels = pod_template_labels.clone();
407        pod_template_labels.extend(recommended_k8s_labels("console".into()));
408
409        let deployment_spec = DeploymentSpec {
410            replicas: Some(console.replicas()),
411            selector: LabelSelector {
412                match_labels: Some(match_labels),
413                ..Default::default()
414            },
415            template: PodTemplateSpec {
416                // not using managed_resource_meta because the pod should be
417                // owned by the deployment, not the materialize instance
418                metadata: Some(ObjectMeta {
419                    labels: Some(pod_template_labels),
420                    ..Default::default()
421                }),
422                spec: Some(PodSpec {
423                    containers: vec![container],
424                    node_selector: Some(
425                        self.config
426                            .console_node_selector
427                            .iter()
428                            .map(|selector| (selector.key.clone(), selector.value.clone()))
429                            .collect(),
430                    ),
431                    affinity: self.config.console_affinity.clone(),
432                    tolerations: self.config.console_tolerations.clone(),
433                    scheduler_name: self.config.scheduler_name.clone(),
434                    volumes: Some(volumes),
435                    security_context: Some(PodSecurityContext {
436                        fs_group: Some(101),
437                        ..Default::default()
438                    }),
439                    ..Default::default()
440                }),
441            },
442            ..Default::default()
443        };
444
445        Deployment {
446            metadata: ObjectMeta {
447                ..console.managed_resource_meta(console.deployment_name())
448            },
449            spec: Some(deployment_spec),
450            status: None,
451        }
452    }
453
454    fn create_console_service_object(&self, console: &Console) -> Service {
455        let selector =
456            btreemap! {"materialize.cloud/name".to_string() => console.deployment_name()};
457
458        let ports = vec![ServicePort {
459            name: Some("http".to_string()),
460            protocol: Some("TCP".to_string()),
461            port: self.config.console_http_port.into(),
462            target_port: Some(IntOrString::Int(self.config.console_http_port.into())),
463            ..Default::default()
464        }];
465
466        let spec = ServiceSpec {
467            type_: Some("ClusterIP".to_string()),
468            cluster_ip: Some("None".to_string()),
469            selector: Some(selector),
470            ports: Some(ports),
471            ..Default::default()
472        };
473
474        Service {
475            metadata: console.managed_resource_meta(console.service_name()),
476            spec: Some(spec),
477            status: None,
478        }
479    }
480
481    // TODO: remove this once everyone is upgraded to an orchestratord
482    // version with the separate console operator
483    async fn fix_deployment(
484        &self,
485        deployment_api: &Api<Deployment>,
486        new_deployment: &Deployment,
487    ) -> Result<(), Error> {
488        let Some(mut existing_deployment) =
489            get_resource(deployment_api, &new_deployment.name_unchecked()).await?
490        else {
491            return Ok(());
492        };
493
494        if existing_deployment.spec.as_ref().unwrap().selector
495            == new_deployment.spec.as_ref().unwrap().selector
496        {
497            return Ok(());
498        }
499
500        warn!("found existing deployment with old label selector, fixing");
501
502        // this is sufficient because the new labels are a superset of the
503        // old labels, so the existing label selector should still be valid
504        existing_deployment
505            .spec
506            .as_mut()
507            .unwrap()
508            .template
509            .metadata
510            .as_mut()
511            .unwrap()
512            .labels = new_deployment
513            .spec
514            .as_ref()
515            .unwrap()
516            .template
517            .metadata
518            .as_ref()
519            .unwrap()
520            .labels
521            .clone();
522
523        // using await_condition is not ideal in a controller loop, but this
524        // is very temporary and will only ever happen once, so this feels
525        // simpler than trying to introduce an entire state machine here
526        replace_resource(deployment_api, &existing_deployment).await?;
527        await_condition(
528            deployment_api.clone(),
529            &existing_deployment.name_unchecked(),
530            |deployment: Option<&Deployment>| {
531                let observed_generation = deployment
532                    .and_then(|deployment| deployment.status.as_ref())
533                    .and_then(|status| status.observed_generation)
534                    .unwrap_or(0);
535                let current_generation = deployment
536                    .and_then(|deployment| deployment.meta().generation)
537                    .unwrap_or(0);
538                let previous_generation = existing_deployment.meta().generation.unwrap_or(0);
539                observed_generation == current_generation
540                    && current_generation > previous_generation
541            },
542        )
543        .await
544        .map_err(|e| anyhow::anyhow!(e))?;
545        await_condition(
546            deployment_api.clone(),
547            &existing_deployment.name_unchecked(),
548            is_deployment_completed(),
549        )
550        .await
551        .map_err(|e| anyhow::anyhow!(e))?;
552
553        // delete the deployment but leave the pods around (via
554        // DeleteParams::orphan)
555        match kube::runtime::wait::delete::delete_and_finalize(
556            deployment_api.clone(),
557            &existing_deployment.name_unchecked(),
558            &DeleteParams::orphan(),
559        )
560        .await
561        {
562            Ok(_) => {}
563            Err(kube::runtime::wait::delete::Error::Delete(kube::Error::Api(e)))
564                if e.code == 404 =>
565            {
566                // the resource already doesn't exist
567            }
568            Err(e) => return Err(anyhow::anyhow!(e).into()),
569        }
570
571        // now, the normal apply of the new deployment (in the main loop)
572        // will take over the existing pods from the old deployment we just
573        // deleted, since we already updated the pod labels to be the same as
574        // the new label selector
575
576        Ok(())
577    }
578}
579
580#[async_trait::async_trait]
581impl k8s_controller::Context for Context {
582    type Resource = Console;
583    type Error = Error;
584
585    #[instrument(fields())]
586    async fn apply(
587        &self,
588        client: Client,
589        console: &Self::Resource,
590        _metadata: &mut TraceMetadata,
591    ) -> Result<Option<Action>, Self::Error> {
592        if console.status.is_none() {
593            let console_api: Api<Console> =
594                Api::namespaced(client.clone(), &console.meta().namespace.clone().unwrap());
595            let mut new_console = console.clone();
596            new_console.status = Some(console.status());
597            console_api
598                .replace_status(
599                    &console.name_unchecked(),
600                    &PostParams::default(),
601                    &new_console,
602                )
603                .await?;
604            // Updating the status should trigger a reconciliation
605            // which will include a status this time.
606            return Ok(None);
607        }
608
609        let namespace = console.namespace();
610        let network_policy_api: Api<NetworkPolicy> = Api::namespaced(client.clone(), &namespace);
611        let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), &namespace);
612        let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
613        let service_api: Api<Service> = Api::namespaced(client.clone(), &namespace);
614        let certificate_api: Api<Certificate> = Api::namespaced(client.clone(), &namespace);
615
616        trace!("creating new network policies");
617        let network_policies = self.create_network_policies(console);
618        for network_policy in &network_policies {
619            apply_resource(&network_policy_api, network_policy).await?;
620        }
621
622        trace!("creating new console configmap");
623        let console_configmap = self.create_console_app_configmap_object(console);
624        apply_resource(&configmap_api, &console_configmap).await?;
625
626        trace!("creating new console deployment");
627        let console_deployment = self.create_console_deployment_object(console);
628        self.fix_deployment(&deployment_api, &console_deployment)
629            .await?;
630        apply_resource(&deployment_api, &console_deployment).await?;
631
632        trace!("creating new console service");
633        let console_service = self.create_console_service_object(console);
634        apply_resource(&service_api, &console_service).await?;
635
636        let console_external_certificate = self.create_console_external_certificate(console)?;
637        if let Some(certificate) = &console_external_certificate {
638            trace!("creating new console external certificate");
639            apply_resource(&certificate_api, certificate).await?;
640        }
641
642        self.sync_deployment_status(&client, console).await?;
643
644        Ok(None)
645    }
646}