1use std::{
11 collections::BTreeSet,
12 sync::{Arc, Mutex},
13 time::Duration,
14};
15
16use anyhow::Context as _;
17use http::HeaderValue;
18use k8s_controller::{
19 Outcome, TraceMetadata,
20 events::{Event, EventType},
21};
22use k8s_openapi::{
23 api::core::v1::{Affinity, Probe, ResourceRequirements, Secret, Toleration},
24 apimachinery::pkg::apis::meta::v1::{Condition, Time},
25 jiff::{SignedDuration, Timestamp},
26};
27use kube::{
28 Api, Client, Resource, ResourceExt,
29 api::{ListParams, PostParams},
30 runtime::controller::Action,
31};
32use tracing::{debug, trace, warn};
33use uuid::Uuid;
34
35use crate::{
36 Error,
37 controller::materialize::generation::V161,
38 k8s::{apply_resource, delete_resource},
39 matching_image_from_environmentd_image_ref,
40 metrics::Metrics,
41 parse_image_tag,
42 tls::{DefaultCertificateSpecs, issuer_ref_defined, resolved_dns_names},
43};
44use mz_cloud_provider::CloudProvider;
45use mz_cloud_resources::crd::{
46 ManagedResource,
47 balancer::v1alpha1::{Balancer, BalancerSpec},
48 console::v1alpha1::{BalancerdRef, Console, ConsoleSpec, HttpConnectionScheme},
49 materialize::MaterializeRolloutStrategy,
50 materialize::v1alpha1::{Materialize, MaterializeStatus},
51};
52use mz_license_keys::validate;
53use mz_orchestrator_kubernetes::KubernetesImagePullPolicy;
54use mz_orchestrator_tracing::TracingCliArgs;
55use mz_ore::{cast::CastFrom, cli::KeyValueArg, instrument};
56
57pub mod generation;
58pub mod global;
59pub mod teleport;
60
61pub const CONTROLLER_NAME: &str = "materialize";
64
65#[derive(Clone)]
66pub struct Config {
67 pub cloud_provider: CloudProvider,
68 pub region: String,
69 pub create_balancers: bool,
70 pub create_console: bool,
71 pub helm_chart_version: Option<String>,
72 pub secrets_controller: String,
73 pub collect_pod_metrics: bool,
74 pub enable_prometheus_scrape_annotations: bool,
75
76 pub segment_api_key: Option<String>,
77 pub segment_client_side: bool,
78
79 pub console_image_tag_default: String,
80 pub console_image_tag_map: Vec<KeyValueArg<String, String>>,
81
82 pub aws_account_id: Option<String>,
83 pub environmentd_iam_role_arn: Option<String>,
84 pub environmentd_connection_role_arn: Option<String>,
85 pub aws_secrets_controller_tags: Vec<String>,
86 pub environmentd_availability_zones: Option<Vec<String>>,
87
88 pub ephemeral_volume_class: Option<String>,
89 pub scheduler_name: Option<String>,
90 pub enable_security_context: bool,
91 pub enable_user_namespaces: bool,
94 pub enable_internal_statement_logging: bool,
95 pub statement_logging_max_sample_rate: Option<f64>,
96 pub statement_logging_target_data_rate: Option<usize>,
97
98 pub orchestratord_pod_selector_labels: Vec<KeyValueArg<String, String>>,
99 pub environmentd_node_selector: Vec<KeyValueArg<String, String>>,
100 pub environmentd_affinity: Option<Affinity>,
101 pub environmentd_tolerations: Option<Vec<Toleration>>,
102 pub environmentd_default_resources: Option<ResourceRequirements>,
103 pub environmentd_default_readiness_probe: Option<Probe>,
106 pub environmentd_default_liveness_probe: Option<Probe>,
109 pub environmentd_default_startup_probe: Option<Probe>,
112 pub environmentd_priority_class_name: Option<String>,
114 pub clusterd_node_selector: Vec<KeyValueArg<String, String>>,
115 pub clusterd_affinity: Option<Affinity>,
116 pub clusterd_tolerations: Option<Vec<Toleration>>,
117 pub clusterd_priority_class_name: Option<String>,
119 pub image_pull_policy: KubernetesImagePullPolicy,
120 pub network_policies_internal_enabled: bool,
121 pub network_policies_ingress_enabled: bool,
122 pub network_policies_ingress_cidrs: Vec<String>,
123 pub network_policies_egress_enabled: bool,
124 pub network_policies_egress_cidrs: Vec<String>,
125
126 pub environmentd_cluster_replica_sizes: Option<String>,
127 pub bootstrap_default_cluster_replica_size: Option<String>,
128 pub bootstrap_builtin_system_cluster_replica_size: Option<String>,
129 pub bootstrap_builtin_probe_cluster_replica_size: Option<String>,
130 pub bootstrap_builtin_support_cluster_replica_size: Option<String>,
131 pub bootstrap_builtin_catalog_server_cluster_replica_size: Option<String>,
132 pub bootstrap_builtin_analytics_cluster_replica_size: Option<String>,
133 pub bootstrap_builtin_system_cluster_replication_factor: Option<u32>,
134 pub bootstrap_builtin_probe_cluster_replication_factor: Option<u32>,
135 pub bootstrap_builtin_support_cluster_replication_factor: Option<u32>,
136 pub bootstrap_builtin_analytics_cluster_replication_factor: Option<u32>,
137
138 pub environmentd_allowed_origins: Vec<HeaderValue>,
139 pub internal_console_proxy_url: String,
140
141 pub environmentd_sql_port: u16,
142 pub environmentd_http_port: u16,
143 pub environmentd_internal_sql_port: u16,
144 pub environmentd_internal_http_port: u16,
145 pub environmentd_internal_persist_pubsub_port: u16,
146
147 pub default_certificate_specs: DefaultCertificateSpecs,
148
149 pub disable_license_key_checks: bool,
150 pub disable_database_network_policies: bool,
151
152 pub tracing: TracingCliArgs,
153 pub orchestratord_namespace: String,
154
155 pub teleport: Option<teleport::TeleportConfig>,
156}
157
158pub struct Context {
159 config: Config,
160 metrics: Arc<Metrics>,
161 needs_update: Arc<Mutex<BTreeSet<String>>>,
162}
163
164impl Context {
165 pub fn new(config: Config, metrics: Arc<Metrics>) -> Self {
166 if config.cloud_provider == CloudProvider::Aws {
167 assert!(
168 config.aws_account_id.is_some(),
169 "--aws-account-id is required when using --cloud-provider=aws"
170 );
171 }
172
173 Self {
174 config,
175 metrics,
176 needs_update: Default::default(),
177 }
178 }
179
180 fn set_needs_update(&self, mz: &Materialize, needs_update: bool) {
181 let mut needs_update_set = self.needs_update.lock().unwrap();
182 if needs_update {
183 needs_update_set.insert(mz.name_unchecked());
184 } else {
185 needs_update_set.remove(&mz.name_unchecked());
186 }
187 self.metrics
188 .environmentd_needs_update
189 .set(u64::cast_from(needs_update_set.len()));
190 }
191
192 async fn update_status(
196 &self,
197 metadata: &TraceMetadata,
198 mz_api: &Api<Materialize>,
199 mz: &Materialize,
200 status: MaterializeStatus,
201 needs_update: bool,
202 ) -> Result<Materialize, kube::Error> {
203 self.set_needs_update(mz, needs_update);
204
205 let mut new_mz = mz.clone();
206 if !mz
207 .status
208 .as_ref()
209 .map_or(true, |mz_status| mz_status.needs_update(&status))
210 {
211 return Ok(new_mz);
212 }
213
214 let condition = status.conditions.first().cloned();
215 new_mz.status = Some(status);
216 let new_mz = mz_api
217 .replace_status(&mz.name_unchecked(), &PostParams::default(), &new_mz)
218 .await?;
219
220 if let Some(condition) = condition {
221 metadata
222 .publish_event(Event {
223 type_: transition_event_type(&condition),
224 reason: condition.reason,
225 action: "Reconcile".into(),
226 note: Some(condition.message),
227 related: None,
228 })
229 .await;
230 }
231
232 Ok(new_mz)
233 }
234
235 async fn teardown_generation(
238 &self,
239 metadata: &TraceMetadata,
240 client: &Client,
241 mz: &Materialize,
242 resources: &generation::Resources,
243 generation: u64,
244 ) -> Result<(), Error> {
245 let step = metadata.step("teardown_generation");
246 resources
247 .teardown_generation(client, mz, generation)
248 .await?;
249 step.finish(Outcome::Completed);
250 Ok(())
251 }
252
253 async fn promote(
254 &self,
255 metadata: &TraceMetadata,
256 client: &Client,
257 mz: &Materialize,
258 resources: generation::Resources,
259 active_generation: u64,
260 desired_generation: u64,
261 resources_hash: String,
262 ) -> Result<Option<Action>, Error> {
263 let step = metadata.step("promote");
264 if let Some(action) = resources.promote_services(client, &mz.namespace()).await? {
265 step.finish(Outcome::Waiting);
266 return Ok(Some(action));
267 }
268 step.finish(Outcome::Completed);
269
270 self.teardown_generation(metadata, client, mz, &resources, active_generation)
271 .await?;
272 let mz_api: Api<Materialize> = Api::namespaced(client.clone(), &mz.namespace());
273 self.update_status(
274 metadata,
275 &mz_api,
276 mz,
277 MaterializeStatus {
278 active_generation: desired_generation,
279 last_completed_rollout_request: mz.requested_reconciliation_id(),
280 last_completed_rollout_environmentd_image_ref: Some(
281 mz.spec.environmentd_image_ref.clone(),
282 ),
283 resource_id: mz.status().resource_id,
284 resources_hash,
285 last_completed_rollout_hash: None,
286 conditions: vec![Condition {
287 type_: "UpToDate".into(),
288 status: "True".into(),
289 last_transition_time: Time(Timestamp::now()),
290 message: format!(
291 "Successfully applied changes for generation {desired_generation}"
292 ),
293 observed_generation: mz.meta().generation,
294 reason: "Applied".into(),
295 }],
296 },
297 false,
298 )
299 .await?;
300 Ok(None)
301 }
302
303 async fn check_environment_id_conflicts(
304 &self,
305 client: &Client,
306 mz: &Materialize,
307 ) -> Result<(), Error> {
308 if mz.spec.environment_id.is_nil() {
309 return Err(Error::Anyhow(anyhow::anyhow!(
313 "trying to reconcile a materialize resource with no environment id - this is a bug!"
314 )));
315 }
316
317 let mz_api: Api<Materialize> = Api::all(client.clone());
318 let all_mz = mz_api.list(&ListParams::default()).await?;
319 for existing_mz in &all_mz.items {
320 if existing_mz.spec.environment_id == mz.spec.environment_id
321 && existing_mz.metadata.uid != mz.metadata.uid
322 {
323 return Err(Error::Anyhow(anyhow::anyhow!(
324 "Materialize resources {}/{} and {}/{} have the environmentId field set to the same value. This field must be unique across environments.",
325 mz.namespace(),
326 mz.name_unchecked(),
327 existing_mz.namespace(),
328 existing_mz.name_unchecked(),
329 )));
330 }
331 }
332
333 Ok(())
334 }
335}
336
337#[async_trait::async_trait]
338impl k8s_controller::Context for Context {
339 type Resource = Materialize;
340 type Error = Error;
341
342 const FINALIZER_NAME: Option<&'static str> =
343 Some("orchestratord.materialize.cloud/materialize");
344
345 #[instrument(fields(organization_name=mz.name_unchecked()))]
346 async fn apply(
347 &self,
348 client: Client,
349 mz: &Self::Resource,
350 metadata: &mut TraceMetadata,
351 ) -> Result<Option<Action>, Self::Error> {
352 let metadata = &*metadata;
353 let mz_api: Api<Materialize> = Api::namespaced(client.clone(), &mz.namespace());
354 let balancer_api: Api<Balancer> = Api::namespaced(client.clone(), &mz.namespace());
355 let console_api: Api<Console> = Api::namespaced(client.clone(), &mz.namespace());
356 let secret_api: Api<Secret> = Api::namespaced(client.clone(), &mz.namespace());
357
358 let status = mz.status();
359 if mz.status.is_none() {
360 let step = metadata.step("initialize_status");
361 self.update_status(metadata, &mz_api, mz, status, true)
362 .await?;
363 step.finish(Outcome::Completed);
364 return Ok(None);
367 }
368
369 let step = metadata.step("resolve_environment_id");
370 let backend_secret = secret_api.get(&mz.spec.backend_secret_name).await?;
371 let license_key_environment_id: Option<Uuid> = if let Some(license_key) = backend_secret
372 .data
373 .as_ref()
374 .and_then(|data| data.get("license_key"))
375 {
376 let license_key = validate(
377 str::from_utf8(&license_key.0)
378 .context("invalid utf8")?
379 .trim(),
380 )?;
381 let environment_id = license_key
382 .environment_id
383 .parse()
384 .context("invalid environment id in license key")?;
385 Some(environment_id)
386 } else {
387 if mz.meets_minimum_version(&V161) {
388 return Err(Error::Anyhow(anyhow::anyhow!(
389 "license_key is required when running in kubernetes",
390 )));
391 } else {
392 None
393 }
394 };
395
396 if mz.spec.request_rollout.is_nil() || mz.spec.environment_id.is_nil() {
397 let mut mz = mz.clone();
398 if mz.spec.request_rollout.is_nil() {
399 mz.spec.request_rollout = Uuid::new_v4();
400 }
401 if mz.spec.environment_id.is_nil() {
402 if let Some(environment_id) = license_key_environment_id {
403 if environment_id.is_nil() {
404 mz.spec.environment_id = Uuid::new_v4();
407 } else {
408 mz.spec.environment_id = environment_id;
409 }
410 } else {
411 if mz.meets_minimum_version(&V161) {
412 return Err(Error::Anyhow(anyhow::anyhow!(
413 "environmentId is not set in materialize resource {}/{} but no license key was given",
414 mz.namespace(),
415 mz.name_unchecked()
416 )));
417 } else {
418 mz.spec.environment_id = Uuid::new_v4();
419 }
420 }
421 }
422 mz_api
423 .replace(&mz.name_unchecked(), &PostParams::default(), &mz)
424 .await?;
425 step.finish(Outcome::Completed);
426 return Ok(None);
430 }
431
432 if let Some(environment_id) = license_key_environment_id {
433 if !environment_id.is_nil() && mz.spec.environment_id != environment_id {
436 return Err(Error::Anyhow(anyhow::anyhow!(
437 "environment_id is set in materialize resource {}/{} but does not match the environment_id set in the associated license key {}",
438 mz.namespace(),
439 mz.name_unchecked(),
440 environment_id,
441 )));
442 }
443 }
444
445 for (field, probe) in [
446 (
447 "environmentdReadinessProbe",
448 &mz.spec.environmentd_readiness_probe,
449 ),
450 (
451 "environmentdLivenessProbe",
452 &mz.spec.environmentd_liveness_probe,
453 ),
454 (
455 "environmentdStartupProbe",
456 &mz.spec.environmentd_startup_probe,
457 ),
458 ] {
459 if let Some(probe) = probe {
460 generation::validate_probe_handlers(probe).map_err(|e| {
461 Error::Anyhow(anyhow::anyhow!(
462 "invalid {field} in materialize resource {}/{}: {e}",
463 mz.namespace(),
464 mz.name_unchecked(),
465 ))
466 })?;
467 }
468 }
469
470 self.check_environment_id_conflicts(&client, mz).await?;
471 step.finish(Outcome::Completed);
472
473 let step = metadata.step("global_resources");
474 global::Resources::new(&self.config, mz)?
475 .apply(&client, &mz.namespace())
476 .await?;
477 step.finish(Outcome::Completed);
478
479 let active_resources =
485 generation::Resources::new(&self.config, mz, status.active_generation);
486 let has_current_changes = status.resources_hash != active_resources.generate_hash();
487 let active_generation = status.active_generation;
488 let next_generation = active_generation + 1;
489 let desired_generation = if has_current_changes {
490 next_generation
491 } else {
492 active_generation
493 };
494
495 let resources = generation::Resources::new(&self.config, mz, desired_generation);
498 let resources_hash = resources.generate_hash();
499
500 let mut result = match (
501 mz.is_promoting(),
502 has_current_changes,
503 mz.rollout_requested(),
504 ) {
505 (true, _, _) => {
508 self.promote(
509 metadata,
510 &client,
511 mz,
512 resources,
513 active_generation,
514 desired_generation,
515 resources_hash,
516 )
517 .await
518 }
519 (false, true, true) => {
521 if !mz.should_force_promote() {
533 if let Some(started) = mz.rollout_in_progress_since() {
534 let timeout = mz.rollout_request_timeout();
535 let elapsed = Timestamp::now().duration_since(started);
536 let timed_out = SignedDuration::try_from(timeout)
537 .is_ok_and(|timeout| elapsed >= timeout);
538 if timed_out {
539 warn!(
540 "rollout to generation {desired_generation} exceeded timeout, cancelling"
541 );
542 self.teardown_generation(
545 metadata,
546 &client,
547 mz,
548 &resources,
549 next_generation,
550 )
551 .await?;
552 self.update_status(
553 metadata,
554 &mz_api,
555 mz,
556 MaterializeStatus {
557 active_generation,
558 last_completed_rollout_request: mz
563 .requested_reconciliation_id(),
564 last_completed_rollout_environmentd_image_ref: status
565 .last_completed_rollout_environmentd_image_ref
566 .clone(),
567 resource_id: status.resource_id.clone(),
568 resources_hash: status.resources_hash.clone(),
569 last_completed_rollout_hash: None,
570 conditions: vec![Condition {
571 type_: "UpToDate".into(),
572 status: "False".into(),
573 last_transition_time: Time(Timestamp::now()),
574 message: format!(
575 "Cancelled rollout to generation \
576 {desired_generation} after it \
577 exceeded the rollout timeout of {}",
578 humantime::format_duration(timeout),
579 ),
580 observed_generation: mz.meta().generation,
581 reason: "RolloutTimeout".into(),
582 }],
583 },
584 active_generation != desired_generation,
585 )
586 .await?;
587 return Ok(None);
588 }
589 }
590 }
591
592 if !mz.within_upgrade_window() {
593 let last_completed_rollout_environmentd_image_ref =
594 status.last_completed_rollout_environmentd_image_ref;
595
596 self.update_status(
597 metadata,
598 &mz_api,
599 mz,
600 MaterializeStatus {
601 active_generation,
602 last_completed_rollout_request: status.last_completed_rollout_request,
603 last_completed_rollout_environmentd_image_ref:
604 last_completed_rollout_environmentd_image_ref.clone(),
605 resource_id: status.resource_id,
606 resources_hash: status.resources_hash,
607 last_completed_rollout_hash: None,
608 conditions: vec![Condition {
609 type_: "UpToDate".into(),
610 status: "False".into(),
611 last_transition_time: Time(Timestamp::now()),
612 message: format!(
613 "Refusing to upgrade from {} to {}. \
614 More than one major version from \
615 last successful rollout. If coming \
616 from Self Managed 25.2, upgrade to \
617 materialize/environmentd:v0.147.20 \
618 first.",
619 last_completed_rollout_environmentd_image_ref
620 .expect("should be set if upgrade window check fails"),
621 mz.spec.environmentd_image_ref,
622 ),
623 observed_generation: mz.meta().generation,
624 reason: "FailedDeploy".into(),
625 }],
626 },
627 active_generation != desired_generation,
628 )
629 .await?;
630 return Ok(None);
631 }
632
633 let mz = if mz.is_ready_to_promote(&resources_hash) {
645 mz
646 } else {
647 &self
648 .update_status(
649 metadata,
650 &mz_api,
651 mz,
652 MaterializeStatus {
653 active_generation,
654 last_completed_rollout_request: status
659 .last_completed_rollout_request,
660 last_completed_rollout_environmentd_image_ref: status
661 .last_completed_rollout_environmentd_image_ref,
662 resource_id: status.resource_id.clone(),
663 resources_hash: String::new(),
664 last_completed_rollout_hash: None,
665 conditions: vec![Condition {
666 type_: "UpToDate".into(),
667 status: "Unknown".into(),
668 last_transition_time: Time(Timestamp::now()),
669 message: format!(
670 "Applying changes for generation {desired_generation}"
671 ),
672 observed_generation: mz.meta().generation,
673 reason: "Applying".into(),
674 }],
675 },
676 active_generation != desired_generation,
677 )
678 .await?
679 };
680 let status = mz.status();
681
682 if mz.spec.rollout_strategy
683 == MaterializeRolloutStrategy::ImmediatelyPromoteCausingDowntime
684 {
685 self.teardown_generation(metadata, &client, mz, &resources, active_generation)
689 .await?;
690 }
691
692 trace!("applying environment resources");
693 let step = metadata.step("generation_resources");
694 let applied = resources
695 .apply(&client, mz.should_force_promote(), &mz.namespace())
696 .await;
697 step.finish_with(&applied);
698 match applied {
699 Ok(Some(action)) => {
700 trace!("new environment is not yet ready");
701 Ok(Some(action))
702 }
703 Ok(None) => {
704 if mz.spec.rollout_strategy == MaterializeRolloutStrategy::ManuallyPromote
705 && !mz.should_force_promote()
706 {
707 trace!(
708 "Ready to promote, but not promoting because the instance is configured with ManuallyPromote rollout strategy."
709 );
710 self.update_status(
711 metadata,
712 &mz_api,
713 mz,
714 MaterializeStatus {
715 active_generation,
716 last_completed_rollout_request: status
717 .last_completed_rollout_request,
718 last_completed_rollout_environmentd_image_ref: status
719 .last_completed_rollout_environmentd_image_ref,
720 resource_id: status.resource_id,
721 resources_hash,
722 last_completed_rollout_hash: None,
723 conditions: vec![Condition {
724 type_: "UpToDate".into(),
725 status: "Unknown".into(),
726 last_transition_time: Time(mz.up_to_date_transition_time(
732 "Unknown",
733 Timestamp::now(),
734 )),
735 message: format!(
736 "Ready to promote generation {desired_generation}"
737 ),
738 observed_generation: mz.meta().generation,
739 reason: "ReadyToPromote".into(),
740 }],
741 },
742 active_generation != desired_generation,
743 )
744 .await?;
745 return Ok(None);
746 }
747 self.update_status(
755 metadata,
756 &mz_api,
757 mz,
758 MaterializeStatus {
759 active_generation,
760 last_completed_rollout_request: status
765 .last_completed_rollout_request,
766 last_completed_rollout_environmentd_image_ref: status
767 .last_completed_rollout_environmentd_image_ref,
768 resource_id: status.resource_id,
769 resources_hash: resources_hash.clone(),
770 last_completed_rollout_hash: None,
771 conditions: vec![Condition {
772 type_: "UpToDate".into(),
773 status: "Unknown".into(),
774 last_transition_time: Time(Timestamp::now()),
775 message: format!(
776 "Attempting to promote generation {desired_generation}"
777 ),
778 observed_generation: mz.meta().generation,
779 reason: "Promoting".into(),
780 }],
781 },
782 active_generation != desired_generation,
783 )
784 .await?;
785 self.promote(
786 metadata,
787 &client,
788 mz,
789 resources,
790 active_generation,
791 desired_generation,
792 resources_hash,
793 )
794 .await
795 }
796 Err(e) => {
797 self.update_status(
798 metadata,
799 &mz_api,
800 mz,
801 MaterializeStatus {
802 active_generation,
803 last_completed_rollout_request: status
808 .last_completed_rollout_request,
809 last_completed_rollout_environmentd_image_ref: status
810 .last_completed_rollout_environmentd_image_ref,
811 resource_id: status.resource_id,
812 resources_hash: status.resources_hash,
813 last_completed_rollout_hash: None,
814 conditions: vec![Condition {
815 type_: "UpToDate".into(),
816 status: "False".into(),
817 last_transition_time: Time(Timestamp::now()),
818 message: format!(
819 "Failed to apply changes for \
820 generation {desired_generation}: {e}"
821 ),
822 observed_generation: mz.meta().generation,
823 reason: "FailedDeploy".into(),
824 }],
825 },
826 active_generation != desired_generation,
827 )
828 .await?;
829 Err(e)
830 }
831 }
832 }
833 (false, true, false) => {
835 let mut needs_update = mz.conditions_need_update();
836 if mz.update_in_progress() {
837 self.teardown_generation(metadata, &client, mz, &resources, next_generation)
838 .await?;
839 needs_update = true;
840 }
841 if needs_update {
842 self.update_status(metadata, &mz_api,
843 mz,
844 MaterializeStatus {
845 active_generation,
846 last_completed_rollout_request: mz.requested_reconciliation_id(),
847 last_completed_rollout_environmentd_image_ref: status
848 .last_completed_rollout_environmentd_image_ref,
849 resource_id: status.resource_id.clone(),
850 resources_hash: status.resources_hash,
851 last_completed_rollout_hash: None,
852 conditions: vec![Condition {
853 type_: "UpToDate".into(),
854 status: "False".into(),
855 last_transition_time: Time(Timestamp::now()),
856 message: format!(
857 "Changes detected, waiting for approval for generation {desired_generation}"
858 ),
859 observed_generation: mz.meta().generation,
860 reason: "WaitingForApproval".into(),
861 }],
862 },
863 active_generation != desired_generation,
864 )
865 .await?;
866 }
867 debug!("changes detected, waiting for approval");
868 Ok(None)
869 }
870 (false, false, _) => {
872 let mut needs_update = mz.conditions_need_update() || mz.rollout_requested();
877 if mz.update_in_progress() {
878 self.teardown_generation(metadata, &client, mz, &resources, next_generation)
879 .await?;
880 needs_update = true;
881 }
882 if needs_update {
883 self.update_status(
884 metadata,
885 &mz_api,
886 mz,
887 MaterializeStatus {
888 active_generation,
889 last_completed_rollout_request: mz.requested_reconciliation_id(),
890 last_completed_rollout_environmentd_image_ref: status
891 .last_completed_rollout_environmentd_image_ref,
892 resource_id: status.resource_id.clone(),
893 resources_hash: status.resources_hash,
894 last_completed_rollout_hash: None,
895 conditions: vec![Condition {
896 type_: "UpToDate".into(),
897 status: "True".into(),
898 last_transition_time: Time(Timestamp::now()),
899 message: format!(
900 "No changes found from generation {active_generation}"
901 ),
902 observed_generation: mz.meta().generation,
903 reason: "Applied".into(),
904 }],
905 },
906 active_generation != desired_generation,
907 )
908 .await?;
909 }
910 debug!("no changes");
911 Ok(None)
912 }
913 }?;
914
915 if let Some(action) = result {
916 return Ok(Some(action));
917 }
918
919 let step = metadata.step("balancer");
924 if self.config.create_balancers {
925 let balancer = Balancer {
926 metadata: mz.managed_resource_meta(mz.name_unchecked()),
927 spec: BalancerSpec {
928 balancerd_image_ref: matching_image_from_environmentd_image_ref(
929 mz.active_environmentd_image_ref(),
930 "balancerd",
931 None,
932 ),
933 resource_requirements: mz.spec.balancerd_resource_requirements.clone(),
934 configmap_name: mz.spec.balancerd_configmap_name.clone(),
935 replicas: Some(mz.balancerd_replicas()),
936 externally_scaled: mz.spec.balancerd_externally_scaled,
937 external_certificate_spec: mz.spec.balancerd_external_certificate_spec.clone(),
938 internal_certificate_spec: mz.spec.internal_certificate_spec.clone(),
939 pod_annotations: mz.spec.pod_annotations.clone(),
940 pod_labels: mz.spec.pod_labels.clone(),
941 static_routing: Some(
942 mz_cloud_resources::crd::balancer::v1alpha1::StaticRoutingConfig {
943 environmentd_namespace: mz.namespace(),
944 environmentd_service_name: mz.environmentd_service_name(),
945 },
946 ),
947 frontegg_routing: None,
948 resource_id: Some(status.resource_id.clone()),
949 },
950 status: None,
951 };
952 let balancer = apply_resource(&balancer_api, &balancer).await?;
953 result = wait_for_balancer(&balancer)?;
954 step.finish(match result {
955 Some(_) => Outcome::Waiting,
956 None => Outcome::Completed,
957 });
958 } else {
959 delete_resource(&balancer_api, &mz.name_unchecked()).await?;
960 step.finish(Outcome::Skipped);
961 }
962
963 if let Some(action) = result {
964 return Ok(Some(action));
965 }
966
967 let step = metadata.step("console");
971 if self.config.create_console {
972 let active_environmentd_image_ref = mz.active_environmentd_image_ref();
973 let environmentd_image_tag =
974 parse_image_tag(active_environmentd_image_ref).unwrap_or("latest");
975 let console_image_tag = self
976 .config
977 .console_image_tag_map
978 .iter()
979 .find(|kv| kv.key == environmentd_image_tag)
980 .map(|kv| kv.value.clone())
981 .unwrap_or_else(|| self.config.console_image_tag_default.clone());
982 let console = Console {
983 metadata: mz.managed_resource_meta(mz.name_unchecked()),
984 spec: ConsoleSpec {
985 console_image_ref: matching_image_from_environmentd_image_ref(
986 active_environmentd_image_ref,
987 "console",
988 Some(&console_image_tag),
989 ),
990 resource_requirements: mz.spec.console_resource_requirements.clone(),
991 replicas: Some(mz.console_replicas()),
992 external_certificate_spec: mz.spec.console_external_certificate_spec.clone(),
993 pod_annotations: mz.spec.pod_annotations.clone(),
994 pod_labels: mz.spec.pod_labels.clone(),
995 balancerd: BalancerdRef {
996 service_name: mz.balancerd_service_name(),
997 namespace: mz.namespace(),
998 scheme: if issuer_ref_defined(
999 &self.config.default_certificate_specs.balancerd_external,
1000 &mz.spec.balancerd_external_certificate_spec,
1001 ) {
1002 HttpConnectionScheme::Https
1003 } else {
1004 HttpConnectionScheme::Http
1005 },
1006 dns_names: resolved_dns_names(
1007 &self.config.default_certificate_specs.balancerd_external,
1008 &mz.spec.balancerd_external_certificate_spec,
1009 ),
1010 },
1011 authenticator_kind: mz.spec.authenticator_kind,
1012 resource_id: Some(status.resource_id),
1013 },
1014 status: None,
1015 };
1016 apply_resource(&console_api, &console).await?;
1017 step.finish(Outcome::Completed);
1018 } else {
1019 delete_resource(&console_api, &mz.name_unchecked()).await?;
1020 step.finish(Outcome::Skipped);
1021 }
1022
1023 Ok(result)
1024 }
1025
1026 #[instrument(fields(organization_name=mz.name_unchecked()))]
1027 async fn cleanup(
1028 &self,
1029 _client: Client,
1030 mz: &Self::Resource,
1031 _metadata: &mut TraceMetadata,
1032 ) -> Result<Option<Action>, Self::Error> {
1033 self.set_needs_update(mz, false);
1034
1035 Ok(None)
1036 }
1037}
1038
1039fn transition_event_type(condition: &Condition) -> EventType {
1044 match condition.reason.as_str() {
1045 "WaitingForApproval" => EventType::Normal,
1046 _ if condition.status == "False" => EventType::Warning,
1047 _ => EventType::Normal,
1048 }
1049}
1050
1051fn wait_for_balancer(balancer: &Balancer) -> Result<Option<Action>, Error> {
1052 if let Some(conditions) = balancer
1053 .status
1054 .as_ref()
1055 .map(|status| status.conditions.as_slice())
1056 {
1057 if conditions
1058 .iter()
1059 .any(|condition| condition.type_ == "Ready" && condition.status == "True")
1060 {
1061 return Ok(None);
1062 }
1063 }
1064
1065 Ok(Some(Action::requeue(Duration::from_secs(1))))
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 fn condition(status: &str, reason: &str) -> Condition {
1073 Condition {
1074 type_: "UpToDate".into(),
1075 status: status.into(),
1076 reason: reason.into(),
1077 message: String::new(),
1078 last_transition_time: Time(Timestamp::now()),
1079 observed_generation: None,
1080 }
1081 }
1082
1083 #[mz_ore::test]
1084 fn test_transition_event_type() {
1085 for (status, reason, expected) in [
1086 ("True", "Applied", EventType::Normal),
1087 ("Unknown", "Applying", EventType::Normal),
1088 ("Unknown", "ReadyToPromote", EventType::Normal),
1089 ("Unknown", "Promoting", EventType::Normal),
1090 ("False", "WaitingForApproval", EventType::Normal),
1091 ("False", "FailedDeploy", EventType::Warning),
1092 ("False", "RolloutTimeout", EventType::Warning),
1093 ("False", "SomeFutureFailure", EventType::Warning),
1094 ("Unknown", "SomeFuturePhase", EventType::Normal),
1095 ] {
1096 assert_eq!(
1097 transition_event_type(&condition(status, reason)),
1098 expected,
1099 "status={status} reason={reason}",
1100 );
1101 }
1102 }
1103}