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