1use std::{
11 collections::BTreeMap,
12 net::{IpAddr, Ipv4Addr, SocketAddr},
13 sync::LazyLock,
14 time::Duration,
15};
16
17use k8s_openapi::{
18 api::{
19 apps::v1::{StatefulSet, StatefulSetSpec},
20 core::v1::{
21 Capabilities, ConfigMap, ConfigMapVolumeSource, Container, ContainerPort, EnvVar,
22 EnvVarSource, KeyToPath, PodSecurityContext, PodSpec, PodTemplateSpec, Probe,
23 SeccompProfile, Secret, SecretKeySelector, SecretVolumeSource, SecurityContext,
24 Service, ServicePort, ServiceSpec, TCPSocketAction, Toleration, Volume, VolumeMount,
25 },
26 },
27 apimachinery::pkg::{apis::meta::v1::LabelSelector, util::intstr::IntOrString},
28};
29use kube::{Api, Client, ResourceExt, api::ObjectMeta, runtime::controller::Action};
30use maplit::btreemap;
31use mz_server_core::listeners::{
32 AllowedRoles, AuthenticatorKind, BaseListenerConfig, RouteGroup, SqlListenerConfig,
33 VersionedListenersConfig, v0_147_0, v26_32_0,
34};
35use reqwest::{Client as HttpClient, StatusCode};
36use semver::{BuildMetadata, Prerelease, Version};
37use serde::{Deserialize, Serialize};
38use sha2::{Digest, Sha256};
39use tracing::{error, trace};
40
41use super::Error;
42use super::matching_image_from_environmentd_image_ref;
43use crate::k8s::{apply_resource, delete_resource, get_resource};
44use crate::tls::issuer_ref_defined;
45use mz_cloud_provider::CloudProvider;
46use mz_cloud_resources::crd::materialize::v1alpha1::Materialize;
47use mz_cloud_resources::crd::{ManagedResource, recommended_k8s_labels};
48use mz_ore::instrument;
49
50static V140_DEV0: LazyLock<Version> = LazyLock::new(|| Version {
51 major: 0,
52 minor: 140,
53 patch: 0,
54 pre: Prerelease::new("dev.0").expect("dev.0 is valid prerelease"),
55 build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
56});
57const V143: Version = Version::new(0, 143, 0);
58const V144: Version = Version::new(0, 144, 0);
59static V147_DEV0: LazyLock<Version> = LazyLock::new(|| Version {
60 major: 0,
61 minor: 147,
62 patch: 0,
63 pre: Prerelease::new("dev.0").expect("dev.0 is valid prerelease"),
64 build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
65});
66const V153: Version = Version::new(0, 153, 0);
67static V154_DEV0: LazyLock<Version> = LazyLock::new(|| Version {
68 major: 0,
69 minor: 154,
70 patch: 0,
71 pre: Prerelease::new("").expect("dev.0 is valid prerelease"),
72 build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
73});
74pub const V161: Version = Version::new(0, 161, 0);
75
76static V26_40_0: LazyLock<Version> = LazyLock::new(|| Version {
81 major: 26,
82 minor: 40,
83 patch: 0,
84 pre: Prerelease::new("dev.0").expect("dev.0 is valid prerelease"),
85 build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
86});
87
88static V26_1_0: LazyLock<Version> = LazyLock::new(|| Version {
89 major: 26,
90 minor: 1,
91 patch: 0,
92 pre: Prerelease::new("dev.0").expect("dev.0 is valid prerelease"),
93 build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
94});
95
96static PER_ROUTE_GROUP_ROLES_VERSION: LazyLock<Version> = LazyLock::new(|| Version {
100 major: 26,
101 minor: 32,
102 patch: 0,
103 pre: Prerelease::new("dev.0").expect("dev.0 is valid prerelease"),
104 build: BuildMetadata::new("").expect("empty string is valid buildmetadata"),
105});
106
107#[derive(
112 Debug,
113 Serialize,
114 Deserialize,
115 Clone,
116 Copy,
117 PartialEq,
118 Eq,
119 PartialOrd,
120 Ord
121)]
122pub enum DeploymentStatus {
123 Initializing,
126 ReadyToPromote,
128 Promoting,
130 IsLeader,
132}
133
134#[derive(
135 Debug,
136 Serialize,
137 Deserialize,
138 Clone,
139 Copy,
140 PartialEq,
141 Eq,
142 PartialOrd,
143 Ord
144)]
145pub struct GetLeaderStatusResponse {
146 status: DeploymentStatus,
147}
148
149#[derive(Deserialize, Serialize)]
150pub struct LoginCredentials {
151 username: String,
152 password: String,
154}
155
156#[derive(Debug, Serialize)]
157pub struct ConnectionInfo {
158 pub environmentd_url: String,
159 pub mz_system_secret_name: Option<String>,
160 pub listeners_configmap: ConfigMap,
161}
162
163#[derive(Debug, Serialize)]
164pub struct Resources {
165 pub generation: u64,
166 pub public_service: Box<Service>,
167 pub generation_service: Box<Service>,
168 pub persist_pubsub_service: Box<Service>,
169 pub environmentd_statefulset: Box<StatefulSet>,
170 pub connection_info: Box<ConnectionInfo>,
171}
172
173impl Resources {
174 pub fn new(config: &super::Config, mz: &Materialize, generation: u64) -> Self {
175 let public_service = Box::new(create_public_service_object(config, mz, generation));
176 let generation_service = Box::new(create_generation_service_object(config, mz, generation));
177 let persist_pubsub_service =
178 Box::new(create_persist_pubsub_service(config, mz, generation));
179 let environmentd_statefulset = Box::new(create_environmentd_statefulset_object(
180 config, mz, generation,
181 ));
182 let connection_info = Box::new(create_connection_info(config, mz, generation));
183
184 Self {
185 generation,
186 public_service,
187 generation_service,
188 persist_pubsub_service,
189 environmentd_statefulset,
190 connection_info,
191 }
192 }
193
194 #[instrument]
195 pub async fn apply(
196 &self,
197 client: &Client,
198 force_promote: bool,
199 namespace: &str,
200 ) -> Result<Option<Action>, Error> {
201 let service_api: Api<Service> = Api::namespaced(client.clone(), namespace);
202 let statefulset_api: Api<StatefulSet> = Api::namespaced(client.clone(), namespace);
203 let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), namespace);
204
205 trace!("applying environmentd per-generation service");
206 apply_resource(&service_api, &*self.generation_service).await?;
207
208 trace!("creating persist pubsub service");
209 apply_resource(&service_api, &*self.persist_pubsub_service).await?;
210
211 trace!("applying listeners configmap");
212 apply_resource(&configmap_api, &self.connection_info.listeners_configmap).await?;
213
214 trace!("creating new environmentd statefulset");
215 apply_resource(&statefulset_api, &*self.environmentd_statefulset).await?;
216
217 let retry_action = Action::requeue(Duration::from_secs(rand::random_range(5..10)));
218
219 let statefulset = get_resource(
220 &statefulset_api,
221 &self.environmentd_statefulset.name_unchecked(),
222 )
223 .await?;
224 if statefulset
225 .and_then(|statefulset| statefulset.status)
226 .and_then(|status| status.ready_replicas)
227 .unwrap_or(0)
228 == 0
229 {
230 trace!("environmentd statefulset is not ready yet...");
231 return Ok(Some(retry_action));
232 }
233
234 let Some(http_client) = self.get_http_client(client.clone(), namespace).await else {
235 return Ok(Some(retry_action));
236 };
237 let status_url = reqwest::Url::parse(&format!(
238 "{}/api/leader/status",
239 self.connection_info.environmentd_url,
240 ))
241 .unwrap();
242
243 match http_client.get(status_url.clone()).send().await {
244 Ok(response) => {
245 let response: GetLeaderStatusResponse = match response.error_for_status() {
246 Ok(response) => response.json().await?,
247 Err(e) => {
248 trace!("failed to get status of environmentd, retrying... ({e})");
249 return Ok(Some(retry_action));
250 }
251 };
252 if force_promote {
253 trace!("skipping cluster catchup");
254 let skip_catchup_url = reqwest::Url::parse(&format!(
255 "{}/api/leader/skip-catchup",
256 self.connection_info.environmentd_url,
257 ))
258 .unwrap();
259 let response = http_client.post(skip_catchup_url).send().await?;
260 if response.status() == StatusCode::BAD_REQUEST {
261 let err: SkipCatchupError = response.json().await?;
262 return Err(
263 anyhow::anyhow!("failed to skip catchup: {}", err.message).into()
264 );
265 }
266 } else {
267 match response.status {
268 DeploymentStatus::Initializing => {
269 trace!("environmentd is still initializing, retrying...");
270 return Ok(Some(retry_action));
271 }
272 DeploymentStatus::ReadyToPromote
273 | DeploymentStatus::Promoting
274 | DeploymentStatus::IsLeader => trace!("environmentd is ready"),
275 }
276 }
277 }
278 Err(e) => {
279 trace!("failed to connect to environmentd, retrying... ({e})");
280 return Ok(Some(retry_action));
281 }
282 }
283
284 Ok(None)
285 }
286
287 #[instrument]
288 async fn get_http_client(&self, client: Client, namespace: &str) -> Option<HttpClient> {
289 let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
290 Some(match &self.connection_info.mz_system_secret_name {
291 Some(mz_system_secret_name) => {
292 let http_client = reqwest::Client::builder()
293 .timeout(std::time::Duration::from_secs(10))
294 .cookie_store(true)
295 .danger_accept_invalid_certs(true)
297 .build()
298 .unwrap();
299
300 let secret = secret_api
301 .get(mz_system_secret_name)
302 .await
303 .map_err(|e| {
304 error!("Failed to get backend secret: {:?}", e);
305 e
306 })
307 .ok()?;
308 if let Some(data) = secret.data {
309 if let Some(password) = data.get("external_login_password_mz_system").cloned() {
310 let password = String::from_utf8_lossy(&password.0).to_string();
311 let login_url = reqwest::Url::parse(&format!(
312 "{}/api/login",
313 self.connection_info.environmentd_url,
314 ))
315 .unwrap();
316 match http_client
317 .post(login_url)
318 .body(
319 serde_json::to_string(&LoginCredentials {
320 username: "mz_system".to_owned(),
321 password,
322 })
323 .expect(
324 "Serializing a simple struct with utf8 strings doesn't fail.",
325 ),
326 )
327 .header("Content-Type", "application/json")
328 .send()
329 .await
330 {
331 Ok(response) => {
332 if let Err(e) = response.error_for_status() {
333 trace!("failed to login to environmentd, retrying... ({e})");
334 return None;
335 }
336 }
337 Err(e) => {
338 trace!("failed to connect to environmentd, retrying... ({e})");
339 return None;
340 }
341 };
342 }
343 };
344 http_client
345 }
346 None => reqwest::Client::builder()
347 .timeout(std::time::Duration::from_secs(10))
348 .build()
349 .unwrap(),
350 })
351 }
352
353 #[instrument]
354 pub async fn promote_services(
355 &self,
356 client: &Client,
357 namespace: &str,
358 ) -> Result<Option<Action>, Error> {
359 let service_api: Api<Service> = Api::namespaced(client.clone(), namespace);
360 let retry_action = Action::requeue(Duration::from_secs(rand::random_range(5..10)));
361
362 let promote_url = reqwest::Url::parse(&format!(
363 "{}/api/leader/promote",
364 self.connection_info.environmentd_url,
365 ))
366 .unwrap();
367
368 let Some(http_client) = self.get_http_client(client.clone(), namespace).await else {
369 return Ok(Some(retry_action));
370 };
371
372 trace!("promoting new environmentd to leader");
373 let response = http_client.post(promote_url).send().await?;
374 let response: BecomeLeaderResponse = response.error_for_status()?.json().await?;
375 if let BecomeLeaderResult::Failure { message } = response.result {
376 return Err(Error::Anyhow(anyhow::anyhow!(
377 "failed to promote new environmentd: {message}"
378 )));
379 }
380
381 let status_url = reqwest::Url::parse(&format!(
393 "{}/api/leader/status",
394 self.connection_info.environmentd_url,
395 ))
396 .unwrap();
397 match http_client.get(status_url.clone()).send().await {
398 Ok(response) => {
399 let response: GetLeaderStatusResponse = response.json().await?;
400 if response.status != DeploymentStatus::IsLeader {
401 trace!(
402 "environmentd is still promoting (status: {:?}), retrying...",
403 response.status
404 );
405 return Ok(Some(retry_action));
406 } else {
407 trace!("environmentd is ready");
408 }
409 }
410 Err(e) => {
411 trace!("failed to connect to environmentd, retrying... ({e})");
412 return Ok(Some(retry_action));
413 }
414 }
415
416 trace!("applying environmentd public service");
417 apply_resource(&service_api, &*self.public_service).await?;
418
419 Ok(None)
420 }
421
422 #[instrument]
423 pub async fn teardown_generation(
424 &self,
425 client: &Client,
426 mz: &Materialize,
427 generation: u64,
428 ) -> Result<(), anyhow::Error> {
429 let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), &mz.namespace());
430 let service_api: Api<Service> = Api::namespaced(client.clone(), &mz.namespace());
431 let statefulset_api: Api<StatefulSet> = Api::namespaced(client.clone(), &mz.namespace());
432
433 trace!("deleting environmentd statefulset for generation {generation}");
434 delete_resource(
435 &statefulset_api,
436 &mz.environmentd_statefulset_name(generation),
437 )
438 .await?;
439
440 trace!("deleting persist pubsub service for generation {generation}");
441 delete_resource(&service_api, &mz.persist_pubsub_service_name(generation)).await?;
442
443 trace!("deleting environmentd per-generation service for generation {generation}");
444 delete_resource(
445 &service_api,
446 &mz.environmentd_generation_service_name(generation),
447 )
448 .await?;
449
450 trace!("deleting listeners configmap for generation {generation}");
451 delete_resource(&configmap_api, &mz.listeners_configmap_name(generation)).await?;
452
453 Ok(())
454 }
455
456 pub fn generate_hash(&self) -> String {
459 let mut hasher = Sha256::new();
460 hasher.update(&serde_json::to_string(self).unwrap());
461 format!("{:x}", hasher.finalize())
462 }
463}
464
465fn create_public_service_object(
466 config: &super::Config,
467 mz: &Materialize,
468 generation: u64,
469) -> Service {
470 create_base_service_object(
471 config,
472 mz,
473 generation,
474 &mz.environmentd_service_name(),
475 true,
476 )
477}
478
479fn create_generation_service_object(
480 config: &super::Config,
481 mz: &Materialize,
482 generation: u64,
483) -> Service {
484 create_base_service_object(
485 config,
486 mz,
487 generation,
488 &mz.environmentd_generation_service_name(generation),
489 false,
490 )
491}
492
493fn create_base_service_object(
494 config: &super::Config,
495 mz: &Materialize,
496 generation: u64,
497 service_name: &str,
498 headless: bool,
499) -> Service {
500 let ports = vec![
501 ServicePort {
502 port: config.environmentd_sql_port.into(),
503 protocol: Some("TCP".to_string()),
504 name: Some("sql".to_string()),
505 ..Default::default()
506 },
507 ServicePort {
508 port: config.environmentd_http_port.into(),
509 protocol: Some("TCP".to_string()),
510 name: Some("https".to_string()),
511 ..Default::default()
512 },
513 ServicePort {
514 port: config.environmentd_internal_sql_port.into(),
515 protocol: Some("TCP".to_string()),
516 name: Some("internal-sql".to_string()),
517 ..Default::default()
518 },
519 ServicePort {
520 port: config.environmentd_internal_http_port.into(),
521 protocol: Some("TCP".to_string()),
522 name: Some("internal-http".to_string()),
523 ..Default::default()
524 },
525 ];
526
527 let selector = btreemap! {"materialize.cloud/name".to_string() => mz.environmentd_statefulset_name(generation)};
528
529 let spec = ServiceSpec {
530 type_: Some("ClusterIP".to_string()),
531 cluster_ip: if headless {
532 Some("None".to_string())
533 } else {
534 None
535 },
536 selector: Some(selector),
537 ports: Some(ports),
538 ..Default::default()
539 };
540
541 Service {
542 metadata: mz.managed_resource_meta(service_name.to_string()),
543 spec: Some(spec),
544 status: None,
545 }
546}
547
548fn create_persist_pubsub_service(
549 config: &super::Config,
550 mz: &Materialize,
551 generation: u64,
552) -> Service {
553 Service {
554 metadata: mz.managed_resource_meta(mz.persist_pubsub_service_name(generation)),
555 spec: Some(ServiceSpec {
556 type_: Some("ClusterIP".to_string()),
557 cluster_ip: Some("None".to_string()),
558 selector: Some(btreemap! {
559 "materialize.cloud/name".to_string() => mz.environmentd_statefulset_name(generation),
560 }),
561 ports: Some(vec![ServicePort {
562 name: Some("grpc".to_string()),
563 protocol: Some("TCP".to_string()),
564 port: config.environmentd_internal_persist_pubsub_port.into(),
565 ..Default::default()
566 }]),
567 ..Default::default()
568 }),
569 status: None,
570 }
571}
572
573fn create_environmentd_statefulset_object(
574 config: &super::Config,
575 mz: &Materialize,
576 generation: u64,
577) -> StatefulSet {
578 let mut env = vec![
588 EnvVar {
589 name: "MZ_METADATA_BACKEND_URL".to_string(),
590 value_from: Some(EnvVarSource {
591 secret_key_ref: Some(SecretKeySelector {
592 name: mz.backend_secret_name(),
593 key: "metadata_backend_url".to_string(),
594 optional: Some(false),
595 }),
596 ..Default::default()
597 }),
598 ..Default::default()
599 },
600 EnvVar {
601 name: "MZ_PERSIST_BLOB_URL".to_string(),
602 value_from: Some(EnvVarSource {
603 secret_key_ref: Some(SecretKeySelector {
604 name: mz.backend_secret_name(),
605 key: "persist_backend_url".to_string(),
606 optional: Some(false),
607 }),
608 ..Default::default()
609 }),
610 ..Default::default()
611 },
612 ];
613
614 env.push(EnvVar {
615 name: "AWS_REGION".to_string(),
616 value: Some(config.region.clone()),
617 ..Default::default()
618 });
619
620 env.extend(mz.spec.environmentd_extra_env.iter().flatten().cloned());
621
622 let mut args = vec![];
623
624 if let Some(helm_chart_version) = &config.helm_chart_version {
625 args.push(format!("--helm-chart-version={helm_chart_version}"));
626 }
627
628 args.push(format!(
630 "--environment-id={}",
631 mz.environment_id(&config.cloud_provider.to_string(), &config.region)
632 ));
633
634 args.push(format!(
636 "--clusterd-image={}",
637 matching_image_from_environmentd_image_ref(
638 &mz.spec.environmentd_image_ref,
639 "clusterd",
640 None
641 )
642 ));
643
644 args.extend(
646 [
647 config
648 .environmentd_cluster_replica_sizes
649 .as_ref()
650 .map(|sizes| format!("--cluster-replica-sizes={sizes}")),
651 config
652 .bootstrap_default_cluster_replica_size
653 .as_ref()
654 .map(|size| format!("--bootstrap-default-cluster-replica-size={size}")),
655 config
656 .bootstrap_builtin_system_cluster_replica_size
657 .as_ref()
658 .map(|size| format!("--bootstrap-builtin-system-cluster-replica-size={size}")),
659 config
660 .bootstrap_builtin_probe_cluster_replica_size
661 .as_ref()
662 .map(|size| format!("--bootstrap-builtin-probe-cluster-replica-size={size}")),
663 config
664 .bootstrap_builtin_support_cluster_replica_size
665 .as_ref()
666 .map(|size| format!("--bootstrap-builtin-support-cluster-replica-size={size}")),
667 config
668 .bootstrap_builtin_catalog_server_cluster_replica_size
669 .as_ref()
670 .map(|size| format!("--bootstrap-builtin-catalog-server-cluster-replica-size={size}")),
671 config
672 .bootstrap_builtin_analytics_cluster_replica_size
673 .as_ref()
674 .map(|size| format!("--bootstrap-builtin-analytics-cluster-replica-size={size}")),
675 config
676 .bootstrap_builtin_system_cluster_replication_factor
677 .as_ref()
678 .map(|replication_factor| {
679 format!("--bootstrap-builtin-system-cluster-replication-factor={replication_factor}")
680 }),
681 config
682 .bootstrap_builtin_probe_cluster_replication_factor
683 .as_ref()
684 .map(|replication_factor| format!("--bootstrap-builtin-probe-cluster-replication-factor={replication_factor}")),
685 config
686 .bootstrap_builtin_support_cluster_replication_factor
687 .as_ref()
688 .map(|replication_factor| format!("--bootstrap-builtin-support-cluster-replication-factor={replication_factor}")),
689 config
690 .bootstrap_builtin_analytics_cluster_replication_factor
691 .as_ref()
692 .map(|replication_factor| format!("--bootstrap-builtin-analytics-cluster-replication-factor={replication_factor}")),
693 ]
694 .into_iter()
695 .flatten(),
696 );
697
698 args.extend(
699 config
700 .environmentd_allowed_origins
701 .iter()
702 .map(|origin| format!("--cors-allowed-origin={}", origin.to_str().unwrap())),
703 );
704
705 args.push(format!(
706 "--secrets-controller={}",
707 config.secrets_controller
708 ));
709
710 if let Some(cluster_replica_sizes) = &config.environmentd_cluster_replica_sizes {
711 if let Ok(cluster_replica_sizes) =
712 serde_json::from_str::<BTreeMap<String, serde_json::Value>>(cluster_replica_sizes)
713 {
714 let cluster_replica_sizes: Vec<_> =
715 cluster_replica_sizes.keys().map(|s| s.as_str()).collect();
716 args.push(format!(
717 "--system-parameter-default=allowed_cluster_replica_sizes='{}'",
718 cluster_replica_sizes.join("', '")
719 ));
720 }
721 }
722 if !config.cloud_provider.is_cloud() {
723 args.push("--system-parameter-default=cluster_enable_topology_spread=false".into());
724 }
725
726 if config.enable_internal_statement_logging {
727 args.push("--system-parameter-default=enable_internal_statement_logging=true".into());
728 }
729
730 if let Some(rate) = config.statement_logging_max_sample_rate {
731 args.push(format!(
732 "--system-parameter-default=statement_logging_max_sample_rate={rate}"
733 ));
734 }
735
736 if let Some(rate) = config.statement_logging_target_data_rate {
737 args.push(format!(
738 "--system-parameter-default=statement_logging_target_data_rate={rate}"
739 ));
740 }
741
742 if !mz.spec.enable_rbac {
743 args.push("--system-parameter-default=enable_rbac_checks=false".into());
744 }
745
746 args.push("--persist-isolated-runtime-threads=-1".to_string());
750
751 if config.cloud_provider == CloudProvider::Aws {
753 if let Some(azs) = config.environmentd_availability_zones.as_ref() {
754 for az in azs {
755 args.push(format!("--availability-zone={az}"));
756 }
757 }
758
759 if let Some(environmentd_connection_role_arn) = mz
760 .spec
761 .environmentd_connection_role_arn
762 .as_deref()
763 .or(config.environmentd_connection_role_arn.as_deref())
764 {
765 args.push(format!(
766 "--aws-connection-role-arn={}",
767 environmentd_connection_role_arn
768 ));
769 }
770 if let Some(account_id) = &config.aws_account_id {
771 args.push(format!("--aws-account-id={account_id}"));
772 }
773
774 args.extend([format!(
775 "--aws-secrets-controller-tags=Environment={}",
776 mz.name_unchecked()
777 )]);
778 args.extend_from_slice(&config.aws_secrets_controller_tags);
779 }
780
781 args.extend([
783 "--orchestrator=kubernetes".into(),
784 format!(
785 "--orchestrator-kubernetes-service-account={}",
786 mz.service_account_name()
787 ),
788 format!(
789 "--orchestrator-kubernetes-image-pull-policy={}",
790 config.image_pull_policy.as_kebab_case_str(),
791 ),
792 ]);
793 for selector in &config.clusterd_node_selector {
794 args.push(format!(
795 "--orchestrator-kubernetes-service-node-selector={}={}",
796 selector.key, selector.value,
797 ));
798 }
799 if mz.meets_minimum_version(&V144) {
800 if let Some(affinity) = &config.clusterd_affinity {
801 let affinity = serde_json::to_string(affinity).unwrap();
802 args.push(format!(
803 "--orchestrator-kubernetes-service-affinity={affinity}"
804 ))
805 }
806 if let Some(tolerations) = &config.clusterd_tolerations {
807 let tolerations = serde_json::to_string(tolerations).unwrap();
808 args.push(format!(
809 "--orchestrator-kubernetes-service-tolerations={tolerations}"
810 ))
811 }
812 }
813 if let Some(scheduler_name) = &config.scheduler_name {
814 args.push(format!(
815 "--orchestrator-kubernetes-scheduler-name={}",
816 scheduler_name
817 ));
818 }
819 if mz.meets_minimum_version(&V26_40_0) {
820 if let Some(priority_class_name) = &config.clusterd_priority_class_name {
821 args.push(format!(
822 "--orchestrator-kubernetes-priority-class-name={priority_class_name}"
823 ));
824 }
825 }
826 if mz.meets_minimum_version(&V154_DEV0) {
827 args.extend(
828 mz.spec
829 .pod_annotations
830 .as_ref()
831 .map(|annotations| annotations.iter())
832 .unwrap_or_default()
833 .map(|(key, val)| {
834 format!("--orchestrator-kubernetes-service-annotation={key}={val}")
835 }),
836 );
837 }
838 args.extend(
839 mz.default_labels()
840 .iter()
841 .chain(
842 mz.spec
843 .pod_labels
844 .as_ref()
845 .map(|labels| labels.iter())
846 .unwrap_or_default(),
847 )
848 .map(|(key, val)| format!("--orchestrator-kubernetes-service-label={key}={val}")),
849 );
850 if let Some(status) = &mz.status {
851 args.push(format!(
852 "--orchestrator-kubernetes-name-prefix=mz{}-",
853 status.resource_id
854 ));
855 }
856
857 args.extend(["--log-format=json".into()]);
859 if let Some(endpoint) = &config.tracing.opentelemetry_endpoint {
860 args.push(format!("--opentelemetry-endpoint={}", endpoint));
861 }
862 args.extend([
864 format!(
865 "--opentelemetry-resource=organization_id={}",
866 mz.spec.environment_id
867 ),
868 format!(
869 "--opentelemetry-resource=environment_name={}",
870 mz.name_unchecked()
871 ),
872 ]);
873
874 if let Some(segment_api_key) = &config.segment_api_key {
875 args.push(format!("--segment-api-key={}", segment_api_key));
876 if config.segment_client_side {
877 args.push("--segment-client-side".into());
878 }
879 }
880
881 let mut volumes = Vec::new();
882 let mut volume_mounts = Vec::new();
883 if issuer_ref_defined(
884 &config.default_certificate_specs.internal,
885 &mz.spec.internal_certificate_spec,
886 ) {
887 volumes.push(Volume {
888 name: "certificate".to_owned(),
889 secret: Some(SecretVolumeSource {
890 default_mode: Some(0o400),
891 secret_name: Some(mz.environmentd_certificate_secret_name()),
892 items: None,
893 optional: Some(false),
894 }),
895 ..Default::default()
896 });
897 volume_mounts.push(VolumeMount {
898 name: "certificate".to_owned(),
899 mount_path: "/etc/materialized".to_owned(),
900 read_only: Some(true),
901 ..Default::default()
902 });
903 args.extend([
904 "--tls-mode=require".into(),
905 "--tls-cert=/etc/materialized/tls.crt".into(),
906 "--tls-key=/etc/materialized/tls.key".into(),
907 ]);
908 } else {
909 args.push("--tls-mode=disable".to_string());
910 }
911 if let Some(ephemeral_volume_class) = &config.ephemeral_volume_class {
912 args.push(format!(
913 "--orchestrator-kubernetes-ephemeral-volume-class={}",
914 ephemeral_volume_class
915 ));
916 }
917 args.push("--orchestrator-kubernetes-service-fs-group=999".to_string());
919
920 if mz.meets_minimum_version(&V26_1_0) {
924 if let Some(ref name) = mz.spec.system_parameter_configmap_name {
925 volumes.push(Volume {
926 name: "system-params".to_string(),
927 config_map: Some(ConfigMapVolumeSource {
928 default_mode: Some(0o400),
929 name: name.to_owned(),
930 items: None,
931 optional: Some(true),
932 }),
933 ..Default::default()
934 });
935 volume_mounts.push(VolumeMount {
936 name: "system-params".to_string(),
937 mount_path: "/system-params".to_owned(),
939 read_only: Some(true),
940 ..Default::default()
941 });
942 args.push("--config-sync-file-path=/system-params/system-params.json".to_string());
943 args.push("--config-sync-loop-interval=1s".to_string());
944 }
945 }
946
947 if let Some(sentry_dsn) = &config.tracing.sentry_dsn {
949 args.push(format!("--sentry-dsn={}", sentry_dsn));
950 if let Some(sentry_environment) = &config.tracing.sentry_environment {
951 args.push(format!("--sentry-environment={}", sentry_environment));
952 }
953 args.push(format!("--sentry-tag=region={}", config.region));
954 }
955
956 args.push(format!(
958 "--persist-pubsub-url=http://{}:{}",
959 mz.persist_pubsub_service_name(generation),
960 config.environmentd_internal_persist_pubsub_port,
961 ));
962 args.push(format!(
963 "--internal-persist-pubsub-listen-addr=0.0.0.0:{}",
964 config.environmentd_internal_persist_pubsub_port
965 ));
966
967 args.push(format!("--deploy-generation={}", generation));
968
969 args.push(format!(
971 "--internal-console-redirect-url={}",
972 config.internal_console_proxy_url,
973 ));
974
975 if !config.collect_pod_metrics {
976 args.push("--orchestrator-kubernetes-disable-pod-metrics-collection".into());
977 }
978 if config.enable_prometheus_scrape_annotations {
979 args.push("--orchestrator-kubernetes-enable-prometheus-scrape-annotations".into());
980 }
981
982 if config.disable_license_key_checks {
985 if mz.meets_minimum_version(&V143) && !mz.meets_minimum_version(&V153) {
986 args.push("--disable-license-key-checks".into());
987 }
988 }
989
990 if (mz.meets_minimum_version(&V140_DEV0) && !config.disable_license_key_checks)
993 || mz.meets_minimum_version(&V153)
994 {
995 volume_mounts.push(VolumeMount {
996 name: "license-key".to_string(),
997 mount_path: "/license_key".to_string(),
998 ..Default::default()
999 });
1000 volumes.push(Volume {
1001 name: "license-key".to_string(),
1002 secret: Some(SecretVolumeSource {
1003 default_mode: Some(256),
1004 optional: Some(false),
1005 secret_name: Some(mz.backend_secret_name()),
1006 items: Some(vec![KeyToPath {
1007 key: "license_key".to_string(),
1008 path: "license_key".to_string(),
1009 ..Default::default()
1010 }]),
1011 ..Default::default()
1012 }),
1013 ..Default::default()
1014 });
1015 env.push(EnvVar {
1016 name: "MZ_LICENSE_KEY".to_string(),
1017 value: Some("/license_key/license_key".to_string()),
1018 ..Default::default()
1019 });
1020 }
1021
1022 if let Some(extra_args) = &mz.spec.environmentd_extra_args {
1024 args.extend(extra_args.iter().cloned());
1025 }
1026
1027 let probe = Probe {
1028 initial_delay_seconds: Some(1),
1029 failure_threshold: Some(12),
1030 tcp_socket: Some(TCPSocketAction {
1031 host: None,
1032 port: IntOrString::Int(config.environmentd_sql_port.into()),
1033 }),
1034 ..Default::default()
1035 };
1036
1037 let security_context = if config.enable_security_context {
1038 Some(SecurityContext {
1042 run_as_non_root: Some(true),
1043 capabilities: Some(Capabilities {
1044 drop: Some(vec!["ALL".to_string()]),
1045 ..Default::default()
1046 }),
1047 seccomp_profile: Some(SeccompProfile {
1048 type_: "RuntimeDefault".to_string(),
1049 ..Default::default()
1050 }),
1051 allow_privilege_escalation: Some(false),
1052 ..Default::default()
1053 })
1054 } else {
1055 None
1056 };
1057
1058 let ports = vec![
1059 ContainerPort {
1060 container_port: config.environmentd_sql_port.into(),
1061 name: Some("sql".to_owned()),
1062 ..Default::default()
1063 },
1064 ContainerPort {
1065 container_port: config.environmentd_internal_sql_port.into(),
1066 name: Some("internal-sql".to_owned()),
1067 ..Default::default()
1068 },
1069 ContainerPort {
1070 container_port: config.environmentd_http_port.into(),
1071 name: Some("http".to_owned()),
1072 ..Default::default()
1073 },
1074 ContainerPort {
1075 container_port: config.environmentd_internal_http_port.into(),
1076 name: Some("internal-http".to_owned()),
1077 ..Default::default()
1078 },
1079 ContainerPort {
1080 container_port: config.environmentd_internal_persist_pubsub_port.into(),
1081 name: Some("persist-pubsub".to_owned()),
1082 ..Default::default()
1083 },
1084 ];
1085
1086 if mz.meets_minimum_version(&V147_DEV0) {
1088 volume_mounts.push(VolumeMount {
1089 name: "listeners-configmap".to_string(),
1090 mount_path: "/listeners".to_string(),
1091 ..Default::default()
1092 });
1093 volumes.push(Volume {
1094 name: "listeners-configmap".to_string(),
1095 config_map: Some(ConfigMapVolumeSource {
1096 name: mz.listeners_configmap_name(generation),
1097 default_mode: Some(256),
1098 optional: Some(false),
1099 items: Some(vec![KeyToPath {
1100 key: "listeners.json".to_string(),
1101 path: "listeners.json".to_string(),
1102 ..Default::default()
1103 }]),
1104 }),
1105 ..Default::default()
1106 });
1107 args.push("--listeners-config-path=/listeners/listeners.json".to_owned());
1108 if matches!(
1109 mz.spec.authenticator_kind,
1110 AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1111 ) {
1112 args.push("--system-parameter-default=enable_password_auth=true".into());
1113 env.push(EnvVar {
1114 name: "MZ_EXTERNAL_LOGIN_PASSWORD_MZ_SYSTEM".to_string(),
1115 value_from: Some(EnvVarSource {
1116 secret_key_ref: Some(SecretKeySelector {
1117 name: mz.backend_secret_name(),
1118 key: "external_login_password_mz_system".to_string(),
1119 optional: Some(false),
1120 }),
1121 ..Default::default()
1122 }),
1123 ..Default::default()
1124 })
1125 }
1126 } else {
1127 args.extend([
1128 format!("--sql-listen-addr=0.0.0.0:{}", config.environmentd_sql_port),
1129 format!(
1130 "--http-listen-addr=0.0.0.0:{}",
1131 config.environmentd_http_port
1132 ),
1133 format!(
1134 "--internal-sql-listen-addr=0.0.0.0:{}",
1135 config.environmentd_internal_sql_port
1136 ),
1137 format!(
1138 "--internal-http-listen-addr=0.0.0.0:{}",
1139 config.environmentd_internal_http_port
1140 ),
1141 ]);
1142 }
1143
1144 let container = Container {
1145 name: "environmentd".to_owned(),
1146 image: Some(mz.spec.environmentd_image_ref.to_owned()),
1147 image_pull_policy: Some(config.image_pull_policy.to_string()),
1148 ports: Some(ports),
1149 args: Some(args),
1150 env: Some(env),
1151 volume_mounts: Some(volume_mounts),
1152 liveness_probe: Some(probe.clone()),
1153 readiness_probe: Some(probe),
1154 resources: mz
1155 .spec
1156 .environmentd_resource_requirements
1157 .clone()
1158 .or_else(|| config.environmentd_default_resources.clone()),
1159 security_context: security_context.clone(),
1160 ..Default::default()
1161 };
1162
1163 let mut pod_template_labels = mz.default_labels();
1164 pod_template_labels.insert(
1165 "materialize.cloud/name".to_owned(),
1166 mz.environmentd_statefulset_name(generation),
1167 );
1168 pod_template_labels.insert(
1169 "materialize.cloud/app".to_owned(),
1170 mz.environmentd_app_name(),
1171 );
1172 pod_template_labels.extend(recommended_k8s_labels("environmentd".into()));
1173 pod_template_labels.extend(
1174 mz.spec
1175 .pod_labels
1176 .as_ref()
1177 .map(|labels| labels.iter())
1178 .unwrap_or_default()
1179 .map(|(key, value)| (key.clone(), value.clone())),
1180 );
1181
1182 let mut pod_template_annotations = btreemap! {
1183 "cluster-autoscaler.kubernetes.io/safe-to-evict".to_owned() => "false".to_string(),
1185
1186 "karpenter.sh/do-not-evict".to_owned() => "true".to_string(),
1188 "karpenter.sh/do-not-disrupt".to_owned() => "true".to_string(),
1189 "materialize.cloud/generation".to_owned() => generation.to_string(),
1190 };
1191 if config.enable_prometheus_scrape_annotations {
1192 pod_template_annotations.insert("prometheus.io/scrape".to_owned(), "true".to_string());
1193 pod_template_annotations.insert(
1194 "prometheus.io/port".to_owned(),
1195 config.environmentd_internal_http_port.to_string(),
1196 );
1197 pod_template_annotations.insert("prometheus.io/path".to_owned(), "/metrics".to_string());
1198 pod_template_annotations.insert("prometheus.io/scheme".to_owned(), "http".to_string());
1199 pod_template_annotations.insert(
1200 "materialize.prometheus.io/mz_usage_path".to_owned(),
1201 "/metrics/mz_usage".to_string(),
1202 );
1203 pod_template_annotations.insert(
1204 "materialize.prometheus.io/mz_frontier_path".to_owned(),
1205 "/metrics/mz_frontier".to_string(),
1206 );
1207 pod_template_annotations.insert(
1208 "materialize.prometheus.io/mz_compute_path".to_owned(),
1209 "/metrics/mz_compute".to_string(),
1210 );
1211 pod_template_annotations.insert(
1212 "materialize.prometheus.io/mz_storage_path".to_owned(),
1213 "/metrics/mz_storage".to_string(),
1214 );
1215 }
1216 pod_template_annotations.extend(
1217 mz.spec
1218 .pod_annotations
1219 .as_ref()
1220 .map(|annotations| annotations.iter())
1221 .unwrap_or_default()
1222 .map(|(key, value)| (key.clone(), value.clone())),
1223 );
1224
1225 let mut tolerations = vec![
1226 Toleration {
1230 effect: Some("NoExecute".into()),
1231 key: Some("node.kubernetes.io/not-ready".into()),
1232 operator: Some("Exists".into()),
1233 toleration_seconds: Some(30),
1234 value: None,
1235 },
1236 Toleration {
1237 effect: Some("NoExecute".into()),
1238 key: Some("node.kubernetes.io/unreachable".into()),
1239 operator: Some("Exists".into()),
1240 toleration_seconds: Some(30),
1241 value: None,
1242 },
1243 ];
1244 if let Some(user_tolerations) = &config.environmentd_tolerations {
1245 tolerations.extend(user_tolerations.iter().cloned());
1246 }
1247 let tolerations = Some(tolerations);
1248
1249 let pod_template_spec = PodTemplateSpec {
1250 metadata: Some(ObjectMeta {
1253 labels: Some(pod_template_labels),
1254 annotations: Some(pod_template_annotations), ..Default::default()
1256 }),
1257 spec: Some(PodSpec {
1258 containers: vec![container],
1259 node_selector: Some(
1260 config
1261 .environmentd_node_selector
1262 .iter()
1263 .map(|selector| (selector.key.clone(), selector.value.clone()))
1264 .collect(),
1265 ),
1266 affinity: config.environmentd_affinity.clone(),
1267 scheduler_name: config.scheduler_name.clone(),
1268 priority_class_name: config.environmentd_priority_class_name.clone(),
1269 service_account_name: Some(mz.service_account_name()),
1270 volumes: Some(volumes),
1271 security_context: Some(PodSecurityContext {
1272 fs_group: Some(999),
1273 run_as_user: Some(999),
1274 run_as_group: Some(999),
1275 ..Default::default()
1276 }),
1277 tolerations,
1278 termination_grace_period_seconds: Some(0),
1299 ..Default::default()
1300 }),
1301 };
1302
1303 let mut match_labels = BTreeMap::new();
1304 match_labels.insert(
1305 "materialize.cloud/name".to_owned(),
1306 mz.environmentd_statefulset_name(generation),
1307 );
1308
1309 let statefulset_spec = StatefulSetSpec {
1310 replicas: Some(1),
1311 template: pod_template_spec,
1312 service_name: Some(mz.environmentd_service_name()),
1313 selector: LabelSelector {
1314 match_expressions: None,
1315 match_labels: Some(match_labels),
1316 },
1317 ..Default::default()
1318 };
1319
1320 StatefulSet {
1321 metadata: ObjectMeta {
1322 annotations: Some(btreemap! {
1323 "materialize.cloud/generation".to_owned() => generation.to_string(),
1324 "materialize.cloud/force".to_owned() => mz.force_rollout_value(),
1325 }),
1326 ..mz.managed_resource_meta(mz.environmentd_statefulset_name(generation))
1327 },
1328 spec: Some(statefulset_spec),
1329 status: None,
1330 }
1331}
1332
1333fn create_v0_147_0_listeners_config(
1334 config: &super::Config,
1335 mz: &Materialize,
1336) -> v0_147_0::ListenersConfig {
1337 let external_enable_tls = issuer_ref_defined(
1338 &config.default_certificate_specs.internal,
1339 &mz.spec.internal_certificate_spec,
1340 );
1341 let authenticator_kind = mz.spec.authenticator_kind;
1342
1343 let mut listeners_config = v0_147_0::ListenersConfig {
1344 sql: btreemap! {
1345 "external".to_owned() => SqlListenerConfig{
1346 addr: SocketAddr::new(
1347 IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1348 config.environmentd_sql_port,
1349 ),
1350 authenticator_kind,
1351 allowed_roles: AllowedRoles::Normal,
1352 enable_tls: external_enable_tls,
1353 },
1354 "internal".to_owned() => SqlListenerConfig{
1355 addr: SocketAddr::new(
1356 IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1357 config.environmentd_internal_sql_port,
1358 ),
1359 authenticator_kind: AuthenticatorKind::None,
1360 allowed_roles: AllowedRoles::NormalAndInternal,
1362 enable_tls: false,
1363 },
1364 },
1365 http: btreemap! {
1366 "external".to_owned() => v0_147_0::HttpListenerConfig{
1367 base: BaseListenerConfig {
1368 addr: SocketAddr::new(
1369 IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1370 config.environmentd_http_port,
1371 ),
1372 authenticator_kind: if authenticator_kind == AuthenticatorKind::Sasl {
1376 AuthenticatorKind::Password
1377 } else {
1378 authenticator_kind
1379 },
1380 allowed_roles: AllowedRoles::Normal,
1381 enable_tls: external_enable_tls,
1382 },
1383 routes: v0_147_0::HttpRoutes{
1384 base: true,
1385 webhook: true,
1386 internal: false,
1387 metrics: false,
1388 profiling: false,
1389 mcp_agent: true,
1390 mcp_developer: true,
1391 console_config: true,
1392 },
1393 },
1394 "internal".to_owned() => v0_147_0::HttpListenerConfig{
1395 base: BaseListenerConfig {
1396 addr: SocketAddr::new(
1397 IpAddr::V4(Ipv4Addr::new(0,0,0,0)),
1398 config.environmentd_internal_http_port,
1399 ),
1400 authenticator_kind: AuthenticatorKind::None,
1401 allowed_roles: AllowedRoles::NormalAndInternal,
1403 enable_tls: false,
1404 },
1405 routes: v0_147_0::HttpRoutes{
1406 base: true,
1407 webhook: true,
1408 internal: true,
1409 metrics: true,
1410 profiling: true,
1411 mcp_agent: true,
1412 mcp_developer: true,
1413 console_config: false,
1414 },
1415 },
1416 },
1417 };
1418
1419 if matches!(
1422 authenticator_kind,
1423 AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1424 ) {
1425 listeners_config.sql.remove("internal");
1426 listeners_config.http.remove("internal");
1427
1428 listeners_config.sql.get_mut("external").map(|listener| {
1429 listener.allowed_roles = AllowedRoles::NormalAndInternal;
1430 listener
1431 });
1432 listeners_config.http.get_mut("external").map(|listener| {
1433 listener.base.allowed_roles = AllowedRoles::NormalAndInternal;
1434 listener.routes.internal = true;
1435 listener.routes.profiling = true;
1436 listener
1437 });
1438
1439 listeners_config.http.insert(
1440 "metrics".to_owned(),
1441 v0_147_0::HttpListenerConfig {
1442 base: BaseListenerConfig {
1443 addr: SocketAddr::new(
1444 IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
1445 config.environmentd_internal_http_port,
1446 ),
1447 authenticator_kind: AuthenticatorKind::None,
1448 allowed_roles: AllowedRoles::NormalAndInternal,
1449 enable_tls: false,
1450 },
1451 routes: v0_147_0::HttpRoutes {
1452 base: false,
1453 webhook: false,
1454 internal: false,
1455 metrics: true,
1456 profiling: false,
1457 mcp_agent: false,
1458 mcp_developer: false,
1459 console_config: false,
1460 },
1461 },
1462 );
1463 };
1464 listeners_config
1465}
1466
1467fn create_connection_info(
1468 config: &super::Config,
1469 mz: &Materialize,
1470 generation: u64,
1471) -> ConnectionInfo {
1472 let external_enable_tls = issuer_ref_defined(
1473 &config.default_certificate_specs.internal,
1474 &mz.spec.internal_certificate_spec,
1475 );
1476 let authenticator_kind = mz.spec.authenticator_kind;
1477
1478 let listeners_config = create_v0_147_0_listeners_config(config, mz);
1479
1480 let listeners_json = if mz.meets_minimum_version(&PER_ROUTE_GROUP_ROLES_VERSION) {
1484 let mut listeners_config: v26_32_0::ListenersConfig = listeners_config.into();
1489 if matches!(
1496 authenticator_kind,
1497 AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc
1498 ) {
1499 listeners_config.http.get_mut("external").map(|listener| {
1500 listener.routes.internal = RouteGroup::Enabled(AllowedRoles::Internal);
1501 listener.routes.profiling = RouteGroup::Enabled(AllowedRoles::Internal);
1502 listener
1503 });
1504 }
1505
1506 serde_json::to_string(&VersionedListenersConfig::V2(listeners_config)).expect("known valid")
1507 } else {
1508 serde_json::to_string(&VersionedListenersConfig::V1(listeners_config)).expect("known valid")
1509 };
1510 let listeners_configmap = ConfigMap {
1511 binary_data: None,
1512 data: Some(btreemap! {
1513 "listeners.json".to_owned() => listeners_json,
1514 }),
1515 immutable: None,
1516 metadata: ObjectMeta {
1517 annotations: Some(btreemap! {
1518 "materialize.cloud/generation".to_owned() => generation.to_string(),
1519 }),
1520 ..mz.managed_resource_meta(mz.listeners_configmap_name(generation))
1521 },
1522 };
1523
1524 let (scheme, leader_api_port, mz_system_secret_name) = match authenticator_kind {
1525 AuthenticatorKind::Password | AuthenticatorKind::Sasl | AuthenticatorKind::Oidc => {
1526 let scheme = if external_enable_tls { "https" } else { "http" };
1527 (
1528 scheme,
1529 config.environmentd_http_port,
1530 Some(mz.spec.backend_secret_name.clone()),
1531 )
1532 }
1533 _ => ("http", config.environmentd_internal_http_port, None),
1534 };
1535 let environmentd_url = format!(
1536 "{}://{}.{}.svc.cluster.local:{}",
1537 scheme,
1538 mz.environmentd_generation_service_name(generation),
1539 mz.namespace(),
1540 leader_api_port,
1541 );
1542 ConnectionInfo {
1543 environmentd_url,
1544 listeners_configmap,
1545 mz_system_secret_name,
1546 }
1547}
1548
1549#[derive(Debug, Deserialize, PartialEq, Eq)]
1551struct BecomeLeaderResponse {
1552 result: BecomeLeaderResult,
1553}
1554
1555#[derive(Debug, Deserialize, PartialEq, Eq)]
1556enum BecomeLeaderResult {
1557 Success,
1558 Failure { message: String },
1559}
1560
1561#[derive(Debug, Deserialize, PartialEq, Eq)]
1562struct SkipCatchupError {
1563 message: String,
1564}