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