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