Skip to main content

mz_orchestrator_kubernetes/
lib.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::collections::BTreeMap;
11use std::future::Future;
12use std::num::NonZero;
13use std::sync::{Arc, Mutex};
14use std::time::{Duration, Instant};
15use std::{env, fmt};
16
17use anyhow::{Context, anyhow, bail};
18use async_trait::async_trait;
19use chrono::DateTime;
20use clap::ValueEnum;
21use cloud_resource_controller::KubernetesResourceReader;
22use futures::TryFutureExt;
23use futures::stream::{BoxStream, StreamExt};
24use k8s_openapi::DeepMerge;
25use k8s_openapi::api::apps::v1::{StatefulSet, StatefulSetSpec, StatefulSetUpdateStrategy};
26use k8s_openapi::api::core::v1::{
27    Affinity, Capabilities, Container, ContainerPort, EnvVar, EnvVarSource, EphemeralVolumeSource,
28    NodeAffinity, NodeSelector, NodeSelectorRequirement, NodeSelectorTerm, ObjectFieldSelector,
29    ObjectReference, PersistentVolumeClaim, PersistentVolumeClaimSpec,
30    PersistentVolumeClaimTemplate, Pod, PodAffinity, PodAffinityTerm, PodAntiAffinity,
31    PodSecurityContext, PodSpec, PodTemplateSpec, PreferredSchedulingTerm, ResourceRequirements,
32    SeccompProfile, Secret, SecurityContext, Service as K8sService, ServicePort, ServiceSpec,
33    Sysctl, Toleration, TopologySpreadConstraint, Volume, VolumeMount, VolumeResourceRequirements,
34    WeightedPodAffinityTerm,
35};
36use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
37use k8s_openapi::apimachinery::pkg::apis::meta::v1::{
38    LabelSelector, LabelSelectorRequirement, OwnerReference,
39};
40use k8s_openapi::jiff::Timestamp;
41use kube::ResourceExt;
42use kube::api::{Api, DeleteParams, ObjectMeta, PartialObjectMetaExt, Patch, PatchParams};
43use kube::client::Client;
44use kube::error::Error as K8sError;
45use kube::runtime::{WatchStreamExt, watcher};
46use maplit::btreemap;
47use mz_cloud_resources::AwsExternalIdPrefix;
48use mz_cloud_resources::crd::vpc_endpoint::v1::VpcEndpoint;
49use mz_orchestrator::{
50    DiskLimit, LabelSelectionLogic, LabelSelector as MzLabelSelector, NamespacedOrchestrator,
51    OfflineReason, Orchestrator, Service, ServiceAssignments, ServiceConfig, ServiceEvent,
52    ServiceProcessMetrics, ServiceStatus, recommended_k8s_labels, scheduling_config::*,
53};
54use mz_ore::cast::CastInto;
55use mz_ore::retry::Retry;
56use mz_ore::task::AbortOnDropHandle;
57use serde::Deserialize;
58use sha2::{Digest, Sha256};
59use tokio::sync::{mpsc, oneshot};
60use tracing::{error, info, warn};
61
62pub mod cloud_resource_controller;
63pub mod secrets;
64pub mod util;
65
66const FIELD_MANAGER: &str = "environmentd";
67const NODE_FAILURE_THRESHOLD_SECONDS: i64 = 30;
68
69const POD_TEMPLATE_HASH_ANNOTATION: &str = "environmentd.materialize.cloud/pod-template-hash";
70
71/// Configures a [`KubernetesOrchestrator`].
72#[derive(Debug, Clone)]
73pub struct KubernetesOrchestratorConfig {
74    /// The name of a Kubernetes context to use, if the Kubernetes configuration
75    /// is loaded from the local kubeconfig.
76    pub context: String,
77    /// The name of a non-default Kubernetes scheduler to use, if any.
78    pub scheduler_name: Option<String>,
79    /// The name of a `PriorityClass` to assign to services, if any.
80    pub priority_class_name: Option<String>,
81    /// Annotations to install on every service created by the orchestrator.
82    pub service_annotations: BTreeMap<String, String>,
83    /// Labels to install on every service created by the orchestrator.
84    pub service_labels: BTreeMap<String, String>,
85    /// Node selector to install on every service created by the orchestrator.
86    pub service_node_selector: BTreeMap<String, String>,
87    /// Affinity to install on every service created by the orchestrator.
88    pub service_affinity: Option<String>,
89    /// Tolerations to install on every service created by the orchestrator.
90    pub service_tolerations: Option<String>,
91    /// The service account that each service should run as, if any.
92    pub service_account: Option<String>,
93    /// The image pull policy to set for services created by the orchestrator.
94    pub image_pull_policy: KubernetesImagePullPolicy,
95    /// An AWS external ID prefix to use when making AWS operations on behalf
96    /// of the environment.
97    pub aws_external_id_prefix: Option<AwsExternalIdPrefix>,
98    /// Whether to use code coverage mode or not. Always false for production.
99    pub coverage: bool,
100    /// The Kubernetes StorageClass to use for the ephemeral volume attached to
101    /// services that request disk.
102    ///
103    /// If unspecified, the orchestrator will refuse to create services that
104    /// request disk.
105    pub ephemeral_volume_storage_class: Option<String>,
106    /// The optional fs group for service's pods' `securityContext`.
107    pub service_fs_group: Option<i64>,
108    /// The prefix to prepend to all object names
109    pub name_prefix: Option<String>,
110    /// Whether we should attempt to collect metrics from kubernetes
111    pub collect_pod_metrics: bool,
112    /// Whether to annotate pods for prometheus service discovery.
113    pub enable_prometheus_scrape_annotations: bool,
114}
115
116impl KubernetesOrchestratorConfig {
117    pub fn name_prefix(&self) -> String {
118        self.name_prefix.clone().unwrap_or_default()
119    }
120}
121
122/// Specifies whether Kubernetes should pull Docker images when creating pods.
123#[derive(ValueEnum, Debug, Clone, Copy)]
124pub enum KubernetesImagePullPolicy {
125    /// Always pull the Docker image from the registry.
126    Always,
127    /// Pull the Docker image only if the image is not present.
128    IfNotPresent,
129    /// Never pull the Docker image.
130    Never,
131}
132
133impl fmt::Display for KubernetesImagePullPolicy {
134    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
135        match self {
136            KubernetesImagePullPolicy::Always => f.write_str("Always"),
137            KubernetesImagePullPolicy::IfNotPresent => f.write_str("IfNotPresent"),
138            KubernetesImagePullPolicy::Never => f.write_str("Never"),
139        }
140    }
141}
142
143impl KubernetesImagePullPolicy {
144    pub fn as_kebab_case_str(&self) -> &'static str {
145        match self {
146            Self::Always => "always",
147            Self::IfNotPresent => "if-not-present",
148            Self::Never => "never",
149        }
150    }
151}
152
153/// An orchestrator backed by Kubernetes.
154pub struct KubernetesOrchestrator {
155    client: Client,
156    kubernetes_namespace: String,
157    config: KubernetesOrchestratorConfig,
158    secret_api: Api<Secret>,
159    vpc_endpoint_api: Api<VpcEndpoint>,
160    namespaces: Mutex<BTreeMap<String, Arc<dyn NamespacedOrchestrator>>>,
161    resource_reader: Arc<KubernetesResourceReader>,
162}
163
164impl fmt::Debug for KubernetesOrchestrator {
165    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
166        f.debug_struct("KubernetesOrchestrator").finish()
167    }
168}
169
170impl KubernetesOrchestrator {
171    /// Creates a new Kubernetes orchestrator from the provided configuration.
172    pub async fn new(
173        config: KubernetesOrchestratorConfig,
174    ) -> Result<KubernetesOrchestrator, anyhow::Error> {
175        let (client, kubernetes_namespace) = util::create_client(config.context.clone()).await?;
176        let resource_reader =
177            Arc::new(KubernetesResourceReader::new(config.context.clone()).await?);
178        Ok(KubernetesOrchestrator {
179            client: client.clone(),
180            kubernetes_namespace,
181            config,
182            secret_api: Api::default_namespaced(client.clone()),
183            vpc_endpoint_api: Api::default_namespaced(client),
184            namespaces: Mutex::new(BTreeMap::new()),
185            resource_reader,
186        })
187    }
188}
189
190impl Orchestrator for KubernetesOrchestrator {
191    fn namespace(&self, namespace: &str) -> Arc<dyn NamespacedOrchestrator> {
192        let mut namespaces = self.namespaces.lock().expect("lock poisoned");
193        Arc::clone(namespaces.entry(namespace.into()).or_insert_with(|| {
194            let (command_tx, command_rx) = mpsc::unbounded_channel();
195            let worker = OrchestratorWorker {
196                metrics_api: Api::default_namespaced(self.client.clone()),
197                service_api: Api::default_namespaced(self.client.clone()),
198                stateful_set_api: Api::default_namespaced(self.client.clone()),
199                pod_api: Api::default_namespaced(self.client.clone()),
200                owner_references: vec![],
201                command_rx,
202                name_prefix: self.config.name_prefix.clone().unwrap_or_default(),
203                collect_pod_metrics: self.config.collect_pod_metrics,
204            }
205            .spawn(format!("kubernetes-orchestrator-worker:{namespace}"));
206
207            Arc::new(NamespacedKubernetesOrchestrator {
208                pod_api: Api::default_namespaced(self.client.clone()),
209                kubernetes_namespace: self.kubernetes_namespace.clone(),
210                namespace: namespace.into(),
211                config: self.config.clone(),
212                // TODO(guswynn): make this configurable.
213                scheduling_config: Default::default(),
214                service_infos: std::sync::Mutex::new(BTreeMap::new()),
215                command_tx,
216                _worker: worker,
217            })
218        }))
219    }
220}
221
222#[derive(Clone, Copy)]
223struct ServiceInfo {
224    scale: NonZero<u16>,
225}
226
227struct NamespacedKubernetesOrchestrator {
228    pod_api: Api<Pod>,
229    kubernetes_namespace: String,
230    namespace: String,
231    config: KubernetesOrchestratorConfig,
232    scheduling_config: std::sync::RwLock<ServiceSchedulingConfig>,
233    service_infos: std::sync::Mutex<BTreeMap<String, ServiceInfo>>,
234    command_tx: mpsc::UnboundedSender<WorkerCommand>,
235    _worker: AbortOnDropHandle<()>,
236}
237
238impl fmt::Debug for NamespacedKubernetesOrchestrator {
239    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
240        f.debug_struct("NamespacedKubernetesOrchestrator")
241            .field("kubernetes_namespace", &self.kubernetes_namespace)
242            .field("namespace", &self.namespace)
243            .field("config", &self.config)
244            .finish()
245    }
246}
247
248/// Commands sent from a [`NamespacedKubernetesOrchestrator`] to its
249/// [`OrchestratorWorker`].
250///
251/// Commands for which the caller expects a result include a `result_tx` on which the
252/// [`OrchestratorWorker`] will deliver the result.
253enum WorkerCommand {
254    EnsureService {
255        desc: ServiceDescription,
256    },
257    DropService {
258        name: String,
259    },
260    ListServices {
261        namespace: String,
262        result_tx: oneshot::Sender<Vec<String>>,
263    },
264    FetchServiceMetrics {
265        name: String,
266        info: ServiceInfo,
267        result_tx: oneshot::Sender<Vec<ServiceProcessMetrics>>,
268    },
269}
270
271/// A description of a service to be created by an [`OrchestratorWorker`].
272#[derive(Debug, Clone)]
273struct ServiceDescription {
274    name: String,
275    scale: NonZero<u16>,
276    service: K8sService,
277    stateful_set: StatefulSet,
278    pod_template_hash: String,
279}
280
281/// A task executing blocking work for a [`NamespacedKubernetesOrchestrator`] in the background.
282///
283/// This type exists to enable making [`NamespacedKubernetesOrchestrator::ensure_service`] and
284/// [`NamespacedKubernetesOrchestrator::drop_service`] non-blocking, allowing invocation of these
285/// methods in latency-sensitive contexts.
286///
287/// Note that, apart from `ensure_service` and `drop_service`, this worker also handles blocking
288/// orchestrator calls that query service state (such as `list_services`). These need to be
289/// sequenced through the worker loop to ensure they linearize as expected. For example, we want to
290/// ensure that a `list_services` result contains exactly those services that were previously
291/// created with `ensure_service` and not yet dropped with `drop_service`.
292struct OrchestratorWorker {
293    metrics_api: Api<PodMetrics>,
294    service_api: Api<K8sService>,
295    stateful_set_api: Api<StatefulSet>,
296    pod_api: Api<Pod>,
297    owner_references: Vec<OwnerReference>,
298    command_rx: mpsc::UnboundedReceiver<WorkerCommand>,
299    name_prefix: String,
300    collect_pod_metrics: bool,
301}
302
303#[derive(Deserialize, Clone, Debug)]
304pub struct PodMetricsContainer {
305    pub name: String,
306    pub usage: PodMetricsContainerUsage,
307}
308
309#[derive(Deserialize, Clone, Debug)]
310pub struct PodMetricsContainerUsage {
311    pub cpu: Quantity,
312    pub memory: Quantity,
313}
314
315#[derive(Deserialize, Clone, Debug)]
316pub struct PodMetrics {
317    pub metadata: ObjectMeta,
318    pub timestamp: String,
319    pub window: String,
320    pub containers: Vec<PodMetricsContainer>,
321}
322
323impl k8s_openapi::Resource for PodMetrics {
324    const GROUP: &'static str = "metrics.k8s.io";
325    const KIND: &'static str = "PodMetrics";
326    const VERSION: &'static str = "v1beta1";
327    const API_VERSION: &'static str = "metrics.k8s.io/v1beta1";
328    const URL_PATH_SEGMENT: &'static str = "pods";
329
330    type Scope = k8s_openapi::NamespaceResourceScope;
331}
332
333impl k8s_openapi::Metadata for PodMetrics {
334    type Ty = ObjectMeta;
335
336    fn metadata(&self) -> &Self::Ty {
337        &self.metadata
338    }
339
340    fn metadata_mut(&mut self) -> &mut Self::Ty {
341        &mut self.metadata
342    }
343}
344
345// Note that these types are very weird. We are `get`-ing a
346// `List` object, and lying about it having an `ObjectMeta`
347// (it deserializes as empty, but we don't need it). The custom
348// metrics API is designed this way, which is very non-standard.
349// A discussion in the `kube` channel in the `tokio` discord
350// confirmed that this layout + using `get_subresource` is the
351// best way to handle this.
352
353#[derive(Deserialize, Clone, Debug)]
354pub struct MetricIdentifier {
355    #[serde(rename = "metricName")]
356    pub name: String,
357    // We skip `selector` for now, as we don't use it
358}
359
360#[derive(Deserialize, Clone, Debug)]
361pub struct MetricValue {
362    #[serde(rename = "describedObject")]
363    pub described_object: ObjectReference,
364    #[serde(flatten)]
365    pub metric_identifier: MetricIdentifier,
366    pub timestamp: String,
367    pub value: Quantity,
368    // We skip `windowSeconds`, as we don't need it
369}
370
371impl NamespacedKubernetesOrchestrator {
372    fn service_name(&self, id: &str) -> String {
373        format!(
374            "{}{}-{id}",
375            self.config.name_prefix.as_deref().unwrap_or(""),
376            self.namespace
377        )
378    }
379
380    /// Return a `watcher::Config` instance that limits results to the namespace
381    /// assigned to this orchestrator.
382    fn watch_pod_params(&self) -> watcher::Config {
383        let ns_selector = format!(
384            "environmentd.materialize.cloud/namespace={}",
385            self.namespace
386        );
387        // This watcher timeout must be shorter than the client read timeout.
388        watcher::Config::default().timeout(59).labels(&ns_selector)
389    }
390
391    /// Convert a higher-level label key to the actual one we
392    /// will give to Kubernetes
393    fn make_label_key(&self, key: &str) -> String {
394        format!("{}.environmentd.materialize.cloud/{}", self.namespace, key)
395    }
396
397    fn label_selector_to_k8s(
398        &self,
399        MzLabelSelector { label_name, logic }: MzLabelSelector,
400    ) -> Result<LabelSelectorRequirement, anyhow::Error> {
401        let (operator, values) = match logic {
402            LabelSelectionLogic::Eq { value } => Ok(("In", vec![value])),
403            LabelSelectionLogic::NotEq { value } => Ok(("NotIn", vec![value])),
404            LabelSelectionLogic::Exists => Ok(("Exists", vec![])),
405            LabelSelectionLogic::NotExists => Ok(("DoesNotExist", vec![])),
406            LabelSelectionLogic::InSet { values } => {
407                if values.is_empty() {
408                    Err(anyhow!(
409                        "Invalid selector logic for {label_name}: empty `in` set"
410                    ))
411                } else {
412                    Ok(("In", values))
413                }
414            }
415            LabelSelectionLogic::NotInSet { values } => {
416                if values.is_empty() {
417                    Err(anyhow!(
418                        "Invalid selector logic for {label_name}: empty `notin` set"
419                    ))
420                } else {
421                    Ok(("NotIn", values))
422                }
423            }
424        }?;
425        let lsr = LabelSelectorRequirement {
426            key: self.make_label_key(&label_name),
427            operator: operator.to_string(),
428            values: Some(values),
429        };
430        Ok(lsr)
431    }
432
433    fn send_command(&self, cmd: WorkerCommand) {
434        self.command_tx.send(cmd).expect("worker task not dropped");
435    }
436}
437
438#[derive(Debug)]
439struct ScaledQuantity {
440    integral_part: u64,
441    exponent: i8,
442    base10: bool,
443}
444
445impl ScaledQuantity {
446    pub fn try_to_integer(&self, scale: i8, base10: bool) -> Option<u64> {
447        if base10 != self.base10 {
448            return None;
449        }
450        let exponent = self.exponent - scale;
451        let mut result = self.integral_part;
452        let base = if self.base10 { 10 } else { 2 };
453        if exponent < 0 {
454            for _ in exponent..0 {
455                result /= base;
456            }
457        } else {
458            for _ in 0..exponent {
459                result = result.checked_mul(base)?;
460            }
461        }
462        Some(result)
463    }
464}
465
466// Parse a k8s `Quantity` object
467// into a numeric value.
468//
469// This is intended to support collecting CPU and Memory data.
470// Thus, there are a few that things Kubernetes attempts to do, that we don't,
471// because I've never observed metrics-server specifically sending them:
472// (1) Handle negative numbers (because it's not useful for that use-case)
473// (2) Handle non-integers (because I have never observed them being actually sent)
474// (3) Handle scientific notation (e.g. 1.23e2)
475fn parse_k8s_quantity(s: &str) -> Result<ScaledQuantity, anyhow::Error> {
476    const DEC_SUFFIXES: &[(&str, i8)] = &[
477        ("n", -9),
478        ("u", -6),
479        ("m", -3),
480        ("", 0),
481        ("k", 3), // yep, intentionally lowercase.
482        ("M", 6),
483        ("G", 9),
484        ("T", 12),
485        ("P", 15),
486        ("E", 18),
487    ];
488    const BIN_SUFFIXES: &[(&str, i8)] = &[
489        ("", 0),
490        ("Ki", 10),
491        ("Mi", 20),
492        ("Gi", 30),
493        ("Ti", 40),
494        ("Pi", 50),
495        ("Ei", 60),
496    ];
497
498    let (positive, s) = match s.chars().next() {
499        Some('+') => (true, &s[1..]),
500        Some('-') => (false, &s[1..]),
501        _ => (true, s),
502    };
503
504    if !positive {
505        anyhow::bail!("Negative numbers not supported")
506    }
507
508    fn is_suffix_char(ch: char) -> bool {
509        "numkMGTPEKi".contains(ch)
510    }
511    let (num, suffix) = match s.find(is_suffix_char) {
512        None => (s, ""),
513        Some(idx) => s.split_at(idx),
514    };
515    let num: u64 = num.parse()?;
516    let (exponent, base10) = if let Some((_, exponent)) =
517        DEC_SUFFIXES.iter().find(|(target, _)| suffix == *target)
518    {
519        (exponent, true)
520    } else if let Some((_, exponent)) = BIN_SUFFIXES.iter().find(|(target, _)| suffix == *target) {
521        (exponent, false)
522    } else {
523        anyhow::bail!("Unrecognized suffix: {suffix}");
524    };
525    Ok(ScaledQuantity {
526        integral_part: num,
527        exponent: *exponent,
528        base10,
529    })
530}
531
532#[async_trait]
533impl NamespacedOrchestrator for NamespacedKubernetesOrchestrator {
534    async fn fetch_service_metrics(
535        &self,
536        id: &str,
537    ) -> Result<Vec<ServiceProcessMetrics>, anyhow::Error> {
538        let info = if let Some(info) = self.service_infos.lock().expect("poisoned lock").get(id) {
539            *info
540        } else {
541            // This should have been set in `ensure_service`.
542            tracing::error!("Failed to get info for {id}");
543            anyhow::bail!("Failed to get info for {id}");
544        };
545
546        let (result_tx, result_rx) = oneshot::channel();
547        self.send_command(WorkerCommand::FetchServiceMetrics {
548            name: self.service_name(id),
549            info,
550            result_tx,
551        });
552
553        let metrics = result_rx.await.expect("worker task not dropped");
554        Ok(metrics)
555    }
556
557    fn ensure_service(
558        &self,
559        id: &str,
560        ServiceConfig {
561            app_name,
562            image,
563            init_container_image,
564            args,
565            ports: ports_in,
566            memory_limit,
567            memory_request,
568            cpu_limit,
569            cpu_request,
570            scale,
571            labels: labels_in,
572            annotations: annotations_in,
573            availability_zones,
574            other_replicas_selector,
575            replicas_selector,
576            disk_limit,
577            node_selector,
578        }: ServiceConfig,
579    ) -> Result<Box<dyn Service>, anyhow::Error> {
580        // This is extremely cheap to clone, so just look into the lock once.
581        let scheduling_config: ServiceSchedulingConfig =
582            self.scheduling_config.read().expect("poisoned").clone();
583
584        // Enable disk if the size does not disable it.
585        let disk = disk_limit != Some(DiskLimit::ZERO);
586
587        let name = self.service_name(id);
588        // The match labels should be the minimal set of labels that uniquely
589        // identify the pods in the stateful set. Changing these after the
590        // `StatefulSet` is created is not permitted by Kubernetes, and we're
591        // not yet smart enough to handle deleting and recreating the
592        // `StatefulSet`.
593        let mut match_labels = btreemap! {
594            "environmentd.materialize.cloud/namespace".into() => self.namespace.clone(),
595            "environmentd.materialize.cloud/service-id".into() => id.into(),
596        };
597        for (key, value) in &self.config.service_labels {
598            match_labels.insert(key.clone(), value.clone());
599        }
600
601        let mut labels = match_labels.clone();
602        for (key, value) in labels_in {
603            labels.insert(self.make_label_key(&key), value);
604        }
605
606        let standard_labels = recommended_k8s_labels(app_name);
607
608        // Standard Kubernetes labels
609        labels.extend(standard_labels.clone());
610
611        labels.insert(self.make_label_key("scale"), scale.to_string());
612
613        for port in &ports_in {
614            labels.insert(
615                format!("environmentd.materialize.cloud/port-{}", port.name),
616                "true".into(),
617            );
618        }
619        let mut limits = BTreeMap::new();
620        let mut requests = BTreeMap::new();
621        if let Some(memory_limit) = memory_limit {
622            limits.insert(
623                "memory".into(),
624                Quantity(memory_limit.0.as_u64().to_string()),
625            );
626            requests.insert(
627                "memory".into(),
628                Quantity(memory_limit.0.as_u64().to_string()),
629            );
630        }
631        if let Some(memory_request) = memory_request {
632            requests.insert(
633                "memory".into(),
634                Quantity(memory_request.0.as_u64().to_string()),
635            );
636        }
637        if let Some(cpu_limit) = cpu_limit {
638            limits.insert(
639                "cpu".into(),
640                Quantity(format!("{}m", cpu_limit.as_millicpus())),
641            );
642            requests.insert(
643                "cpu".into(),
644                Quantity(format!("{}m", cpu_limit.as_millicpus())),
645            );
646        }
647        if let Some(cpu_request) = cpu_request {
648            requests.insert(
649                "cpu".into(),
650                Quantity(format!("{}m", cpu_request.as_millicpus())),
651            );
652        }
653        let service = K8sService {
654            metadata: ObjectMeta {
655                name: Some(name.clone()),
656                labels: Some(standard_labels.clone()),
657                ..Default::default()
658            },
659            spec: Some(ServiceSpec {
660                ports: Some(
661                    ports_in
662                        .iter()
663                        .map(|port| ServicePort {
664                            port: port.port_hint.into(),
665                            name: Some(port.name.clone()),
666                            ..Default::default()
667                        })
668                        .collect(),
669                ),
670                cluster_ip: Some("None".to_string()),
671                selector: Some(match_labels.clone()),
672                ..Default::default()
673            }),
674            status: None,
675        };
676
677        let hosts = (0..scale.get())
678            .map(|i| {
679                format!(
680                    "{name}-{i}.{name}.{}.svc.cluster.local",
681                    self.kubernetes_namespace
682                )
683            })
684            .collect::<Vec<_>>();
685        let ports = ports_in
686            .iter()
687            .map(|p| (p.name.clone(), p.port_hint))
688            .collect::<BTreeMap<_, _>>();
689
690        let mut listen_addrs = BTreeMap::new();
691        let mut peer_addrs = vec![BTreeMap::new(); hosts.len()];
692        for (name, port) in &ports {
693            listen_addrs.insert(name.clone(), format!("0.0.0.0:{port}"));
694            for (i, host) in hosts.iter().enumerate() {
695                peer_addrs[i].insert(name.clone(), format!("{host}:{port}"));
696            }
697        }
698        let mut args = args(ServiceAssignments {
699            listen_addrs: &listen_addrs,
700            peer_addrs: &peer_addrs,
701        });
702
703        // This constrains the orchestrator (for those orchestrators that support
704        // anti-affinity, today just k8s) to never schedule pods for different replicas
705        // of the same cluster on the same node. Pods from the _same_ replica are fine;
706        // pods from different clusters are also fine.
707        //
708        // The point is that if pods of two replicas are on the same node, that node
709        // going down would kill both replicas, and so the replication factor of the
710        // cluster in question is illusory.
711        let anti_affinity = Some({
712            let label_selector_requirements = other_replicas_selector
713                .clone()
714                .into_iter()
715                .map(|ls| self.label_selector_to_k8s(ls))
716                .collect::<Result<Vec<_>, _>>()?;
717            let ls = LabelSelector {
718                match_expressions: Some(label_selector_requirements),
719                ..Default::default()
720            };
721            let pat = PodAffinityTerm {
722                label_selector: Some(ls),
723                topology_key: "kubernetes.io/hostname".to_string(),
724                ..Default::default()
725            };
726
727            if !scheduling_config.soften_replication_anti_affinity {
728                PodAntiAffinity {
729                    required_during_scheduling_ignored_during_execution: Some(vec![pat]),
730                    ..Default::default()
731                }
732            } else {
733                PodAntiAffinity {
734                    preferred_during_scheduling_ignored_during_execution: Some(vec![
735                        WeightedPodAffinityTerm {
736                            weight: scheduling_config.soften_replication_anti_affinity_weight,
737                            pod_affinity_term: pat,
738                        },
739                    ]),
740                    ..Default::default()
741                }
742            }
743        });
744
745        let pod_affinity = if let Some(weight) = scheduling_config.multi_pod_az_affinity_weight {
746            // `match_labels` sufficiently selects pods in the same replica.
747            let ls = LabelSelector {
748                match_labels: Some(match_labels.clone()),
749                ..Default::default()
750            };
751            let pat = PodAffinityTerm {
752                label_selector: Some(ls),
753                topology_key: "topology.kubernetes.io/zone".to_string(),
754                ..Default::default()
755            };
756
757            Some(PodAffinity {
758                preferred_during_scheduling_ignored_during_execution: Some(vec![
759                    WeightedPodAffinityTerm {
760                        weight,
761                        pod_affinity_term: pat,
762                    },
763                ]),
764                ..Default::default()
765            })
766        } else {
767            None
768        };
769
770        let topology_spread = if scheduling_config.topology_spread.enabled {
771            let config = &scheduling_config.topology_spread;
772
773            if !config.ignore_non_singular_scale || scale.get() == 1 {
774                let label_selector_requirements = (if config.ignore_non_singular_scale {
775                    let mut replicas_selector_ignoring_scale = replicas_selector.clone();
776
777                    replicas_selector_ignoring_scale.push(mz_orchestrator::LabelSelector {
778                        label_name: "scale".into(),
779                        logic: mz_orchestrator::LabelSelectionLogic::Eq {
780                            value: "1".to_string(),
781                        },
782                    });
783
784                    replicas_selector_ignoring_scale
785                } else {
786                    replicas_selector
787                })
788                .into_iter()
789                .map(|ls| self.label_selector_to_k8s(ls))
790                .collect::<Result<Vec<_>, _>>()?;
791                let ls = LabelSelector {
792                    match_expressions: Some(label_selector_requirements),
793                    ..Default::default()
794                };
795
796                if config.soft && config.min_domains.is_some() {
797                    warn!(
798                        "topology spread is soft but min_domains is set; \
799                         Kubernetes rejects minDomains with ScheduleAnyway, \
800                         so min_domains will be ignored"
801                    );
802                }
803                if availability_zones.is_some() && config.min_domains.is_some() {
804                    warn!(
805                        "topology spread has min_domains set but availability_zones \
806                         constrains eligible topology domains via node affinity; \
807                         minDomains will be ignored to avoid preventing pod scheduling"
808                    );
809                }
810
811                let constraint = TopologySpreadConstraint {
812                    label_selector: Some(ls),
813                    min_domains: topology_spread_min_domains(
814                        config.soft,
815                        availability_zones.is_some(),
816                        config.min_domains,
817                    ),
818                    max_skew: config.max_skew,
819                    topology_key: "topology.kubernetes.io/zone".to_string(),
820                    when_unsatisfiable: if config.soft {
821                        "ScheduleAnyway".to_string()
822                    } else {
823                        "DoNotSchedule".to_string()
824                    },
825                    // TODO(guswynn): restore these once they are supported.
826                    // Consider node affinities when calculating topology spread. This is the
827                    // default: <https://docs.rs/k8s-openapi/latest/k8s_openapi/api/core/v1/struct.TopologySpreadConstraint.html#structfield.node_affinity_policy>,
828                    // made explicit.
829                    // node_affinity_policy: Some("Honor".to_string()),
830                    // Do not consider node taints when calculating topology spread. This is the
831                    // default: <https://docs.rs/k8s-openapi/latest/k8s_openapi/api/core/v1/struct.TopologySpreadConstraint.html#structfield.node_taints_policy>,
832                    // made explicit.
833                    // node_taints_policy: Some("Ignore".to_string()),
834                    match_label_keys: None,
835                    // Once the above are restorted, we should't have `..Default::default()` here because the specifics of these fields are
836                    // subtle enough where we want compilation failures when we upgrade
837                    ..Default::default()
838                };
839                Some(vec![constraint])
840            } else {
841                None
842            }
843        } else {
844            None
845        };
846
847        let mut pod_annotations = btreemap! {
848            // Prevent the cluster-autoscaler (or karpenter) from evicting these pods in attempts to scale down
849            // and terminate nodes.
850            // This will cost us more money, but should give us better uptime.
851            // This does not prevent all evictions by Kubernetes, only the ones initiated by the
852            // cluster-autoscaler (or karpenter). Notably, eviction of pods for resource overuse is still enabled.
853            "cluster-autoscaler.kubernetes.io/safe-to-evict".to_owned() => "false".to_string(),
854            "karpenter.sh/do-not-evict".to_owned() => "true".to_string(),
855
856            // It's called do-not-disrupt in newer versions of karpenter, so adding for forward/backward compatibility
857            "karpenter.sh/do-not-disrupt".to_owned() => "true".to_string(),
858        };
859        for (key, value) in annotations_in {
860            // We want to use the same prefix as our labels keys
861            pod_annotations.insert(self.make_label_key(&key), value);
862        }
863        if self.config.enable_prometheus_scrape_annotations {
864            if let Some(internal_http_port) = ports_in
865                .iter()
866                .find(|port| port.name == "internal-http")
867                .map(|port| port.port_hint.to_string())
868            {
869                // Enable prometheus scrape discovery
870                pod_annotations.insert("prometheus.io/scrape".to_owned(), "true".to_string());
871                pod_annotations.insert("prometheus.io/port".to_owned(), internal_http_port);
872                pod_annotations.insert("prometheus.io/path".to_owned(), "/metrics".to_string());
873                pod_annotations.insert("prometheus.io/scheme".to_owned(), "http".to_string());
874            }
875        }
876        for (key, value) in &self.config.service_annotations {
877            pod_annotations.insert(key.clone(), value.clone());
878        }
879
880        let default_node_selector = if disk {
881            vec![("materialize.cloud/disk".to_string(), disk.to_string())]
882        } else {
883            // if the cluster doesn't require disk, we can omit the selector
884            // allowing it to be scheduled onto nodes with and without the
885            // selector
886            vec![]
887        };
888
889        let node_selector: BTreeMap<String, String> = default_node_selector
890            .into_iter()
891            .chain(self.config.service_node_selector.clone())
892            .chain(node_selector)
893            .collect();
894
895        let node_affinity = if let Some(availability_zones) = availability_zones {
896            let selector = NodeSelectorTerm {
897                match_expressions: Some(vec![NodeSelectorRequirement {
898                    key: "materialize.cloud/availability-zone".to_string(),
899                    operator: "In".to_string(),
900                    values: Some(availability_zones),
901                }]),
902                match_fields: None,
903            };
904
905            if scheduling_config.soften_az_affinity {
906                Some(NodeAffinity {
907                    preferred_during_scheduling_ignored_during_execution: Some(vec![
908                        PreferredSchedulingTerm {
909                            preference: selector,
910                            weight: scheduling_config.soften_az_affinity_weight,
911                        },
912                    ]),
913                    required_during_scheduling_ignored_during_execution: None,
914                })
915            } else {
916                Some(NodeAffinity {
917                    preferred_during_scheduling_ignored_during_execution: None,
918                    required_during_scheduling_ignored_during_execution: Some(NodeSelector {
919                        node_selector_terms: vec![selector],
920                    }),
921                })
922            }
923        } else {
924            None
925        };
926
927        let mut affinity = Affinity {
928            pod_anti_affinity: anti_affinity,
929            pod_affinity,
930            node_affinity,
931            ..Default::default()
932        };
933        if let Some(service_affinity) = &self.config.service_affinity {
934            affinity.merge_from(serde_json::from_str(service_affinity)?);
935        }
936
937        let container_name = image
938            .rsplit_once('/')
939            .and_then(|(_, name_version)| name_version.rsplit_once(':'))
940            .context("`image` is not ORG/NAME:VERSION")?
941            .0
942            .to_string();
943
944        let container_security_context = if scheduling_config.security_context_enabled {
945            Some(SecurityContext {
946                privileged: Some(false),
947                run_as_non_root: Some(true),
948                allow_privilege_escalation: Some(false),
949                seccomp_profile: Some(SeccompProfile {
950                    type_: "RuntimeDefault".to_string(),
951                    ..Default::default()
952                }),
953                capabilities: Some(Capabilities {
954                    drop: Some(vec!["ALL".to_string()]),
955                    ..Default::default()
956                }),
957                ..Default::default()
958            })
959        } else {
960            None
961        };
962
963        let init_containers = init_container_image.map(|image| {
964            vec![Container {
965                name: "init".to_string(),
966                image: Some(image),
967                image_pull_policy: Some(self.config.image_pull_policy.to_string()),
968                resources: Some(ResourceRequirements {
969                    claims: None,
970                    limits: Some(limits.clone()),
971                    requests: Some(requests.clone()),
972                }),
973                security_context: container_security_context.clone(),
974                env: Some(vec![
975                    EnvVar {
976                        name: "MZ_NAMESPACE".to_string(),
977                        value_from: Some(EnvVarSource {
978                            field_ref: Some(ObjectFieldSelector {
979                                field_path: "metadata.namespace".to_string(),
980                                ..Default::default()
981                            }),
982                            ..Default::default()
983                        }),
984                        ..Default::default()
985                    },
986                    EnvVar {
987                        name: "MZ_POD_NAME".to_string(),
988                        value_from: Some(EnvVarSource {
989                            field_ref: Some(ObjectFieldSelector {
990                                field_path: "metadata.name".to_string(),
991                                ..Default::default()
992                            }),
993                            ..Default::default()
994                        }),
995                        ..Default::default()
996                    },
997                    EnvVar {
998                        name: "MZ_NODE_NAME".to_string(),
999                        value_from: Some(EnvVarSource {
1000                            field_ref: Some(ObjectFieldSelector {
1001                                field_path: "spec.nodeName".to_string(),
1002                                ..Default::default()
1003                            }),
1004                            ..Default::default()
1005                        }),
1006                        ..Default::default()
1007                    },
1008                ]),
1009                ..Default::default()
1010            }]
1011        });
1012
1013        let env = if self.config.coverage {
1014            Some(vec![EnvVar {
1015                name: "LLVM_PROFILE_FILE".to_string(),
1016                value: Some(format!("/coverage/{}-%p-%9m%c.profraw", self.namespace)),
1017                ..Default::default()
1018            }])
1019        } else {
1020            None
1021        };
1022
1023        let mut volume_mounts = vec![];
1024
1025        if self.config.coverage {
1026            volume_mounts.push(VolumeMount {
1027                name: "coverage".to_string(),
1028                mount_path: "/coverage".to_string(),
1029                ..Default::default()
1030            })
1031        }
1032
1033        let volumes = match (disk, &self.config.ephemeral_volume_storage_class) {
1034            (true, Some(ephemeral_volume_storage_class)) => {
1035                volume_mounts.push(VolumeMount {
1036                    name: "scratch".to_string(),
1037                    mount_path: "/scratch".to_string(),
1038                    ..Default::default()
1039                });
1040                args.push("--scratch-directory=/scratch".into());
1041
1042                Some(vec![Volume {
1043                    name: "scratch".to_string(),
1044                    ephemeral: Some(EphemeralVolumeSource {
1045                        volume_claim_template: Some(PersistentVolumeClaimTemplate {
1046                            spec: PersistentVolumeClaimSpec {
1047                                access_modes: Some(vec!["ReadWriteOnce".to_string()]),
1048                                storage_class_name: Some(
1049                                    ephemeral_volume_storage_class.to_string(),
1050                                ),
1051                                resources: Some(VolumeResourceRequirements {
1052                                    requests: Some(BTreeMap::from([(
1053                                        "storage".to_string(),
1054                                        Quantity(
1055                                            disk_limit
1056                                                .unwrap_or(DiskLimit::ARBITRARY)
1057                                                .0
1058                                                .as_u64()
1059                                                .to_string(),
1060                                        ),
1061                                    )])),
1062                                    ..Default::default()
1063                                }),
1064                                ..Default::default()
1065                            },
1066                            ..Default::default()
1067                        }),
1068                        ..Default::default()
1069                    }),
1070                    ..Default::default()
1071                }])
1072            }
1073            (true, None) => {
1074                return Err(anyhow!(
1075                    "service requested disk but no ephemeral volume storage class was configured"
1076                ));
1077            }
1078            (false, _) => None,
1079        };
1080
1081        if let Some(name_prefix) = &self.config.name_prefix {
1082            args.push(format!("--secrets-reader-name-prefix={}", name_prefix));
1083        }
1084
1085        let volume_claim_templates = if self.config.coverage {
1086            Some(vec![PersistentVolumeClaim {
1087                metadata: ObjectMeta {
1088                    name: Some("coverage".to_string()),
1089                    ..Default::default()
1090                },
1091                spec: Some(PersistentVolumeClaimSpec {
1092                    access_modes: Some(vec!["ReadWriteOnce".to_string()]),
1093                    resources: Some(VolumeResourceRequirements {
1094                        requests: Some(BTreeMap::from([(
1095                            "storage".to_string(),
1096                            Quantity("10Gi".to_string()),
1097                        )])),
1098                        ..Default::default()
1099                    }),
1100                    ..Default::default()
1101                }),
1102                ..Default::default()
1103            }])
1104        } else {
1105            None
1106        };
1107
1108        let tcp_keepalive_sysctls = vec![
1109            Sysctl {
1110                name: "net.ipv4.tcp_keepalive_time".to_string(),
1111                value: "300".to_string(),
1112            },
1113            Sysctl {
1114                name: "net.ipv4.tcp_keepalive_intvl".to_string(),
1115                value: "30".to_string(),
1116            },
1117            Sysctl {
1118                name: "net.ipv4.tcp_keepalive_probes".to_string(),
1119                value: "3".to_string(),
1120            },
1121        ];
1122
1123        let security_context = if let Some(fs_group) = self.config.service_fs_group {
1124            Some(PodSecurityContext {
1125                fs_group: Some(fs_group),
1126                run_as_user: Some(fs_group),
1127                run_as_group: Some(fs_group),
1128                sysctls: Some(tcp_keepalive_sysctls),
1129                ..Default::default()
1130            })
1131        } else {
1132            Some(PodSecurityContext {
1133                sysctls: Some(tcp_keepalive_sysctls),
1134                ..Default::default()
1135            })
1136        };
1137
1138        let mut tolerations = vec![
1139            // When the node becomes `NotReady` it indicates there is a problem
1140            // with the node. By default Kubernetes waits 300s (5 minutes)
1141            // before descheduling the pod, but we tune this to 30s for faster
1142            // recovery in the case of node failure.
1143            Toleration {
1144                effect: Some("NoExecute".into()),
1145                key: Some("node.kubernetes.io/not-ready".into()),
1146                operator: Some("Exists".into()),
1147                toleration_seconds: Some(NODE_FAILURE_THRESHOLD_SECONDS),
1148                value: None,
1149            },
1150            Toleration {
1151                effect: Some("NoExecute".into()),
1152                key: Some("node.kubernetes.io/unreachable".into()),
1153                operator: Some("Exists".into()),
1154                toleration_seconds: Some(NODE_FAILURE_THRESHOLD_SECONDS),
1155                value: None,
1156            },
1157        ];
1158        if let Some(service_tolerations) = &self.config.service_tolerations {
1159            tolerations.extend(serde_json::from_str::<Vec<_>>(service_tolerations)?);
1160        }
1161        let tolerations = Some(tolerations);
1162
1163        let mut pod_template_spec = PodTemplateSpec {
1164            metadata: Some(ObjectMeta {
1165                labels: Some(labels.clone()),
1166                // Only set `annotations` _after_ we have computed the pod template hash, to
1167                // avoid that annotation changes cause pod replacements.
1168                ..Default::default()
1169            }),
1170            spec: Some(PodSpec {
1171                init_containers,
1172                containers: vec![Container {
1173                    name: container_name,
1174                    image: Some(image),
1175                    args: Some(args),
1176                    image_pull_policy: Some(self.config.image_pull_policy.to_string()),
1177                    ports: Some(
1178                        ports_in
1179                            .iter()
1180                            .map(|port| ContainerPort {
1181                                container_port: port.port_hint.into(),
1182                                name: Some(port.name.clone()),
1183                                ..Default::default()
1184                            })
1185                            .collect(),
1186                    ),
1187                    security_context: container_security_context.clone(),
1188                    resources: Some(ResourceRequirements {
1189                        claims: None,
1190                        limits: Some(limits),
1191                        requests: Some(requests),
1192                    }),
1193                    volume_mounts: if !volume_mounts.is_empty() {
1194                        Some(volume_mounts)
1195                    } else {
1196                        None
1197                    },
1198                    env,
1199                    ..Default::default()
1200                }],
1201                volumes,
1202                security_context,
1203                node_selector: Some(node_selector),
1204                scheduler_name: self.config.scheduler_name.clone(),
1205                priority_class_name: self.config.priority_class_name.clone(),
1206                service_account: self.config.service_account.clone(),
1207                affinity: Some(affinity),
1208                topology_spread_constraints: topology_spread,
1209                tolerations,
1210                // Setting a 0s termination grace period has the side effect of
1211                // automatically starting a new pod when the previous pod is
1212                // currently terminating. This enables recovery from a node
1213                // failure with no manual intervention. Without this setting,
1214                // the StatefulSet controller will refuse to start a new pod
1215                // until the failed node is manually removed from the Kubernetes
1216                // cluster.
1217                //
1218                // The Kubernetes documentation strongly advises against this
1219                // setting, as StatefulSets attempt to provide "at most once"
1220                // semantics [0]--that is, the guarantee that for a given pod in
1221                // a StatefulSet there is *at most* one pod with that identity
1222                // running in the cluster.
1223                //
1224                // Materialize services, however, are carefully designed to
1225                // *not* rely on this guarantee. In fact, we do not believe that
1226                // correct distributed systems can meaningfully rely on
1227                // Kubernetes's guarantee--network packets from a pod can be
1228                // arbitrarily delayed, long past that pod's termination.
1229                //
1230                // [0]: https://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/#statefulset-considerations
1231                termination_grace_period_seconds: Some(0),
1232                ..Default::default()
1233            }),
1234        };
1235        let pod_template_json = serde_json::to_string(&pod_template_spec).unwrap();
1236        let mut hasher = Sha256::new();
1237        hasher.update(pod_template_json);
1238        let pod_template_hash = format!("{:x}", hasher.finalize());
1239        pod_annotations.insert(
1240            POD_TEMPLATE_HASH_ANNOTATION.to_owned(),
1241            pod_template_hash.clone(),
1242        );
1243
1244        pod_template_spec.metadata.as_mut().unwrap().annotations = Some(pod_annotations);
1245
1246        let stateful_set = StatefulSet {
1247            metadata: ObjectMeta {
1248                name: Some(name.clone()),
1249                labels: Some(standard_labels.clone()),
1250                ..Default::default()
1251            },
1252            spec: Some(StatefulSetSpec {
1253                selector: LabelSelector {
1254                    match_labels: Some(match_labels),
1255                    ..Default::default()
1256                },
1257                service_name: Some(name.clone()),
1258                replicas: Some(scale.cast_into()),
1259                template: pod_template_spec,
1260                update_strategy: Some(StatefulSetUpdateStrategy {
1261                    type_: Some("OnDelete".to_owned()),
1262                    ..Default::default()
1263                }),
1264                pod_management_policy: Some("Parallel".to_string()),
1265                volume_claim_templates,
1266                ..Default::default()
1267            }),
1268            status: None,
1269        };
1270
1271        self.send_command(WorkerCommand::EnsureService {
1272            desc: ServiceDescription {
1273                name,
1274                scale,
1275                service,
1276                stateful_set,
1277                pod_template_hash,
1278            },
1279        });
1280
1281        self.service_infos
1282            .lock()
1283            .expect("poisoned lock")
1284            .insert(id.to_string(), ServiceInfo { scale });
1285
1286        Ok(Box::new(KubernetesService { hosts, ports }))
1287    }
1288
1289    /// Drops the identified service, if it exists.
1290    fn drop_service(&self, id: &str) -> Result<(), anyhow::Error> {
1291        fail::fail_point!("kubernetes_drop_service", |_| Err(anyhow!("failpoint")));
1292        self.service_infos.lock().expect("poisoned lock").remove(id);
1293
1294        self.send_command(WorkerCommand::DropService {
1295            name: self.service_name(id),
1296        });
1297
1298        Ok(())
1299    }
1300
1301    /// Lists the identifiers of all known services.
1302    async fn list_services(&self) -> Result<Vec<String>, anyhow::Error> {
1303        let (result_tx, result_rx) = oneshot::channel();
1304        self.send_command(WorkerCommand::ListServices {
1305            namespace: self.namespace.clone(),
1306            result_tx,
1307        });
1308
1309        let list = result_rx.await.expect("worker task not dropped");
1310        Ok(list)
1311    }
1312
1313    fn watch_services(&self) -> BoxStream<'static, Result<ServiceEvent, anyhow::Error>> {
1314        fn into_service_event(pod: Pod) -> Result<ServiceEvent, anyhow::Error> {
1315            let process_id = pod.name_any().split('-').next_back().unwrap().parse()?;
1316            let service_id_label = "environmentd.materialize.cloud/service-id";
1317            let service_id = pod
1318                .labels()
1319                .get(service_id_label)
1320                .ok_or_else(|| anyhow!("missing label: {service_id_label}"))?
1321                .clone();
1322
1323            let oomed = pod
1324                .status
1325                .as_ref()
1326                .and_then(|status| status.container_statuses.as_ref())
1327                .map(|container_statuses| {
1328                    container_statuses.iter().any(|cs| {
1329                        // The container might have already transitioned from "terminated" to
1330                        // "waiting"/"running" state, in which case we need to check its previous
1331                        // state to find out why it terminated.
1332                        let current_state = cs.state.as_ref().and_then(|s| s.terminated.as_ref());
1333                        let last_state = cs.last_state.as_ref().and_then(|s| s.terminated.as_ref());
1334                        let termination_state = current_state.or(last_state);
1335
1336                        // The interesting exit codes are:
1337                        //  * 135 (SIGBUS): occurs when lgalloc runs out of disk
1338                        //  * 137 (SIGKILL): occurs when the OOM killer terminates the container
1339                        //  * 167: occurs when the lgalloc or memory limiter terminates the process
1340                        // We treat the all of these as OOM conditions since swap and lgalloc use
1341                        // disk only for spilling memory.
1342                        let exit_code = termination_state.map(|s| s.exit_code);
1343                        exit_code.is_some_and(|e| [135, 137, 167].contains(&e))
1344                    })
1345                })
1346                .unwrap_or(false);
1347
1348            // Sum the per-container restart counts to get a per-process (per-pod)
1349            // restart count. This is cumulative and survives gaps in the watch
1350            // stream, so it lets consumers detect restarts they'd otherwise miss
1351            // by only sampling the ready/not-ready status.
1352            let restart_count = pod
1353                .status
1354                .as_ref()
1355                .and_then(|status| status.container_statuses.as_ref())
1356                .map(|container_statuses| {
1357                    container_statuses
1358                        .iter()
1359                        .map(|cs| u64::try_from(cs.restart_count).unwrap_or(0))
1360                        .sum()
1361                })
1362                .unwrap_or(0);
1363
1364            let (pod_ready, last_probe_time) = pod
1365                .status
1366                .and_then(|status| status.conditions)
1367                .and_then(|conditions| conditions.into_iter().find(|c| c.type_ == "Ready"))
1368                .map(|c| (c.status == "True", c.last_probe_time))
1369                .unwrap_or((false, None));
1370
1371            let status = if pod_ready {
1372                ServiceStatus::Online
1373            } else {
1374                ServiceStatus::Offline(oomed.then_some(OfflineReason::OomKilled))
1375            };
1376            let time = if let Some(time) = last_probe_time {
1377                time.0
1378            } else {
1379                Timestamp::now()
1380            };
1381
1382            Ok(ServiceEvent {
1383                service_id,
1384                process_id,
1385                status,
1386                restart_count,
1387                time: DateTime::from_timestamp_nanos(
1388                    time.as_nanosecond().try_into().expect("must fit"),
1389                ),
1390            })
1391        }
1392
1393        let stream = watcher(self.pod_api.clone(), self.watch_pod_params())
1394            .touched_objects()
1395            .filter_map(|object| async move {
1396                match object {
1397                    Ok(pod) => Some(into_service_event(pod)),
1398                    Err(error) => {
1399                        // We assume that errors returned by Kubernetes are usually transient, so we
1400                        // just log a warning and ignore them otherwise.
1401                        tracing::warn!("service watch error: {error}");
1402                        None
1403                    }
1404                }
1405            });
1406        Box::pin(stream)
1407    }
1408
1409    fn update_scheduling_config(&self, config: ServiceSchedulingConfig) {
1410        *self.scheduling_config.write().expect("poisoned") = config;
1411    }
1412}
1413
1414impl OrchestratorWorker {
1415    fn spawn(self, name: String) -> AbortOnDropHandle<()> {
1416        mz_ore::task::spawn(|| name, self.run()).abort_on_drop()
1417    }
1418
1419    async fn run(mut self) {
1420        {
1421            info!("initializing Kubernetes orchestrator worker");
1422            let start = Instant::now();
1423
1424            // Fetch the owner reference for our own pod (usually a
1425            // StatefulSet), so that we can propagate it to the services we
1426            // create.
1427            let hostname = env::var("HOSTNAME").unwrap_or_else(|_| panic!("HOSTNAME environment variable missing or invalid; required for Kubernetes orchestrator"));
1428            let orchestrator_pod = Retry::default()
1429                .clamp_backoff(Duration::from_secs(10))
1430                .retry_async(|_| self.pod_api.get(&hostname))
1431                .await
1432                .expect("always retries on error");
1433            self.owner_references
1434                .extend(orchestrator_pod.owner_references().into_iter().cloned());
1435
1436            if !self.collect_pod_metrics {
1437                info!(
1438                    "pod metrics collection is disabled; resource usage graphs in the console will not be available"
1439                );
1440            }
1441
1442            info!(
1443                "Kubernetes orchestrator worker initialized in {:?}",
1444                start.elapsed()
1445            );
1446        }
1447
1448        while let Some(cmd) = self.command_rx.recv().await {
1449            self.handle_command(cmd).await;
1450        }
1451    }
1452
1453    /// Handle a worker command.
1454    ///
1455    /// If handling the command fails, it is automatically retried. All command handlers return
1456    /// [`K8sError`], so we can reasonably assume that a failure is caused by issues communicating
1457    /// with the K8S server and that retrying resolves them eventually.
1458    async fn handle_command(&self, cmd: WorkerCommand) {
1459        async fn retry<F, U, R>(f: F, cmd_type: &str) -> R
1460        where
1461            F: Fn() -> U,
1462            U: Future<Output = Result<R, K8sError>>,
1463        {
1464            Retry::default()
1465                .clamp_backoff(Duration::from_secs(10))
1466                .retry_async(|_| {
1467                    f().map_err(
1468                        |error| tracing::error!(%cmd_type, "orchestrator call failed: {error}"),
1469                    )
1470                })
1471                .await
1472                .expect("always retries on error")
1473        }
1474
1475        use WorkerCommand::*;
1476        match cmd {
1477            EnsureService { desc } => {
1478                retry(|| self.ensure_service(desc.clone()), "EnsureService").await
1479            }
1480            DropService { name } => retry(|| self.drop_service(&name), "DropService").await,
1481            ListServices {
1482                namespace,
1483                result_tx,
1484            } => {
1485                let result = retry(|| self.list_services(&namespace), "ListServices").await;
1486                let _ = result_tx.send(result);
1487            }
1488            FetchServiceMetrics {
1489                name,
1490                info,
1491                result_tx,
1492            } => {
1493                let result = self.fetch_service_metrics(&name, &info).await;
1494                let _ = result_tx.send(result);
1495            }
1496        }
1497    }
1498
1499    async fn fetch_service_metrics(
1500        &self,
1501        name: &str,
1502        info: &ServiceInfo,
1503    ) -> Vec<ServiceProcessMetrics> {
1504        if !self.collect_pod_metrics {
1505            return (0..info.scale.get())
1506                .map(|_| ServiceProcessMetrics::default())
1507                .collect();
1508        }
1509
1510        /// Usage metrics reported by clusterd processes.
1511        #[derive(Deserialize)]
1512        pub(crate) struct ClusterdUsage {
1513            disk_bytes: Option<u64>,
1514            memory_bytes: Option<u64>,
1515            swap_bytes: Option<u64>,
1516            heap_limit: Option<u64>,
1517        }
1518
1519        /// Get metrics for a particular service and process, converting them into a sane (i.e., numeric) format.
1520        ///
1521        /// Note that we want to keep going even if a lookup fails for whatever reason,
1522        /// so this function is infallible. If we fail to get cpu or memory for a particular pod,
1523        /// we just log a warning and install `None` in the returned struct.
1524        async fn get_metrics(
1525            self_: &OrchestratorWorker,
1526            service_name: &str,
1527            i: usize,
1528        ) -> ServiceProcessMetrics {
1529            let name = format!("{service_name}-{i}");
1530
1531            let clusterd_usage_fut = get_clusterd_usage(self_, service_name, i);
1532            let (metrics, clusterd_usage) =
1533                match futures::future::join(self_.metrics_api.get(&name), clusterd_usage_fut).await
1534                {
1535                    (Ok(metrics), Ok(clusterd_usage)) => (metrics, Some(clusterd_usage)),
1536                    (Ok(metrics), Err(e)) => {
1537                        warn!("Failed to fetch clusterd usage for {name}: {e}");
1538                        (metrics, None)
1539                    }
1540                    (Err(e), _) => {
1541                        warn!("Failed to get metrics for {name}: {e}");
1542                        return ServiceProcessMetrics::default();
1543                    }
1544                };
1545            let Some(PodMetricsContainer {
1546                usage:
1547                    PodMetricsContainerUsage {
1548                        cpu: Quantity(cpu_str),
1549                        memory: Quantity(mem_str),
1550                    },
1551                ..
1552            }) = metrics.containers.get(0)
1553            else {
1554                warn!("metrics result contained no containers for {name}");
1555                return ServiceProcessMetrics::default();
1556            };
1557
1558            let mut process_metrics = ServiceProcessMetrics::default();
1559
1560            match parse_k8s_quantity(cpu_str) {
1561                Ok(q) => match q.try_to_integer(-9, true) {
1562                    Some(nano_cores) => process_metrics.cpu_nano_cores = Some(nano_cores),
1563                    None => error!("CPU value {q:?} out of range"),
1564                },
1565                Err(e) => error!("failed to parse CPU value {cpu_str}: {e}"),
1566            }
1567            match parse_k8s_quantity(mem_str) {
1568                Ok(q) => match q.try_to_integer(0, false) {
1569                    Some(mem) => process_metrics.memory_bytes = Some(mem),
1570                    None => error!("memory value {q:?} out of range"),
1571                },
1572                Err(e) => error!("failed to parse memory value {mem_str}: {e}"),
1573            }
1574
1575            if let Some(usage) = clusterd_usage {
1576                // clusterd may report disk usage as either `disk_bytes`, or `swap_bytes`, or both.
1577                //
1578                // For now the Console expects the swap size to be reported in `disk_bytes`.
1579                // Once the Console has been ported to use `heap_bytes`/`heap_limit`, we can
1580                // simplify things by setting `process_metrics.disk_bytes = usage.disk_bytes`.
1581                process_metrics.disk_bytes = match (usage.disk_bytes, usage.swap_bytes) {
1582                    (Some(disk), Some(swap)) => Some(disk + swap),
1583                    (disk, swap) => disk.or(swap),
1584                };
1585
1586                // clusterd may report heap usage as `memory_bytes` and optionally `swap_bytes`.
1587                // If no `memory_bytes` is reported, we can't know the heap usage.
1588                process_metrics.heap_bytes = match (usage.memory_bytes, usage.swap_bytes) {
1589                    (Some(memory), Some(swap)) => Some(memory + swap),
1590                    (Some(memory), None) => Some(memory),
1591                    (None, _) => None,
1592                };
1593
1594                process_metrics.heap_limit = usage.heap_limit;
1595            }
1596
1597            process_metrics
1598        }
1599
1600        /// Get the current usage metrics exposed by a clusterd process.
1601        ///
1602        /// Usage metrics are collected by connecting to a metrics endpoint exposed by the process.
1603        /// The endpoint is assumed to be reachable at the 'internal-http' under the HTTP path
1604        /// `/api/usage-metrics`.
1605        async fn get_clusterd_usage(
1606            self_: &OrchestratorWorker,
1607            service_name: &str,
1608            i: usize,
1609        ) -> anyhow::Result<ClusterdUsage> {
1610            let service = self_
1611                .service_api
1612                .get(service_name)
1613                .await
1614                .with_context(|| format!("failed to get service {service_name}"))?;
1615            let namespace = service
1616                .metadata
1617                .namespace
1618                .context("missing service namespace")?;
1619            let internal_http_port = service
1620                .spec
1621                .and_then(|spec| spec.ports)
1622                .and_then(|ports| {
1623                    ports
1624                        .into_iter()
1625                        .find(|p| p.name == Some("internal-http".into()))
1626                })
1627                .map(|p| p.port);
1628            let Some(port) = internal_http_port else {
1629                bail!("internal-http port missing in service spec");
1630            };
1631            let metrics_url = format!(
1632                "http://{service_name}-{i}.{service_name}.{namespace}.svc.cluster.local:{port}\
1633                 /api/usage-metrics"
1634            );
1635
1636            let http_client = reqwest::Client::builder()
1637                .timeout(Duration::from_secs(10))
1638                .build()
1639                .context("error building HTTP client")?;
1640            let resp = http_client.get(metrics_url).send().await?;
1641            let usage = resp.json().await?;
1642
1643            Ok(usage)
1644        }
1645
1646        let ret = futures::future::join_all(
1647            (0..info.scale.cast_into()).map(|i| get_metrics(self, name, i)),
1648        );
1649
1650        ret.await
1651    }
1652
1653    async fn ensure_service(&self, mut desc: ServiceDescription) -> Result<(), K8sError> {
1654        // We inject our own pod's owner references into the Kubernetes objects
1655        // created for the service so that if the
1656        // Deployment/StatefulSet/whatever that owns the pod running the
1657        // orchestrator gets deleted, so do all services spawned by this
1658        // orchestrator.
1659        desc.service
1660            .metadata
1661            .owner_references
1662            .get_or_insert(vec![])
1663            .extend(self.owner_references.iter().cloned());
1664        desc.stateful_set
1665            .metadata
1666            .owner_references
1667            .get_or_insert(vec![])
1668            .extend(self.owner_references.iter().cloned());
1669
1670        let ss_spec = desc.stateful_set.spec.as_ref().unwrap();
1671        let pod_metadata = ss_spec.template.metadata.as_ref().unwrap();
1672        let pod_annotations = pod_metadata.annotations.clone();
1673
1674        self.service_api
1675            .patch(
1676                &desc.name,
1677                &PatchParams::apply(FIELD_MANAGER).force(),
1678                &Patch::Apply(desc.service),
1679            )
1680            .await?;
1681        self.stateful_set_api
1682            .patch(
1683                &desc.name,
1684                &PatchParams::apply(FIELD_MANAGER).force(),
1685                &Patch::Apply(desc.stateful_set),
1686            )
1687            .await?;
1688
1689        // We manage pod recreation manually, using the OnDelete StatefulSet update strategy, for
1690        // two reasons:
1691        //  * Kubernetes doesn't always automatically replace StatefulSet pods when their specs
1692        //    change, see https://github.com/kubernetes/kubernetes#67250.
1693        //  * Kubernetes replaces StatefulSet pods when their annotations change, which is not
1694        //    something we want as it could cause unavailability.
1695        //
1696        // Our pod recreation policy is simple: If a pod's template hash changed, delete it, and
1697        // let the StatefulSet controller recreate it. Otherwise, patch the existing pod's
1698        // annotations to line up with the ones in the spec.
1699        for pod_id in 0..desc.scale.get() {
1700            let pod_name = format!("{}-{pod_id}", desc.name);
1701            let pod = match self.pod_api.get(&pod_name).await {
1702                Ok(pod) => pod,
1703                // Pod already doesn't exist.
1704                Err(kube::Error::Api(e)) if e.code == 404 => continue,
1705                Err(e) => return Err(e),
1706            };
1707
1708            let result = if pod.annotations().get(POD_TEMPLATE_HASH_ANNOTATION)
1709                != Some(&desc.pod_template_hash)
1710            {
1711                self.pod_api
1712                    .delete(&pod_name, &DeleteParams::default())
1713                    .await
1714                    .map(|_| ())
1715            } else {
1716                let metadata = ObjectMeta {
1717                    annotations: pod_annotations.clone(),
1718                    ..Default::default()
1719                }
1720                .into_request_partial::<Pod>();
1721                self.pod_api
1722                    .patch_metadata(
1723                        &pod_name,
1724                        &PatchParams::apply(FIELD_MANAGER).force(),
1725                        &Patch::Apply(&metadata),
1726                    )
1727                    .await
1728                    .map(|_| ())
1729            };
1730
1731            match result {
1732                Ok(()) => (),
1733                // Pod was deleted concurrently.
1734                Err(kube::Error::Api(e)) if e.code == 404 => continue,
1735                Err(e) => return Err(e),
1736            }
1737        }
1738
1739        Ok(())
1740    }
1741
1742    async fn drop_service(&self, name: &str) -> Result<(), K8sError> {
1743        let res = self
1744            .stateful_set_api
1745            .delete(name, &DeleteParams::default())
1746            .await;
1747        match res {
1748            Ok(_) => (),
1749            Err(K8sError::Api(e)) if e.code == 404 => (),
1750            Err(e) => return Err(e),
1751        }
1752
1753        let res = self
1754            .service_api
1755            .delete(name, &DeleteParams::default())
1756            .await;
1757        match res {
1758            Ok(_) => Ok(()),
1759            Err(K8sError::Api(e)) if e.code == 404 => Ok(()),
1760            Err(e) => Err(e),
1761        }
1762    }
1763
1764    async fn list_services(&self, namespace: &str) -> Result<Vec<String>, K8sError> {
1765        let stateful_sets = self.stateful_set_api.list(&Default::default()).await?;
1766        let name_prefix = format!("{}{namespace}-", self.name_prefix);
1767        Ok(stateful_sets
1768            .into_iter()
1769            .filter_map(|ss| {
1770                ss.metadata
1771                    .name
1772                    .unwrap()
1773                    .strip_prefix(&name_prefix)
1774                    .map(Into::into)
1775            })
1776            .collect())
1777    }
1778}
1779
1780#[derive(Debug, Clone)]
1781struct KubernetesService {
1782    hosts: Vec<String>,
1783    ports: BTreeMap<String, u16>,
1784}
1785
1786impl Service for KubernetesService {
1787    fn addresses(&self, port: &str) -> Vec<String> {
1788        let port = self.ports[port];
1789        self.hosts
1790            .iter()
1791            .map(|host| format!("{host}:{port}"))
1792            .collect()
1793    }
1794}
1795
1796/// Returns the `minDomains` value for a `TopologySpreadConstraint`.
1797///
1798/// `minDomains` must be suppressed when spread is soft (Kubernetes rejects
1799/// `minDomains` with `ScheduleAnyway`) and when `availability_zones` is set
1800/// (node affinity already constrains eligible domains; if `minDomains` exceeds
1801/// the number of pinned zones the global minimum is treated as 0, causing all
1802/// but one replica to remain pending with `maxSkew=1`).
1803fn topology_spread_min_domains(
1804    soft: bool,
1805    az_pinned: bool,
1806    min_domains: Option<i32>,
1807) -> Option<i32> {
1808    if soft || az_pinned { None } else { min_domains }
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813    use super::*;
1814
1815    #[mz_ore::test]
1816    fn topology_spread_min_domains_suppression() {
1817        // min_domains is kept when neither soft nor az-pinned
1818        assert_eq!(topology_spread_min_domains(false, false, Some(3)), Some(3));
1819        // min_domains is None when not set regardless of flags
1820        assert_eq!(topology_spread_min_domains(false, false, None), None);
1821        // suppressed when soft (Kubernetes rejects minDomains with ScheduleAnyway)
1822        assert_eq!(topology_spread_min_domains(true, false, Some(3)), None);
1823        // suppressed when availability_zones pins to specific AZs
1824        assert_eq!(topology_spread_min_domains(false, true, Some(3)), None);
1825        // suppressed when both
1826        assert_eq!(topology_spread_min_domains(true, true, Some(3)), None);
1827    }
1828
1829    #[mz_ore::test]
1830    fn k8s_quantity_base10_large() {
1831        let cases = &[
1832            ("42", 42),
1833            ("42k", 42000),
1834            ("42M", 42000000),
1835            ("42G", 42000000000),
1836            ("42T", 42000000000000),
1837            ("42P", 42000000000000000),
1838        ];
1839
1840        for (input, expected) in cases {
1841            let quantity = parse_k8s_quantity(input).unwrap();
1842            let number = quantity.try_to_integer(0, true).unwrap();
1843            assert_eq!(number, *expected, "input={input}, quantity={quantity:?}");
1844        }
1845    }
1846
1847    #[mz_ore::test]
1848    fn k8s_quantity_base10_small() {
1849        let cases = &[("42n", 42), ("42u", 42000), ("42m", 42000000)];
1850
1851        for (input, expected) in cases {
1852            let quantity = parse_k8s_quantity(input).unwrap();
1853            let number = quantity.try_to_integer(-9, true).unwrap();
1854            assert_eq!(number, *expected, "input={input}, quantity={quantity:?}");
1855        }
1856    }
1857
1858    #[mz_ore::test]
1859    fn k8s_quantity_base2() {
1860        let cases = &[
1861            ("42Ki", 42 << 10),
1862            ("42Mi", 42 << 20),
1863            ("42Gi", 42 << 30),
1864            ("42Ti", 42 << 40),
1865            ("42Pi", 42 << 50),
1866        ];
1867
1868        for (input, expected) in cases {
1869            let quantity = parse_k8s_quantity(input).unwrap();
1870            let number = quantity.try_to_integer(0, false).unwrap();
1871            assert_eq!(number, *expected, "input={input}, quantity={quantity:?}");
1872        }
1873    }
1874}