Skip to main content

mz_orchestratord/controller/
materialize.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use 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
61/// The name identifying this controller in its reconciliation metrics and in
62/// the reporter of the events it publishes.
63pub 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    /// Whether to run environmentd and clusterd pods in a Kubernetes user
92    /// namespace (`hostUsers: false`).
93    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    /// Overlaid onto the built-in environmentd readiness probe. See
104    /// `generation::environmentd_probe` for the overlay semantics.
105    pub environmentd_default_readiness_probe: Option<Probe>,
106    /// Overlaid onto the built-in environmentd liveness probe. See
107    /// `generation::environmentd_probe` for the overlay semantics.
108    pub environmentd_default_liveness_probe: Option<Probe>,
109    /// Overlaid onto the built-in environmentd probe to form the startup
110    /// probe. See `generation::environmentd_probe` for the overlay semantics.
111    pub environmentd_default_startup_probe: Option<Probe>,
112    /// The name of a `PriorityClass` to assign to environmentd pods, if any.
113    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    /// The name of a `PriorityClass` to assign to clusterd pods, if any.
118    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    /// Writes `status`, if it differs from the current status in anything but
193    /// its timestamps, and publishes the new `UpToDate` condition as an event
194    /// once it has been written.
195    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    /// Deletes `generation`'s resources, releasing the read holds its
236    /// environmentd was keeping.
237    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            // this is always a bug - we delay doing this check until the
310            // resource should have an environment id set, either from the
311            // license key, or explicitly given, or randomly defaulted.
312            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            // Updating the status should trigger a reconciliation
365            // which will include a status this time.
366            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                        // this makes it easier to use a license key in
405                        // development with no environment id set
406                        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            // Updating the spec should also trigger a reconciliation.
427            // We can't do that as part of the above check because you can't
428            // update both the spec and the status in a single api call.
429            return Ok(None);
430        }
431
432        if let Some(environment_id) = license_key_environment_id {
433            // we still allow a nil environment id in the license key to be
434            // accepted for any provided environment id, to support cloud
435            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        // we compare the hash against the environment resources generated
480        // for the current active generation, since that's what we expect to
481        // have been applied earlier, but we don't want to use these
482        // environment resources because when we apply them, we want to apply
483        // them with data that uses the new generation
484        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        // here we regenerate the environment resources using the
496        // same inputs except with an updated generation
497        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            // If we're in status promoting, we MUST promote now.
506            // We don't know if we successfully promoted or not yet.
507            (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            // There are changes pending, and we want to apply them.
520            (false, true, true) => {
521                // If a rollout has been in progress for longer than the
522                // configured timeout, cancel it. While a rollout is in
523                // progress the new generation runs un-promoted and holds back
524                // compaction via read holds; promoting it after a long delay
525                // can cause incident-inducing load, so we abort instead and
526                // let the user retry by requesting a fresh rollout.
527                //
528                // We never cancel a force-promoting rollout (including the
529                // `ImmediatelyPromoteCausingDowntime` strategy), because by
530                // then the previously-active generation may already be torn
531                // down, leaving nothing to fall back to.
532                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                            // Tear down the un-promoted generation to release
543                            // its read holds.
544                            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                                    // Mark this rollout request as completed so
559                                    // that we don't immediately retry it; the
560                                    // user must request a new rollout to try
561                                    // again.
562                                    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                // we remove the environment resources hash annotation here
634                // because if we fail halfway through applying the resources,
635                // things will be in an inconsistent state, and we don't want
636                // to allow the possibility of the user making a second
637                // change which reverts to the original state and then
638                // skipping retrying this apply, since that would leave
639                // things in a permanently inconsistent state.
640                // note that environment.spec will be empty here after
641                // replace_status, but this is fine because we already
642                // extracted all of the information we want from the spec
643                // earlier.
644                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                                // don't update the reconciliation id yet,
655                                // because the rollout hasn't yet completed. if
656                                // we fail later on, we want to ensure that the
657                                // rollout gets retried.
658                                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                    // The only reason someone would choose this strategy is if they didn't have
686                    // space for the two generations of pods.
687                    // Lets make room for the new ones by deleting the old generation.
688                    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                                        // Carry the `Applying` phase's
727                                        // timestamp forward (both phases are
728                                        // `Unknown`) so the rollout timeout
729                                        // spans Applying + ReadyToPromote
730                                        // rather than resetting here.
731                                        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                        // do this last, so that we keep traffic pointing at
748                        // the previous environmentd until the new one is
749                        // fully ready
750
751                        // Update the status before calling promote, so that we know
752                        // we've crossed the point of no return.
753                        // Once we see this status, we must promote without taking other actions.
754                        self.update_status(
755                            metadata,
756                            &mz_api,
757                            mz,
758                            MaterializeStatus {
759                                active_generation,
760                                // don't update the reconciliation id yet,
761                                // because the rollout hasn't yet completed. if
762                                // we fail later on, we want to ensure that the
763                                // rollout gets retried.
764                                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                                // also don't update the reconciliation id
804                                // here, because there was an error during
805                                // the rollout and we want to ensure it gets
806                                // retried.
807                                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            // There are changes pending, but we don't want to apply them yet.
834            (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            // No changes pending, but we might need to clean up a partially applied rollout.
871            (false, false, _) => {
872                // this can happen if we update the environment, but then revert
873                // that update before the update was deployed. in this case, we
874                // don't want the environment to still show up as
875                // WaitingForApproval.
876                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        // balancers rely on the environmentd service existing, which is
920        // enforced by the environmentd rollout process being able to call
921        // into the promotion endpoint
922
923        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        // and the console relies on the balancer service existing, which is
968        // enforced by wait_for_balancer
969
970        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
1039/// The type of the event reporting that the `UpToDate` condition became
1040/// `condition`: a warning if the environment is not up to date for any reason
1041/// other than waiting for a rollout to be approved, which is the operator
1042/// following its configuration.
1043fn 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}