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    pub enable_internal_statement_logging: bool,
92    pub statement_logging_max_sample_rate: Option<f64>,
93    pub statement_logging_target_data_rate: Option<usize>,
94
95    pub orchestratord_pod_selector_labels: Vec<KeyValueArg<String, String>>,
96    pub environmentd_node_selector: Vec<KeyValueArg<String, String>>,
97    pub environmentd_affinity: Option<Affinity>,
98    pub environmentd_tolerations: Option<Vec<Toleration>>,
99    pub environmentd_default_resources: Option<ResourceRequirements>,
100    /// Overlaid onto the built-in environmentd readiness probe. See
101    /// `generation::environmentd_probe` for the overlay semantics.
102    pub environmentd_default_readiness_probe: Option<Probe>,
103    /// Overlaid onto the built-in environmentd liveness probe. See
104    /// `generation::environmentd_probe` for the overlay semantics.
105    pub environmentd_default_liveness_probe: Option<Probe>,
106    /// Overlaid onto the built-in environmentd probe to form the startup
107    /// probe. See `generation::environmentd_probe` for the overlay semantics.
108    pub environmentd_default_startup_probe: Option<Probe>,
109    /// The name of a `PriorityClass` to assign to environmentd pods, if any.
110    pub environmentd_priority_class_name: Option<String>,
111    pub clusterd_node_selector: Vec<KeyValueArg<String, String>>,
112    pub clusterd_affinity: Option<Affinity>,
113    pub clusterd_tolerations: Option<Vec<Toleration>>,
114    /// The name of a `PriorityClass` to assign to clusterd pods, if any.
115    pub clusterd_priority_class_name: Option<String>,
116    pub image_pull_policy: KubernetesImagePullPolicy,
117    pub network_policies_internal_enabled: bool,
118    pub network_policies_ingress_enabled: bool,
119    pub network_policies_ingress_cidrs: Vec<String>,
120    pub network_policies_egress_enabled: bool,
121    pub network_policies_egress_cidrs: Vec<String>,
122
123    pub environmentd_cluster_replica_sizes: Option<String>,
124    pub bootstrap_default_cluster_replica_size: Option<String>,
125    pub bootstrap_builtin_system_cluster_replica_size: Option<String>,
126    pub bootstrap_builtin_probe_cluster_replica_size: Option<String>,
127    pub bootstrap_builtin_support_cluster_replica_size: Option<String>,
128    pub bootstrap_builtin_catalog_server_cluster_replica_size: Option<String>,
129    pub bootstrap_builtin_analytics_cluster_replica_size: Option<String>,
130    pub bootstrap_builtin_system_cluster_replication_factor: Option<u32>,
131    pub bootstrap_builtin_probe_cluster_replication_factor: Option<u32>,
132    pub bootstrap_builtin_support_cluster_replication_factor: Option<u32>,
133    pub bootstrap_builtin_analytics_cluster_replication_factor: Option<u32>,
134
135    pub environmentd_allowed_origins: Vec<HeaderValue>,
136    pub internal_console_proxy_url: String,
137
138    pub environmentd_sql_port: u16,
139    pub environmentd_http_port: u16,
140    pub environmentd_internal_sql_port: u16,
141    pub environmentd_internal_http_port: u16,
142    pub environmentd_internal_persist_pubsub_port: u16,
143
144    pub default_certificate_specs: DefaultCertificateSpecs,
145
146    pub disable_license_key_checks: bool,
147    pub disable_database_network_policies: bool,
148
149    pub tracing: TracingCliArgs,
150    pub orchestratord_namespace: String,
151
152    pub teleport: Option<teleport::TeleportConfig>,
153}
154
155pub struct Context {
156    config: Config,
157    metrics: Arc<Metrics>,
158    needs_update: Arc<Mutex<BTreeSet<String>>>,
159}
160
161impl Context {
162    pub fn new(config: Config, metrics: Arc<Metrics>) -> Self {
163        if config.cloud_provider == CloudProvider::Aws {
164            assert!(
165                config.aws_account_id.is_some(),
166                "--aws-account-id is required when using --cloud-provider=aws"
167            );
168        }
169
170        Self {
171            config,
172            metrics,
173            needs_update: Default::default(),
174        }
175    }
176
177    fn set_needs_update(&self, mz: &Materialize, needs_update: bool) {
178        let mut needs_update_set = self.needs_update.lock().unwrap();
179        if needs_update {
180            needs_update_set.insert(mz.name_unchecked());
181        } else {
182            needs_update_set.remove(&mz.name_unchecked());
183        }
184        self.metrics
185            .environmentd_needs_update
186            .set(u64::cast_from(needs_update_set.len()));
187    }
188
189    /// Writes `status`, if it differs from the current status in anything but
190    /// its timestamps, and publishes the new `UpToDate` condition as an event
191    /// once it has been written.
192    async fn update_status(
193        &self,
194        metadata: &TraceMetadata,
195        mz_api: &Api<Materialize>,
196        mz: &Materialize,
197        status: MaterializeStatus,
198        needs_update: bool,
199    ) -> Result<Materialize, kube::Error> {
200        self.set_needs_update(mz, needs_update);
201
202        let mut new_mz = mz.clone();
203        if !mz
204            .status
205            .as_ref()
206            .map_or(true, |mz_status| mz_status.needs_update(&status))
207        {
208            return Ok(new_mz);
209        }
210
211        let condition = status.conditions.first().cloned();
212        new_mz.status = Some(status);
213        let new_mz = mz_api
214            .replace_status(&mz.name_unchecked(), &PostParams::default(), &new_mz)
215            .await?;
216
217        if let Some(condition) = condition {
218            metadata
219                .publish_event(Event {
220                    type_: transition_event_type(&condition),
221                    reason: condition.reason,
222                    action: "Reconcile".into(),
223                    note: Some(condition.message),
224                    related: None,
225                })
226                .await;
227        }
228
229        Ok(new_mz)
230    }
231
232    /// Deletes `generation`'s resources, releasing the read holds its
233    /// environmentd was keeping.
234    async fn teardown_generation(
235        &self,
236        metadata: &TraceMetadata,
237        client: &Client,
238        mz: &Materialize,
239        resources: &generation::Resources,
240        generation: u64,
241    ) -> Result<(), Error> {
242        let step = metadata.step("teardown_generation");
243        resources
244            .teardown_generation(client, mz, generation)
245            .await?;
246        step.finish(Outcome::Completed);
247        Ok(())
248    }
249
250    async fn promote(
251        &self,
252        metadata: &TraceMetadata,
253        client: &Client,
254        mz: &Materialize,
255        resources: generation::Resources,
256        active_generation: u64,
257        desired_generation: u64,
258        resources_hash: String,
259    ) -> Result<Option<Action>, Error> {
260        let step = metadata.step("promote");
261        if let Some(action) = resources.promote_services(client, &mz.namespace()).await? {
262            step.finish(Outcome::Waiting);
263            return Ok(Some(action));
264        }
265        step.finish(Outcome::Completed);
266
267        self.teardown_generation(metadata, client, mz, &resources, active_generation)
268            .await?;
269        let mz_api: Api<Materialize> = Api::namespaced(client.clone(), &mz.namespace());
270        self.update_status(
271            metadata,
272            &mz_api,
273            mz,
274            MaterializeStatus {
275                active_generation: desired_generation,
276                last_completed_rollout_request: mz.requested_reconciliation_id(),
277                last_completed_rollout_environmentd_image_ref: Some(
278                    mz.spec.environmentd_image_ref.clone(),
279                ),
280                resource_id: mz.status().resource_id,
281                resources_hash,
282                last_completed_rollout_hash: None,
283                conditions: vec![Condition {
284                    type_: "UpToDate".into(),
285                    status: "True".into(),
286                    last_transition_time: Time(Timestamp::now()),
287                    message: format!(
288                        "Successfully applied changes for generation {desired_generation}"
289                    ),
290                    observed_generation: mz.meta().generation,
291                    reason: "Applied".into(),
292                }],
293            },
294            false,
295        )
296        .await?;
297        Ok(None)
298    }
299
300    async fn check_environment_id_conflicts(
301        &self,
302        client: &Client,
303        mz: &Materialize,
304    ) -> Result<(), Error> {
305        if mz.spec.environment_id.is_nil() {
306            // this is always a bug - we delay doing this check until the
307            // resource should have an environment id set, either from the
308            // license key, or explicitly given, or randomly defaulted.
309            return Err(Error::Anyhow(anyhow::anyhow!(
310                "trying to reconcile a materialize resource with no environment id - this is a bug!"
311            )));
312        }
313
314        let mz_api: Api<Materialize> = Api::all(client.clone());
315        let all_mz = mz_api.list(&ListParams::default()).await?;
316        for existing_mz in &all_mz.items {
317            if existing_mz.spec.environment_id == mz.spec.environment_id
318                && existing_mz.metadata.uid != mz.metadata.uid
319            {
320                return Err(Error::Anyhow(anyhow::anyhow!(
321                    "Materialize resources {}/{} and {}/{} have the environmentId field set to the same value. This field must be unique across environments.",
322                    mz.namespace(),
323                    mz.name_unchecked(),
324                    existing_mz.namespace(),
325                    existing_mz.name_unchecked(),
326                )));
327            }
328        }
329
330        Ok(())
331    }
332}
333
334#[async_trait::async_trait]
335impl k8s_controller::Context for Context {
336    type Resource = Materialize;
337    type Error = Error;
338
339    const FINALIZER_NAME: Option<&'static str> =
340        Some("orchestratord.materialize.cloud/materialize");
341
342    #[instrument(fields(organization_name=mz.name_unchecked()))]
343    async fn apply(
344        &self,
345        client: Client,
346        mz: &Self::Resource,
347        metadata: &mut TraceMetadata,
348    ) -> Result<Option<Action>, Self::Error> {
349        let metadata = &*metadata;
350        let mz_api: Api<Materialize> = Api::namespaced(client.clone(), &mz.namespace());
351        let balancer_api: Api<Balancer> = Api::namespaced(client.clone(), &mz.namespace());
352        let console_api: Api<Console> = Api::namespaced(client.clone(), &mz.namespace());
353        let secret_api: Api<Secret> = Api::namespaced(client.clone(), &mz.namespace());
354
355        let status = mz.status();
356        if mz.status.is_none() {
357            let step = metadata.step("initialize_status");
358            self.update_status(metadata, &mz_api, mz, status, true)
359                .await?;
360            step.finish(Outcome::Completed);
361            // Updating the status should trigger a reconciliation
362            // which will include a status this time.
363            return Ok(None);
364        }
365
366        let step = metadata.step("resolve_environment_id");
367        let backend_secret = secret_api.get(&mz.spec.backend_secret_name).await?;
368        let license_key_environment_id: Option<Uuid> = if let Some(license_key) = backend_secret
369            .data
370            .as_ref()
371            .and_then(|data| data.get("license_key"))
372        {
373            let license_key = validate(
374                str::from_utf8(&license_key.0)
375                    .context("invalid utf8")?
376                    .trim(),
377            )?;
378            let environment_id = license_key
379                .environment_id
380                .parse()
381                .context("invalid environment id in license key")?;
382            Some(environment_id)
383        } else {
384            if mz.meets_minimum_version(&V161) {
385                return Err(Error::Anyhow(anyhow::anyhow!(
386                    "license_key is required when running in kubernetes",
387                )));
388            } else {
389                None
390            }
391        };
392
393        if mz.spec.request_rollout.is_nil() || mz.spec.environment_id.is_nil() {
394            let mut mz = mz.clone();
395            if mz.spec.request_rollout.is_nil() {
396                mz.spec.request_rollout = Uuid::new_v4();
397            }
398            if mz.spec.environment_id.is_nil() {
399                if let Some(environment_id) = license_key_environment_id {
400                    if environment_id.is_nil() {
401                        // this makes it easier to use a license key in
402                        // development with no environment id set
403                        mz.spec.environment_id = Uuid::new_v4();
404                    } else {
405                        mz.spec.environment_id = environment_id;
406                    }
407                } else {
408                    if mz.meets_minimum_version(&V161) {
409                        return Err(Error::Anyhow(anyhow::anyhow!(
410                            "environmentId is not set in materialize resource {}/{} but no license key was given",
411                            mz.namespace(),
412                            mz.name_unchecked()
413                        )));
414                    } else {
415                        mz.spec.environment_id = Uuid::new_v4();
416                    }
417                }
418            }
419            mz_api
420                .replace(&mz.name_unchecked(), &PostParams::default(), &mz)
421                .await?;
422            step.finish(Outcome::Completed);
423            // Updating the spec should also trigger a reconciliation.
424            // We can't do that as part of the above check because you can't
425            // update both the spec and the status in a single api call.
426            return Ok(None);
427        }
428
429        if let Some(environment_id) = license_key_environment_id {
430            // we still allow a nil environment id in the license key to be
431            // accepted for any provided environment id, to support cloud
432            if !environment_id.is_nil() && mz.spec.environment_id != environment_id {
433                return Err(Error::Anyhow(anyhow::anyhow!(
434                    "environment_id is set in materialize resource {}/{} but does not match the environment_id set in the associated license key {}",
435                    mz.namespace(),
436                    mz.name_unchecked(),
437                    environment_id,
438                )));
439            }
440        }
441
442        for (field, probe) in [
443            (
444                "environmentdReadinessProbe",
445                &mz.spec.environmentd_readiness_probe,
446            ),
447            (
448                "environmentdLivenessProbe",
449                &mz.spec.environmentd_liveness_probe,
450            ),
451            (
452                "environmentdStartupProbe",
453                &mz.spec.environmentd_startup_probe,
454            ),
455        ] {
456            if let Some(probe) = probe {
457                generation::validate_probe_handlers(probe).map_err(|e| {
458                    Error::Anyhow(anyhow::anyhow!(
459                        "invalid {field} in materialize resource {}/{}: {e}",
460                        mz.namespace(),
461                        mz.name_unchecked(),
462                    ))
463                })?;
464            }
465        }
466
467        self.check_environment_id_conflicts(&client, mz).await?;
468        step.finish(Outcome::Completed);
469
470        let step = metadata.step("global_resources");
471        global::Resources::new(&self.config, mz)?
472            .apply(&client, &mz.namespace())
473            .await?;
474        step.finish(Outcome::Completed);
475
476        // we compare the hash against the environment resources generated
477        // for the current active generation, since that's what we expect to
478        // have been applied earlier, but we don't want to use these
479        // environment resources because when we apply them, we want to apply
480        // them with data that uses the new generation
481        let active_resources =
482            generation::Resources::new(&self.config, mz, status.active_generation);
483        let has_current_changes = status.resources_hash != active_resources.generate_hash();
484        let active_generation = status.active_generation;
485        let next_generation = active_generation + 1;
486        let desired_generation = if has_current_changes {
487            next_generation
488        } else {
489            active_generation
490        };
491
492        // here we regenerate the environment resources using the
493        // same inputs except with an updated generation
494        let resources = generation::Resources::new(&self.config, mz, desired_generation);
495        let resources_hash = resources.generate_hash();
496
497        let mut result = match (
498            mz.is_promoting(),
499            has_current_changes,
500            mz.rollout_requested(),
501        ) {
502            // If we're in status promoting, we MUST promote now.
503            // We don't know if we successfully promoted or not yet.
504            (true, _, _) => {
505                self.promote(
506                    metadata,
507                    &client,
508                    mz,
509                    resources,
510                    active_generation,
511                    desired_generation,
512                    resources_hash,
513                )
514                .await
515            }
516            // There are changes pending, and we want to apply them.
517            (false, true, true) => {
518                // If a rollout has been in progress for longer than the
519                // configured timeout, cancel it. While a rollout is in
520                // progress the new generation runs un-promoted and holds back
521                // compaction via read holds; promoting it after a long delay
522                // can cause incident-inducing load, so we abort instead and
523                // let the user retry by requesting a fresh rollout.
524                //
525                // We never cancel a force-promoting rollout (including the
526                // `ImmediatelyPromoteCausingDowntime` strategy), because by
527                // then the previously-active generation may already be torn
528                // down, leaving nothing to fall back to.
529                if !mz.should_force_promote() {
530                    if let Some(started) = mz.rollout_in_progress_since() {
531                        let timeout = mz.rollout_request_timeout();
532                        let elapsed = Timestamp::now().duration_since(started);
533                        let timed_out = SignedDuration::try_from(timeout)
534                            .is_ok_and(|timeout| elapsed >= timeout);
535                        if timed_out {
536                            warn!(
537                                "rollout to generation {desired_generation} exceeded timeout, cancelling"
538                            );
539                            // Tear down the un-promoted generation to release
540                            // its read holds.
541                            self.teardown_generation(
542                                metadata,
543                                &client,
544                                mz,
545                                &resources,
546                                next_generation,
547                            )
548                            .await?;
549                            self.update_status(
550                                metadata,
551                                &mz_api,
552                                mz,
553                                MaterializeStatus {
554                                    active_generation,
555                                    // Mark this rollout request as completed so
556                                    // that we don't immediately retry it; the
557                                    // user must request a new rollout to try
558                                    // again.
559                                    last_completed_rollout_request: mz
560                                        .requested_reconciliation_id(),
561                                    last_completed_rollout_environmentd_image_ref: status
562                                        .last_completed_rollout_environmentd_image_ref
563                                        .clone(),
564                                    resource_id: status.resource_id.clone(),
565                                    resources_hash: status.resources_hash.clone(),
566                                    last_completed_rollout_hash: None,
567                                    conditions: vec![Condition {
568                                        type_: "UpToDate".into(),
569                                        status: "False".into(),
570                                        last_transition_time: Time(Timestamp::now()),
571                                        message: format!(
572                                            "Cancelled rollout to generation \
573                                             {desired_generation} after it \
574                                             exceeded the rollout timeout of {}",
575                                            humantime::format_duration(timeout),
576                                        ),
577                                        observed_generation: mz.meta().generation,
578                                        reason: "RolloutTimeout".into(),
579                                    }],
580                                },
581                                active_generation != desired_generation,
582                            )
583                            .await?;
584                            return Ok(None);
585                        }
586                    }
587                }
588
589                if !mz.within_upgrade_window() {
590                    let last_completed_rollout_environmentd_image_ref =
591                        status.last_completed_rollout_environmentd_image_ref;
592
593                    self.update_status(
594                        metadata,
595                        &mz_api,
596                        mz,
597                        MaterializeStatus {
598                            active_generation,
599                            last_completed_rollout_request: status.last_completed_rollout_request,
600                            last_completed_rollout_environmentd_image_ref:
601                                last_completed_rollout_environmentd_image_ref.clone(),
602                            resource_id: status.resource_id,
603                            resources_hash: status.resources_hash,
604                            last_completed_rollout_hash: None,
605                            conditions: vec![Condition {
606                                type_: "UpToDate".into(),
607                                status: "False".into(),
608                                last_transition_time: Time(Timestamp::now()),
609                                message: format!(
610                                    "Refusing to upgrade from {} to {}. \
611                                     More than one major version from \
612                                     last successful rollout. If coming \
613                                     from Self Managed 25.2, upgrade to \
614                                     materialize/environmentd:v0.147.20 \
615                                     first.",
616                                    last_completed_rollout_environmentd_image_ref
617                                        .expect("should be set if upgrade window check fails"),
618                                    mz.spec.environmentd_image_ref,
619                                ),
620                                observed_generation: mz.meta().generation,
621                                reason: "FailedDeploy".into(),
622                            }],
623                        },
624                        active_generation != desired_generation,
625                    )
626                    .await?;
627                    return Ok(None);
628                }
629
630                // we remove the environment resources hash annotation here
631                // because if we fail halfway through applying the resources,
632                // things will be in an inconsistent state, and we don't want
633                // to allow the possibility of the user making a second
634                // change which reverts to the original state and then
635                // skipping retrying this apply, since that would leave
636                // things in a permanently inconsistent state.
637                // note that environment.spec will be empty here after
638                // replace_status, but this is fine because we already
639                // extracted all of the information we want from the spec
640                // earlier.
641                let mz = if mz.is_ready_to_promote(&resources_hash) {
642                    mz
643                } else {
644                    &self
645                        .update_status(
646                            metadata,
647                            &mz_api,
648                            mz,
649                            MaterializeStatus {
650                                active_generation,
651                                // don't update the reconciliation id yet,
652                                // because the rollout hasn't yet completed. if
653                                // we fail later on, we want to ensure that the
654                                // rollout gets retried.
655                                last_completed_rollout_request: status
656                                    .last_completed_rollout_request,
657                                last_completed_rollout_environmentd_image_ref: status
658                                    .last_completed_rollout_environmentd_image_ref,
659                                resource_id: status.resource_id.clone(),
660                                resources_hash: String::new(),
661                                last_completed_rollout_hash: None,
662                                conditions: vec![Condition {
663                                    type_: "UpToDate".into(),
664                                    status: "Unknown".into(),
665                                    last_transition_time: Time(Timestamp::now()),
666                                    message: format!(
667                                        "Applying changes for generation {desired_generation}"
668                                    ),
669                                    observed_generation: mz.meta().generation,
670                                    reason: "Applying".into(),
671                                }],
672                            },
673                            active_generation != desired_generation,
674                        )
675                        .await?
676                };
677                let status = mz.status();
678
679                if mz.spec.rollout_strategy
680                    == MaterializeRolloutStrategy::ImmediatelyPromoteCausingDowntime
681                {
682                    // The only reason someone would choose this strategy is if they didn't have
683                    // space for the two generations of pods.
684                    // Lets make room for the new ones by deleting the old generation.
685                    self.teardown_generation(metadata, &client, mz, &resources, active_generation)
686                        .await?;
687                }
688
689                trace!("applying environment resources");
690                let step = metadata.step("generation_resources");
691                let applied = resources
692                    .apply(&client, mz.should_force_promote(), &mz.namespace())
693                    .await;
694                step.finish_with(&applied);
695                match applied {
696                    Ok(Some(action)) => {
697                        trace!("new environment is not yet ready");
698                        Ok(Some(action))
699                    }
700                    Ok(None) => {
701                        if mz.spec.rollout_strategy == MaterializeRolloutStrategy::ManuallyPromote
702                            && !mz.should_force_promote()
703                        {
704                            trace!(
705                                "Ready to promote, but not promoting because the instance is configured with ManuallyPromote rollout strategy."
706                            );
707                            self.update_status(
708                                metadata,
709                                &mz_api,
710                                mz,
711                                MaterializeStatus {
712                                    active_generation,
713                                    last_completed_rollout_request: status
714                                        .last_completed_rollout_request,
715                                    last_completed_rollout_environmentd_image_ref: status
716                                        .last_completed_rollout_environmentd_image_ref,
717                                    resource_id: status.resource_id,
718                                    resources_hash,
719                                    last_completed_rollout_hash: None,
720                                    conditions: vec![Condition {
721                                        type_: "UpToDate".into(),
722                                        status: "Unknown".into(),
723                                        // Carry the `Applying` phase's
724                                        // timestamp forward (both phases are
725                                        // `Unknown`) so the rollout timeout
726                                        // spans Applying + ReadyToPromote
727                                        // rather than resetting here.
728                                        last_transition_time: Time(mz.up_to_date_transition_time(
729                                            "Unknown",
730                                            Timestamp::now(),
731                                        )),
732                                        message: format!(
733                                            "Ready to promote generation {desired_generation}"
734                                        ),
735                                        observed_generation: mz.meta().generation,
736                                        reason: "ReadyToPromote".into(),
737                                    }],
738                                },
739                                active_generation != desired_generation,
740                            )
741                            .await?;
742                            return Ok(None);
743                        }
744                        // do this last, so that we keep traffic pointing at
745                        // the previous environmentd until the new one is
746                        // fully ready
747
748                        // Update the status before calling promote, so that we know
749                        // we've crossed the point of no return.
750                        // Once we see this status, we must promote without taking other actions.
751                        self.update_status(
752                            metadata,
753                            &mz_api,
754                            mz,
755                            MaterializeStatus {
756                                active_generation,
757                                // don't update the reconciliation id yet,
758                                // because the rollout hasn't yet completed. if
759                                // we fail later on, we want to ensure that the
760                                // rollout gets retried.
761                                last_completed_rollout_request: status
762                                    .last_completed_rollout_request,
763                                last_completed_rollout_environmentd_image_ref: status
764                                    .last_completed_rollout_environmentd_image_ref,
765                                resource_id: status.resource_id,
766                                resources_hash: resources_hash.clone(),
767                                last_completed_rollout_hash: None,
768                                conditions: vec![Condition {
769                                    type_: "UpToDate".into(),
770                                    status: "Unknown".into(),
771                                    last_transition_time: Time(Timestamp::now()),
772                                    message: format!(
773                                        "Attempting to promote generation {desired_generation}"
774                                    ),
775                                    observed_generation: mz.meta().generation,
776                                    reason: "Promoting".into(),
777                                }],
778                            },
779                            active_generation != desired_generation,
780                        )
781                        .await?;
782                        self.promote(
783                            metadata,
784                            &client,
785                            mz,
786                            resources,
787                            active_generation,
788                            desired_generation,
789                            resources_hash,
790                        )
791                        .await
792                    }
793                    Err(e) => {
794                        self.update_status(
795                            metadata,
796                            &mz_api,
797                            mz,
798                            MaterializeStatus {
799                                active_generation,
800                                // also don't update the reconciliation id
801                                // here, because there was an error during
802                                // the rollout and we want to ensure it gets
803                                // retried.
804                                last_completed_rollout_request: status
805                                    .last_completed_rollout_request,
806                                last_completed_rollout_environmentd_image_ref: status
807                                    .last_completed_rollout_environmentd_image_ref,
808                                resource_id: status.resource_id,
809                                resources_hash: status.resources_hash,
810                                last_completed_rollout_hash: None,
811                                conditions: vec![Condition {
812                                    type_: "UpToDate".into(),
813                                    status: "False".into(),
814                                    last_transition_time: Time(Timestamp::now()),
815                                    message: format!(
816                                        "Failed to apply changes for \
817                                         generation {desired_generation}: {e}"
818                                    ),
819                                    observed_generation: mz.meta().generation,
820                                    reason: "FailedDeploy".into(),
821                                }],
822                            },
823                            active_generation != desired_generation,
824                        )
825                        .await?;
826                        Err(e)
827                    }
828                }
829            }
830            // There are changes pending, but we don't want to apply them yet.
831            (false, true, false) => {
832                let mut needs_update = mz.conditions_need_update();
833                if mz.update_in_progress() {
834                    self.teardown_generation(metadata, &client, mz, &resources, next_generation)
835                        .await?;
836                    needs_update = true;
837                }
838                if needs_update {
839                    self.update_status(metadata, &mz_api,
840                        mz,
841                        MaterializeStatus {
842                            active_generation,
843                            last_completed_rollout_request: mz.requested_reconciliation_id(),
844                            last_completed_rollout_environmentd_image_ref: status
845                                .last_completed_rollout_environmentd_image_ref,
846                            resource_id: status.resource_id.clone(),
847                            resources_hash: status.resources_hash,
848                            last_completed_rollout_hash: None,
849                            conditions: vec![Condition {
850                                type_: "UpToDate".into(),
851                                status: "False".into(),
852                                last_transition_time: Time(Timestamp::now()),
853                                message: format!(
854                                    "Changes detected, waiting for approval for generation {desired_generation}"
855                                ),
856                                observed_generation: mz.meta().generation,
857                                reason: "WaitingForApproval".into(),
858                            }],
859                        },
860                        active_generation != desired_generation,
861                    )
862                    .await?;
863                }
864                debug!("changes detected, waiting for approval");
865                Ok(None)
866            }
867            // No changes pending, but we might need to clean up a partially applied rollout.
868            (false, false, _) => {
869                // this can happen if we update the environment, but then revert
870                // that update before the update was deployed. in this case, we
871                // don't want the environment to still show up as
872                // WaitingForApproval.
873                let mut needs_update = mz.conditions_need_update() || mz.rollout_requested();
874                if mz.update_in_progress() {
875                    self.teardown_generation(metadata, &client, mz, &resources, next_generation)
876                        .await?;
877                    needs_update = true;
878                }
879                if needs_update {
880                    self.update_status(
881                        metadata,
882                        &mz_api,
883                        mz,
884                        MaterializeStatus {
885                            active_generation,
886                            last_completed_rollout_request: mz.requested_reconciliation_id(),
887                            last_completed_rollout_environmentd_image_ref: status
888                                .last_completed_rollout_environmentd_image_ref,
889                            resource_id: status.resource_id.clone(),
890                            resources_hash: status.resources_hash,
891                            last_completed_rollout_hash: None,
892                            conditions: vec![Condition {
893                                type_: "UpToDate".into(),
894                                status: "True".into(),
895                                last_transition_time: Time(Timestamp::now()),
896                                message: format!(
897                                    "No changes found from generation {active_generation}"
898                                ),
899                                observed_generation: mz.meta().generation,
900                                reason: "Applied".into(),
901                            }],
902                        },
903                        active_generation != desired_generation,
904                    )
905                    .await?;
906                }
907                debug!("no changes");
908                Ok(None)
909            }
910        }?;
911
912        if let Some(action) = result {
913            return Ok(Some(action));
914        }
915
916        // balancers rely on the environmentd service existing, which is
917        // enforced by the environmentd rollout process being able to call
918        // into the promotion endpoint
919
920        let step = metadata.step("balancer");
921        if self.config.create_balancers {
922            let balancer = Balancer {
923                metadata: mz.managed_resource_meta(mz.name_unchecked()),
924                spec: BalancerSpec {
925                    balancerd_image_ref: matching_image_from_environmentd_image_ref(
926                        mz.active_environmentd_image_ref(),
927                        "balancerd",
928                        None,
929                    ),
930                    resource_requirements: mz.spec.balancerd_resource_requirements.clone(),
931                    configmap_name: mz.spec.balancerd_configmap_name.clone(),
932                    replicas: Some(mz.balancerd_replicas()),
933                    externally_scaled: mz.spec.balancerd_externally_scaled,
934                    external_certificate_spec: mz.spec.balancerd_external_certificate_spec.clone(),
935                    internal_certificate_spec: mz.spec.internal_certificate_spec.clone(),
936                    pod_annotations: mz.spec.pod_annotations.clone(),
937                    pod_labels: mz.spec.pod_labels.clone(),
938                    static_routing: Some(
939                        mz_cloud_resources::crd::balancer::v1alpha1::StaticRoutingConfig {
940                            environmentd_namespace: mz.namespace(),
941                            environmentd_service_name: mz.environmentd_service_name(),
942                        },
943                    ),
944                    frontegg_routing: None,
945                    resource_id: Some(status.resource_id.clone()),
946                },
947                status: None,
948            };
949            let balancer = apply_resource(&balancer_api, &balancer).await?;
950            result = wait_for_balancer(&balancer)?;
951            step.finish(match result {
952                Some(_) => Outcome::Waiting,
953                None => Outcome::Completed,
954            });
955        } else {
956            delete_resource(&balancer_api, &mz.name_unchecked()).await?;
957            step.finish(Outcome::Skipped);
958        }
959
960        if let Some(action) = result {
961            return Ok(Some(action));
962        }
963
964        // and the console relies on the balancer service existing, which is
965        // enforced by wait_for_balancer
966
967        let step = metadata.step("console");
968        if self.config.create_console {
969            let active_environmentd_image_ref = mz.active_environmentd_image_ref();
970            let environmentd_image_tag =
971                parse_image_tag(active_environmentd_image_ref).unwrap_or("latest");
972            let console_image_tag = self
973                .config
974                .console_image_tag_map
975                .iter()
976                .find(|kv| kv.key == environmentd_image_tag)
977                .map(|kv| kv.value.clone())
978                .unwrap_or_else(|| self.config.console_image_tag_default.clone());
979            let console = Console {
980                metadata: mz.managed_resource_meta(mz.name_unchecked()),
981                spec: ConsoleSpec {
982                    console_image_ref: matching_image_from_environmentd_image_ref(
983                        active_environmentd_image_ref,
984                        "console",
985                        Some(&console_image_tag),
986                    ),
987                    resource_requirements: mz.spec.console_resource_requirements.clone(),
988                    replicas: Some(mz.console_replicas()),
989                    external_certificate_spec: mz.spec.console_external_certificate_spec.clone(),
990                    pod_annotations: mz.spec.pod_annotations.clone(),
991                    pod_labels: mz.spec.pod_labels.clone(),
992                    balancerd: BalancerdRef {
993                        service_name: mz.balancerd_service_name(),
994                        namespace: mz.namespace(),
995                        scheme: if issuer_ref_defined(
996                            &self.config.default_certificate_specs.balancerd_external,
997                            &mz.spec.balancerd_external_certificate_spec,
998                        ) {
999                            HttpConnectionScheme::Https
1000                        } else {
1001                            HttpConnectionScheme::Http
1002                        },
1003                        dns_names: resolved_dns_names(
1004                            &self.config.default_certificate_specs.balancerd_external,
1005                            &mz.spec.balancerd_external_certificate_spec,
1006                        ),
1007                    },
1008                    authenticator_kind: mz.spec.authenticator_kind,
1009                    resource_id: Some(status.resource_id),
1010                },
1011                status: None,
1012            };
1013            apply_resource(&console_api, &console).await?;
1014            step.finish(Outcome::Completed);
1015        } else {
1016            delete_resource(&console_api, &mz.name_unchecked()).await?;
1017            step.finish(Outcome::Skipped);
1018        }
1019
1020        Ok(result)
1021    }
1022
1023    #[instrument(fields(organization_name=mz.name_unchecked()))]
1024    async fn cleanup(
1025        &self,
1026        _client: Client,
1027        mz: &Self::Resource,
1028        _metadata: &mut TraceMetadata,
1029    ) -> Result<Option<Action>, Self::Error> {
1030        self.set_needs_update(mz, false);
1031
1032        Ok(None)
1033    }
1034}
1035
1036/// The type of the event reporting that the `UpToDate` condition became
1037/// `condition`: a warning if the environment is not up to date for any reason
1038/// other than waiting for a rollout to be approved, which is the operator
1039/// following its configuration.
1040fn transition_event_type(condition: &Condition) -> EventType {
1041    match condition.reason.as_str() {
1042        "WaitingForApproval" => EventType::Normal,
1043        _ if condition.status == "False" => EventType::Warning,
1044        _ => EventType::Normal,
1045    }
1046}
1047
1048fn wait_for_balancer(balancer: &Balancer) -> Result<Option<Action>, Error> {
1049    if let Some(conditions) = balancer
1050        .status
1051        .as_ref()
1052        .map(|status| status.conditions.as_slice())
1053    {
1054        if conditions
1055            .iter()
1056            .any(|condition| condition.type_ == "Ready" && condition.status == "True")
1057        {
1058            return Ok(None);
1059        }
1060    }
1061
1062    Ok(Some(Action::requeue(Duration::from_secs(1))))
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068
1069    fn condition(status: &str, reason: &str) -> Condition {
1070        Condition {
1071            type_: "UpToDate".into(),
1072            status: status.into(),
1073            reason: reason.into(),
1074            message: String::new(),
1075            last_transition_time: Time(Timestamp::now()),
1076            observed_generation: None,
1077        }
1078    }
1079
1080    #[mz_ore::test]
1081    fn test_transition_event_type() {
1082        for (status, reason, expected) in [
1083            ("True", "Applied", EventType::Normal),
1084            ("Unknown", "Applying", EventType::Normal),
1085            ("Unknown", "ReadyToPromote", EventType::Normal),
1086            ("Unknown", "Promoting", EventType::Normal),
1087            ("False", "WaitingForApproval", EventType::Normal),
1088            ("False", "FailedDeploy", EventType::Warning),
1089            ("False", "RolloutTimeout", EventType::Warning),
1090            ("False", "SomeFutureFailure", EventType::Warning),
1091            ("Unknown", "SomeFuturePhase", EventType::Normal),
1092        ] {
1093            assert_eq!(
1094                transition_event_type(&condition(status, reason)),
1095                expected,
1096                "status={status} reason={reason}",
1097            );
1098        }
1099    }
1100}