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 base_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    let readiness_probe = environmentd_probe(
1062        base_probe.clone(),
1063        config.environmentd_default_readiness_probe.as_ref(),
1064        mz.spec.environmentd_readiness_probe.as_ref(),
1065    );
1066    let liveness_probe = environmentd_probe(
1067        base_probe.clone(),
1068        config.environmentd_default_liveness_probe.as_ref(),
1069        mz.spec.environmentd_liveness_probe.as_ref(),
1070    );
1071    // Unlike readiness and liveness, environmentd has no startup probe unless
1072    // one is configured.
1073    let startup_probe = (config.environmentd_default_startup_probe.is_some()
1074        || mz.spec.environmentd_startup_probe.is_some())
1075    .then(|| {
1076        environmentd_probe(
1077            base_probe,
1078            config.environmentd_default_startup_probe.as_ref(),
1079            mz.spec.environmentd_startup_probe.as_ref(),
1080        )
1081    });
1082
1083    let security_context = if config.enable_security_context {
1084        // Since we want to adhere to the most restrictive security context, all
1085        // of these fields have to be set how they are.
1086        // See https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
1087        Some(SecurityContext {
1088            run_as_non_root: Some(true),
1089            capabilities: Some(Capabilities {
1090                drop: Some(vec!["ALL".to_string()]),
1091                ..Default::default()
1092            }),
1093            seccomp_profile: Some(SeccompProfile {
1094                type_: "RuntimeDefault".to_string(),
1095                ..Default::default()
1096            }),
1097            allow_privilege_escalation: Some(false),
1098            ..Default::default()
1099        })
1100    } else {
1101        None
1102    };
1103
1104    let ports = vec![
1105        ContainerPort {
1106            container_port: config.environmentd_sql_port.into(),
1107            name: Some("sql".to_owned()),
1108            ..Default::default()
1109        },
1110        ContainerPort {
1111            container_port: config.environmentd_internal_sql_port.into(),
1112            name: Some("internal-sql".to_owned()),
1113            ..Default::default()
1114        },
1115        ContainerPort {
1116            container_port: config.environmentd_http_port.into(),
1117            name: Some("http".to_owned()),
1118            ..Default::default()
1119        },
1120        ContainerPort {
1121            container_port: config.environmentd_internal_http_port.into(),
1122            name: Some("internal-http".to_owned()),
1123            ..Default::default()
1124        },
1125        ContainerPort {
1126            container_port: config.environmentd_internal_persist_pubsub_port.into(),
1127            name: Some("persist-pubsub".to_owned()),
1128            ..Default::default()
1129        },
1130    ];
1131
1132    // Add networking arguments.
1133    if mz.meets_minimum_version(&V147_DEV0) {
1134        volume_mounts.push(VolumeMount {
1135            name: "listeners-configmap".to_string(),
1136            mount_path: "/listeners".to_string(),
1137            ..Default::default()
1138        });
1139        volumes.push(Volume {
1140            name: "listeners-configmap".to_string(),
1141            config_map: Some(ConfigMapVolumeSource {
1142                name: mz.listeners_configmap_name(generation),
1143                default_mode: Some(256),
1144                optional: Some(false),
1145                items: Some(vec![KeyToPath {
1146                    key: "listeners.json".to_string(),
1147                    path: "listeners.json".to_string(),
1148                    ..Default::default()
1149                }]),
1150            }),
1151            ..Default::default()
1152        });
1153        args.push("--listeners-config-path=/listeners/listeners.json".to_owned());
1154        if matches!(
1155            mz.spec.authenticator_kind,
1156            AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1157        ) {
1158            args.push("--system-parameter-default=enable_password_auth=true".into());
1159            env.push(EnvVar {
1160                name: "MZ_EXTERNAL_LOGIN_PASSWORD_MZ_SYSTEM".to_string(),
1161                value_from: Some(EnvVarSource {
1162                    secret_key_ref: Some(SecretKeySelector {
1163                        name: mz.backend_secret_name(),
1164                        key: "external_login_password_mz_system".to_string(),
1165                        optional: Some(false),
1166                    }),
1167                    ..Default::default()
1168                }),
1169                ..Default::default()
1170            })
1171        }
1172    } else {
1173        args.extend([
1174            format!("--sql-listen-addr=0.0.0.0:{}", config.environmentd_sql_port),
1175            format!(
1176                "--http-listen-addr=0.0.0.0:{}",
1177                config.environmentd_http_port
1178            ),
1179            format!(
1180                "--internal-sql-listen-addr=0.0.0.0:{}",
1181                config.environmentd_internal_sql_port
1182            ),
1183            format!(
1184                "--internal-http-listen-addr=0.0.0.0:{}",
1185                config.environmentd_internal_http_port
1186            ),
1187        ]);
1188    }
1189
1190    let container = Container {
1191        name: "environmentd".to_owned(),
1192        image: Some(mz.spec.environmentd_image_ref.to_owned()),
1193        image_pull_policy: Some(config.image_pull_policy.to_string()),
1194        ports: Some(ports),
1195        args: Some(args),
1196        env: Some(env),
1197        volume_mounts: Some(volume_mounts),
1198        startup_probe,
1199        liveness_probe: Some(liveness_probe),
1200        readiness_probe: Some(readiness_probe),
1201        resources: mz
1202            .spec
1203            .environmentd_resource_requirements
1204            .clone()
1205            .or_else(|| config.environmentd_default_resources.clone()),
1206        security_context: security_context.clone(),
1207        ..Default::default()
1208    };
1209
1210    let mut pod_template_labels = mz.default_labels();
1211    pod_template_labels.insert(
1212        "materialize.cloud/name".to_owned(),
1213        mz.environmentd_statefulset_name(generation),
1214    );
1215    pod_template_labels.insert(
1216        "materialize.cloud/app".to_owned(),
1217        mz.environmentd_app_name(),
1218    );
1219    pod_template_labels.extend(recommended_k8s_labels("environmentd".into()));
1220    pod_template_labels.extend(
1221        mz.spec
1222            .pod_labels
1223            .as_ref()
1224            .map(|labels| labels.iter())
1225            .unwrap_or_default()
1226            .map(|(key, value)| (key.clone(), value.clone())),
1227    );
1228
1229    let mut pod_template_annotations = btreemap! {
1230        // We can re-enable eviction once we have HA
1231        "cluster-autoscaler.kubernetes.io/safe-to-evict".to_owned() => "false".to_string(),
1232
1233        // Prevents old (< 0.30) and new versions of karpenter from evicting database pods
1234        "karpenter.sh/do-not-evict".to_owned() => "true".to_string(),
1235        "karpenter.sh/do-not-disrupt".to_owned() => "true".to_string(),
1236        "materialize.cloud/generation".to_owned() => generation.to_string(),
1237    };
1238    if config.enable_prometheus_scrape_annotations {
1239        pod_template_annotations.insert("prometheus.io/scrape".to_owned(), "true".to_string());
1240        pod_template_annotations.insert(
1241            "prometheus.io/port".to_owned(),
1242            config.environmentd_internal_http_port.to_string(),
1243        );
1244        pod_template_annotations.insert("prometheus.io/path".to_owned(), "/metrics".to_string());
1245        pod_template_annotations.insert("prometheus.io/scheme".to_owned(), "http".to_string());
1246        pod_template_annotations.insert(
1247            "materialize.prometheus.io/mz_usage_path".to_owned(),
1248            "/metrics/mz_usage".to_string(),
1249        );
1250        pod_template_annotations.insert(
1251            "materialize.prometheus.io/mz_frontier_path".to_owned(),
1252            "/metrics/mz_frontier".to_string(),
1253        );
1254        pod_template_annotations.insert(
1255            "materialize.prometheus.io/mz_compute_path".to_owned(),
1256            "/metrics/mz_compute".to_string(),
1257        );
1258        pod_template_annotations.insert(
1259            "materialize.prometheus.io/mz_storage_path".to_owned(),
1260            "/metrics/mz_storage".to_string(),
1261        );
1262    }
1263    pod_template_annotations.extend(
1264        mz.spec
1265            .pod_annotations
1266            .as_ref()
1267            .map(|annotations| annotations.iter())
1268            .unwrap_or_default()
1269            .map(|(key, value)| (key.clone(), value.clone())),
1270    );
1271
1272    let mut tolerations = vec![
1273        // When the node becomes `NotReady` it indicates there is a problem with the node,
1274        // By default kubernetes waits 300s (5 minutes) before doing anything in this case,
1275        // But we want to limit this to 30s for faster recovery
1276        Toleration {
1277            effect: Some("NoExecute".into()),
1278            key: Some("node.kubernetes.io/not-ready".into()),
1279            operator: Some("Exists".into()),
1280            toleration_seconds: Some(30),
1281            value: None,
1282        },
1283        Toleration {
1284            effect: Some("NoExecute".into()),
1285            key: Some("node.kubernetes.io/unreachable".into()),
1286            operator: Some("Exists".into()),
1287            toleration_seconds: Some(30),
1288            value: None,
1289        },
1290    ];
1291    if let Some(user_tolerations) = &config.environmentd_tolerations {
1292        tolerations.extend(user_tolerations.iter().cloned());
1293    }
1294    let tolerations = Some(tolerations);
1295
1296    let pod_template_spec = PodTemplateSpec {
1297        // not using managed_resource_meta because the pod should be owned
1298        // by the statefulset, not the materialize instance
1299        metadata: Some(ObjectMeta {
1300            labels: Some(pod_template_labels),
1301            annotations: Some(pod_template_annotations), // This is inserted into later, do not delete.
1302            ..Default::default()
1303        }),
1304        spec: Some(PodSpec {
1305            containers: vec![container],
1306            node_selector: Some(
1307                config
1308                    .environmentd_node_selector
1309                    .iter()
1310                    .map(|selector| (selector.key.clone(), selector.value.clone()))
1311                    .collect(),
1312            ),
1313            affinity: config.environmentd_affinity.clone(),
1314            scheduler_name: config.scheduler_name.clone(),
1315            priority_class_name: config.environmentd_priority_class_name.clone(),
1316            service_account_name: Some(mz.service_account_name()),
1317            volumes: Some(volumes),
1318            security_context: Some(PodSecurityContext {
1319                fs_group: Some(999),
1320                run_as_user: Some(999),
1321                run_as_group: Some(999),
1322                ..Default::default()
1323            }),
1324            tolerations,
1325            // This (apparently) has the side effect of automatically starting a new pod
1326            // when the previous pod is currently terminating. This side steps the statefulset fencing
1327            // but allows for quicker recovery from a failed node
1328            // The Kubernetes documentation strongly advises against this
1329            // setting, as StatefulSets attempt to provide "at most once"
1330            // semantics [0]-- that is, the guarantee that for a given pod in a
1331            // StatefulSet there is *at most* one pod with that identity running
1332            // in the cluster
1333            //
1334            // Materialize, however, has been carefully designed to *not* rely
1335            // on this guarantee. (In fact, we do not believe that correct
1336            // distributed systems can meaningfully rely on Kubernetes's
1337            // guarantee--network packets from a pod can be arbitrarily delayed,
1338            // long past that pod's termination.) Running two `environmentd`
1339            // processes is safe: the newer process will safely and correctly
1340            // fence out the older process via CockroachDB. In the future,
1341            // we'll support running multiple `environmentd` processes in
1342            // parallel for high availability.
1343            //
1344            // [0]: https://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/#statefulset-considerations
1345            termination_grace_period_seconds: Some(0),
1346            ..Default::default()
1347        }),
1348    };
1349
1350    let mut match_labels = BTreeMap::new();
1351    match_labels.insert(
1352        "materialize.cloud/name".to_owned(),
1353        mz.environmentd_statefulset_name(generation),
1354    );
1355
1356    let statefulset_spec = StatefulSetSpec {
1357        replicas: Some(1),
1358        template: pod_template_spec,
1359        service_name: Some(mz.environmentd_service_name()),
1360        selector: LabelSelector {
1361            match_expressions: None,
1362            match_labels: Some(match_labels),
1363        },
1364        ..Default::default()
1365    };
1366
1367    StatefulSet {
1368        metadata: ObjectMeta {
1369            annotations: Some(btreemap! {
1370                "materialize.cloud/generation".to_owned() => generation.to_string(),
1371                "materialize.cloud/force".to_owned() => mz.force_rollout_value(),
1372            }),
1373            ..mz.managed_resource_meta(mz.environmentd_statefulset_name(generation))
1374        },
1375        spec: Some(statefulset_spec),
1376        status: None,
1377    }
1378}
1379
1380/// Rejects a probe that sets more than one handler (`exec`, `grpc`, `httpGet`,
1381/// `tcpSocket`), which Kubernetes would reject when the pod is created.
1382///
1383/// `environmentd_probe` only produces a valid probe if every layer passes
1384/// this check.
1385pub fn validate_probe_handlers(probe: &Probe) -> Result<(), anyhow::Error> {
1386    let handlers: Vec<_> = [
1387        ("exec", probe.exec.is_some()),
1388        ("grpc", probe.grpc.is_some()),
1389        ("httpGet", probe.http_get.is_some()),
1390        ("tcpSocket", probe.tcp_socket.is_some()),
1391    ]
1392    .into_iter()
1393    .filter_map(|(name, set)| set.then_some(name))
1394    .collect();
1395    if handlers.len() > 1 {
1396        anyhow::bail!(
1397            "probe sets more than one handler ({}), but at most one is allowed",
1398            handlers.join(", ")
1399        );
1400    }
1401    Ok(())
1402}
1403
1404/// Layers the operator-wide default and then the per-environment override onto
1405/// `base`, so each layer only has to set the fields it changes.
1406///
1407/// Fields set in a later layer win. The handler (`exec`, `grpc`, `httpGet`,
1408/// `tcpSocket`) is replaced as a unit when a later layer sets any of them,
1409/// because Kubernetes rejects a probe with more than one handler.
1410fn environmentd_probe(
1411    base: Probe,
1412    operator_default: Option<&Probe>,
1413    environment_override: Option<&Probe>,
1414) -> Probe {
1415    [operator_default, environment_override]
1416        .into_iter()
1417        .flatten()
1418        .fold(base, overlay_probe)
1419}
1420
1421fn overlay_probe(base: Probe, overlay: &Probe) -> Probe {
1422    // Exhaustive so that a new `Probe` field fails to compile here instead of
1423    // being silently dropped.
1424    let Probe {
1425        exec,
1426        failure_threshold,
1427        grpc,
1428        http_get,
1429        initial_delay_seconds,
1430        period_seconds,
1431        success_threshold,
1432        tcp_socket,
1433        termination_grace_period_seconds,
1434        timeout_seconds,
1435    } = overlay.clone();
1436    let has_handler =
1437        exec.is_some() || grpc.is_some() || http_get.is_some() || tcp_socket.is_some();
1438    let (exec, grpc, http_get, tcp_socket) = if has_handler {
1439        (exec, grpc, http_get, tcp_socket)
1440    } else {
1441        (base.exec, base.grpc, base.http_get, base.tcp_socket)
1442    };
1443    Probe {
1444        exec,
1445        failure_threshold: failure_threshold.or(base.failure_threshold),
1446        grpc,
1447        http_get,
1448        initial_delay_seconds: initial_delay_seconds.or(base.initial_delay_seconds),
1449        period_seconds: period_seconds.or(base.period_seconds),
1450        success_threshold: success_threshold.or(base.success_threshold),
1451        tcp_socket,
1452        termination_grace_period_seconds: termination_grace_period_seconds
1453            .or(base.termination_grace_period_seconds),
1454        timeout_seconds: timeout_seconds.or(base.timeout_seconds),
1455    }
1456}
1457
1458fn create_v0_147_0_listeners_config(
1459    config: &super::Config,
1460    mz: &Materialize,
1461) -> v0_147_0::ListenersConfig {
1462    let external_enable_tls = issuer_ref_defined(
1463        &config.default_certificate_specs.internal,
1464        &mz.spec.internal_certificate_spec,
1465    );
1466    let authenticator_kind = mz.spec.authenticator_kind;
1467
1468    let mut listeners_config = v0_147_0::ListenersConfig {
1469        sql: btreemap! {
1470            "external".to_owned() => SqlListenerConfig{
1471                addr: SocketAddr::new(
1472                    IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1473                    config.environmentd_sql_port,
1474                ),
1475                authenticator_kind,
1476                allowed_roles: AllowedRoles::Normal,
1477                enable_tls: external_enable_tls,
1478            },
1479            "internal".to_owned() => SqlListenerConfig{
1480                addr: SocketAddr::new(
1481                    IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1482                    config.environmentd_internal_sql_port,
1483                ),
1484                authenticator_kind: AuthenticatorKind::None,
1485                // Should this just be Internal?
1486                allowed_roles: AllowedRoles::NormalAndInternal,
1487                enable_tls: false,
1488            },
1489        },
1490        http: btreemap! {
1491            "external".to_owned() => v0_147_0::HttpListenerConfig{
1492                base: BaseListenerConfig {
1493                    addr: SocketAddr::new(
1494                        IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1495                        config.environmentd_http_port,
1496                    ),
1497                    // SASL authentication is only supported for SQL (PostgreSQL wire protocol).
1498                    // HTTP listeners must use Password authentication when SASL is enabled.
1499                    // This is validated at environmentd startup via ListenerConfig::validate().
1500                    authenticator_kind: if authenticator_kind == AuthenticatorKind::Sasl {
1501                        AuthenticatorKind::Password
1502                    } else {
1503                        authenticator_kind
1504                    },
1505                    allowed_roles: AllowedRoles::Normal,
1506                    enable_tls: external_enable_tls,
1507                },
1508                routes: v0_147_0::HttpRoutes{
1509                    base: true,
1510                    webhook: true,
1511                    internal: false,
1512                    metrics: false,
1513                    profiling: false,
1514                    mcp_agent: true,
1515                    mcp_developer: true,
1516                    console_config: true,
1517                },
1518            },
1519            "internal".to_owned() => v0_147_0::HttpListenerConfig{
1520                base: BaseListenerConfig {
1521                    addr: SocketAddr::new(
1522                        IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1523                        config.environmentd_internal_http_port,
1524                    ),
1525                    authenticator_kind: AuthenticatorKind::None,
1526                    // Should this just be Internal?
1527                    allowed_roles: AllowedRoles::NormalAndInternal,
1528                    enable_tls: false,
1529                },
1530                routes: v0_147_0::HttpRoutes{
1531                    base: true,
1532                    webhook: true,
1533                    internal: true,
1534                    metrics: true,
1535                    profiling: true,
1536                    mcp_agent: true,
1537                    mcp_developer: true,
1538                    console_config: false,
1539                },
1540            },
1541        },
1542    };
1543
1544    // For password/oidc/sasl auth, we want to get rid of the internal port and
1545    // combine the external and internal listeners into a single listener.
1546    if matches!(
1547        authenticator_kind,
1548        AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1549    ) {
1550        listeners_config.sql.remove("internal");
1551        listeners_config.http.remove("internal");
1552
1553        listeners_config.sql.get_mut("external").map(|listener| {
1554            listener.allowed_roles = AllowedRoles::NormalAndInternal;
1555            listener
1556        });
1557        listeners_config.http.get_mut("external").map(|listener| {
1558            listener.base.allowed_roles = AllowedRoles::NormalAndInternal;
1559            listener.routes.internal = true;
1560            listener.routes.profiling = true;
1561            listener
1562        });
1563
1564        listeners_config.http.insert(
1565            "metrics".to_owned(),
1566            v0_147_0::HttpListenerConfig {
1567                base: BaseListenerConfig {
1568                    addr: SocketAddr::new(
1569                        IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
1570                        config.environmentd_internal_http_port,
1571                    ),
1572                    authenticator_kind: AuthenticatorKind::None,
1573                    allowed_roles: AllowedRoles::NormalAndInternal,
1574                    enable_tls: false,
1575                },
1576                routes: v0_147_0::HttpRoutes {
1577                    base: false,
1578                    webhook: false,
1579                    internal: false,
1580                    metrics: true,
1581                    profiling: false,
1582                    mcp_agent: false,
1583                    mcp_developer: false,
1584                    console_config: false,
1585                },
1586            },
1587        );
1588    };
1589    listeners_config
1590}
1591
1592fn create_connection_info(
1593    config: &super::Config,
1594    mz: &Materialize,
1595    generation: u64,
1596) -> ConnectionInfo {
1597    let external_enable_tls = issuer_ref_defined(
1598        &config.default_certificate_specs.internal,
1599        &mz.spec.internal_certificate_spec,
1600    );
1601    let authenticator_kind = mz.spec.authenticator_kind;
1602
1603    let listeners_config = create_v0_147_0_listeners_config(config, mz);
1604
1605    // Serve each `environmentd` the schema its version understands. Newer
1606    // versions parse the current (`ListenersConfig`) schema; older versions
1607    // parse the legacy (`v0_147_0::ListenersConfig`) schema.
1608    let listeners_json = if mz.meets_minimum_version(&PER_ROUTE_GROUP_ROLES_VERSION) {
1609        // Convert the legacy `v0_147_0::ListenersConfig` schema to the current `ListenersConfig` schema.
1610        // The main difference is instead of having a single `allowed_roles` property in the HTTP listener
1611        // for all route groups, we now have a per-route group `allowed_roles` property. The migration cascades
1612        // the top level `allowed_roles` into every route group.
1613        let mut listeners_config: v26_32_0::ListenersConfig = listeners_config.into();
1614        // The sole reason for the new `ListenersConfig` schema is because for password/oidc/sasl
1615        // authentication, we were allowing normal roles to access internal and profiling routes
1616        // since we now have a single listener for both external and internal traffic. However we
1617        // want to only allow internal roles to access these routes. Thus we set the `allowed_roles`
1618        // for the internal and profiling route groups to `Internal` and leave the other route groups
1619        // as `NormalAndInternal`.
1620        if matches!(
1621            authenticator_kind,
1622            AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1623        ) {
1624            listeners_config.http.get_mut("external").map(|listener| {
1625                listener.routes.internal = RouteGroup::Enabled(AllowedRoles::Internal);
1626                listener.routes.profiling = RouteGroup::Enabled(AllowedRoles::Internal);
1627                listener
1628            });
1629        }
1630
1631        serde_json::to_string(&VersionedListenersConfig::V2(listeners_config)).expect("known valid")
1632    } else {
1633        serde_json::to_string(&VersionedListenersConfig::V1(listeners_config)).expect("known valid")
1634    };
1635    let listeners_configmap = ConfigMap {
1636        binary_data: None,
1637        data: Some(btreemap! {
1638            "listeners.json".to_owned() => listeners_json,
1639        }),
1640        immutable: None,
1641        metadata: ObjectMeta {
1642            annotations: Some(btreemap! {
1643                "materialize.cloud/generation".to_owned() => generation.to_string(),
1644            }),
1645            ..mz.managed_resource_meta(mz.listeners_configmap_name(generation))
1646        },
1647    };
1648
1649    let (scheme, leader_api_port, mz_system_secret_name) = match authenticator_kind {
1650        AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc => {
1651            let scheme = if external_enable_tls { "https" } else { "http" };
1652            (
1653                scheme,
1654                config.environmentd_http_port,
1655                Some(mz.spec.backend_secret_name.clone()),
1656            )
1657        }
1658        _ => ("http", config.environmentd_internal_http_port, None),
1659    };
1660    let environmentd_url = format!(
1661        "{}://{}.{}.svc.cluster.local:{}",
1662        scheme,
1663        mz.environmentd_generation_service_name(generation),
1664        mz.namespace(),
1665        leader_api_port,
1666    );
1667    ConnectionInfo {
1668        environmentd_url,
1669        listeners_configmap,
1670        mz_system_secret_name,
1671    }
1672}
1673
1674// see materialize/src/environmentd/src/http.rs
1675#[derive(Debug, Deserialize, PartialEq, Eq)]
1676struct BecomeLeaderResponse {
1677    result: BecomeLeaderResult,
1678}
1679
1680#[derive(Debug, Deserialize, PartialEq, Eq)]
1681enum BecomeLeaderResult {
1682    Success,
1683    Failure { message: String },
1684}
1685
1686#[derive(Debug, Deserialize, PartialEq, Eq)]
1687struct SkipCatchupError {
1688    message: String,
1689}
1690
1691#[cfg(test)]
1692mod tests {
1693    use k8s_openapi::api::core::v1::{HTTPGetAction, Probe, TCPSocketAction};
1694    use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
1695
1696    use super::{environmentd_probe, validate_probe_handlers};
1697
1698    fn base() -> Probe {
1699        Probe {
1700            initial_delay_seconds: Some(1),
1701            failure_threshold: Some(12),
1702            tcp_socket: Some(TCPSocketAction {
1703                host: None,
1704                port: IntOrString::Int(6877),
1705            }),
1706            ..Default::default()
1707        }
1708    }
1709
1710    #[mz_ore::test]
1711    fn environmentd_probe_without_overrides_is_base() {
1712        assert_eq!(environmentd_probe(base(), None, None), base());
1713    }
1714
1715    #[mz_ore::test]
1716    fn environmentd_probe_layers_fields() {
1717        let operator_default = Probe {
1718            failure_threshold: Some(30),
1719            period_seconds: Some(20),
1720            ..Default::default()
1721        };
1722        let environment_override = Probe {
1723            period_seconds: Some(5),
1724            ..Default::default()
1725        };
1726        assert_eq!(
1727            environmentd_probe(base(), Some(&operator_default), Some(&environment_override)),
1728            Probe {
1729                failure_threshold: Some(30),
1730                period_seconds: Some(5),
1731                ..base()
1732            }
1733        );
1734    }
1735
1736    #[mz_ore::test]
1737    fn environmentd_probe_replaces_handler_as_unit() {
1738        let http_get = HTTPGetAction {
1739            path: Some("/api/readyz".to_owned()),
1740            port: IntOrString::Int(6878),
1741            ..Default::default()
1742        };
1743        let environment_override = Probe {
1744            http_get: Some(http_get.clone()),
1745            ..Default::default()
1746        };
1747        assert_eq!(
1748            environmentd_probe(base(), None, Some(&environment_override)),
1749            Probe {
1750                tcp_socket: None,
1751                http_get: Some(http_get),
1752                ..base()
1753            }
1754        );
1755    }
1756
1757    #[mz_ore::test]
1758    fn validate_probe_handlers_rejects_multiple_handlers() {
1759        assert!(validate_probe_handlers(&Probe::default()).is_ok());
1760        assert!(validate_probe_handlers(&base()).is_ok());
1761        let both = Probe {
1762            http_get: Some(HTTPGetAction {
1763                port: IntOrString::Int(6878),
1764                ..Default::default()
1765            }),
1766            ..base()
1767        };
1768        let err = validate_probe_handlers(&both).unwrap_err();
1769        assert!(
1770            err.to_string().contains("httpGet, tcpSocket"),
1771            "unexpected error: {err}"
1772        );
1773    }
1774}