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