1use 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;
73const RETRY_WARN_ATTEMPTS: usize = 5;
78
79const POD_TEMPLATE_HASH_ANNOTATION: &str = "environmentd.materialize.cloud/pod-template-hash";
80
81#[derive(Debug, Clone)]
83pub struct KubernetesOrchestratorConfig {
84 pub context: String,
87 pub scheduler_name: Option<String>,
89 pub priority_class_name: Option<String>,
91 pub service_annotations: BTreeMap<String, String>,
93 pub service_labels: BTreeMap<String, String>,
95 pub service_node_selector: BTreeMap<String, String>,
97 pub service_affinity: Option<String>,
99 pub service_tolerations: Option<String>,
101 pub service_account: Option<String>,
103 pub image_pull_policy: KubernetesImagePullPolicy,
105 pub aws_external_id_prefix: Option<AwsExternalIdPrefix>,
108 pub coverage: bool,
110 pub ephemeral_volume_storage_class: Option<String>,
116 pub service_fs_group: Option<i64>,
118 pub name_prefix: Option<String>,
120 pub collect_pod_metrics: bool,
122 pub enable_prometheus_scrape_annotations: bool,
124 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#[derive(ValueEnum, Debug, Clone, Copy)]
137pub enum KubernetesImagePullPolicy {
138 Always,
140 IfNotPresent,
142 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
166pub 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 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 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
265enum 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#[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
301struct 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#[derive(Deserialize, Clone, Debug)]
375pub struct MetricIdentifier {
376 #[serde(rename = "metricName")]
377 pub name: String,
378 }
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 }
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 fn watch_pod_params(&self) -> watcher::Config {
404 let ns_selector = format!(
405 "environmentd.materialize.cloud/namespace={}",
406 self.namespace
407 );
408 watcher::Config::default().timeout(59).labels(&ns_selector)
410 }
411
412 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
487fn 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), ("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 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 let scheduling_config: ServiceSchedulingConfig =
604 self.scheduling_config.read().expect("poisoned").clone();
605
606 let disk = disk_limit != Some(DiskLimit::ZERO);
608
609 let name = self.service_name(id);
610 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 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 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 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 match_label_keys: None,
860 ..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 "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 "karpenter.sh/do-not-disrupt".to_owned() => "true".to_string(),
883 };
884 for (key, value) in annotations_in {
885 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 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 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 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 ..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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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
1865fn 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 assert_eq!(topology_spread_min_domains(false, false, Some(3)), Some(3));
1888 assert_eq!(topology_spread_min_domains(false, false, None), None);
1890 assert_eq!(topology_spread_min_domains(true, false, Some(3)), None);
1892 assert_eq!(topology_spread_min_domains(false, true, Some(3)), None);
1894 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}