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}
125
126impl KubernetesOrchestratorConfig {
127 pub fn name_prefix(&self) -> String {
128 self.name_prefix.clone().unwrap_or_default()
129 }
130}
131
132#[derive(ValueEnum, Debug, Clone, Copy)]
134pub enum KubernetesImagePullPolicy {
135 Always,
137 IfNotPresent,
139 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
163pub 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 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 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
262enum 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#[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
298struct 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#[derive(Deserialize, Clone, Debug)]
372pub struct MetricIdentifier {
373 #[serde(rename = "metricName")]
374 pub name: String,
375 }
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 }
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 fn watch_pod_params(&self) -> watcher::Config {
401 let ns_selector = format!(
402 "environmentd.materialize.cloud/namespace={}",
403 self.namespace
404 );
405 watcher::Config::default().timeout(59).labels(&ns_selector)
407 }
408
409 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
484fn 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), ("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 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 let scheduling_config: ServiceSchedulingConfig =
600 self.scheduling_config.read().expect("poisoned").clone();
601
602 let disk = disk_limit != Some(DiskLimit::ZERO);
604
605 let name = self.service_name(id);
606 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 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 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 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 match_label_keys: None,
853 ..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 "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 "karpenter.sh/do-not-disrupt".to_owned() => "true".to_string(),
876 };
877 for (key, value) in annotations_in {
878 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 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 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 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 ..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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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
1855fn 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 assert_eq!(topology_spread_min_domains(false, false, Some(3)), Some(3));
1878 assert_eq!(topology_spread_min_domains(false, false, None), None);
1880 assert_eq!(topology_spread_min_domains(true, false, Some(3)), None);
1882 assert_eq!(topology_spread_min_domains(false, true, Some(3)), None);
1884 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}