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::retry::Retry;
56use mz_ore::task::AbortOnDropHandle;
57use serde::Deserialize;
58use sha2::{Digest, Sha256};
59use tokio::sync::{mpsc, oneshot};
60use tracing::{error, info, warn};
61
62pub mod cloud_resource_controller;
63pub mod secrets;
64pub mod util;
65
66const FIELD_MANAGER: &str = "environmentd";
67const NODE_FAILURE_THRESHOLD_SECONDS: i64 = 30;
68
69const POD_TEMPLATE_HASH_ANNOTATION: &str = "environmentd.materialize.cloud/pod-template-hash";
70
71#[derive(Debug, Clone)]
73pub struct KubernetesOrchestratorConfig {
74 pub context: String,
77 pub scheduler_name: Option<String>,
79 pub priority_class_name: Option<String>,
81 pub service_annotations: BTreeMap<String, String>,
83 pub service_labels: BTreeMap<String, String>,
85 pub service_node_selector: BTreeMap<String, String>,
87 pub service_affinity: Option<String>,
89 pub service_tolerations: Option<String>,
91 pub service_account: Option<String>,
93 pub image_pull_policy: KubernetesImagePullPolicy,
95 pub aws_external_id_prefix: Option<AwsExternalIdPrefix>,
98 pub coverage: bool,
100 pub ephemeral_volume_storage_class: Option<String>,
106 pub service_fs_group: Option<i64>,
108 pub name_prefix: Option<String>,
110 pub collect_pod_metrics: bool,
112 pub enable_prometheus_scrape_annotations: bool,
114}
115
116impl KubernetesOrchestratorConfig {
117 pub fn name_prefix(&self) -> String {
118 self.name_prefix.clone().unwrap_or_default()
119 }
120}
121
122#[derive(ValueEnum, Debug, Clone, Copy)]
124pub enum KubernetesImagePullPolicy {
125 Always,
127 IfNotPresent,
129 Never,
131}
132
133impl fmt::Display for KubernetesImagePullPolicy {
134 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
135 match self {
136 KubernetesImagePullPolicy::Always => f.write_str("Always"),
137 KubernetesImagePullPolicy::IfNotPresent => f.write_str("IfNotPresent"),
138 KubernetesImagePullPolicy::Never => f.write_str("Never"),
139 }
140 }
141}
142
143impl KubernetesImagePullPolicy {
144 pub fn as_kebab_case_str(&self) -> &'static str {
145 match self {
146 Self::Always => "always",
147 Self::IfNotPresent => "if-not-present",
148 Self::Never => "never",
149 }
150 }
151}
152
153pub struct KubernetesOrchestrator {
155 client: Client,
156 kubernetes_namespace: String,
157 config: KubernetesOrchestratorConfig,
158 secret_api: Api<Secret>,
159 vpc_endpoint_api: Api<VpcEndpoint>,
160 namespaces: Mutex<BTreeMap<String, Arc<dyn NamespacedOrchestrator>>>,
161 resource_reader: Arc<KubernetesResourceReader>,
162}
163
164impl fmt::Debug for KubernetesOrchestrator {
165 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
166 f.debug_struct("KubernetesOrchestrator").finish()
167 }
168}
169
170impl KubernetesOrchestrator {
171 pub async fn new(
173 config: KubernetesOrchestratorConfig,
174 ) -> Result<KubernetesOrchestrator, anyhow::Error> {
175 let (client, kubernetes_namespace) = util::create_client(config.context.clone()).await?;
176 let resource_reader =
177 Arc::new(KubernetesResourceReader::new(config.context.clone()).await?);
178 Ok(KubernetesOrchestrator {
179 client: client.clone(),
180 kubernetes_namespace,
181 config,
182 secret_api: Api::default_namespaced(client.clone()),
183 vpc_endpoint_api: Api::default_namespaced(client),
184 namespaces: Mutex::new(BTreeMap::new()),
185 resource_reader,
186 })
187 }
188}
189
190impl Orchestrator for KubernetesOrchestrator {
191 fn namespace(&self, namespace: &str) -> Arc<dyn NamespacedOrchestrator> {
192 let mut namespaces = self.namespaces.lock().expect("lock poisoned");
193 Arc::clone(namespaces.entry(namespace.into()).or_insert_with(|| {
194 let (command_tx, command_rx) = mpsc::unbounded_channel();
195 let worker = OrchestratorWorker {
196 metrics_api: Api::default_namespaced(self.client.clone()),
197 service_api: Api::default_namespaced(self.client.clone()),
198 stateful_set_api: Api::default_namespaced(self.client.clone()),
199 pod_api: Api::default_namespaced(self.client.clone()),
200 owner_references: vec![],
201 command_rx,
202 name_prefix: self.config.name_prefix.clone().unwrap_or_default(),
203 collect_pod_metrics: self.config.collect_pod_metrics,
204 }
205 .spawn(format!("kubernetes-orchestrator-worker:{namespace}"));
206
207 Arc::new(NamespacedKubernetesOrchestrator {
208 pod_api: Api::default_namespaced(self.client.clone()),
209 kubernetes_namespace: self.kubernetes_namespace.clone(),
210 namespace: namespace.into(),
211 config: self.config.clone(),
212 scheduling_config: Default::default(),
214 service_infos: std::sync::Mutex::new(BTreeMap::new()),
215 command_tx,
216 _worker: worker,
217 })
218 }))
219 }
220}
221
222#[derive(Clone, Copy)]
223struct ServiceInfo {
224 scale: NonZero<u16>,
225}
226
227struct NamespacedKubernetesOrchestrator {
228 pod_api: Api<Pod>,
229 kubernetes_namespace: String,
230 namespace: String,
231 config: KubernetesOrchestratorConfig,
232 scheduling_config: std::sync::RwLock<ServiceSchedulingConfig>,
233 service_infos: std::sync::Mutex<BTreeMap<String, ServiceInfo>>,
234 command_tx: mpsc::UnboundedSender<WorkerCommand>,
235 _worker: AbortOnDropHandle<()>,
236}
237
238impl fmt::Debug for NamespacedKubernetesOrchestrator {
239 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
240 f.debug_struct("NamespacedKubernetesOrchestrator")
241 .field("kubernetes_namespace", &self.kubernetes_namespace)
242 .field("namespace", &self.namespace)
243 .field("config", &self.config)
244 .finish()
245 }
246}
247
248enum WorkerCommand {
254 EnsureService {
255 desc: ServiceDescription,
256 },
257 DropService {
258 name: String,
259 },
260 ListServices {
261 namespace: String,
262 result_tx: oneshot::Sender<Vec<String>>,
263 },
264 FetchServiceMetrics {
265 name: String,
266 info: ServiceInfo,
267 result_tx: oneshot::Sender<Vec<ServiceProcessMetrics>>,
268 },
269}
270
271#[derive(Debug, Clone)]
273struct ServiceDescription {
274 name: String,
275 scale: NonZero<u16>,
276 service: K8sService,
277 stateful_set: StatefulSet,
278 pod_template_hash: String,
279}
280
281struct OrchestratorWorker {
293 metrics_api: Api<PodMetrics>,
294 service_api: Api<K8sService>,
295 stateful_set_api: Api<StatefulSet>,
296 pod_api: Api<Pod>,
297 owner_references: Vec<OwnerReference>,
298 command_rx: mpsc::UnboundedReceiver<WorkerCommand>,
299 name_prefix: String,
300 collect_pod_metrics: bool,
301}
302
303#[derive(Deserialize, Clone, Debug)]
304pub struct PodMetricsContainer {
305 pub name: String,
306 pub usage: PodMetricsContainerUsage,
307}
308
309#[derive(Deserialize, Clone, Debug)]
310pub struct PodMetricsContainerUsage {
311 pub cpu: Quantity,
312 pub memory: Quantity,
313}
314
315#[derive(Deserialize, Clone, Debug)]
316pub struct PodMetrics {
317 pub metadata: ObjectMeta,
318 pub timestamp: String,
319 pub window: String,
320 pub containers: Vec<PodMetricsContainer>,
321}
322
323impl k8s_openapi::Resource for PodMetrics {
324 const GROUP: &'static str = "metrics.k8s.io";
325 const KIND: &'static str = "PodMetrics";
326 const VERSION: &'static str = "v1beta1";
327 const API_VERSION: &'static str = "metrics.k8s.io/v1beta1";
328 const URL_PATH_SEGMENT: &'static str = "pods";
329
330 type Scope = k8s_openapi::NamespaceResourceScope;
331}
332
333impl k8s_openapi::Metadata for PodMetrics {
334 type Ty = ObjectMeta;
335
336 fn metadata(&self) -> &Self::Ty {
337 &self.metadata
338 }
339
340 fn metadata_mut(&mut self) -> &mut Self::Ty {
341 &mut self.metadata
342 }
343}
344
345#[derive(Deserialize, Clone, Debug)]
354pub struct MetricIdentifier {
355 #[serde(rename = "metricName")]
356 pub name: String,
357 }
359
360#[derive(Deserialize, Clone, Debug)]
361pub struct MetricValue {
362 #[serde(rename = "describedObject")]
363 pub described_object: ObjectReference,
364 #[serde(flatten)]
365 pub metric_identifier: MetricIdentifier,
366 pub timestamp: String,
367 pub value: Quantity,
368 }
370
371impl NamespacedKubernetesOrchestrator {
372 fn service_name(&self, id: &str) -> String {
373 format!(
374 "{}{}-{id}",
375 self.config.name_prefix.as_deref().unwrap_or(""),
376 self.namespace
377 )
378 }
379
380 fn watch_pod_params(&self) -> watcher::Config {
383 let ns_selector = format!(
384 "environmentd.materialize.cloud/namespace={}",
385 self.namespace
386 );
387 watcher::Config::default().timeout(59).labels(&ns_selector)
389 }
390
391 fn make_label_key(&self, key: &str) -> String {
394 format!("{}.environmentd.materialize.cloud/{}", self.namespace, key)
395 }
396
397 fn label_selector_to_k8s(
398 &self,
399 MzLabelSelector { label_name, logic }: MzLabelSelector,
400 ) -> Result<LabelSelectorRequirement, anyhow::Error> {
401 let (operator, values) = match logic {
402 LabelSelectionLogic::Eq { value } => Ok(("In", vec![value])),
403 LabelSelectionLogic::NotEq { value } => Ok(("NotIn", vec![value])),
404 LabelSelectionLogic::Exists => Ok(("Exists", vec![])),
405 LabelSelectionLogic::NotExists => Ok(("DoesNotExist", vec![])),
406 LabelSelectionLogic::InSet { values } => {
407 if values.is_empty() {
408 Err(anyhow!(
409 "Invalid selector logic for {label_name}: empty `in` set"
410 ))
411 } else {
412 Ok(("In", values))
413 }
414 }
415 LabelSelectionLogic::NotInSet { values } => {
416 if values.is_empty() {
417 Err(anyhow!(
418 "Invalid selector logic for {label_name}: empty `notin` set"
419 ))
420 } else {
421 Ok(("NotIn", values))
422 }
423 }
424 }?;
425 let lsr = LabelSelectorRequirement {
426 key: self.make_label_key(&label_name),
427 operator: operator.to_string(),
428 values: Some(values),
429 };
430 Ok(lsr)
431 }
432
433 fn send_command(&self, cmd: WorkerCommand) {
434 self.command_tx.send(cmd).expect("worker task not dropped");
435 }
436}
437
438#[derive(Debug)]
439struct ScaledQuantity {
440 integral_part: u64,
441 exponent: i8,
442 base10: bool,
443}
444
445impl ScaledQuantity {
446 pub fn try_to_integer(&self, scale: i8, base10: bool) -> Option<u64> {
447 if base10 != self.base10 {
448 return None;
449 }
450 let exponent = self.exponent - scale;
451 let mut result = self.integral_part;
452 let base = if self.base10 { 10 } else { 2 };
453 if exponent < 0 {
454 for _ in exponent..0 {
455 result /= base;
456 }
457 } else {
458 for _ in 0..exponent {
459 result = result.checked_mul(base)?;
460 }
461 }
462 Some(result)
463 }
464}
465
466fn parse_k8s_quantity(s: &str) -> Result<ScaledQuantity, anyhow::Error> {
476 const DEC_SUFFIXES: &[(&str, i8)] = &[
477 ("n", -9),
478 ("u", -6),
479 ("m", -3),
480 ("", 0),
481 ("k", 3), ("M", 6),
483 ("G", 9),
484 ("T", 12),
485 ("P", 15),
486 ("E", 18),
487 ];
488 const BIN_SUFFIXES: &[(&str, i8)] = &[
489 ("", 0),
490 ("Ki", 10),
491 ("Mi", 20),
492 ("Gi", 30),
493 ("Ti", 40),
494 ("Pi", 50),
495 ("Ei", 60),
496 ];
497
498 let (positive, s) = match s.chars().next() {
499 Some('+') => (true, &s[1..]),
500 Some('-') => (false, &s[1..]),
501 _ => (true, s),
502 };
503
504 if !positive {
505 anyhow::bail!("Negative numbers not supported")
506 }
507
508 fn is_suffix_char(ch: char) -> bool {
509 "numkMGTPEKi".contains(ch)
510 }
511 let (num, suffix) = match s.find(is_suffix_char) {
512 None => (s, ""),
513 Some(idx) => s.split_at(idx),
514 };
515 let num: u64 = num.parse()?;
516 let (exponent, base10) = if let Some((_, exponent)) =
517 DEC_SUFFIXES.iter().find(|(target, _)| suffix == *target)
518 {
519 (exponent, true)
520 } else if let Some((_, exponent)) = BIN_SUFFIXES.iter().find(|(target, _)| suffix == *target) {
521 (exponent, false)
522 } else {
523 anyhow::bail!("Unrecognized suffix: {suffix}");
524 };
525 Ok(ScaledQuantity {
526 integral_part: num,
527 exponent: *exponent,
528 base10,
529 })
530}
531
532#[async_trait]
533impl NamespacedOrchestrator for NamespacedKubernetesOrchestrator {
534 async fn fetch_service_metrics(
535 &self,
536 id: &str,
537 ) -> Result<Vec<ServiceProcessMetrics>, anyhow::Error> {
538 let info = if let Some(info) = self.service_infos.lock().expect("poisoned lock").get(id) {
539 *info
540 } else {
541 tracing::error!("Failed to get info for {id}");
543 anyhow::bail!("Failed to get info for {id}");
544 };
545
546 let (result_tx, result_rx) = oneshot::channel();
547 self.send_command(WorkerCommand::FetchServiceMetrics {
548 name: self.service_name(id),
549 info,
550 result_tx,
551 });
552
553 let metrics = result_rx.await.expect("worker task not dropped");
554 Ok(metrics)
555 }
556
557 fn ensure_service(
558 &self,
559 id: &str,
560 ServiceConfig {
561 app_name,
562 image,
563 init_container_image,
564 args,
565 ports: ports_in,
566 memory_limit,
567 memory_request,
568 cpu_limit,
569 cpu_request,
570 scale,
571 labels: labels_in,
572 annotations: annotations_in,
573 availability_zones,
574 other_replicas_selector,
575 replicas_selector,
576 disk_limit,
577 node_selector,
578 }: ServiceConfig,
579 ) -> Result<Box<dyn Service>, anyhow::Error> {
580 let scheduling_config: ServiceSchedulingConfig =
582 self.scheduling_config.read().expect("poisoned").clone();
583
584 let disk = disk_limit != Some(DiskLimit::ZERO);
586
587 let name = self.service_name(id);
588 let mut match_labels = btreemap! {
594 "environmentd.materialize.cloud/namespace".into() => self.namespace.clone(),
595 "environmentd.materialize.cloud/service-id".into() => id.into(),
596 };
597 for (key, value) in &self.config.service_labels {
598 match_labels.insert(key.clone(), value.clone());
599 }
600
601 let mut labels = match_labels.clone();
602 for (key, value) in labels_in {
603 labels.insert(self.make_label_key(&key), value);
604 }
605
606 let standard_labels = recommended_k8s_labels(app_name);
607
608 labels.extend(standard_labels.clone());
610
611 labels.insert(self.make_label_key("scale"), scale.to_string());
612
613 for port in &ports_in {
614 labels.insert(
615 format!("environmentd.materialize.cloud/port-{}", port.name),
616 "true".into(),
617 );
618 }
619 let mut limits = BTreeMap::new();
620 let mut requests = BTreeMap::new();
621 if let Some(memory_limit) = memory_limit {
622 limits.insert(
623 "memory".into(),
624 Quantity(memory_limit.0.as_u64().to_string()),
625 );
626 requests.insert(
627 "memory".into(),
628 Quantity(memory_limit.0.as_u64().to_string()),
629 );
630 }
631 if let Some(memory_request) = memory_request {
632 requests.insert(
633 "memory".into(),
634 Quantity(memory_request.0.as_u64().to_string()),
635 );
636 }
637 if let Some(cpu_limit) = cpu_limit {
638 limits.insert(
639 "cpu".into(),
640 Quantity(format!("{}m", cpu_limit.as_millicpus())),
641 );
642 requests.insert(
643 "cpu".into(),
644 Quantity(format!("{}m", cpu_limit.as_millicpus())),
645 );
646 }
647 if let Some(cpu_request) = cpu_request {
648 requests.insert(
649 "cpu".into(),
650 Quantity(format!("{}m", cpu_request.as_millicpus())),
651 );
652 }
653 let service = K8sService {
654 metadata: ObjectMeta {
655 name: Some(name.clone()),
656 labels: Some(standard_labels.clone()),
657 ..Default::default()
658 },
659 spec: Some(ServiceSpec {
660 ports: Some(
661 ports_in
662 .iter()
663 .map(|port| ServicePort {
664 port: port.port_hint.into(),
665 name: Some(port.name.clone()),
666 ..Default::default()
667 })
668 .collect(),
669 ),
670 cluster_ip: Some("None".to_string()),
671 selector: Some(match_labels.clone()),
672 ..Default::default()
673 }),
674 status: None,
675 };
676
677 let hosts = (0..scale.get())
678 .map(|i| {
679 format!(
680 "{name}-{i}.{name}.{}.svc.cluster.local",
681 self.kubernetes_namespace
682 )
683 })
684 .collect::<Vec<_>>();
685 let ports = ports_in
686 .iter()
687 .map(|p| (p.name.clone(), p.port_hint))
688 .collect::<BTreeMap<_, _>>();
689
690 let mut listen_addrs = BTreeMap::new();
691 let mut peer_addrs = vec![BTreeMap::new(); hosts.len()];
692 for (name, port) in &ports {
693 listen_addrs.insert(name.clone(), format!("0.0.0.0:{port}"));
694 for (i, host) in hosts.iter().enumerate() {
695 peer_addrs[i].insert(name.clone(), format!("{host}:{port}"));
696 }
697 }
698 let mut args = args(ServiceAssignments {
699 listen_addrs: &listen_addrs,
700 peer_addrs: &peer_addrs,
701 });
702
703 let anti_affinity = Some({
712 let label_selector_requirements = other_replicas_selector
713 .clone()
714 .into_iter()
715 .map(|ls| self.label_selector_to_k8s(ls))
716 .collect::<Result<Vec<_>, _>>()?;
717 let ls = LabelSelector {
718 match_expressions: Some(label_selector_requirements),
719 ..Default::default()
720 };
721 let pat = PodAffinityTerm {
722 label_selector: Some(ls),
723 topology_key: "kubernetes.io/hostname".to_string(),
724 ..Default::default()
725 };
726
727 if !scheduling_config.soften_replication_anti_affinity {
728 PodAntiAffinity {
729 required_during_scheduling_ignored_during_execution: Some(vec![pat]),
730 ..Default::default()
731 }
732 } else {
733 PodAntiAffinity {
734 preferred_during_scheduling_ignored_during_execution: Some(vec![
735 WeightedPodAffinityTerm {
736 weight: scheduling_config.soften_replication_anti_affinity_weight,
737 pod_affinity_term: pat,
738 },
739 ]),
740 ..Default::default()
741 }
742 }
743 });
744
745 let pod_affinity = if let Some(weight) = scheduling_config.multi_pod_az_affinity_weight {
746 let ls = LabelSelector {
748 match_labels: Some(match_labels.clone()),
749 ..Default::default()
750 };
751 let pat = PodAffinityTerm {
752 label_selector: Some(ls),
753 topology_key: "topology.kubernetes.io/zone".to_string(),
754 ..Default::default()
755 };
756
757 Some(PodAffinity {
758 preferred_during_scheduling_ignored_during_execution: Some(vec![
759 WeightedPodAffinityTerm {
760 weight,
761 pod_affinity_term: pat,
762 },
763 ]),
764 ..Default::default()
765 })
766 } else {
767 None
768 };
769
770 let topology_spread = if scheduling_config.topology_spread.enabled {
771 let config = &scheduling_config.topology_spread;
772
773 if !config.ignore_non_singular_scale || scale.get() == 1 {
774 let label_selector_requirements = (if config.ignore_non_singular_scale {
775 let mut replicas_selector_ignoring_scale = replicas_selector.clone();
776
777 replicas_selector_ignoring_scale.push(mz_orchestrator::LabelSelector {
778 label_name: "scale".into(),
779 logic: mz_orchestrator::LabelSelectionLogic::Eq {
780 value: "1".to_string(),
781 },
782 });
783
784 replicas_selector_ignoring_scale
785 } else {
786 replicas_selector
787 })
788 .into_iter()
789 .map(|ls| self.label_selector_to_k8s(ls))
790 .collect::<Result<Vec<_>, _>>()?;
791 let ls = LabelSelector {
792 match_expressions: Some(label_selector_requirements),
793 ..Default::default()
794 };
795
796 if config.soft && config.min_domains.is_some() {
797 warn!(
798 "topology spread is soft but min_domains is set; \
799 Kubernetes rejects minDomains with ScheduleAnyway, \
800 so min_domains will be ignored"
801 );
802 }
803 if availability_zones.is_some() && config.min_domains.is_some() {
804 warn!(
805 "topology spread has min_domains set but availability_zones \
806 constrains eligible topology domains via node affinity; \
807 minDomains will be ignored to avoid preventing pod scheduling"
808 );
809 }
810
811 let constraint = TopologySpreadConstraint {
812 label_selector: Some(ls),
813 min_domains: topology_spread_min_domains(
814 config.soft,
815 availability_zones.is_some(),
816 config.min_domains,
817 ),
818 max_skew: config.max_skew,
819 topology_key: "topology.kubernetes.io/zone".to_string(),
820 when_unsatisfiable: if config.soft {
821 "ScheduleAnyway".to_string()
822 } else {
823 "DoNotSchedule".to_string()
824 },
825 match_label_keys: None,
835 ..Default::default()
838 };
839 Some(vec![constraint])
840 } else {
841 None
842 }
843 } else {
844 None
845 };
846
847 let mut pod_annotations = btreemap! {
848 "cluster-autoscaler.kubernetes.io/safe-to-evict".to_owned() => "false".to_string(),
854 "karpenter.sh/do-not-evict".to_owned() => "true".to_string(),
855
856 "karpenter.sh/do-not-disrupt".to_owned() => "true".to_string(),
858 };
859 for (key, value) in annotations_in {
860 pod_annotations.insert(self.make_label_key(&key), value);
862 }
863 if self.config.enable_prometheus_scrape_annotations {
864 if let Some(internal_http_port) = ports_in
865 .iter()
866 .find(|port| port.name == "internal-http")
867 .map(|port| port.port_hint.to_string())
868 {
869 pod_annotations.insert("prometheus.io/scrape".to_owned(), "true".to_string());
871 pod_annotations.insert("prometheus.io/port".to_owned(), internal_http_port);
872 pod_annotations.insert("prometheus.io/path".to_owned(), "/metrics".to_string());
873 pod_annotations.insert("prometheus.io/scheme".to_owned(), "http".to_string());
874 }
875 }
876 for (key, value) in &self.config.service_annotations {
877 pod_annotations.insert(key.clone(), value.clone());
878 }
879
880 let default_node_selector = if disk {
881 vec![("materialize.cloud/disk".to_string(), disk.to_string())]
882 } else {
883 vec![]
887 };
888
889 let node_selector: BTreeMap<String, String> = default_node_selector
890 .into_iter()
891 .chain(self.config.service_node_selector.clone())
892 .chain(node_selector)
893 .collect();
894
895 let node_affinity = if let Some(availability_zones) = availability_zones {
896 let selector = NodeSelectorTerm {
897 match_expressions: Some(vec![NodeSelectorRequirement {
898 key: "materialize.cloud/availability-zone".to_string(),
899 operator: "In".to_string(),
900 values: Some(availability_zones),
901 }]),
902 match_fields: None,
903 };
904
905 if scheduling_config.soften_az_affinity {
906 Some(NodeAffinity {
907 preferred_during_scheduling_ignored_during_execution: Some(vec![
908 PreferredSchedulingTerm {
909 preference: selector,
910 weight: scheduling_config.soften_az_affinity_weight,
911 },
912 ]),
913 required_during_scheduling_ignored_during_execution: None,
914 })
915 } else {
916 Some(NodeAffinity {
917 preferred_during_scheduling_ignored_during_execution: None,
918 required_during_scheduling_ignored_during_execution: Some(NodeSelector {
919 node_selector_terms: vec![selector],
920 }),
921 })
922 }
923 } else {
924 None
925 };
926
927 let mut affinity = Affinity {
928 pod_anti_affinity: anti_affinity,
929 pod_affinity,
930 node_affinity,
931 ..Default::default()
932 };
933 if let Some(service_affinity) = &self.config.service_affinity {
934 affinity.merge_from(serde_json::from_str(service_affinity)?);
935 }
936
937 let container_name = image
938 .rsplit_once('/')
939 .and_then(|(_, name_version)| name_version.rsplit_once(':'))
940 .context("`image` is not ORG/NAME:VERSION")?
941 .0
942 .to_string();
943
944 let container_security_context = if scheduling_config.security_context_enabled {
945 Some(SecurityContext {
946 privileged: Some(false),
947 run_as_non_root: Some(true),
948 allow_privilege_escalation: Some(false),
949 seccomp_profile: Some(SeccompProfile {
950 type_: "RuntimeDefault".to_string(),
951 ..Default::default()
952 }),
953 capabilities: Some(Capabilities {
954 drop: Some(vec!["ALL".to_string()]),
955 ..Default::default()
956 }),
957 ..Default::default()
958 })
959 } else {
960 None
961 };
962
963 let init_containers = init_container_image.map(|image| {
964 vec![Container {
965 name: "init".to_string(),
966 image: Some(image),
967 image_pull_policy: Some(self.config.image_pull_policy.to_string()),
968 resources: Some(ResourceRequirements {
969 claims: None,
970 limits: Some(limits.clone()),
971 requests: Some(requests.clone()),
972 }),
973 security_context: container_security_context.clone(),
974 env: Some(vec![
975 EnvVar {
976 name: "MZ_NAMESPACE".to_string(),
977 value_from: Some(EnvVarSource {
978 field_ref: Some(ObjectFieldSelector {
979 field_path: "metadata.namespace".to_string(),
980 ..Default::default()
981 }),
982 ..Default::default()
983 }),
984 ..Default::default()
985 },
986 EnvVar {
987 name: "MZ_POD_NAME".to_string(),
988 value_from: Some(EnvVarSource {
989 field_ref: Some(ObjectFieldSelector {
990 field_path: "metadata.name".to_string(),
991 ..Default::default()
992 }),
993 ..Default::default()
994 }),
995 ..Default::default()
996 },
997 EnvVar {
998 name: "MZ_NODE_NAME".to_string(),
999 value_from: Some(EnvVarSource {
1000 field_ref: Some(ObjectFieldSelector {
1001 field_path: "spec.nodeName".to_string(),
1002 ..Default::default()
1003 }),
1004 ..Default::default()
1005 }),
1006 ..Default::default()
1007 },
1008 ]),
1009 ..Default::default()
1010 }]
1011 });
1012
1013 let env = if self.config.coverage {
1014 Some(vec![EnvVar {
1015 name: "LLVM_PROFILE_FILE".to_string(),
1016 value: Some(format!("/coverage/{}-%p-%9m%c.profraw", self.namespace)),
1017 ..Default::default()
1018 }])
1019 } else {
1020 None
1021 };
1022
1023 let mut volume_mounts = vec![];
1024
1025 if self.config.coverage {
1026 volume_mounts.push(VolumeMount {
1027 name: "coverage".to_string(),
1028 mount_path: "/coverage".to_string(),
1029 ..Default::default()
1030 })
1031 }
1032
1033 let volumes = match (disk, &self.config.ephemeral_volume_storage_class) {
1034 (true, Some(ephemeral_volume_storage_class)) => {
1035 volume_mounts.push(VolumeMount {
1036 name: "scratch".to_string(),
1037 mount_path: "/scratch".to_string(),
1038 ..Default::default()
1039 });
1040 args.push("--scratch-directory=/scratch".into());
1041
1042 Some(vec![Volume {
1043 name: "scratch".to_string(),
1044 ephemeral: Some(EphemeralVolumeSource {
1045 volume_claim_template: Some(PersistentVolumeClaimTemplate {
1046 spec: PersistentVolumeClaimSpec {
1047 access_modes: Some(vec!["ReadWriteOnce".to_string()]),
1048 storage_class_name: Some(
1049 ephemeral_volume_storage_class.to_string(),
1050 ),
1051 resources: Some(VolumeResourceRequirements {
1052 requests: Some(BTreeMap::from([(
1053 "storage".to_string(),
1054 Quantity(
1055 disk_limit
1056 .unwrap_or(DiskLimit::ARBITRARY)
1057 .0
1058 .as_u64()
1059 .to_string(),
1060 ),
1061 )])),
1062 ..Default::default()
1063 }),
1064 ..Default::default()
1065 },
1066 ..Default::default()
1067 }),
1068 ..Default::default()
1069 }),
1070 ..Default::default()
1071 }])
1072 }
1073 (true, None) => {
1074 return Err(anyhow!(
1075 "service requested disk but no ephemeral volume storage class was configured"
1076 ));
1077 }
1078 (false, _) => None,
1079 };
1080
1081 if let Some(name_prefix) = &self.config.name_prefix {
1082 args.push(format!("--secrets-reader-name-prefix={}", name_prefix));
1083 }
1084
1085 let volume_claim_templates = if self.config.coverage {
1086 Some(vec![PersistentVolumeClaim {
1087 metadata: ObjectMeta {
1088 name: Some("coverage".to_string()),
1089 ..Default::default()
1090 },
1091 spec: Some(PersistentVolumeClaimSpec {
1092 access_modes: Some(vec!["ReadWriteOnce".to_string()]),
1093 resources: Some(VolumeResourceRequirements {
1094 requests: Some(BTreeMap::from([(
1095 "storage".to_string(),
1096 Quantity("10Gi".to_string()),
1097 )])),
1098 ..Default::default()
1099 }),
1100 ..Default::default()
1101 }),
1102 ..Default::default()
1103 }])
1104 } else {
1105 None
1106 };
1107
1108 let tcp_keepalive_sysctls = vec![
1109 Sysctl {
1110 name: "net.ipv4.tcp_keepalive_time".to_string(),
1111 value: "300".to_string(),
1112 },
1113 Sysctl {
1114 name: "net.ipv4.tcp_keepalive_intvl".to_string(),
1115 value: "30".to_string(),
1116 },
1117 Sysctl {
1118 name: "net.ipv4.tcp_keepalive_probes".to_string(),
1119 value: "3".to_string(),
1120 },
1121 ];
1122
1123 let security_context = if let Some(fs_group) = self.config.service_fs_group {
1124 Some(PodSecurityContext {
1125 fs_group: Some(fs_group),
1126 run_as_user: Some(fs_group),
1127 run_as_group: Some(fs_group),
1128 sysctls: Some(tcp_keepalive_sysctls),
1129 ..Default::default()
1130 })
1131 } else {
1132 Some(PodSecurityContext {
1133 sysctls: Some(tcp_keepalive_sysctls),
1134 ..Default::default()
1135 })
1136 };
1137
1138 let mut tolerations = vec![
1139 Toleration {
1144 effect: Some("NoExecute".into()),
1145 key: Some("node.kubernetes.io/not-ready".into()),
1146 operator: Some("Exists".into()),
1147 toleration_seconds: Some(NODE_FAILURE_THRESHOLD_SECONDS),
1148 value: None,
1149 },
1150 Toleration {
1151 effect: Some("NoExecute".into()),
1152 key: Some("node.kubernetes.io/unreachable".into()),
1153 operator: Some("Exists".into()),
1154 toleration_seconds: Some(NODE_FAILURE_THRESHOLD_SECONDS),
1155 value: None,
1156 },
1157 ];
1158 if let Some(service_tolerations) = &self.config.service_tolerations {
1159 tolerations.extend(serde_json::from_str::<Vec<_>>(service_tolerations)?);
1160 }
1161 let tolerations = Some(tolerations);
1162
1163 let mut pod_template_spec = PodTemplateSpec {
1164 metadata: Some(ObjectMeta {
1165 labels: Some(labels.clone()),
1166 ..Default::default()
1169 }),
1170 spec: Some(PodSpec {
1171 init_containers,
1172 containers: vec![Container {
1173 name: container_name,
1174 image: Some(image),
1175 args: Some(args),
1176 image_pull_policy: Some(self.config.image_pull_policy.to_string()),
1177 ports: Some(
1178 ports_in
1179 .iter()
1180 .map(|port| ContainerPort {
1181 container_port: port.port_hint.into(),
1182 name: Some(port.name.clone()),
1183 ..Default::default()
1184 })
1185 .collect(),
1186 ),
1187 security_context: container_security_context.clone(),
1188 resources: Some(ResourceRequirements {
1189 claims: None,
1190 limits: Some(limits),
1191 requests: Some(requests),
1192 }),
1193 volume_mounts: if !volume_mounts.is_empty() {
1194 Some(volume_mounts)
1195 } else {
1196 None
1197 },
1198 env,
1199 ..Default::default()
1200 }],
1201 volumes,
1202 security_context,
1203 node_selector: Some(node_selector),
1204 scheduler_name: self.config.scheduler_name.clone(),
1205 priority_class_name: self.config.priority_class_name.clone(),
1206 service_account: self.config.service_account.clone(),
1207 affinity: Some(affinity),
1208 topology_spread_constraints: topology_spread,
1209 tolerations,
1210 termination_grace_period_seconds: Some(0),
1232 ..Default::default()
1233 }),
1234 };
1235 let pod_template_json = serde_json::to_string(&pod_template_spec).unwrap();
1236 let mut hasher = Sha256::new();
1237 hasher.update(pod_template_json);
1238 let pod_template_hash = format!("{:x}", hasher.finalize());
1239 pod_annotations.insert(
1240 POD_TEMPLATE_HASH_ANNOTATION.to_owned(),
1241 pod_template_hash.clone(),
1242 );
1243
1244 pod_template_spec.metadata.as_mut().unwrap().annotations = Some(pod_annotations);
1245
1246 let stateful_set = StatefulSet {
1247 metadata: ObjectMeta {
1248 name: Some(name.clone()),
1249 labels: Some(standard_labels.clone()),
1250 ..Default::default()
1251 },
1252 spec: Some(StatefulSetSpec {
1253 selector: LabelSelector {
1254 match_labels: Some(match_labels),
1255 ..Default::default()
1256 },
1257 service_name: Some(name.clone()),
1258 replicas: Some(scale.cast_into()),
1259 template: pod_template_spec,
1260 update_strategy: Some(StatefulSetUpdateStrategy {
1261 type_: Some("OnDelete".to_owned()),
1262 ..Default::default()
1263 }),
1264 pod_management_policy: Some("Parallel".to_string()),
1265 volume_claim_templates,
1266 ..Default::default()
1267 }),
1268 status: None,
1269 };
1270
1271 self.send_command(WorkerCommand::EnsureService {
1272 desc: ServiceDescription {
1273 name,
1274 scale,
1275 service,
1276 stateful_set,
1277 pod_template_hash,
1278 },
1279 });
1280
1281 self.service_infos
1282 .lock()
1283 .expect("poisoned lock")
1284 .insert(id.to_string(), ServiceInfo { scale });
1285
1286 Ok(Box::new(KubernetesService { hosts, ports }))
1287 }
1288
1289 fn drop_service(&self, id: &str) -> Result<(), anyhow::Error> {
1291 fail::fail_point!("kubernetes_drop_service", |_| Err(anyhow!("failpoint")));
1292 self.service_infos.lock().expect("poisoned lock").remove(id);
1293
1294 self.send_command(WorkerCommand::DropService {
1295 name: self.service_name(id),
1296 });
1297
1298 Ok(())
1299 }
1300
1301 async fn list_services(&self) -> Result<Vec<String>, anyhow::Error> {
1303 let (result_tx, result_rx) = oneshot::channel();
1304 self.send_command(WorkerCommand::ListServices {
1305 namespace: self.namespace.clone(),
1306 result_tx,
1307 });
1308
1309 let list = result_rx.await.expect("worker task not dropped");
1310 Ok(list)
1311 }
1312
1313 fn watch_services(&self) -> BoxStream<'static, Result<ServiceEvent, anyhow::Error>> {
1314 fn into_service_event(pod: Pod) -> Result<ServiceEvent, anyhow::Error> {
1315 let process_id = pod.name_any().split('-').next_back().unwrap().parse()?;
1316 let service_id_label = "environmentd.materialize.cloud/service-id";
1317 let service_id = pod
1318 .labels()
1319 .get(service_id_label)
1320 .ok_or_else(|| anyhow!("missing label: {service_id_label}"))?
1321 .clone();
1322
1323 let oomed = pod
1324 .status
1325 .as_ref()
1326 .and_then(|status| status.container_statuses.as_ref())
1327 .map(|container_statuses| {
1328 container_statuses.iter().any(|cs| {
1329 let current_state = cs.state.as_ref().and_then(|s| s.terminated.as_ref());
1333 let last_state = cs.last_state.as_ref().and_then(|s| s.terminated.as_ref());
1334 let termination_state = current_state.or(last_state);
1335
1336 let exit_code = termination_state.map(|s| s.exit_code);
1343 exit_code.is_some_and(|e| [135, 137, 167].contains(&e))
1344 })
1345 })
1346 .unwrap_or(false);
1347
1348 let restart_count = pod
1353 .status
1354 .as_ref()
1355 .and_then(|status| status.container_statuses.as_ref())
1356 .map(|container_statuses| {
1357 container_statuses
1358 .iter()
1359 .map(|cs| u64::try_from(cs.restart_count).unwrap_or(0))
1360 .sum()
1361 })
1362 .unwrap_or(0);
1363
1364 let (pod_ready, last_probe_time) = pod
1365 .status
1366 .and_then(|status| status.conditions)
1367 .and_then(|conditions| conditions.into_iter().find(|c| c.type_ == "Ready"))
1368 .map(|c| (c.status == "True", c.last_probe_time))
1369 .unwrap_or((false, None));
1370
1371 let status = if pod_ready {
1372 ServiceStatus::Online
1373 } else {
1374 ServiceStatus::Offline(oomed.then_some(OfflineReason::OomKilled))
1375 };
1376 let time = if let Some(time) = last_probe_time {
1377 time.0
1378 } else {
1379 Timestamp::now()
1380 };
1381
1382 Ok(ServiceEvent {
1383 service_id,
1384 process_id,
1385 status,
1386 restart_count,
1387 time: DateTime::from_timestamp_nanos(
1388 time.as_nanosecond().try_into().expect("must fit"),
1389 ),
1390 })
1391 }
1392
1393 let stream = watcher(self.pod_api.clone(), self.watch_pod_params())
1394 .touched_objects()
1395 .filter_map(|object| async move {
1396 match object {
1397 Ok(pod) => Some(into_service_event(pod)),
1398 Err(error) => {
1399 tracing::warn!("service watch error: {error}");
1402 None
1403 }
1404 }
1405 });
1406 Box::pin(stream)
1407 }
1408
1409 fn update_scheduling_config(&self, config: ServiceSchedulingConfig) {
1410 *self.scheduling_config.write().expect("poisoned") = config;
1411 }
1412}
1413
1414impl OrchestratorWorker {
1415 fn spawn(self, name: String) -> AbortOnDropHandle<()> {
1416 mz_ore::task::spawn(|| name, self.run()).abort_on_drop()
1417 }
1418
1419 async fn run(mut self) {
1420 {
1421 info!("initializing Kubernetes orchestrator worker");
1422 let start = Instant::now();
1423
1424 let hostname = env::var("HOSTNAME").unwrap_or_else(|_| panic!("HOSTNAME environment variable missing or invalid; required for Kubernetes orchestrator"));
1428 let orchestrator_pod = Retry::default()
1429 .clamp_backoff(Duration::from_secs(10))
1430 .retry_async(|_| self.pod_api.get(&hostname))
1431 .await
1432 .expect("always retries on error");
1433 self.owner_references
1434 .extend(orchestrator_pod.owner_references().into_iter().cloned());
1435
1436 if !self.collect_pod_metrics {
1437 info!(
1438 "pod metrics collection is disabled; resource usage graphs in the console will not be available"
1439 );
1440 }
1441
1442 info!(
1443 "Kubernetes orchestrator worker initialized in {:?}",
1444 start.elapsed()
1445 );
1446 }
1447
1448 while let Some(cmd) = self.command_rx.recv().await {
1449 self.handle_command(cmd).await;
1450 }
1451 }
1452
1453 async fn handle_command(&self, cmd: WorkerCommand) {
1459 async fn retry<F, U, R>(f: F, cmd_type: &str) -> R
1460 where
1461 F: Fn() -> U,
1462 U: Future<Output = Result<R, K8sError>>,
1463 {
1464 Retry::default()
1465 .clamp_backoff(Duration::from_secs(10))
1466 .retry_async(|_| {
1467 f().map_err(
1468 |error| tracing::error!(%cmd_type, "orchestrator call failed: {error}"),
1469 )
1470 })
1471 .await
1472 .expect("always retries on error")
1473 }
1474
1475 use WorkerCommand::*;
1476 match cmd {
1477 EnsureService { desc } => {
1478 retry(|| self.ensure_service(desc.clone()), "EnsureService").await
1479 }
1480 DropService { name } => retry(|| self.drop_service(&name), "DropService").await,
1481 ListServices {
1482 namespace,
1483 result_tx,
1484 } => {
1485 let result = retry(|| self.list_services(&namespace), "ListServices").await;
1486 let _ = result_tx.send(result);
1487 }
1488 FetchServiceMetrics {
1489 name,
1490 info,
1491 result_tx,
1492 } => {
1493 let result = self.fetch_service_metrics(&name, &info).await;
1494 let _ = result_tx.send(result);
1495 }
1496 }
1497 }
1498
1499 async fn fetch_service_metrics(
1500 &self,
1501 name: &str,
1502 info: &ServiceInfo,
1503 ) -> Vec<ServiceProcessMetrics> {
1504 if !self.collect_pod_metrics {
1505 return (0..info.scale.get())
1506 .map(|_| ServiceProcessMetrics::default())
1507 .collect();
1508 }
1509
1510 #[derive(Deserialize)]
1512 pub(crate) struct ClusterdUsage {
1513 disk_bytes: Option<u64>,
1514 memory_bytes: Option<u64>,
1515 swap_bytes: Option<u64>,
1516 heap_limit: Option<u64>,
1517 }
1518
1519 async fn get_metrics(
1525 self_: &OrchestratorWorker,
1526 service_name: &str,
1527 i: usize,
1528 ) -> ServiceProcessMetrics {
1529 let name = format!("{service_name}-{i}");
1530
1531 let clusterd_usage_fut = get_clusterd_usage(self_, service_name, i);
1532 let (metrics, clusterd_usage) =
1533 match futures::future::join(self_.metrics_api.get(&name), clusterd_usage_fut).await
1534 {
1535 (Ok(metrics), Ok(clusterd_usage)) => (metrics, Some(clusterd_usage)),
1536 (Ok(metrics), Err(e)) => {
1537 warn!("Failed to fetch clusterd usage for {name}: {e}");
1538 (metrics, None)
1539 }
1540 (Err(e), _) => {
1541 warn!("Failed to get metrics for {name}: {e}");
1542 return ServiceProcessMetrics::default();
1543 }
1544 };
1545 let Some(PodMetricsContainer {
1546 usage:
1547 PodMetricsContainerUsage {
1548 cpu: Quantity(cpu_str),
1549 memory: Quantity(mem_str),
1550 },
1551 ..
1552 }) = metrics.containers.get(0)
1553 else {
1554 warn!("metrics result contained no containers for {name}");
1555 return ServiceProcessMetrics::default();
1556 };
1557
1558 let mut process_metrics = ServiceProcessMetrics::default();
1559
1560 match parse_k8s_quantity(cpu_str) {
1561 Ok(q) => match q.try_to_integer(-9, true) {
1562 Some(nano_cores) => process_metrics.cpu_nano_cores = Some(nano_cores),
1563 None => error!("CPU value {q:?} out of range"),
1564 },
1565 Err(e) => error!("failed to parse CPU value {cpu_str}: {e}"),
1566 }
1567 match parse_k8s_quantity(mem_str) {
1568 Ok(q) => match q.try_to_integer(0, false) {
1569 Some(mem) => process_metrics.memory_bytes = Some(mem),
1570 None => error!("memory value {q:?} out of range"),
1571 },
1572 Err(e) => error!("failed to parse memory value {mem_str}: {e}"),
1573 }
1574
1575 if let Some(usage) = clusterd_usage {
1576 process_metrics.disk_bytes = match (usage.disk_bytes, usage.swap_bytes) {
1582 (Some(disk), Some(swap)) => Some(disk + swap),
1583 (disk, swap) => disk.or(swap),
1584 };
1585
1586 process_metrics.heap_bytes = match (usage.memory_bytes, usage.swap_bytes) {
1589 (Some(memory), Some(swap)) => Some(memory + swap),
1590 (Some(memory), None) => Some(memory),
1591 (None, _) => None,
1592 };
1593
1594 process_metrics.heap_limit = usage.heap_limit;
1595 }
1596
1597 process_metrics
1598 }
1599
1600 async fn get_clusterd_usage(
1606 self_: &OrchestratorWorker,
1607 service_name: &str,
1608 i: usize,
1609 ) -> anyhow::Result<ClusterdUsage> {
1610 let service = self_
1611 .service_api
1612 .get(service_name)
1613 .await
1614 .with_context(|| format!("failed to get service {service_name}"))?;
1615 let namespace = service
1616 .metadata
1617 .namespace
1618 .context("missing service namespace")?;
1619 let internal_http_port = service
1620 .spec
1621 .and_then(|spec| spec.ports)
1622 .and_then(|ports| {
1623 ports
1624 .into_iter()
1625 .find(|p| p.name == Some("internal-http".into()))
1626 })
1627 .map(|p| p.port);
1628 let Some(port) = internal_http_port else {
1629 bail!("internal-http port missing in service spec");
1630 };
1631 let metrics_url = format!(
1632 "http://{service_name}-{i}.{service_name}.{namespace}.svc.cluster.local:{port}\
1633 /api/usage-metrics"
1634 );
1635
1636 let http_client = reqwest::Client::builder()
1637 .timeout(Duration::from_secs(10))
1638 .build()
1639 .context("error building HTTP client")?;
1640 let resp = http_client.get(metrics_url).send().await?;
1641 let usage = resp.json().await?;
1642
1643 Ok(usage)
1644 }
1645
1646 let ret = futures::future::join_all(
1647 (0..info.scale.cast_into()).map(|i| get_metrics(self, name, i)),
1648 );
1649
1650 ret.await
1651 }
1652
1653 async fn ensure_service(&self, mut desc: ServiceDescription) -> Result<(), K8sError> {
1654 desc.service
1660 .metadata
1661 .owner_references
1662 .get_or_insert(vec![])
1663 .extend(self.owner_references.iter().cloned());
1664 desc.stateful_set
1665 .metadata
1666 .owner_references
1667 .get_or_insert(vec![])
1668 .extend(self.owner_references.iter().cloned());
1669
1670 let ss_spec = desc.stateful_set.spec.as_ref().unwrap();
1671 let pod_metadata = ss_spec.template.metadata.as_ref().unwrap();
1672 let pod_annotations = pod_metadata.annotations.clone();
1673
1674 self.service_api
1675 .patch(
1676 &desc.name,
1677 &PatchParams::apply(FIELD_MANAGER).force(),
1678 &Patch::Apply(desc.service),
1679 )
1680 .await?;
1681 self.stateful_set_api
1682 .patch(
1683 &desc.name,
1684 &PatchParams::apply(FIELD_MANAGER).force(),
1685 &Patch::Apply(desc.stateful_set),
1686 )
1687 .await?;
1688
1689 for pod_id in 0..desc.scale.get() {
1700 let pod_name = format!("{}-{pod_id}", desc.name);
1701 let pod = match self.pod_api.get(&pod_name).await {
1702 Ok(pod) => pod,
1703 Err(kube::Error::Api(e)) if e.code == 404 => continue,
1705 Err(e) => return Err(e),
1706 };
1707
1708 let result = if pod.annotations().get(POD_TEMPLATE_HASH_ANNOTATION)
1709 != Some(&desc.pod_template_hash)
1710 {
1711 self.pod_api
1712 .delete(&pod_name, &DeleteParams::default())
1713 .await
1714 .map(|_| ())
1715 } else {
1716 let metadata = ObjectMeta {
1717 annotations: pod_annotations.clone(),
1718 ..Default::default()
1719 }
1720 .into_request_partial::<Pod>();
1721 self.pod_api
1722 .patch_metadata(
1723 &pod_name,
1724 &PatchParams::apply(FIELD_MANAGER).force(),
1725 &Patch::Apply(&metadata),
1726 )
1727 .await
1728 .map(|_| ())
1729 };
1730
1731 match result {
1732 Ok(()) => (),
1733 Err(kube::Error::Api(e)) if e.code == 404 => continue,
1735 Err(e) => return Err(e),
1736 }
1737 }
1738
1739 Ok(())
1740 }
1741
1742 async fn drop_service(&self, name: &str) -> Result<(), K8sError> {
1743 let res = self
1744 .stateful_set_api
1745 .delete(name, &DeleteParams::default())
1746 .await;
1747 match res {
1748 Ok(_) => (),
1749 Err(K8sError::Api(e)) if e.code == 404 => (),
1750 Err(e) => return Err(e),
1751 }
1752
1753 let res = self
1754 .service_api
1755 .delete(name, &DeleteParams::default())
1756 .await;
1757 match res {
1758 Ok(_) => Ok(()),
1759 Err(K8sError::Api(e)) if e.code == 404 => Ok(()),
1760 Err(e) => Err(e),
1761 }
1762 }
1763
1764 async fn list_services(&self, namespace: &str) -> Result<Vec<String>, K8sError> {
1765 let stateful_sets = self.stateful_set_api.list(&Default::default()).await?;
1766 let name_prefix = format!("{}{namespace}-", self.name_prefix);
1767 Ok(stateful_sets
1768 .into_iter()
1769 .filter_map(|ss| {
1770 ss.metadata
1771 .name
1772 .unwrap()
1773 .strip_prefix(&name_prefix)
1774 .map(Into::into)
1775 })
1776 .collect())
1777 }
1778}
1779
1780#[derive(Debug, Clone)]
1781struct KubernetesService {
1782 hosts: Vec<String>,
1783 ports: BTreeMap<String, u16>,
1784}
1785
1786impl Service for KubernetesService {
1787 fn addresses(&self, port: &str) -> Vec<String> {
1788 let port = self.ports[port];
1789 self.hosts
1790 .iter()
1791 .map(|host| format!("{host}:{port}"))
1792 .collect()
1793 }
1794}
1795
1796fn topology_spread_min_domains(
1804 soft: bool,
1805 az_pinned: bool,
1806 min_domains: Option<i32>,
1807) -> Option<i32> {
1808 if soft || az_pinned { None } else { min_domains }
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813 use super::*;
1814
1815 #[mz_ore::test]
1816 fn topology_spread_min_domains_suppression() {
1817 assert_eq!(topology_spread_min_domains(false, false, Some(3)), Some(3));
1819 assert_eq!(topology_spread_min_domains(false, false, None), None);
1821 assert_eq!(topology_spread_min_domains(true, false, Some(3)), None);
1823 assert_eq!(topology_spread_min_domains(false, true, Some(3)), None);
1825 assert_eq!(topology_spread_min_domains(true, true, Some(3)), None);
1827 }
1828
1829 #[mz_ore::test]
1830 fn k8s_quantity_base10_large() {
1831 let cases = &[
1832 ("42", 42),
1833 ("42k", 42000),
1834 ("42M", 42000000),
1835 ("42G", 42000000000),
1836 ("42T", 42000000000000),
1837 ("42P", 42000000000000000),
1838 ];
1839
1840 for (input, expected) in cases {
1841 let quantity = parse_k8s_quantity(input).unwrap();
1842 let number = quantity.try_to_integer(0, true).unwrap();
1843 assert_eq!(number, *expected, "input={input}, quantity={quantity:?}");
1844 }
1845 }
1846
1847 #[mz_ore::test]
1848 fn k8s_quantity_base10_small() {
1849 let cases = &[("42n", 42), ("42u", 42000), ("42m", 42000000)];
1850
1851 for (input, expected) in cases {
1852 let quantity = parse_k8s_quantity(input).unwrap();
1853 let number = quantity.try_to_integer(-9, true).unwrap();
1854 assert_eq!(number, *expected, "input={input}, quantity={quantity:?}");
1855 }
1856 }
1857
1858 #[mz_ore::test]
1859 fn k8s_quantity_base2() {
1860 let cases = &[
1861 ("42Ki", 42 << 10),
1862 ("42Mi", 42 << 20),
1863 ("42Gi", 42 << 30),
1864 ("42Ti", 42 << 40),
1865 ("42Pi", 42 << 50),
1866 ];
1867
1868 for (input, expected) in cases {
1869 let quantity = parse_k8s_quantity(input).unwrap();
1870 let number = quantity.try_to_integer(0, false).unwrap();
1871 assert_eq!(number, *expected, "input={input}, quantity={quantity:?}");
1872 }
1873 }
1874}