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