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