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_42_0: LazyLock<Version> = LazyLock::new(|| Version {
81    major: 26,
82    minor: 42,
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        hex::encode(hasher.finalize())
462    }
463}
464
465fn create_public_service_object(
466    config: &super::Config,
467    mz: &Materialize,
468    generation: u64,
469) -> Service {
470    let mut service = create_base_service_object(
471        config,
472        mz,
473        generation,
474        &mz.environmentd_service_name(),
475        true,
476    );
477    // Only the stable, public Service is annotated for Teleport discovery,
478    // not the per-generation Service: the agent must not see the name churn
479    // on every rollout.
480    super::teleport::apply_teleport_registration(config, mz, &mut service);
481    service
482}
483
484fn create_generation_service_object(
485    config: &super::Config,
486    mz: &Materialize,
487    generation: u64,
488) -> Service {
489    create_base_service_object(
490        config,
491        mz,
492        generation,
493        &mz.environmentd_generation_service_name(generation),
494        false,
495    )
496}
497
498fn create_base_service_object(
499    config: &super::Config,
500    mz: &Materialize,
501    generation: u64,
502    service_name: &str,
503    headless: bool,
504) -> Service {
505    let ports = vec![
506        ServicePort {
507            port: config.environmentd_sql_port.into(),
508            protocol: Some("TCP".to_string()),
509            name: Some("sql".to_string()),
510            ..Default::default()
511        },
512        ServicePort {
513            port: config.environmentd_http_port.into(),
514            protocol: Some("TCP".to_string()),
515            name: Some("https".to_string()),
516            ..Default::default()
517        },
518        ServicePort {
519            port: config.environmentd_internal_sql_port.into(),
520            protocol: Some("TCP".to_string()),
521            name: Some("internal-sql".to_string()),
522            ..Default::default()
523        },
524        ServicePort {
525            port: config.environmentd_internal_http_port.into(),
526            protocol: Some("TCP".to_string()),
527            name: Some("internal-http".to_string()),
528            ..Default::default()
529        },
530    ];
531
532    let selector = btreemap! {"materialize.cloud/name".to_string() => mz.environmentd_statefulset_name(generation)};
533
534    let spec = ServiceSpec {
535        type_: Some("ClusterIP".to_string()),
536        cluster_ip: if headless {
537            Some("None".to_string())
538        } else {
539            None
540        },
541        selector: Some(selector),
542        ports: Some(ports),
543        ..Default::default()
544    };
545
546    Service {
547        metadata: mz.managed_resource_meta(service_name.to_string()),
548        spec: Some(spec),
549        status: None,
550    }
551}
552
553fn create_persist_pubsub_service(
554    config: &super::Config,
555    mz: &Materialize,
556    generation: u64,
557) -> Service {
558    Service {
559        metadata: mz.managed_resource_meta(mz.persist_pubsub_service_name(generation)),
560        spec: Some(ServiceSpec {
561            type_: Some("ClusterIP".to_string()),
562            cluster_ip: Some("None".to_string()),
563            selector: Some(btreemap! {
564                "materialize.cloud/name".to_string() => mz.environmentd_statefulset_name(generation),
565            }),
566            ports: Some(vec![ServicePort {
567                name: Some("grpc".to_string()),
568                protocol: Some("TCP".to_string()),
569                port: config.environmentd_internal_persist_pubsub_port.into(),
570                ..Default::default()
571            }]),
572            ..Default::default()
573        }),
574        status: None,
575    }
576}
577
578fn create_environmentd_statefulset_object(
579    config: &super::Config,
580    mz: &Materialize,
581    generation: u64,
582) -> StatefulSet {
583    // IMPORTANT: Only pass secrets via environment variables. All other
584    // parameters should be passed as command line arguments, possibly gated
585    // with a `meets_minimum_version` call. This ensures typos cause
586    // `environmentd` to fail to start, rather than silently ignoring the
587    // configuration.
588    //
589    // When passing a secret, use a `SecretKeySelector` to forward a secret into
590    // the pod. Do *not* hardcode a secret as the value directly, as doing so
591    // will leak the secret to anyone with permission to describe the pod.
592    let mut env = vec![
593        EnvVar {
594            name: "MZ_METADATA_BACKEND_URL".to_string(),
595            value_from: Some(EnvVarSource {
596                secret_key_ref: Some(SecretKeySelector {
597                    name: mz.backend_secret_name(),
598                    key: "metadata_backend_url".to_string(),
599                    optional: Some(false),
600                }),
601                ..Default::default()
602            }),
603            ..Default::default()
604        },
605        EnvVar {
606            name: "MZ_PERSIST_BLOB_URL".to_string(),
607            value_from: Some(EnvVarSource {
608                secret_key_ref: Some(SecretKeySelector {
609                    name: mz.backend_secret_name(),
610                    key: "persist_backend_url".to_string(),
611                    optional: Some(false),
612                }),
613                ..Default::default()
614            }),
615            ..Default::default()
616        },
617    ];
618
619    env.push(EnvVar {
620        name: "AWS_REGION".to_string(),
621        value: Some(config.region.clone()),
622        ..Default::default()
623    });
624
625    env.extend(mz.spec.environmentd_extra_env.iter().flatten().cloned());
626
627    let mut args = vec![];
628
629    if let Some(helm_chart_version) = &config.helm_chart_version {
630        args.push(format!("--helm-chart-version={helm_chart_version}"));
631    }
632
633    // Add environment ID argument.
634    args.push(format!(
635        "--environment-id={}",
636        mz.environment_id(&config.cloud_provider.to_string(), &config.region)
637    ));
638
639    // Add clusterd image argument based on environmentd tag.
640    args.push(format!(
641        "--clusterd-image={}",
642        matching_image_from_environmentd_image_ref(
643            &mz.spec.environmentd_image_ref,
644            "clusterd",
645            None
646        )
647    ));
648
649    // Add cluster and storage host size arguments.
650    args.extend(
651        [
652            config
653                .environmentd_cluster_replica_sizes
654                .as_ref()
655                .map(|sizes| format!("--cluster-replica-sizes={sizes}")),
656            config
657                .bootstrap_default_cluster_replica_size
658                .as_ref()
659                .map(|size| format!("--bootstrap-default-cluster-replica-size={size}")),
660            config
661                .bootstrap_builtin_system_cluster_replica_size
662                .as_ref()
663                .map(|size| format!("--bootstrap-builtin-system-cluster-replica-size={size}")),
664            config
665                .bootstrap_builtin_probe_cluster_replica_size
666                .as_ref()
667                .map(|size| format!("--bootstrap-builtin-probe-cluster-replica-size={size}")),
668            config
669                .bootstrap_builtin_support_cluster_replica_size
670                .as_ref()
671                .map(|size| format!("--bootstrap-builtin-support-cluster-replica-size={size}")),
672            config
673                .bootstrap_builtin_catalog_server_cluster_replica_size
674                .as_ref()
675                .map(|size| format!("--bootstrap-builtin-catalog-server-cluster-replica-size={size}")),
676            config
677                .bootstrap_builtin_analytics_cluster_replica_size
678                .as_ref()
679                .map(|size| format!("--bootstrap-builtin-analytics-cluster-replica-size={size}")),
680            config
681                .bootstrap_builtin_system_cluster_replication_factor
682                .as_ref()
683                .map(|replication_factor| {
684                    format!("--bootstrap-builtin-system-cluster-replication-factor={replication_factor}")
685                }),
686            config
687                .bootstrap_builtin_probe_cluster_replication_factor
688                .as_ref()
689                .map(|replication_factor| format!("--bootstrap-builtin-probe-cluster-replication-factor={replication_factor}")),
690            config
691                .bootstrap_builtin_support_cluster_replication_factor
692                .as_ref()
693                .map(|replication_factor| format!("--bootstrap-builtin-support-cluster-replication-factor={replication_factor}")),
694            config
695                .bootstrap_builtin_analytics_cluster_replication_factor
696                .as_ref()
697                .map(|replication_factor| format!("--bootstrap-builtin-analytics-cluster-replication-factor={replication_factor}")),
698        ]
699        .into_iter()
700        .flatten(),
701    );
702
703    args.extend(
704        config
705            .environmentd_allowed_origins
706            .iter()
707            .map(|origin| format!("--cors-allowed-origin={}", origin.to_str().unwrap())),
708    );
709
710    args.push(format!(
711        "--secrets-controller={}",
712        config.secrets_controller
713    ));
714
715    if let Some(cluster_replica_sizes) = &config.environmentd_cluster_replica_sizes {
716        if let Ok(cluster_replica_sizes) =
717            serde_json::from_str::<BTreeMap<String, serde_json::Value>>(cluster_replica_sizes)
718        {
719            let cluster_replica_sizes: Vec<_> =
720                cluster_replica_sizes.keys().map(|s| s.as_str()).collect();
721            args.push(format!(
722                "--system-parameter-default=allowed_cluster_replica_sizes='{}'",
723                cluster_replica_sizes.join("', '")
724            ));
725        }
726    }
727    if !config.cloud_provider.is_cloud() {
728        args.push("--system-parameter-default=cluster_enable_topology_spread=false".into());
729    }
730
731    if config.enable_internal_statement_logging {
732        args.push("--system-parameter-default=enable_internal_statement_logging=true".into());
733    }
734
735    if let Some(rate) = config.statement_logging_max_sample_rate {
736        args.push(format!(
737            "--system-parameter-default=statement_logging_max_sample_rate={rate}"
738        ));
739    }
740
741    if let Some(rate) = config.statement_logging_target_data_rate {
742        args.push(format!(
743            "--system-parameter-default=statement_logging_target_data_rate={rate}"
744        ));
745    }
746
747    if !mz.spec.enable_rbac {
748        args.push("--system-parameter-default=enable_rbac_checks=false".into());
749    }
750
751    if config.disable_database_network_policies {
752        args.push("--system-parameter-default=enable_network_policies=false".into());
753    }
754
755    // Add persist arguments.
756
757    // Configure the Persist Isolated Runtime to use one less thread than the total available.
758    args.push("--persist-isolated-runtime-threads=-1".to_string());
759
760    // Add AWS arguments.
761    if config.cloud_provider == CloudProvider::Aws {
762        if let Some(azs) = config.environmentd_availability_zones.as_ref() {
763            for az in azs {
764                args.push(format!("--availability-zone={az}"));
765            }
766        }
767
768        if let Some(environmentd_connection_role_arn) = mz
769            .spec
770            .environmentd_connection_role_arn
771            .as_deref()
772            .or(config.environmentd_connection_role_arn.as_deref())
773        {
774            args.push(format!(
775                "--aws-connection-role-arn={}",
776                environmentd_connection_role_arn
777            ));
778        }
779        if let Some(account_id) = &config.aws_account_id {
780            args.push(format!("--aws-account-id={account_id}"));
781        }
782
783        args.extend([format!(
784            "--aws-secrets-controller-tags=Environment={}",
785            mz.name_unchecked()
786        )]);
787        args.extend_from_slice(&config.aws_secrets_controller_tags);
788    }
789
790    // Add Kubernetes arguments.
791    args.extend([
792        "--orchestrator=kubernetes".into(),
793        format!(
794            "--orchestrator-kubernetes-service-account={}",
795            mz.service_account_name()
796        ),
797        format!(
798            "--orchestrator-kubernetes-image-pull-policy={}",
799            config.image_pull_policy.as_kebab_case_str(),
800        ),
801    ]);
802    for selector in &config.clusterd_node_selector {
803        args.push(format!(
804            "--orchestrator-kubernetes-service-node-selector={}={}",
805            selector.key, selector.value,
806        ));
807    }
808    if mz.meets_minimum_version(&V144) {
809        if let Some(affinity) = &config.clusterd_affinity {
810            let affinity = serde_json::to_string(affinity).unwrap();
811            args.push(format!(
812                "--orchestrator-kubernetes-service-affinity={affinity}"
813            ))
814        }
815        if let Some(tolerations) = &config.clusterd_tolerations {
816            let tolerations = serde_json::to_string(tolerations).unwrap();
817            args.push(format!(
818                "--orchestrator-kubernetes-service-tolerations={tolerations}"
819            ))
820        }
821    }
822    if let Some(scheduler_name) = &config.scheduler_name {
823        args.push(format!(
824            "--orchestrator-kubernetes-scheduler-name={}",
825            scheduler_name
826        ));
827    }
828    if mz.meets_minimum_version(&V26_42_0) {
829        if let Some(priority_class_name) = &config.clusterd_priority_class_name {
830            args.push(format!(
831                "--orchestrator-kubernetes-priority-class-name={priority_class_name}"
832            ));
833        }
834    }
835    if mz.meets_minimum_version(&V154_DEV0) {
836        args.extend(
837            mz.spec
838                .pod_annotations
839                .as_ref()
840                .map(|annotations| annotations.iter())
841                .unwrap_or_default()
842                .map(|(key, val)| {
843                    format!("--orchestrator-kubernetes-service-annotation={key}={val}")
844                }),
845        );
846    }
847    args.extend(
848        mz.default_labels()
849            .iter()
850            .chain(
851                mz.spec
852                    .pod_labels
853                    .as_ref()
854                    .map(|labels| labels.iter())
855                    .unwrap_or_default(),
856            )
857            .map(|(key, val)| format!("--orchestrator-kubernetes-service-label={key}={val}")),
858    );
859    if let Some(status) = &mz.status {
860        args.push(format!(
861            "--orchestrator-kubernetes-name-prefix=mz{}-",
862            status.resource_id
863        ));
864    }
865
866    // Add logging and tracing arguments.
867    args.extend(["--log-format=json".into()]);
868    if let Some(endpoint) = &config.tracing.opentelemetry_endpoint {
869        args.push(format!("--opentelemetry-endpoint={}", endpoint));
870    }
871    // --opentelemetry-resource also configures sentry tags
872    args.extend([
873        format!(
874            "--opentelemetry-resource=organization_id={}",
875            mz.spec.environment_id
876        ),
877        format!(
878            "--opentelemetry-resource=environment_name={}",
879            mz.name_unchecked()
880        ),
881    ]);
882
883    if let Some(segment_api_key) = &config.segment_api_key {
884        args.push(format!("--segment-api-key={}", segment_api_key));
885        if config.segment_client_side {
886            args.push("--segment-client-side".into());
887        }
888    }
889
890    let mut volumes = Vec::new();
891    let mut volume_mounts = Vec::new();
892    if issuer_ref_defined(
893        &config.default_certificate_specs.internal,
894        &mz.spec.internal_certificate_spec,
895    ) {
896        volumes.push(Volume {
897            name: "certificate".to_owned(),
898            secret: Some(SecretVolumeSource {
899                default_mode: Some(0o400),
900                secret_name: Some(mz.environmentd_certificate_secret_name()),
901                items: None,
902                optional: Some(false),
903            }),
904            ..Default::default()
905        });
906        volume_mounts.push(VolumeMount {
907            name: "certificate".to_owned(),
908            mount_path: "/etc/materialized".to_owned(),
909            read_only: Some(true),
910            ..Default::default()
911        });
912        args.extend([
913            "--tls-mode=require".into(),
914            "--tls-cert=/etc/materialized/tls.crt".into(),
915            "--tls-key=/etc/materialized/tls.key".into(),
916        ]);
917    } else {
918        args.push("--tls-mode=disable".to_string());
919    }
920    if let Some(ephemeral_volume_class) = &config.ephemeral_volume_class {
921        args.push(format!(
922            "--orchestrator-kubernetes-ephemeral-volume-class={}",
923            ephemeral_volume_class
924        ));
925    }
926    // The `materialize` user used by clusterd always has gid 999.
927    args.push("--orchestrator-kubernetes-service-fs-group=999".to_string());
928
929    // Add system_param configmap
930    // This feature was enabled in 0.163 but did not have testing until after 0.164.
931    // 0.165 should work with anything greater than 0.164 including v26 and v25.
932    if mz.meets_minimum_version(&V26_1_0) {
933        if let Some(ref name) = mz.spec.system_parameter_configmap_name {
934            volumes.push(Volume {
935                name: "system-params".to_string(),
936                config_map: Some(ConfigMapVolumeSource {
937                    default_mode: Some(0o400),
938                    name: name.to_owned(),
939                    items: None,
940                    optional: Some(true),
941                }),
942                ..Default::default()
943            });
944            volume_mounts.push(VolumeMount {
945                name: "system-params".to_string(),
946                // The user must write to the `system-params.json` entry in the config map
947                mount_path: "/system-params".to_owned(),
948                read_only: Some(true),
949                ..Default::default()
950            });
951            args.push("--config-sync-file-path=/system-params/system-params.json".to_string());
952            args.push("--config-sync-loop-interval=1s".to_string());
953        }
954    }
955
956    // Add Sentry arguments.
957    if let Some(sentry_dsn) = &config.tracing.sentry_dsn {
958        args.push(format!("--sentry-dsn={}", sentry_dsn));
959        if let Some(sentry_environment) = &config.tracing.sentry_environment {
960            args.push(format!("--sentry-environment={}", sentry_environment));
961        }
962        args.push(format!("--sentry-tag=region={}", config.region));
963    }
964
965    // Add Persist PubSub arguments
966    args.push(format!(
967        "--persist-pubsub-url=http://{}:{}",
968        mz.persist_pubsub_service_name(generation),
969        config.environmentd_internal_persist_pubsub_port,
970    ));
971    args.push(format!(
972        "--internal-persist-pubsub-listen-addr=0.0.0.0:{}",
973        config.environmentd_internal_persist_pubsub_port
974    ));
975
976    args.push(format!("--deploy-generation={}", generation));
977
978    // Add URL for internal user impersonation endpoint
979    args.push(format!(
980        "--internal-console-redirect-url={}",
981        config.internal_console_proxy_url,
982    ));
983
984    if !config.collect_pod_metrics {
985        args.push("--orchestrator-kubernetes-disable-pod-metrics-collection".into());
986    }
987    if config.enable_prometheus_scrape_annotations {
988        args.push("--orchestrator-kubernetes-enable-prometheus-scrape-annotations".into());
989    }
990
991    // the --disable-license-key-checks environmentd flag only existed
992    // between these versions
993    if config.disable_license_key_checks {
994        if mz.meets_minimum_version(&V143) && !mz.meets_minimum_version(&V153) {
995            args.push("--disable-license-key-checks".into());
996        }
997    }
998
999    // as of version 0.153, the ability to disable license key checks was
1000    // removed, so we should always set up license keys in that case
1001    if (mz.meets_minimum_version(&V140_DEV0) && !config.disable_license_key_checks)
1002        || mz.meets_minimum_version(&V153)
1003    {
1004        volume_mounts.push(VolumeMount {
1005            name: "license-key".to_string(),
1006            mount_path: "/license_key".to_string(),
1007            ..Default::default()
1008        });
1009        volumes.push(Volume {
1010            name: "license-key".to_string(),
1011            secret: Some(SecretVolumeSource {
1012                default_mode: Some(256),
1013                optional: Some(false),
1014                secret_name: Some(mz.backend_secret_name()),
1015                items: Some(vec![KeyToPath {
1016                    key: "license_key".to_string(),
1017                    path: "license_key".to_string(),
1018                    ..Default::default()
1019                }]),
1020                ..Default::default()
1021            }),
1022            ..Default::default()
1023        });
1024        env.push(EnvVar {
1025            name: "MZ_LICENSE_KEY".to_string(),
1026            value: Some("/license_key/license_key".to_string()),
1027            ..Default::default()
1028        });
1029    }
1030
1031    // Add user-specified extra arguments.
1032    if let Some(extra_args) = &mz.spec.environmentd_extra_args {
1033        args.extend(extra_args.iter().cloned());
1034    }
1035
1036    // NOTE: environmentd binds its SQL listeners before booting but only
1037    // accepts once boot completes, so connections queue in the kernel until
1038    // then. balancerd and clients use the external SQL port and can fill its
1039    // queue, after which TCP probes against it time out and liveness kills the
1040    // pod mid-boot. Probing the internal port keeps client traffic out of the
1041    // probes' queue. Some authenticator kinds have no internal SQL listener,
1042    // and probing an unbound port fails every check, so fall back to the
1043    // external one.
1044    let listeners_config = create_v0_147_0_listeners_config(config, mz);
1045    let probe_port = listeners_config
1046        .sql
1047        .get("internal")
1048        .or_else(|| listeners_config.sql.get("external"))
1049        .expect("external SQL listener is always configured")
1050        .addr
1051        .port();
1052    let probe = Probe {
1053        initial_delay_seconds: Some(1),
1054        failure_threshold: Some(12),
1055        tcp_socket: Some(TCPSocketAction {
1056            host: None,
1057            port: IntOrString::Int(probe_port.into()),
1058        }),
1059        ..Default::default()
1060    };
1061
1062    let security_context = if config.enable_security_context {
1063        // Since we want to adhere to the most restrictive security context, all
1064        // of these fields have to be set how they are.
1065        // See https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
1066        Some(SecurityContext {
1067            run_as_non_root: Some(true),
1068            capabilities: Some(Capabilities {
1069                drop: Some(vec!["ALL".to_string()]),
1070                ..Default::default()
1071            }),
1072            seccomp_profile: Some(SeccompProfile {
1073                type_: "RuntimeDefault".to_string(),
1074                ..Default::default()
1075            }),
1076            allow_privilege_escalation: Some(false),
1077            ..Default::default()
1078        })
1079    } else {
1080        None
1081    };
1082
1083    let ports = vec![
1084        ContainerPort {
1085            container_port: config.environmentd_sql_port.into(),
1086            name: Some("sql".to_owned()),
1087            ..Default::default()
1088        },
1089        ContainerPort {
1090            container_port: config.environmentd_internal_sql_port.into(),
1091            name: Some("internal-sql".to_owned()),
1092            ..Default::default()
1093        },
1094        ContainerPort {
1095            container_port: config.environmentd_http_port.into(),
1096            name: Some("http".to_owned()),
1097            ..Default::default()
1098        },
1099        ContainerPort {
1100            container_port: config.environmentd_internal_http_port.into(),
1101            name: Some("internal-http".to_owned()),
1102            ..Default::default()
1103        },
1104        ContainerPort {
1105            container_port: config.environmentd_internal_persist_pubsub_port.into(),
1106            name: Some("persist-pubsub".to_owned()),
1107            ..Default::default()
1108        },
1109    ];
1110
1111    // Add networking arguments.
1112    if mz.meets_minimum_version(&V147_DEV0) {
1113        volume_mounts.push(VolumeMount {
1114            name: "listeners-configmap".to_string(),
1115            mount_path: "/listeners".to_string(),
1116            ..Default::default()
1117        });
1118        volumes.push(Volume {
1119            name: "listeners-configmap".to_string(),
1120            config_map: Some(ConfigMapVolumeSource {
1121                name: mz.listeners_configmap_name(generation),
1122                default_mode: Some(256),
1123                optional: Some(false),
1124                items: Some(vec![KeyToPath {
1125                    key: "listeners.json".to_string(),
1126                    path: "listeners.json".to_string(),
1127                    ..Default::default()
1128                }]),
1129            }),
1130            ..Default::default()
1131        });
1132        args.push("--listeners-config-path=/listeners/listeners.json".to_owned());
1133        if matches!(
1134            mz.spec.authenticator_kind,
1135            AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1136        ) {
1137            args.push("--system-parameter-default=enable_password_auth=true".into());
1138            env.push(EnvVar {
1139                name: "MZ_EXTERNAL_LOGIN_PASSWORD_MZ_SYSTEM".to_string(),
1140                value_from: Some(EnvVarSource {
1141                    secret_key_ref: Some(SecretKeySelector {
1142                        name: mz.backend_secret_name(),
1143                        key: "external_login_password_mz_system".to_string(),
1144                        optional: Some(false),
1145                    }),
1146                    ..Default::default()
1147                }),
1148                ..Default::default()
1149            })
1150        }
1151    } else {
1152        args.extend([
1153            format!("--sql-listen-addr=0.0.0.0:{}", config.environmentd_sql_port),
1154            format!(
1155                "--http-listen-addr=0.0.0.0:{}",
1156                config.environmentd_http_port
1157            ),
1158            format!(
1159                "--internal-sql-listen-addr=0.0.0.0:{}",
1160                config.environmentd_internal_sql_port
1161            ),
1162            format!(
1163                "--internal-http-listen-addr=0.0.0.0:{}",
1164                config.environmentd_internal_http_port
1165            ),
1166        ]);
1167    }
1168
1169    let container = Container {
1170        name: "environmentd".to_owned(),
1171        image: Some(mz.spec.environmentd_image_ref.to_owned()),
1172        image_pull_policy: Some(config.image_pull_policy.to_string()),
1173        ports: Some(ports),
1174        args: Some(args),
1175        env: Some(env),
1176        volume_mounts: Some(volume_mounts),
1177        liveness_probe: Some(probe.clone()),
1178        readiness_probe: Some(probe),
1179        resources: mz
1180            .spec
1181            .environmentd_resource_requirements
1182            .clone()
1183            .or_else(|| config.environmentd_default_resources.clone()),
1184        security_context: security_context.clone(),
1185        ..Default::default()
1186    };
1187
1188    let mut pod_template_labels = mz.default_labels();
1189    pod_template_labels.insert(
1190        "materialize.cloud/name".to_owned(),
1191        mz.environmentd_statefulset_name(generation),
1192    );
1193    pod_template_labels.insert(
1194        "materialize.cloud/app".to_owned(),
1195        mz.environmentd_app_name(),
1196    );
1197    pod_template_labels.extend(recommended_k8s_labels("environmentd".into()));
1198    pod_template_labels.extend(
1199        mz.spec
1200            .pod_labels
1201            .as_ref()
1202            .map(|labels| labels.iter())
1203            .unwrap_or_default()
1204            .map(|(key, value)| (key.clone(), value.clone())),
1205    );
1206
1207    let mut pod_template_annotations = btreemap! {
1208        // We can re-enable eviction once we have HA
1209        "cluster-autoscaler.kubernetes.io/safe-to-evict".to_owned() => "false".to_string(),
1210
1211        // Prevents old (< 0.30) and new versions of karpenter from evicting database pods
1212        "karpenter.sh/do-not-evict".to_owned() => "true".to_string(),
1213        "karpenter.sh/do-not-disrupt".to_owned() => "true".to_string(),
1214        "materialize.cloud/generation".to_owned() => generation.to_string(),
1215    };
1216    if config.enable_prometheus_scrape_annotations {
1217        pod_template_annotations.insert("prometheus.io/scrape".to_owned(), "true".to_string());
1218        pod_template_annotations.insert(
1219            "prometheus.io/port".to_owned(),
1220            config.environmentd_internal_http_port.to_string(),
1221        );
1222        pod_template_annotations.insert("prometheus.io/path".to_owned(), "/metrics".to_string());
1223        pod_template_annotations.insert("prometheus.io/scheme".to_owned(), "http".to_string());
1224        pod_template_annotations.insert(
1225            "materialize.prometheus.io/mz_usage_path".to_owned(),
1226            "/metrics/mz_usage".to_string(),
1227        );
1228        pod_template_annotations.insert(
1229            "materialize.prometheus.io/mz_frontier_path".to_owned(),
1230            "/metrics/mz_frontier".to_string(),
1231        );
1232        pod_template_annotations.insert(
1233            "materialize.prometheus.io/mz_compute_path".to_owned(),
1234            "/metrics/mz_compute".to_string(),
1235        );
1236        pod_template_annotations.insert(
1237            "materialize.prometheus.io/mz_storage_path".to_owned(),
1238            "/metrics/mz_storage".to_string(),
1239        );
1240    }
1241    pod_template_annotations.extend(
1242        mz.spec
1243            .pod_annotations
1244            .as_ref()
1245            .map(|annotations| annotations.iter())
1246            .unwrap_or_default()
1247            .map(|(key, value)| (key.clone(), value.clone())),
1248    );
1249
1250    let mut tolerations = vec![
1251        // When the node becomes `NotReady` it indicates there is a problem with the node,
1252        // By default kubernetes waits 300s (5 minutes) before doing anything in this case,
1253        // But we want to limit this to 30s for faster recovery
1254        Toleration {
1255            effect: Some("NoExecute".into()),
1256            key: Some("node.kubernetes.io/not-ready".into()),
1257            operator: Some("Exists".into()),
1258            toleration_seconds: Some(30),
1259            value: None,
1260        },
1261        Toleration {
1262            effect: Some("NoExecute".into()),
1263            key: Some("node.kubernetes.io/unreachable".into()),
1264            operator: Some("Exists".into()),
1265            toleration_seconds: Some(30),
1266            value: None,
1267        },
1268    ];
1269    if let Some(user_tolerations) = &config.environmentd_tolerations {
1270        tolerations.extend(user_tolerations.iter().cloned());
1271    }
1272    let tolerations = Some(tolerations);
1273
1274    let pod_template_spec = PodTemplateSpec {
1275        // not using managed_resource_meta because the pod should be owned
1276        // by the statefulset, not the materialize instance
1277        metadata: Some(ObjectMeta {
1278            labels: Some(pod_template_labels),
1279            annotations: Some(pod_template_annotations), // This is inserted into later, do not delete.
1280            ..Default::default()
1281        }),
1282        spec: Some(PodSpec {
1283            containers: vec![container],
1284            node_selector: Some(
1285                config
1286                    .environmentd_node_selector
1287                    .iter()
1288                    .map(|selector| (selector.key.clone(), selector.value.clone()))
1289                    .collect(),
1290            ),
1291            affinity: config.environmentd_affinity.clone(),
1292            scheduler_name: config.scheduler_name.clone(),
1293            priority_class_name: config.environmentd_priority_class_name.clone(),
1294            service_account_name: Some(mz.service_account_name()),
1295            volumes: Some(volumes),
1296            security_context: Some(PodSecurityContext {
1297                fs_group: Some(999),
1298                run_as_user: Some(999),
1299                run_as_group: Some(999),
1300                ..Default::default()
1301            }),
1302            tolerations,
1303            // This (apparently) has the side effect of automatically starting a new pod
1304            // when the previous pod is currently terminating. This side steps the statefulset fencing
1305            // but allows for quicker recovery from a failed node
1306            // The Kubernetes documentation strongly advises against this
1307            // setting, as StatefulSets attempt to provide "at most once"
1308            // semantics [0]-- that is, the guarantee that for a given pod in a
1309            // StatefulSet there is *at most* one pod with that identity running
1310            // in the cluster
1311            //
1312            // Materialize, however, has been carefully designed to *not* rely
1313            // on this guarantee. (In fact, we do not believe that correct
1314            // distributed systems can meaningfully rely on Kubernetes's
1315            // guarantee--network packets from a pod can be arbitrarily delayed,
1316            // long past that pod's termination.) Running two `environmentd`
1317            // processes is safe: the newer process will safely and correctly
1318            // fence out the older process via CockroachDB. In the future,
1319            // we'll support running multiple `environmentd` processes in
1320            // parallel for high availability.
1321            //
1322            // [0]: https://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/#statefulset-considerations
1323            termination_grace_period_seconds: Some(0),
1324            ..Default::default()
1325        }),
1326    };
1327
1328    let mut match_labels = BTreeMap::new();
1329    match_labels.insert(
1330        "materialize.cloud/name".to_owned(),
1331        mz.environmentd_statefulset_name(generation),
1332    );
1333
1334    let statefulset_spec = StatefulSetSpec {
1335        replicas: Some(1),
1336        template: pod_template_spec,
1337        service_name: Some(mz.environmentd_service_name()),
1338        selector: LabelSelector {
1339            match_expressions: None,
1340            match_labels: Some(match_labels),
1341        },
1342        ..Default::default()
1343    };
1344
1345    StatefulSet {
1346        metadata: ObjectMeta {
1347            annotations: Some(btreemap! {
1348                "materialize.cloud/generation".to_owned() => generation.to_string(),
1349                "materialize.cloud/force".to_owned() => mz.force_rollout_value(),
1350            }),
1351            ..mz.managed_resource_meta(mz.environmentd_statefulset_name(generation))
1352        },
1353        spec: Some(statefulset_spec),
1354        status: None,
1355    }
1356}
1357
1358fn create_v0_147_0_listeners_config(
1359    config: &super::Config,
1360    mz: &Materialize,
1361) -> v0_147_0::ListenersConfig {
1362    let external_enable_tls = issuer_ref_defined(
1363        &config.default_certificate_specs.internal,
1364        &mz.spec.internal_certificate_spec,
1365    );
1366    let authenticator_kind = mz.spec.authenticator_kind;
1367
1368    let mut listeners_config = v0_147_0::ListenersConfig {
1369        sql: btreemap! {
1370            "external".to_owned() => SqlListenerConfig{
1371                addr: SocketAddr::new(
1372                    IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1373                    config.environmentd_sql_port,
1374                ),
1375                authenticator_kind,
1376                allowed_roles: AllowedRoles::Normal,
1377                enable_tls: external_enable_tls,
1378            },
1379            "internal".to_owned() => SqlListenerConfig{
1380                addr: SocketAddr::new(
1381                    IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1382                    config.environmentd_internal_sql_port,
1383                ),
1384                authenticator_kind: AuthenticatorKind::None,
1385                // Should this just be Internal?
1386                allowed_roles: AllowedRoles::NormalAndInternal,
1387                enable_tls: false,
1388            },
1389        },
1390        http: btreemap! {
1391            "external".to_owned() => v0_147_0::HttpListenerConfig{
1392                base: BaseListenerConfig {
1393                    addr: SocketAddr::new(
1394                        IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1395                        config.environmentd_http_port,
1396                    ),
1397                    // SASL authentication is only supported for SQL (PostgreSQL wire protocol).
1398                    // HTTP listeners must use Password authentication when SASL is enabled.
1399                    // This is validated at environmentd startup via ListenerConfig::validate().
1400                    authenticator_kind: if authenticator_kind == AuthenticatorKind::Sasl {
1401                        AuthenticatorKind::Password
1402                    } else {
1403                        authenticator_kind
1404                    },
1405                    allowed_roles: AllowedRoles::Normal,
1406                    enable_tls: external_enable_tls,
1407                },
1408                routes: v0_147_0::HttpRoutes{
1409                    base: true,
1410                    webhook: true,
1411                    internal: false,
1412                    metrics: false,
1413                    profiling: false,
1414                    mcp_agent: true,
1415                    mcp_developer: true,
1416                    console_config: true,
1417                },
1418            },
1419            "internal".to_owned() => v0_147_0::HttpListenerConfig{
1420                base: BaseListenerConfig {
1421                    addr: SocketAddr::new(
1422                        IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1423                        config.environmentd_internal_http_port,
1424                    ),
1425                    authenticator_kind: AuthenticatorKind::None,
1426                    // Should this just be Internal?
1427                    allowed_roles: AllowedRoles::NormalAndInternal,
1428                    enable_tls: false,
1429                },
1430                routes: v0_147_0::HttpRoutes{
1431                    base: true,
1432                    webhook: true,
1433                    internal: true,
1434                    metrics: true,
1435                    profiling: true,
1436                    mcp_agent: true,
1437                    mcp_developer: true,
1438                    console_config: false,
1439                },
1440            },
1441        },
1442    };
1443
1444    // For password/oidc/sasl auth, we want to get rid of the internal port and
1445    // combine the external and internal listeners into a single listener.
1446    if matches!(
1447        authenticator_kind,
1448        AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1449    ) {
1450        listeners_config.sql.remove("internal");
1451        listeners_config.http.remove("internal");
1452
1453        listeners_config.sql.get_mut("external").map(|listener| {
1454            listener.allowed_roles = AllowedRoles::NormalAndInternal;
1455            listener
1456        });
1457        listeners_config.http.get_mut("external").map(|listener| {
1458            listener.base.allowed_roles = AllowedRoles::NormalAndInternal;
1459            listener.routes.internal = true;
1460            listener.routes.profiling = true;
1461            listener
1462        });
1463
1464        listeners_config.http.insert(
1465            "metrics".to_owned(),
1466            v0_147_0::HttpListenerConfig {
1467                base: BaseListenerConfig {
1468                    addr: SocketAddr::new(
1469                        IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
1470                        config.environmentd_internal_http_port,
1471                    ),
1472                    authenticator_kind: AuthenticatorKind::None,
1473                    allowed_roles: AllowedRoles::NormalAndInternal,
1474                    enable_tls: false,
1475                },
1476                routes: v0_147_0::HttpRoutes {
1477                    base: false,
1478                    webhook: false,
1479                    internal: false,
1480                    metrics: true,
1481                    profiling: false,
1482                    mcp_agent: false,
1483                    mcp_developer: false,
1484                    console_config: false,
1485                },
1486            },
1487        );
1488    };
1489    listeners_config
1490}
1491
1492fn create_connection_info(
1493    config: &super::Config,
1494    mz: &Materialize,
1495    generation: u64,
1496) -> ConnectionInfo {
1497    let external_enable_tls = issuer_ref_defined(
1498        &config.default_certificate_specs.internal,
1499        &mz.spec.internal_certificate_spec,
1500    );
1501    let authenticator_kind = mz.spec.authenticator_kind;
1502
1503    let listeners_config = create_v0_147_0_listeners_config(config, mz);
1504
1505    // Serve each `environmentd` the schema its version understands. Newer
1506    // versions parse the current (`ListenersConfig`) schema; older versions
1507    // parse the legacy (`v0_147_0::ListenersConfig`) schema.
1508    let listeners_json = if mz.meets_minimum_version(&PER_ROUTE_GROUP_ROLES_VERSION) {
1509        // Convert the legacy `v0_147_0::ListenersConfig` schema to the current `ListenersConfig` schema.
1510        // The main difference is instead of having a single `allowed_roles` property in the HTTP listener
1511        // for all route groups, we now have a per-route group `allowed_roles` property. The migration cascades
1512        // the top level `allowed_roles` into every route group.
1513        let mut listeners_config: v26_32_0::ListenersConfig = listeners_config.into();
1514        // The sole reason for the new `ListenersConfig` schema is because for password/oidc/sasl
1515        // authentication, we were allowing normal roles to access internal and profiling routes
1516        // since we now have a single listener for both external and internal traffic. However we
1517        // want to only allow internal roles to access these routes. Thus we set the `allowed_roles`
1518        // for the internal and profiling route groups to `Internal` and leave the other route groups
1519        // as `NormalAndInternal`.
1520        if matches!(
1521            authenticator_kind,
1522            AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1523        ) {
1524            listeners_config.http.get_mut("external").map(|listener| {
1525                listener.routes.internal = RouteGroup::Enabled(AllowedRoles::Internal);
1526                listener.routes.profiling = RouteGroup::Enabled(AllowedRoles::Internal);
1527                listener
1528            });
1529        }
1530
1531        serde_json::to_string(&VersionedListenersConfig::V2(listeners_config)).expect("known valid")
1532    } else {
1533        serde_json::to_string(&VersionedListenersConfig::V1(listeners_config)).expect("known valid")
1534    };
1535    let listeners_configmap = ConfigMap {
1536        binary_data: None,
1537        data: Some(btreemap! {
1538            "listeners.json".to_owned() => listeners_json,
1539        }),
1540        immutable: None,
1541        metadata: ObjectMeta {
1542            annotations: Some(btreemap! {
1543                "materialize.cloud/generation".to_owned() => generation.to_string(),
1544            }),
1545            ..mz.managed_resource_meta(mz.listeners_configmap_name(generation))
1546        },
1547    };
1548
1549    let (scheme, leader_api_port, mz_system_secret_name) = match authenticator_kind {
1550        AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc => {
1551            let scheme = if external_enable_tls { "https" } else { "http" };
1552            (
1553                scheme,
1554                config.environmentd_http_port,
1555                Some(mz.spec.backend_secret_name.clone()),
1556            )
1557        }
1558        _ => ("http", config.environmentd_internal_http_port, None),
1559    };
1560    let environmentd_url = format!(
1561        "{}://{}.{}.svc.cluster.local:{}",
1562        scheme,
1563        mz.environmentd_generation_service_name(generation),
1564        mz.namespace(),
1565        leader_api_port,
1566    );
1567    ConnectionInfo {
1568        environmentd_url,
1569        listeners_configmap,
1570        mz_system_secret_name,
1571    }
1572}
1573
1574// see materialize/src/environmentd/src/http.rs
1575#[derive(Debug, Deserialize, PartialEq, Eq)]
1576struct BecomeLeaderResponse {
1577    result: BecomeLeaderResult,
1578}
1579
1580#[derive(Debug, Deserialize, PartialEq, Eq)]
1581enum BecomeLeaderResult {
1582    Success,
1583    Failure { message: String },
1584}
1585
1586#[derive(Debug, Deserialize, PartialEq, Eq)]
1587struct SkipCatchupError {
1588    message: String,
1589}