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