Skip to main content

mz_orchestratord/controller/materialize/
generation.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 std::{
11    collections::BTreeMap,
12    net::{IpAddr, Ipv4Addr, SocketAddr},
13    sync::LazyLock,
14    time::Duration,
15};
16
17use k8s_openapi::{
18    api::{
19        apps::v1::{StatefulSet, StatefulSetSpec},
20        core::v1::{
21            Capabilities, ConfigMap, ConfigMapVolumeSource, Container, ContainerPort, EnvVar,
22            EnvVarSource, KeyToPath, PodSecurityContext, PodSpec, PodTemplateSpec, Probe,
23            SeccompProfile, Secret, SecretKeySelector, SecretVolumeSource, SecurityContext,
24            Service, ServicePort, ServiceSpec, TCPSocketAction, Toleration, Volume, VolumeMount,
25        },
26    },
27    apimachinery::pkg::{apis::meta::v1::LabelSelector, util::intstr::IntOrString},
28};
29use kube::{Api, Client, ResourceExt, api::ObjectMeta, runtime::controller::Action};
30use maplit::btreemap;
31use mz_server_core::listeners::{
32    AllowedRoles, AuthenticatorKind, BaseListenerConfig, RouteGroup, SqlListenerConfig,
33    VersionedListenersConfig, v0_147_0, v26_32_0,
34};
35use reqwest::{Client as HttpClient, StatusCode};
36use semver::{BuildMetadata, Prerelease, Version};
37use serde::{Deserialize, Serialize};
38use sha2::{Digest, Sha256};
39use tracing::{error, trace};
40
41use super::Error;
42use super::matching_image_from_environmentd_image_ref;
43use crate::k8s::{apply_resource, delete_resource, get_resource};
44use crate::tls::issuer_ref_defined;
45use mz_cloud_provider::CloudProvider;
46use mz_cloud_resources::crd::materialize::v1alpha1::Materialize;
47use mz_cloud_resources::crd::{ManagedResource, recommended_k8s_labels};
48use mz_ore::instrument;
49
50static V140_DEV0: LazyLock<Version> = LazyLock::new(|| Version {
51    major: 0,
52    minor: 140,
53    patch: 0,
54    pre: Prerelease::new("dev.0").expect("dev.0 is valid prerelease"),
55    build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
56});
57const V143: Version = Version::new(0, 143, 0);
58const V144: Version = Version::new(0, 144, 0);
59static V147_DEV0: LazyLock<Version> = LazyLock::new(|| Version {
60    major: 0,
61    minor: 147,
62    patch: 0,
63    pre: Prerelease::new("dev.0").expect("dev.0 is valid prerelease"),
64    build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
65});
66const V153: Version = Version::new(0, 153, 0);
67static V154_DEV0: LazyLock<Version> = LazyLock::new(|| Version {
68    major: 0,
69    minor: 154,
70    patch: 0,
71    pre: Prerelease::new("").expect("dev.0 is valid prerelease"),
72    build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
73});
74pub const V161: Version = Version::new(0, 161, 0);
75
76static V26_1_0: LazyLock<Version> = LazyLock::new(|| Version {
77    major: 26,
78    minor: 1,
79    patch: 0,
80    pre: Prerelease::new("dev.0").expect("dev.0 is valid prerelease"),
81    build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
82});
83
84/// Version at which HTTP listeners moved `allowed_roles` to be per route group
85/// (the `v26_32_0::ListenersConfig` schema). Older `environmentd` parses the
86/// legacy `v0_147_0::ListenersConfig` schema, so we serve that to them.
87static PER_ROUTE_GROUP_ROLES_VERSION: LazyLock<Version> = LazyLock::new(|| Version {
88    major: 26,
89    minor: 32,
90    patch: 0,
91    pre: Prerelease::new("dev.0").expect("dev.0 is valid prerelease"),
92    build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
93});
94
95/// Describes the status of a deployment.
96///
97/// This is a simplified representation of `DeploymentState`, suitable for
98/// announcement to the external orchestrator.
99#[derive(
100    Debug,
101    Serialize,
102    Deserialize,
103    Clone,
104    Copy,
105    PartialEq,
106    Eq,
107    PartialOrd,
108    Ord
109)]
110pub enum DeploymentStatus {
111    /// This deployment is not the leader. It is initializing and is not yet
112    /// ready to become the leader.
113    Initializing,
114    /// This deployment is not the leader, but it is ready to become the leader.
115    ReadyToPromote,
116    /// This deployment is in the process of becoming the leader.
117    Promoting,
118    /// This deployment is the leader.
119    IsLeader,
120}
121
122#[derive(
123    Debug,
124    Serialize,
125    Deserialize,
126    Clone,
127    Copy,
128    PartialEq,
129    Eq,
130    PartialOrd,
131    Ord
132)]
133pub struct GetLeaderStatusResponse {
134    status: DeploymentStatus,
135}
136
137#[derive(Deserialize, Serialize)]
138pub struct LoginCredentials {
139    username: String,
140    // TODO Password type?
141    password: String,
142}
143
144#[derive(Debug, Serialize)]
145pub struct ConnectionInfo {
146    pub environmentd_url: String,
147    pub mz_system_secret_name: Option<String>,
148    pub listeners_configmap: ConfigMap,
149}
150
151#[derive(Debug, Serialize)]
152pub struct Resources {
153    pub generation: u64,
154    pub public_service: Box<Service>,
155    pub generation_service: Box<Service>,
156    pub persist_pubsub_service: Box<Service>,
157    pub environmentd_statefulset: Box<StatefulSet>,
158    pub connection_info: Box<ConnectionInfo>,
159}
160
161impl Resources {
162    pub fn new(config: &super::Config, mz: &Materialize, generation: u64) -> Self {
163        let public_service = Box::new(create_public_service_object(config, mz, generation));
164        let generation_service = Box::new(create_generation_service_object(config, mz, generation));
165        let persist_pubsub_service =
166            Box::new(create_persist_pubsub_service(config, mz, generation));
167        let environmentd_statefulset = Box::new(create_environmentd_statefulset_object(
168            config, mz, generation,
169        ));
170        let connection_info = Box::new(create_connection_info(config, mz, generation));
171
172        Self {
173            generation,
174            public_service,
175            generation_service,
176            persist_pubsub_service,
177            environmentd_statefulset,
178            connection_info,
179        }
180    }
181
182    #[instrument]
183    pub async fn apply(
184        &self,
185        client: &Client,
186        force_promote: bool,
187        namespace: &str,
188    ) -> Result<Option<Action>, Error> {
189        let service_api: Api<Service> = Api::namespaced(client.clone(), namespace);
190        let statefulset_api: Api<StatefulSet> = Api::namespaced(client.clone(), namespace);
191        let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), namespace);
192
193        trace!("applying environmentd per-generation service");
194        apply_resource(&service_api, &*self.generation_service).await?;
195
196        trace!("creating persist pubsub service");
197        apply_resource(&service_api, &*self.persist_pubsub_service).await?;
198
199        trace!("applying listeners configmap");
200        apply_resource(&configmap_api, &self.connection_info.listeners_configmap).await?;
201
202        trace!("creating new environmentd statefulset");
203        apply_resource(&statefulset_api, &*self.environmentd_statefulset).await?;
204
205        let retry_action = Action::requeue(Duration::from_secs(rand::random_range(5..10)));
206
207        let statefulset = get_resource(
208            &statefulset_api,
209            &self.environmentd_statefulset.name_unchecked(),
210        )
211        .await?;
212        if statefulset
213            .and_then(|statefulset| statefulset.status)
214            .and_then(|status| status.ready_replicas)
215            .unwrap_or(0)
216            == 0
217        {
218            trace!("environmentd statefulset is not ready yet...");
219            return Ok(Some(retry_action));
220        }
221
222        let Some(http_client) = self.get_http_client(client.clone(), namespace).await else {
223            return Ok(Some(retry_action));
224        };
225        let status_url = reqwest::Url::parse(&format!(
226            "{}/api/leader/status",
227            self.connection_info.environmentd_url,
228        ))
229        .unwrap();
230
231        match http_client.get(status_url.clone()).send().await {
232            Ok(response) => {
233                let response: GetLeaderStatusResponse = match response.error_for_status() {
234                    Ok(response) => response.json().await?,
235                    Err(e) => {
236                        trace!("failed to get status of environmentd, retrying... ({e})");
237                        return Ok(Some(retry_action));
238                    }
239                };
240                if force_promote {
241                    trace!("skipping cluster catchup");
242                    let skip_catchup_url = reqwest::Url::parse(&format!(
243                        "{}/api/leader/skip-catchup",
244                        self.connection_info.environmentd_url,
245                    ))
246                    .unwrap();
247                    let response = http_client.post(skip_catchup_url).send().await?;
248                    if response.status() == StatusCode::BAD_REQUEST {
249                        let err: SkipCatchupError = response.json().await?;
250                        return Err(
251                            anyhow::anyhow!("failed to skip catchup: {}", err.message).into()
252                        );
253                    }
254                } else {
255                    match response.status {
256                        DeploymentStatus::Initializing => {
257                            trace!("environmentd is still initializing, retrying...");
258                            return Ok(Some(retry_action));
259                        }
260                        DeploymentStatus::ReadyToPromote
261                        | DeploymentStatus::Promoting
262                        | DeploymentStatus::IsLeader => trace!("environmentd is ready"),
263                    }
264                }
265            }
266            Err(e) => {
267                trace!("failed to connect to environmentd, retrying... ({e})");
268                return Ok(Some(retry_action));
269            }
270        }
271
272        Ok(None)
273    }
274
275    #[instrument]
276    async fn get_http_client(&self, client: Client, namespace: &str) -> Option<HttpClient> {
277        let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
278        Some(match &self.connection_info.mz_system_secret_name {
279            Some(mz_system_secret_name) => {
280                let http_client = reqwest::Client::builder()
281                    .timeout(std::time::Duration::from_secs(10))
282                    .cookie_store(true)
283                    // TODO add_root_certificate instead
284                    .danger_accept_invalid_certs(true)
285                    .build()
286                    .unwrap();
287
288                let secret = secret_api
289                    .get(mz_system_secret_name)
290                    .await
291                    .map_err(|e| {
292                        error!("Failed to get backend secret: {:?}", e);
293                        e
294                    })
295                    .ok()?;
296                if let Some(data) = secret.data {
297                    if let Some(password) = data.get("external_login_password_mz_system").cloned() {
298                        let password = String::from_utf8_lossy(&password.0).to_string();
299                        let login_url = reqwest::Url::parse(&format!(
300                            "{}/api/login",
301                            self.connection_info.environmentd_url,
302                        ))
303                        .unwrap();
304                        match http_client
305                            .post(login_url)
306                            .body(
307                                serde_json::to_string(&LoginCredentials {
308                                    username: "mz_system".to_owned(),
309                                    password,
310                                })
311                                .expect(
312                                    "Serializing a simple struct with utf8 strings doesn't fail.",
313                                ),
314                            )
315                            .header("Content-Type", "application/json")
316                            .send()
317                            .await
318                        {
319                            Ok(response) => {
320                                if let Err(e) = response.error_for_status() {
321                                    trace!("failed to login to environmentd, retrying... ({e})");
322                                    return None;
323                                }
324                            }
325                            Err(e) => {
326                                trace!("failed to connect to environmentd, retrying... ({e})");
327                                return None;
328                            }
329                        };
330                    }
331                };
332                http_client
333            }
334            None => reqwest::Client::builder()
335                .timeout(std::time::Duration::from_secs(10))
336                .build()
337                .unwrap(),
338        })
339    }
340
341    #[instrument]
342    pub async fn promote_services(
343        &self,
344        client: &Client,
345        namespace: &str,
346    ) -> Result<Option<Action>, Error> {
347        let service_api: Api<Service> = Api::namespaced(client.clone(), namespace);
348        let retry_action = Action::requeue(Duration::from_secs(rand::random_range(5..10)));
349
350        let promote_url = reqwest::Url::parse(&format!(
351            "{}/api/leader/promote",
352            self.connection_info.environmentd_url,
353        ))
354        .unwrap();
355
356        let Some(http_client) = self.get_http_client(client.clone(), namespace).await else {
357            return Ok(Some(retry_action));
358        };
359
360        trace!("promoting new environmentd to leader");
361        let response = http_client.post(promote_url).send().await?;
362        let response: BecomeLeaderResponse = response.error_for_status()?.json().await?;
363        if let BecomeLeaderResult::Failure { message } = response.result {
364            return Err(Error::Anyhow(anyhow::anyhow!(
365                "failed to promote new environmentd: {message}"
366            )));
367        }
368
369        // A successful POST to the promotion endpoint only indicates
370        // that the promotion process was kicked off. It does not
371        // guarantee that the environment will be successfully promoted
372        // (e.g., if the environment crashes immediately after responding
373        // to the request, but before executing the takeover, the
374        // promotion will be lost).
375        //
376        // To guarantee the environment has been promoted successfully,
377        // we must wait to see at least one `IsLeader` status returned
378        // from the environment.
379
380        let status_url = reqwest::Url::parse(&format!(
381            "{}/api/leader/status",
382            self.connection_info.environmentd_url,
383        ))
384        .unwrap();
385        match http_client.get(status_url.clone()).send().await {
386            Ok(response) => {
387                let response: GetLeaderStatusResponse = response.json().await?;
388                if response.status != DeploymentStatus::IsLeader {
389                    trace!(
390                        "environmentd is still promoting (status: {:?}), retrying...",
391                        response.status
392                    );
393                    return Ok(Some(retry_action));
394                } else {
395                    trace!("environmentd is ready");
396                }
397            }
398            Err(e) => {
399                trace!("failed to connect to environmentd, retrying... ({e})");
400                return Ok(Some(retry_action));
401            }
402        }
403
404        trace!("applying environmentd public service");
405        apply_resource(&service_api, &*self.public_service).await?;
406
407        Ok(None)
408    }
409
410    #[instrument]
411    pub async fn teardown_generation(
412        &self,
413        client: &Client,
414        mz: &Materialize,
415        generation: u64,
416    ) -> Result<(), anyhow::Error> {
417        let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), &mz.namespace());
418        let service_api: Api<Service> = Api::namespaced(client.clone(), &mz.namespace());
419        let statefulset_api: Api<StatefulSet> = Api::namespaced(client.clone(), &mz.namespace());
420
421        trace!("deleting environmentd statefulset for generation {generation}");
422        delete_resource(
423            &statefulset_api,
424            &mz.environmentd_statefulset_name(generation),
425        )
426        .await?;
427
428        trace!("deleting persist pubsub service for generation {generation}");
429        delete_resource(&service_api, &mz.persist_pubsub_service_name(generation)).await?;
430
431        trace!("deleting environmentd per-generation service for generation {generation}");
432        delete_resource(
433            &service_api,
434            &mz.environmentd_generation_service_name(generation),
435        )
436        .await?;
437
438        trace!("deleting listeners configmap for generation {generation}");
439        delete_resource(&configmap_api, &mz.listeners_configmap_name(generation)).await?;
440
441        Ok(())
442    }
443
444    // ideally we would just be able to hash the objects directly, but the
445    // generated kubernetes objects don't implement the Hash trait
446    pub fn generate_hash(&self) -> String {
447        let mut hasher = Sha256::new();
448        hasher.update(&serde_json::to_string(self).unwrap());
449        format!("{:x}", hasher.finalize())
450    }
451}
452
453fn create_public_service_object(
454    config: &super::Config,
455    mz: &Materialize,
456    generation: u64,
457) -> Service {
458    create_base_service_object(
459        config,
460        mz,
461        generation,
462        &mz.environmentd_service_name(),
463        true,
464    )
465}
466
467fn create_generation_service_object(
468    config: &super::Config,
469    mz: &Materialize,
470    generation: u64,
471) -> Service {
472    create_base_service_object(
473        config,
474        mz,
475        generation,
476        &mz.environmentd_generation_service_name(generation),
477        false,
478    )
479}
480
481fn create_base_service_object(
482    config: &super::Config,
483    mz: &Materialize,
484    generation: u64,
485    service_name: &str,
486    headless: bool,
487) -> Service {
488    let ports = vec![
489        ServicePort {
490            port: config.environmentd_sql_port.into(),
491            protocol: Some("TCP".to_string()),
492            name: Some("sql".to_string()),
493            ..Default::default()
494        },
495        ServicePort {
496            port: config.environmentd_http_port.into(),
497            protocol: Some("TCP".to_string()),
498            name: Some("https".to_string()),
499            ..Default::default()
500        },
501        ServicePort {
502            port: config.environmentd_internal_sql_port.into(),
503            protocol: Some("TCP".to_string()),
504            name: Some("internal-sql".to_string()),
505            ..Default::default()
506        },
507        ServicePort {
508            port: config.environmentd_internal_http_port.into(),
509            protocol: Some("TCP".to_string()),
510            name: Some("internal-http".to_string()),
511            ..Default::default()
512        },
513    ];
514
515    let selector = btreemap! {"materialize.cloud/name".to_string() => mz.environmentd_statefulset_name(generation)};
516
517    let spec = ServiceSpec {
518        type_: Some("ClusterIP".to_string()),
519        cluster_ip: if headless {
520            Some("None".to_string())
521        } else {
522            None
523        },
524        selector: Some(selector),
525        ports: Some(ports),
526        ..Default::default()
527    };
528
529    Service {
530        metadata: mz.managed_resource_meta(service_name.to_string()),
531        spec: Some(spec),
532        status: None,
533    }
534}
535
536fn create_persist_pubsub_service(
537    config: &super::Config,
538    mz: &Materialize,
539    generation: u64,
540) -> Service {
541    Service {
542        metadata: mz.managed_resource_meta(mz.persist_pubsub_service_name(generation)),
543        spec: Some(ServiceSpec {
544            type_: Some("ClusterIP".to_string()),
545            cluster_ip: Some("None".to_string()),
546            selector: Some(btreemap! {
547                "materialize.cloud/name".to_string() => mz.environmentd_statefulset_name(generation),
548            }),
549            ports: Some(vec![ServicePort {
550                name: Some("grpc".to_string()),
551                protocol: Some("TCP".to_string()),
552                port: config.environmentd_internal_persist_pubsub_port.into(),
553                ..Default::default()
554            }]),
555            ..Default::default()
556        }),
557        status: None,
558    }
559}
560
561fn create_environmentd_statefulset_object(
562    config: &super::Config,
563    mz: &Materialize,
564    generation: u64,
565) -> StatefulSet {
566    // IMPORTANT: Only pass secrets via environment variables. All other
567    // parameters should be passed as command line arguments, possibly gated
568    // with a `meets_minimum_version` call. This ensures typos cause
569    // `environmentd` to fail to start, rather than silently ignoring the
570    // configuration.
571    //
572    // When passing a secret, use a `SecretKeySelector` to forward a secret into
573    // the pod. Do *not* hardcode a secret as the value directly, as doing so
574    // will leak the secret to anyone with permission to describe the pod.
575    let mut env = vec![
576        EnvVar {
577            name: "MZ_METADATA_BACKEND_URL".to_string(),
578            value_from: Some(EnvVarSource {
579                secret_key_ref: Some(SecretKeySelector {
580                    name: mz.backend_secret_name(),
581                    key: "metadata_backend_url".to_string(),
582                    optional: Some(false),
583                }),
584                ..Default::default()
585            }),
586            ..Default::default()
587        },
588        EnvVar {
589            name: "MZ_PERSIST_BLOB_URL".to_string(),
590            value_from: Some(EnvVarSource {
591                secret_key_ref: Some(SecretKeySelector {
592                    name: mz.backend_secret_name(),
593                    key: "persist_backend_url".to_string(),
594                    optional: Some(false),
595                }),
596                ..Default::default()
597            }),
598            ..Default::default()
599        },
600    ];
601
602    env.push(EnvVar {
603        name: "AWS_REGION".to_string(),
604        value: Some(config.region.clone()),
605        ..Default::default()
606    });
607
608    env.extend(mz.spec.environmentd_extra_env.iter().flatten().cloned());
609
610    let mut args = vec![];
611
612    if let Some(helm_chart_version) = &config.helm_chart_version {
613        args.push(format!("--helm-chart-version={helm_chart_version}"));
614    }
615
616    // Add environment ID argument.
617    args.push(format!(
618        "--environment-id={}",
619        mz.environment_id(&config.cloud_provider.to_string(), &config.region)
620    ));
621
622    // Add clusterd image argument based on environmentd tag.
623    args.push(format!(
624        "--clusterd-image={}",
625        matching_image_from_environmentd_image_ref(
626            &mz.spec.environmentd_image_ref,
627            "clusterd",
628            None
629        )
630    ));
631
632    // Add cluster and storage host size arguments.
633    args.extend(
634        [
635            config
636                .environmentd_cluster_replica_sizes
637                .as_ref()
638                .map(|sizes| format!("--cluster-replica-sizes={sizes}")),
639            config
640                .bootstrap_default_cluster_replica_size
641                .as_ref()
642                .map(|size| format!("--bootstrap-default-cluster-replica-size={size}")),
643            config
644                .bootstrap_builtin_system_cluster_replica_size
645                .as_ref()
646                .map(|size| format!("--bootstrap-builtin-system-cluster-replica-size={size}")),
647            config
648                .bootstrap_builtin_probe_cluster_replica_size
649                .as_ref()
650                .map(|size| format!("--bootstrap-builtin-probe-cluster-replica-size={size}")),
651            config
652                .bootstrap_builtin_support_cluster_replica_size
653                .as_ref()
654                .map(|size| format!("--bootstrap-builtin-support-cluster-replica-size={size}")),
655            config
656                .bootstrap_builtin_catalog_server_cluster_replica_size
657                .as_ref()
658                .map(|size| format!("--bootstrap-builtin-catalog-server-cluster-replica-size={size}")),
659            config
660                .bootstrap_builtin_analytics_cluster_replica_size
661                .as_ref()
662                .map(|size| format!("--bootstrap-builtin-analytics-cluster-replica-size={size}")),
663            config
664                .bootstrap_builtin_system_cluster_replication_factor
665                .as_ref()
666                .map(|replication_factor| {
667                    format!("--bootstrap-builtin-system-cluster-replication-factor={replication_factor}")
668                }),
669            config
670                .bootstrap_builtin_probe_cluster_replication_factor
671                .as_ref()
672                .map(|replication_factor| format!("--bootstrap-builtin-probe-cluster-replication-factor={replication_factor}")),
673            config
674                .bootstrap_builtin_support_cluster_replication_factor
675                .as_ref()
676                .map(|replication_factor| format!("--bootstrap-builtin-support-cluster-replication-factor={replication_factor}")),
677            config
678                .bootstrap_builtin_analytics_cluster_replication_factor
679                .as_ref()
680                .map(|replication_factor| format!("--bootstrap-builtin-analytics-cluster-replication-factor={replication_factor}")),
681        ]
682        .into_iter()
683        .flatten(),
684    );
685
686    args.extend(
687        config
688            .environmentd_allowed_origins
689            .iter()
690            .map(|origin| format!("--cors-allowed-origin={}", origin.to_str().unwrap())),
691    );
692
693    args.push(format!(
694        "--secrets-controller={}",
695        config.secrets_controller
696    ));
697
698    if let Some(cluster_replica_sizes) = &config.environmentd_cluster_replica_sizes {
699        if let Ok(cluster_replica_sizes) =
700            serde_json::from_str::<BTreeMap<String, serde_json::Value>>(cluster_replica_sizes)
701        {
702            let cluster_replica_sizes: Vec<_> =
703                cluster_replica_sizes.keys().map(|s| s.as_str()).collect();
704            args.push(format!(
705                "--system-parameter-default=allowed_cluster_replica_sizes='{}'",
706                cluster_replica_sizes.join("', '")
707            ));
708        }
709    }
710    if !config.cloud_provider.is_cloud() {
711        args.push("--system-parameter-default=cluster_enable_topology_spread=false".into());
712    }
713
714    if config.enable_internal_statement_logging {
715        args.push("--system-parameter-default=enable_internal_statement_logging=true".into());
716    }
717
718    if config.disable_statement_logging {
719        args.push("--system-parameter-default=statement_logging_max_sample_rate=0".into());
720    }
721
722    if !mz.spec.enable_rbac {
723        args.push("--system-parameter-default=enable_rbac_checks=false".into());
724    }
725
726    // Add persist arguments.
727
728    // Configure the Persist Isolated Runtime to use one less thread than the total available.
729    args.push("--persist-isolated-runtime-threads=-1".to_string());
730
731    // Add AWS arguments.
732    if config.cloud_provider == CloudProvider::Aws {
733        if let Some(azs) = config.environmentd_availability_zones.as_ref() {
734            for az in azs {
735                args.push(format!("--availability-zone={az}"));
736            }
737        }
738
739        if let Some(environmentd_connection_role_arn) = mz
740            .spec
741            .environmentd_connection_role_arn
742            .as_deref()
743            .or(config.environmentd_connection_role_arn.as_deref())
744        {
745            args.push(format!(
746                "--aws-connection-role-arn={}",
747                environmentd_connection_role_arn
748            ));
749        }
750        if let Some(account_id) = &config.aws_account_id {
751            args.push(format!("--aws-account-id={account_id}"));
752        }
753
754        args.extend([format!(
755            "--aws-secrets-controller-tags=Environment={}",
756            mz.name_unchecked()
757        )]);
758        args.extend_from_slice(&config.aws_secrets_controller_tags);
759    }
760
761    // Add Kubernetes arguments.
762    args.extend([
763        "--orchestrator=kubernetes".into(),
764        format!(
765            "--orchestrator-kubernetes-service-account={}",
766            &mz.service_account_name()
767        ),
768        format!(
769            "--orchestrator-kubernetes-image-pull-policy={}",
770            config.image_pull_policy.as_kebab_case_str(),
771        ),
772    ]);
773    for selector in &config.clusterd_node_selector {
774        args.push(format!(
775            "--orchestrator-kubernetes-service-node-selector={}={}",
776            selector.key, selector.value,
777        ));
778    }
779    if mz.meets_minimum_version(&V144) {
780        if let Some(affinity) = &config.clusterd_affinity {
781            let affinity = serde_json::to_string(affinity).unwrap();
782            args.push(format!(
783                "--orchestrator-kubernetes-service-affinity={affinity}"
784            ))
785        }
786        if let Some(tolerations) = &config.clusterd_tolerations {
787            let tolerations = serde_json::to_string(tolerations).unwrap();
788            args.push(format!(
789                "--orchestrator-kubernetes-service-tolerations={tolerations}"
790            ))
791        }
792    }
793    if let Some(scheduler_name) = &config.scheduler_name {
794        args.push(format!(
795            "--orchestrator-kubernetes-scheduler-name={}",
796            scheduler_name
797        ));
798    }
799    if mz.meets_minimum_version(&V154_DEV0) {
800        args.extend(
801            mz.spec
802                .pod_annotations
803                .as_ref()
804                .map(|annotations| annotations.iter())
805                .unwrap_or_default()
806                .map(|(key, val)| {
807                    format!("--orchestrator-kubernetes-service-annotation={key}={val}")
808                }),
809        );
810    }
811    args.extend(
812        mz.default_labels()
813            .iter()
814            .chain(
815                mz.spec
816                    .pod_labels
817                    .as_ref()
818                    .map(|labels| labels.iter())
819                    .unwrap_or_default(),
820            )
821            .map(|(key, val)| format!("--orchestrator-kubernetes-service-label={key}={val}")),
822    );
823    if let Some(status) = &mz.status {
824        args.push(format!(
825            "--orchestrator-kubernetes-name-prefix=mz{}-",
826            status.resource_id
827        ));
828    }
829
830    // Add logging and tracing arguments.
831    args.extend(["--log-format=json".into()]);
832    if let Some(endpoint) = &config.tracing.opentelemetry_endpoint {
833        args.push(format!("--opentelemetry-endpoint={}", endpoint));
834    }
835    // --opentelemetry-resource also configures sentry tags
836    args.extend([
837        format!(
838            "--opentelemetry-resource=organization_id={}",
839            mz.spec.environment_id
840        ),
841        format!(
842            "--opentelemetry-resource=environment_name={}",
843            mz.name_unchecked()
844        ),
845    ]);
846
847    if let Some(segment_api_key) = &config.segment_api_key {
848        args.push(format!("--segment-api-key={}", segment_api_key));
849        if config.segment_client_side {
850            args.push("--segment-client-side".into());
851        }
852    }
853
854    let mut volumes = Vec::new();
855    let mut volume_mounts = Vec::new();
856    if issuer_ref_defined(
857        &config.default_certificate_specs.internal,
858        &mz.spec.internal_certificate_spec,
859    ) {
860        volumes.push(Volume {
861            name: "certificate".to_owned(),
862            secret: Some(SecretVolumeSource {
863                default_mode: Some(0o400),
864                secret_name: Some(mz.environmentd_certificate_secret_name()),
865                items: None,
866                optional: Some(false),
867            }),
868            ..Default::default()
869        });
870        volume_mounts.push(VolumeMount {
871            name: "certificate".to_owned(),
872            mount_path: "/etc/materialized".to_owned(),
873            read_only: Some(true),
874            ..Default::default()
875        });
876        args.extend([
877            "--tls-mode=require".into(),
878            "--tls-cert=/etc/materialized/tls.crt".into(),
879            "--tls-key=/etc/materialized/tls.key".into(),
880        ]);
881    } else {
882        args.push("--tls-mode=disable".to_string());
883    }
884    if let Some(ephemeral_volume_class) = &config.ephemeral_volume_class {
885        args.push(format!(
886            "--orchestrator-kubernetes-ephemeral-volume-class={}",
887            ephemeral_volume_class
888        ));
889    }
890    // The `materialize` user used by clusterd always has gid 999.
891    args.push("--orchestrator-kubernetes-service-fs-group=999".to_string());
892
893    // Add system_param configmap
894    // This feature was enabled in 0.163 but did not have testing until after 0.164.
895    // 0.165 should work with anything greater than 0.164 including v26 and v25.
896    if mz.meets_minimum_version(&V26_1_0) {
897        if let Some(ref name) = mz.spec.system_parameter_configmap_name {
898            volumes.push(Volume {
899                name: "system-params".to_string(),
900                config_map: Some(ConfigMapVolumeSource {
901                    default_mode: Some(0o400),
902                    name: name.to_owned(),
903                    items: None,
904                    optional: Some(true),
905                }),
906                ..Default::default()
907            });
908            volume_mounts.push(VolumeMount {
909                name: "system-params".to_string(),
910                // The user must write to the `system-params.json` entry in the config map
911                mount_path: "/system-params".to_owned(),
912                read_only: Some(true),
913                ..Default::default()
914            });
915            args.push("--config-sync-file-path=/system-params/system-params.json".to_string());
916            args.push("--config-sync-loop-interval=1s".to_string());
917        }
918    }
919
920    // Add Sentry arguments.
921    if let Some(sentry_dsn) = &config.tracing.sentry_dsn {
922        args.push(format!("--sentry-dsn={}", sentry_dsn));
923        if let Some(sentry_environment) = &config.tracing.sentry_environment {
924            args.push(format!("--sentry-environment={}", sentry_environment));
925        }
926        args.push(format!("--sentry-tag=region={}", config.region));
927    }
928
929    // Add Persist PubSub arguments
930    args.push(format!(
931        "--persist-pubsub-url=http://{}:{}",
932        mz.persist_pubsub_service_name(generation),
933        config.environmentd_internal_persist_pubsub_port,
934    ));
935    args.push(format!(
936        "--internal-persist-pubsub-listen-addr=0.0.0.0:{}",
937        config.environmentd_internal_persist_pubsub_port
938    ));
939
940    args.push(format!("--deploy-generation={}", generation));
941
942    // Add URL for internal user impersonation endpoint
943    args.push(format!(
944        "--internal-console-redirect-url={}",
945        &config.internal_console_proxy_url,
946    ));
947
948    if !config.collect_pod_metrics {
949        args.push("--orchestrator-kubernetes-disable-pod-metrics-collection".into());
950    }
951    if config.enable_prometheus_scrape_annotations {
952        args.push("--orchestrator-kubernetes-enable-prometheus-scrape-annotations".into());
953    }
954
955    // the --disable-license-key-checks environmentd flag only existed
956    // between these versions
957    if config.disable_license_key_checks {
958        if mz.meets_minimum_version(&V143) && !mz.meets_minimum_version(&V153) {
959            args.push("--disable-license-key-checks".into());
960        }
961    }
962
963    // as of version 0.153, the ability to disable license key checks was
964    // removed, so we should always set up license keys in that case
965    if (mz.meets_minimum_version(&V140_DEV0) && !config.disable_license_key_checks)
966        || mz.meets_minimum_version(&V153)
967    {
968        volume_mounts.push(VolumeMount {
969            name: "license-key".to_string(),
970            mount_path: "/license_key".to_string(),
971            ..Default::default()
972        });
973        volumes.push(Volume {
974            name: "license-key".to_string(),
975            secret: Some(SecretVolumeSource {
976                default_mode: Some(256),
977                optional: Some(false),
978                secret_name: Some(mz.backend_secret_name()),
979                items: Some(vec![KeyToPath {
980                    key: "license_key".to_string(),
981                    path: "license_key".to_string(),
982                    ..Default::default()
983                }]),
984                ..Default::default()
985            }),
986            ..Default::default()
987        });
988        env.push(EnvVar {
989            name: "MZ_LICENSE_KEY".to_string(),
990            value: Some("/license_key/license_key".to_string()),
991            ..Default::default()
992        });
993    }
994
995    // Add user-specified extra arguments.
996    if let Some(extra_args) = &mz.spec.environmentd_extra_args {
997        args.extend(extra_args.iter().cloned());
998    }
999
1000    let probe = Probe {
1001        initial_delay_seconds: Some(1),
1002        failure_threshold: Some(12),
1003        tcp_socket: Some(TCPSocketAction {
1004            host: None,
1005            port: IntOrString::Int(config.environmentd_sql_port.into()),
1006        }),
1007        ..Default::default()
1008    };
1009
1010    let security_context = if config.enable_security_context {
1011        // Since we want to adhere to the most restrictive security context, all
1012        // of these fields have to be set how they are.
1013        // See https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
1014        Some(SecurityContext {
1015            run_as_non_root: Some(true),
1016            capabilities: Some(Capabilities {
1017                drop: Some(vec!["ALL".to_string()]),
1018                ..Default::default()
1019            }),
1020            seccomp_profile: Some(SeccompProfile {
1021                type_: "RuntimeDefault".to_string(),
1022                ..Default::default()
1023            }),
1024            allow_privilege_escalation: Some(false),
1025            ..Default::default()
1026        })
1027    } else {
1028        None
1029    };
1030
1031    let ports = vec![
1032        ContainerPort {
1033            container_port: config.environmentd_sql_port.into(),
1034            name: Some("sql".to_owned()),
1035            ..Default::default()
1036        },
1037        ContainerPort {
1038            container_port: config.environmentd_internal_sql_port.into(),
1039            name: Some("internal-sql".to_owned()),
1040            ..Default::default()
1041        },
1042        ContainerPort {
1043            container_port: config.environmentd_http_port.into(),
1044            name: Some("http".to_owned()),
1045            ..Default::default()
1046        },
1047        ContainerPort {
1048            container_port: config.environmentd_internal_http_port.into(),
1049            name: Some("internal-http".to_owned()),
1050            ..Default::default()
1051        },
1052        ContainerPort {
1053            container_port: config.environmentd_internal_persist_pubsub_port.into(),
1054            name: Some("persist-pubsub".to_owned()),
1055            ..Default::default()
1056        },
1057    ];
1058
1059    // Add networking arguments.
1060    if mz.meets_minimum_version(&V147_DEV0) {
1061        volume_mounts.push(VolumeMount {
1062            name: "listeners-configmap".to_string(),
1063            mount_path: "/listeners".to_string(),
1064            ..Default::default()
1065        });
1066        volumes.push(Volume {
1067            name: "listeners-configmap".to_string(),
1068            config_map: Some(ConfigMapVolumeSource {
1069                name: mz.listeners_configmap_name(generation),
1070                default_mode: Some(256),
1071                optional: Some(false),
1072                items: Some(vec![KeyToPath {
1073                    key: "listeners.json".to_string(),
1074                    path: "listeners.json".to_string(),
1075                    ..Default::default()
1076                }]),
1077            }),
1078            ..Default::default()
1079        });
1080        args.push("--listeners-config-path=/listeners/listeners.json".to_owned());
1081        if matches!(
1082            mz.spec.authenticator_kind,
1083            AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1084        ) {
1085            args.push("--system-parameter-default=enable_password_auth=true".into());
1086            env.push(EnvVar {
1087                name: "MZ_EXTERNAL_LOGIN_PASSWORD_MZ_SYSTEM".to_string(),
1088                value_from: Some(EnvVarSource {
1089                    secret_key_ref: Some(SecretKeySelector {
1090                        name: mz.backend_secret_name(),
1091                        key: "external_login_password_mz_system".to_string(),
1092                        optional: Some(false),
1093                    }),
1094                    ..Default::default()
1095                }),
1096                ..Default::default()
1097            })
1098        }
1099    } else {
1100        args.extend([
1101            format!("--sql-listen-addr=0.0.0.0:{}", config.environmentd_sql_port),
1102            format!(
1103                "--http-listen-addr=0.0.0.0:{}",
1104                config.environmentd_http_port
1105            ),
1106            format!(
1107                "--internal-sql-listen-addr=0.0.0.0:{}",
1108                config.environmentd_internal_sql_port
1109            ),
1110            format!(
1111                "--internal-http-listen-addr=0.0.0.0:{}",
1112                config.environmentd_internal_http_port
1113            ),
1114        ]);
1115    }
1116
1117    let container = Container {
1118        name: "environmentd".to_owned(),
1119        image: Some(mz.spec.environmentd_image_ref.to_owned()),
1120        image_pull_policy: Some(config.image_pull_policy.to_string()),
1121        ports: Some(ports),
1122        args: Some(args),
1123        env: Some(env),
1124        volume_mounts: Some(volume_mounts),
1125        liveness_probe: Some(probe.clone()),
1126        readiness_probe: Some(probe),
1127        resources: mz
1128            .spec
1129            .environmentd_resource_requirements
1130            .clone()
1131            .or_else(|| config.environmentd_default_resources.clone()),
1132        security_context: security_context.clone(),
1133        ..Default::default()
1134    };
1135
1136    let mut pod_template_labels = mz.default_labels();
1137    pod_template_labels.insert(
1138        "materialize.cloud/name".to_owned(),
1139        mz.environmentd_statefulset_name(generation),
1140    );
1141    pod_template_labels.insert(
1142        "materialize.cloud/app".to_owned(),
1143        mz.environmentd_app_name(),
1144    );
1145    pod_template_labels.extend(recommended_k8s_labels("environmentd".into()));
1146    pod_template_labels.extend(
1147        mz.spec
1148            .pod_labels
1149            .as_ref()
1150            .map(|labels| labels.iter())
1151            .unwrap_or_default()
1152            .map(|(key, value)| (key.clone(), value.clone())),
1153    );
1154
1155    let mut pod_template_annotations = btreemap! {
1156        // We can re-enable eviction once we have HA
1157        "cluster-autoscaler.kubernetes.io/safe-to-evict".to_owned() => "false".to_string(),
1158
1159        // Prevents old (< 0.30) and new versions of karpenter from evicting database pods
1160        "karpenter.sh/do-not-evict".to_owned() => "true".to_string(),
1161        "karpenter.sh/do-not-disrupt".to_owned() => "true".to_string(),
1162        "materialize.cloud/generation".to_owned() => generation.to_string(),
1163    };
1164    if config.enable_prometheus_scrape_annotations {
1165        pod_template_annotations.insert("prometheus.io/scrape".to_owned(), "true".to_string());
1166        pod_template_annotations.insert(
1167            "prometheus.io/port".to_owned(),
1168            config.environmentd_internal_http_port.to_string(),
1169        );
1170        pod_template_annotations.insert("prometheus.io/path".to_owned(), "/metrics".to_string());
1171        pod_template_annotations.insert("prometheus.io/scheme".to_owned(), "http".to_string());
1172        pod_template_annotations.insert(
1173            "materialize.prometheus.io/mz_usage_path".to_owned(),
1174            "/metrics/mz_usage".to_string(),
1175        );
1176        pod_template_annotations.insert(
1177            "materialize.prometheus.io/mz_frontier_path".to_owned(),
1178            "/metrics/mz_frontier".to_string(),
1179        );
1180        pod_template_annotations.insert(
1181            "materialize.prometheus.io/mz_compute_path".to_owned(),
1182            "/metrics/mz_compute".to_string(),
1183        );
1184        pod_template_annotations.insert(
1185            "materialize.prometheus.io/mz_storage_path".to_owned(),
1186            "/metrics/mz_storage".to_string(),
1187        );
1188    }
1189    pod_template_annotations.extend(
1190        mz.spec
1191            .pod_annotations
1192            .as_ref()
1193            .map(|annotations| annotations.iter())
1194            .unwrap_or_default()
1195            .map(|(key, value)| (key.clone(), value.clone())),
1196    );
1197
1198    let mut tolerations = vec![
1199        // When the node becomes `NotReady` it indicates there is a problem with the node,
1200        // By default kubernetes waits 300s (5 minutes) before doing anything in this case,
1201        // But we want to limit this to 30s for faster recovery
1202        Toleration {
1203            effect: Some("NoExecute".into()),
1204            key: Some("node.kubernetes.io/not-ready".into()),
1205            operator: Some("Exists".into()),
1206            toleration_seconds: Some(30),
1207            value: None,
1208        },
1209        Toleration {
1210            effect: Some("NoExecute".into()),
1211            key: Some("node.kubernetes.io/unreachable".into()),
1212            operator: Some("Exists".into()),
1213            toleration_seconds: Some(30),
1214            value: None,
1215        },
1216    ];
1217    if let Some(user_tolerations) = &config.environmentd_tolerations {
1218        tolerations.extend(user_tolerations.iter().cloned());
1219    }
1220    let tolerations = Some(tolerations);
1221
1222    let pod_template_spec = PodTemplateSpec {
1223        // not using managed_resource_meta because the pod should be owned
1224        // by the statefulset, not the materialize instance
1225        metadata: Some(ObjectMeta {
1226            labels: Some(pod_template_labels),
1227            annotations: Some(pod_template_annotations), // This is inserted into later, do not delete.
1228            ..Default::default()
1229        }),
1230        spec: Some(PodSpec {
1231            containers: vec![container],
1232            node_selector: Some(
1233                config
1234                    .environmentd_node_selector
1235                    .iter()
1236                    .map(|selector| (selector.key.clone(), selector.value.clone()))
1237                    .collect(),
1238            ),
1239            affinity: config.environmentd_affinity.clone(),
1240            scheduler_name: config.scheduler_name.clone(),
1241            service_account_name: Some(mz.service_account_name()),
1242            volumes: Some(volumes),
1243            security_context: Some(PodSecurityContext {
1244                fs_group: Some(999),
1245                run_as_user: Some(999),
1246                run_as_group: Some(999),
1247                ..Default::default()
1248            }),
1249            tolerations,
1250            // This (apparently) has the side effect of automatically starting a new pod
1251            // when the previous pod is currently terminating. This side steps the statefulset fencing
1252            // but allows for quicker recovery from a failed node
1253            // The Kubernetes documentation strongly advises against this
1254            // setting, as StatefulSets attempt to provide "at most once"
1255            // semantics [0]-- that is, the guarantee that for a given pod in a
1256            // StatefulSet there is *at most* one pod with that identity running
1257            // in the cluster
1258            //
1259            // Materialize, however, has been carefully designed to *not* rely
1260            // on this guarantee. (In fact, we do not believe that correct
1261            // distributed systems can meaningfully rely on Kubernetes's
1262            // guarantee--network packets from a pod can be arbitrarily delayed,
1263            // long past that pod's termination.) Running two `environmentd`
1264            // processes is safe: the newer process will safely and correctly
1265            // fence out the older process via CockroachDB. In the future,
1266            // we'll support running multiple `environmentd` processes in
1267            // parallel for high availability.
1268            //
1269            // [0]: https://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/#statefulset-considerations
1270            termination_grace_period_seconds: Some(0),
1271            ..Default::default()
1272        }),
1273    };
1274
1275    let mut match_labels = BTreeMap::new();
1276    match_labels.insert(
1277        "materialize.cloud/name".to_owned(),
1278        mz.environmentd_statefulset_name(generation),
1279    );
1280
1281    let statefulset_spec = StatefulSetSpec {
1282        replicas: Some(1),
1283        template: pod_template_spec,
1284        service_name: Some(mz.environmentd_service_name()),
1285        selector: LabelSelector {
1286            match_expressions: None,
1287            match_labels: Some(match_labels),
1288        },
1289        ..Default::default()
1290    };
1291
1292    StatefulSet {
1293        metadata: ObjectMeta {
1294            annotations: Some(btreemap! {
1295                "materialize.cloud/generation".to_owned() => generation.to_string(),
1296                "materialize.cloud/force".to_owned() => mz.spec.force_rollout.to_string(),
1297            }),
1298            ..mz.managed_resource_meta(mz.environmentd_statefulset_name(generation))
1299        },
1300        spec: Some(statefulset_spec),
1301        status: None,
1302    }
1303}
1304
1305fn create_v0_147_0_listeners_config(
1306    config: &super::Config,
1307    mz: &Materialize,
1308) -> v0_147_0::ListenersConfig {
1309    let external_enable_tls = issuer_ref_defined(
1310        &config.default_certificate_specs.internal,
1311        &mz.spec.internal_certificate_spec,
1312    );
1313    let authenticator_kind = mz.spec.authenticator_kind;
1314
1315    let mut listeners_config = v0_147_0::ListenersConfig {
1316        sql: btreemap! {
1317            "external".to_owned() => SqlListenerConfig{
1318                addr: SocketAddr::new(
1319                    IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1320                    config.environmentd_sql_port,
1321                ),
1322                authenticator_kind,
1323                allowed_roles: AllowedRoles::Normal,
1324                enable_tls: external_enable_tls,
1325            },
1326            "internal".to_owned() => SqlListenerConfig{
1327                addr: SocketAddr::new(
1328                    IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1329                    config.environmentd_internal_sql_port,
1330                ),
1331                authenticator_kind: AuthenticatorKind::None,
1332                // Should this just be Internal?
1333                allowed_roles: AllowedRoles::NormalAndInternal,
1334                enable_tls: false,
1335            },
1336        },
1337        http: btreemap! {
1338            "external".to_owned() => v0_147_0::HttpListenerConfig{
1339                base: BaseListenerConfig {
1340                    addr: SocketAddr::new(
1341                        IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1342                        config.environmentd_http_port,
1343                    ),
1344                    // SASL authentication is only supported for SQL (PostgreSQL wire protocol).
1345                    // HTTP listeners must use Password authentication when SASL is enabled.
1346                    // This is validated at environmentd startup via ListenerConfig::validate().
1347                    authenticator_kind: if authenticator_kind == AuthenticatorKind::Sasl {
1348                        AuthenticatorKind::Password
1349                    } else {
1350                        authenticator_kind
1351                    },
1352                    allowed_roles: AllowedRoles::Normal,
1353                    enable_tls: external_enable_tls,
1354                },
1355                routes: v0_147_0::HttpRoutes{
1356                    base: true,
1357                    webhook: true,
1358                    internal: false,
1359                    metrics: false,
1360                    profiling: false,
1361                    mcp_agent: true,
1362                    mcp_developer: true,
1363                    console_config: true,
1364                },
1365            },
1366            "internal".to_owned() => v0_147_0::HttpListenerConfig{
1367                base: BaseListenerConfig {
1368                    addr: SocketAddr::new(
1369                        IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1370                        config.environmentd_internal_http_port,
1371                    ),
1372                    authenticator_kind: AuthenticatorKind::None,
1373                    // Should this just be Internal?
1374                    allowed_roles: AllowedRoles::NormalAndInternal,
1375                    enable_tls: false,
1376                },
1377                routes: v0_147_0::HttpRoutes{
1378                    base: true,
1379                    webhook: true,
1380                    internal: true,
1381                    metrics: true,
1382                    profiling: true,
1383                    mcp_agent: true,
1384                    mcp_developer: true,
1385                    console_config: false,
1386                },
1387            },
1388        },
1389    };
1390
1391    // For password/oidc/sasl auth, we want to get rid of the internal port and
1392    // combine the external and internal listeners into a single listener.
1393    if matches!(
1394        authenticator_kind,
1395        AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1396    ) {
1397        listeners_config.sql.remove("internal");
1398        listeners_config.http.remove("internal");
1399
1400        listeners_config.sql.get_mut("external").map(|listener| {
1401            listener.allowed_roles = AllowedRoles::NormalAndInternal;
1402            listener
1403        });
1404        listeners_config.http.get_mut("external").map(|listener| {
1405            listener.base.allowed_roles = AllowedRoles::NormalAndInternal;
1406            listener.routes.internal = true;
1407            listener.routes.profiling = true;
1408            listener
1409        });
1410
1411        listeners_config.http.insert(
1412            "metrics".to_owned(),
1413            v0_147_0::HttpListenerConfig {
1414                base: BaseListenerConfig {
1415                    addr: SocketAddr::new(
1416                        IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
1417                        config.environmentd_internal_http_port,
1418                    ),
1419                    authenticator_kind: AuthenticatorKind::None,
1420                    allowed_roles: AllowedRoles::NormalAndInternal,
1421                    enable_tls: false,
1422                },
1423                routes: v0_147_0::HttpRoutes {
1424                    base: false,
1425                    webhook: false,
1426                    internal: false,
1427                    metrics: true,
1428                    profiling: false,
1429                    mcp_agent: false,
1430                    mcp_developer: false,
1431                    console_config: false,
1432                },
1433            },
1434        );
1435    };
1436    listeners_config
1437}
1438
1439fn create_connection_info(
1440    config: &super::Config,
1441    mz: &Materialize,
1442    generation: u64,
1443) -> ConnectionInfo {
1444    let external_enable_tls = issuer_ref_defined(
1445        &config.default_certificate_specs.internal,
1446        &mz.spec.internal_certificate_spec,
1447    );
1448    let authenticator_kind = mz.spec.authenticator_kind;
1449
1450    let listeners_config = create_v0_147_0_listeners_config(config, mz);
1451
1452    // Serve each `environmentd` the schema its version understands. Newer
1453    // versions parse the current (`ListenersConfig`) schema; older versions
1454    // parse the legacy (`v0_147_0::ListenersConfig`) schema.
1455    let listeners_json = if mz.meets_minimum_version(&PER_ROUTE_GROUP_ROLES_VERSION) {
1456        // Convert the legacy `v0_147_0::ListenersConfig` schema to the current `ListenersConfig` schema.
1457        // The main difference is instead of having a single `allowed_roles` property in the HTTP listener
1458        // for all route groups, we now have a per-route group `allowed_roles` property. The migration cascades
1459        // the top level `allowed_roles` into every route group.
1460        let mut listeners_config: v26_32_0::ListenersConfig = listeners_config.into();
1461        // The sole reason for the new `ListenersConfig` schema is because for password/oidc/sasl
1462        // authentication, we were allowing normal roles to access internal and profiling routes
1463        // since we now have a single listener for both external and internal traffic. However we
1464        // want to only allow internal roles to access these routes. Thus we set the `allowed_roles`
1465        // for the internal and profiling route groups to `Internal` and leave the other route groups
1466        // as `NormalAndInternal`.
1467        if matches!(
1468            authenticator_kind,
1469            AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1470        ) {
1471            listeners_config.http.get_mut("external").map(|listener| {
1472                listener.routes.internal = RouteGroup::Enabled(AllowedRoles::Internal);
1473                listener.routes.profiling = RouteGroup::Enabled(AllowedRoles::Internal);
1474                listener
1475            });
1476        }
1477
1478        serde_json::to_string(&VersionedListenersConfig::V2(listeners_config)).expect("known valid")
1479    } else {
1480        serde_json::to_string(&VersionedListenersConfig::V1(listeners_config)).expect("known valid")
1481    };
1482    let listeners_configmap = ConfigMap {
1483        binary_data: None,
1484        data: Some(btreemap! {
1485            "listeners.json".to_owned() => listeners_json,
1486        }),
1487        immutable: None,
1488        metadata: ObjectMeta {
1489            annotations: Some(btreemap! {
1490                "materialize.cloud/generation".to_owned() => generation.to_string(),
1491            }),
1492            ..mz.managed_resource_meta(mz.listeners_configmap_name(generation))
1493        },
1494    };
1495
1496    let (scheme, leader_api_port, mz_system_secret_name) = match authenticator_kind {
1497        AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc => {
1498            let scheme = if external_enable_tls { "https" } else { "http" };
1499            (
1500                scheme,
1501                config.environmentd_http_port,
1502                Some(mz.spec.backend_secret_name.clone()),
1503            )
1504        }
1505        _ => ("http", config.environmentd_internal_http_port, None),
1506    };
1507    let environmentd_url = format!(
1508        "{}://{}.{}.svc.cluster.local:{}",
1509        scheme,
1510        mz.environmentd_generation_service_name(generation),
1511        mz.namespace(),
1512        leader_api_port,
1513    );
1514    ConnectionInfo {
1515        environmentd_url,
1516        listeners_configmap,
1517        mz_system_secret_name,
1518    }
1519}
1520
1521// see materialize/src/environmentd/src/http.rs
1522#[derive(Debug, Deserialize, PartialEq, Eq)]
1523struct BecomeLeaderResponse {
1524    result: BecomeLeaderResult,
1525}
1526
1527#[derive(Debug, Deserialize, PartialEq, Eq)]
1528enum BecomeLeaderResult {
1529    Success,
1530    Failure { message: String },
1531}
1532
1533#[derive(Debug, Deserialize, PartialEq, Eq)]
1534struct SkipCatchupError {
1535    message: String,
1536}