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