Skip to main content

mz_cloud_resources/crd/
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
10// The doc comments here become the descriptions in the generated CRD, which the
11// docs site renders through Hugo, so they contain shortcodes like
12// `{{<warning>}}`. Rustdoc reads the inner `<warning>` as an HTML tag and
13// reports it unclosed whenever a shortcode spans more than one Markdown
14// paragraph.
15#![allow(rustdoc::invalid_html_tags)]
16
17use std::collections::BTreeMap;
18use std::time::Duration;
19
20use k8s_openapi::{
21    api::core::v1::{EnvVar, ResourceRequirements},
22    apimachinery::pkg::{
23        api::resource::Quantity,
24        apis::meta::v1::{Condition, Time},
25    },
26    jiff::Timestamp,
27};
28use kube::{CustomResource, Resource, ResourceExt};
29use schemars::JsonSchema;
30use semver::Version;
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33use uuid::Uuid;
34
35use crate::crd::{ManagedResource, MaterializeCertSpec, new_resource_id};
36use mz_server_core::listeners::AuthenticatorKind;
37
38pub const LAST_KNOWN_ACTIVE_GENERATION_ANNOTATION: &str =
39    "materialize.cloud/last-known-active-generation";
40pub const FORCE_ROLLOUT_ANNOTATION: &str = "materialize.cloud/force-rollout";
41
42#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize, JsonSchema)]
43pub enum MaterializeRolloutStrategy {
44    /// Create a new generation of pods, leaving the old generation around until the
45    /// new ones are ready to take over.
46    /// This minimizes downtime, and is what almost everyone should use.
47    #[default]
48    WaitUntilReady,
49
50    /// Create a new generation of pods, leaving the old generation as the serving generation
51    /// until the user manually promotes the new generation.
52    ///
53    /// When using `ManuallyPromote`, the new generation can be promoted at any
54    /// time, even if it has dataflows that are not fully caught up, by setting
55    /// `forcePromote` to the current rollout identifier: in `v1`, the value of
56    /// `status.requestedRolloutHash`; in `v1alpha1`, the `requestRollout` value
57    /// in the spec.
58    ///
59    /// To minimize downtime, promotion should occur when the new generation
60    /// has caught up to the prior generation. To determine if the new
61    /// generation has caught up, consult the `UpToDate` condition in the
62    /// status of the Materialize Resource. If the condition's reason is
63    /// `ReadyToPromote` the new generation is ready to promote.
64    ///
65    /// {{<warning>}}
66    /// Do not leave new generations unpromoted indefinitely.
67    ///
68    /// The new generation keeps open read holds which prevent compaction. Once promoted or
69    /// cancelled, those read holds are released. If left unpromoted for an extended time, this
70    /// data can build up, and can cause extreme deletion load on the metadata backend database
71    /// when finally promoted or cancelled.
72    ///
73    /// To guard against this, a rollout that remains in progress longer
74    /// than `rolloutRequestTimeout` (default 24h) is automatically
75    /// cancelled.
76    /// {{</warning>}}
77    ManuallyPromote,
78
79    /// {{<warning>}}
80    /// THIS WILL CAUSE YOUR MATERIALIZE INSTANCE TO BE UNAVAILABLE FOR SOME TIME!!!
81    ///
82    /// This strategy should ONLY be used by customers with physical hardware who do not have
83    /// enough hardware for the `WaitUntilReady` strategy. If you think you want this, please
84    /// consult with Materialize engineering to discuss your situation.
85    /// {{</warning>}}
86    ///
87    /// Tear down the old generation of pods and promote the new generation of pods immediately,
88    /// without waiting for the new generation of pods to be ready.
89    ImmediatelyPromoteCausingDowntime,
90}
91
92/// Default for [`RolloutRequestTimeout`]. A new generation that sits
93/// un-promoted holds back compaction via read holds, and promoting it
94/// after a long delay can cause incident-inducing load; 24h is a
95/// conservative upper bound on how long any rollout should take.
96pub const DEFAULT_ROLLOUT_REQUEST_TIMEOUT: &str = "24h";
97
98/// The maximum time [`v1alpha1::MaterializeSpec::rollout_request_timeout`] allows a
99/// rollout to remain in progress.
100///
101/// A transparent wrapper around the duration string whose [`Default`] is
102/// [`DEFAULT_ROLLOUT_REQUEST_TIMEOUT`]. Routing the default through `Default`
103/// keeps a single source of truth: the derived `Default` for
104/// [`v1alpha1::MaterializeSpec`], serde's `#[serde(default)]` (applied when the field
105/// is omitted on deserialize), and the schema default surfaced in the
106/// generated CRD (so the API server fills it in and `kubectl explain` shows
107/// it) all resolve to the same value.
108#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
109#[serde(transparent)]
110pub struct RolloutRequestTimeout(pub String);
111
112impl Default for RolloutRequestTimeout {
113    fn default() -> Self {
114        RolloutRequestTimeout(DEFAULT_ROLLOUT_REQUEST_TIMEOUT.to_owned())
115    }
116}
117
118pub mod v1alpha1 {
119    use super::*;
120
121    #[derive(
122        CustomResource,
123        Clone,
124        Debug,
125        Default,
126        PartialEq,
127        Deserialize,
128        Serialize,
129        JsonSchema
130    )]
131    #[serde(rename_all = "camelCase")]
132    #[kube(
133        namespaced,
134        group = "materialize.cloud",
135        version = "v1alpha1",
136        kind = "Materialize",
137        singular = "materialize",
138        plural = "materializes",
139        shortname = "mzs",
140        status = "MaterializeStatus",
141        printcolumn = r#"{"name": "ImageRefRunning", "type": "string", "description": "Reference to the Docker image that is currently in use.", "jsonPath": ".status.lastCompletedRolloutEnvironmentdImageRef", "priority": 1}"#,
142        printcolumn = r#"{"name": "ImageRefToDeploy", "type": "string", "description": "Reference to the Docker image which will be deployed on the next rollout.", "jsonPath": ".spec.environmentdImageRef", "priority": 1}"#,
143        printcolumn = r#"{"name": "UpToDate", "type": "string", "description": "Whether the spec has been applied", "jsonPath": ".status.conditions[?(@.type==\"UpToDate\")].status", "priority": 1}"#
144    )]
145    pub struct MaterializeSpec {
146        /// The environmentd image to run.
147        pub environmentd_image_ref: String,
148        /// Extra args to pass to the environmentd binary.
149        pub environmentd_extra_args: Option<Vec<String>>,
150        /// Extra environment variables to pass to the environmentd binary.
151        pub environmentd_extra_env: Option<Vec<EnvVar>>,
152        /// {{<warning>}}
153        /// Deprecated.
154        ///
155        /// Use `service_account_annotations` to set "eks.amazonaws.com/role-arn" instead.
156        /// {{</warning>}}
157        ///
158        /// If running in AWS, override the IAM role to use to give
159        /// environmentd access to the persist S3 bucket.
160        #[kube(deprecated)]
161        pub environmentd_iam_role_arn: Option<String>,
162        /// If running in AWS, override the IAM role to use to support
163        /// the CREATE CONNECTION feature.
164        pub environmentd_connection_role_arn: Option<String>,
165        /// Resource requirements for the environmentd pod.
166        pub environmentd_resource_requirements: Option<ResourceRequirements>,
167        /// Amount of disk to allocate, if a storage class is provided.
168        pub environmentd_scratch_volume_storage_requirement: Option<Quantity>,
169        /// Resource requirements for the balancerd pod.
170        pub balancerd_resource_requirements: Option<ResourceRequirements>,
171        /// Resource requirements for the console pod.
172        pub console_resource_requirements: Option<ResourceRequirements>,
173        /// Number of balancerd pods to create.
174        pub balancerd_replicas: Option<i32>,
175        /// Number of console pods to create.
176        pub console_replicas: Option<i32>,
177
178        /// Name of the kubernetes service account to use.
179        /// If not set, we will create one with the same name as this Materialize object.
180        pub service_account_name: Option<String>,
181        /// Annotations to apply to the service account.
182        ///
183        /// Annotations on service accounts are commonly used by cloud providers for IAM.
184        /// AWS uses "eks.amazonaws.com/role-arn".
185        /// Azure uses "azure.workload.identity/client-id", but
186        /// additionally requires "azure.workload.identity/use": "true" on the pods.
187        pub service_account_annotations: Option<BTreeMap<String, String>>,
188        /// Labels to apply to the service account.
189        pub service_account_labels: Option<BTreeMap<String, String>>,
190        /// Annotations to apply to the pods.
191        pub pod_annotations: Option<BTreeMap<String, String>>,
192        /// Labels to apply to the pods.
193        pub pod_labels: Option<BTreeMap<String, String>>,
194
195        /// When changes are made to the environmentd resources (either via
196        /// modifying fields in the spec here or by deploying a new
197        /// orchestratord version which changes how resources are generated),
198        /// existing environmentd processes won't be automatically restarted.
199        /// In order to trigger a restart, the request_rollout field should be
200        /// set to a new (random) value. Once the rollout completes, the value
201        /// of `status.lastCompletedRolloutRequest` will be set to this value
202        /// to indicate completion.
203        ///
204        /// Defaults to a random value in order to ensure that the first
205        /// generation rollout is automatically triggered.
206        #[serde(default)]
207        pub request_rollout: Uuid,
208        /// If `forcePromote` is set to the same value as `requestRollout`, the
209        /// current rollout will skip waiting for clusters in the new
210        /// generation to rehydrate before promoting the new environmentd to
211        /// leader.
212        #[serde(default)]
213        pub force_promote: String,
214        /// This value will be written to an annotation in the generated
215        /// environmentd statefulset, in order to force the controller to
216        /// detect the generated resources as changed even if no other changes
217        /// happened. This can be used to force a rollout to a new generation
218        /// even without making any meaningful changes, by setting it to the
219        /// same value as `requestRollout`.
220        #[serde(default)]
221        pub force_rollout: Uuid,
222        /// {{<warning>}}
223        /// Deprecated and ignored. Use `rolloutStrategy` instead.
224        /// {{</warning>}}
225        #[kube(deprecated)]
226        #[serde(default)]
227        pub in_place_rollout: bool,
228        /// Rollout strategy to use when upgrading this Materialize instance.
229        #[serde(default)]
230        pub rollout_strategy: MaterializeRolloutStrategy,
231        /// The maximum amount of time a rollout may remain in progress before
232        /// it is automatically cancelled.
233        ///
234        /// While a rollout is in progress, the new generation of `environmentd`
235        /// runs in a read-only, un-promoted state and holds back compaction via
236        /// read holds. Leaving it in this state for too long can cause
237        /// incident-inducing load when it is eventually promoted, so the
238        /// operator cancels the rollout once this timeout is exceeded: the new
239        /// generation is torn down and the previously-active generation
240        /// continues serving. A new rollout can then be triggered by setting
241        /// `requestRollout` to a new value.
242        ///
243        /// This does not apply to the `ImmediatelyPromoteCausingDowntime`
244        /// rollout strategy or to force-promoted rollouts, since by the time
245        /// those are in progress the old generation may already be gone.
246        ///
247        /// The value is parsed as a human-readable duration, e.g. `24h`,
248        /// `90m`, or `1h 30m`. Defaults to [`DEFAULT_ROLLOUT_REQUEST_TIMEOUT`]
249        /// when omitted (the API server fills it in); an unparseable value also
250        /// falls back to that default.
251        #[serde(default)]
252        pub rollout_request_timeout: RolloutRequestTimeout,
253        /// The name of a secret containing `metadata_backend_url` and `persist_backend_url`.
254        /// It may also contain `external_login_password_mz_system`, which will be used as
255        /// the password for the `mz_system` user if `authenticatorKind` is `Password`,
256        /// `Sasl`, or `Oidc`.
257        pub backend_secret_name: String,
258        /// How to authenticate with Materialize.
259        #[serde(default)]
260        pub authenticator_kind: AuthenticatorKind,
261        /// Whether to enable role based access control. Defaults to false.
262        #[serde(default)]
263        pub enable_rbac: bool,
264
265        /// The value used by environmentd (via the --environment-id flag) to
266        /// uniquely identify this instance. Must be globally unique, and
267        /// is required if a license key is not provided.
268        /// NOTE: This value MUST NOT be changed in an existing instance,
269        /// since it affects things like the way data is stored in the persist
270        /// backend.
271        #[serde(default)]
272        pub environment_id: Uuid,
273
274        /// The name of a ConfigMap containing system parameters in JSON format.
275        /// The ConfigMap must contain a `system-params.json` key whose value
276        /// is a valid JSON object containing valid system parameters.
277        ///
278        /// Run `SHOW ALL` in SQL to see a subset of configurable system parameters.
279        ///
280        /// Example ConfigMap:
281        /// ```yaml
282        /// data:
283        ///   system-params.json: |
284        ///     {
285        ///       "max_connections": 1000
286        ///     }
287        /// ```
288        pub system_parameter_configmap_name: Option<String>,
289
290        /// The configuration for generating an x509 certificate using cert-manager for balancerd
291        /// to present to incoming connections.
292        /// The `dnsNames` and `issuerRef` fields are required.
293        pub balancerd_external_certificate_spec: Option<MaterializeCertSpec>,
294        /// The configuration for generating an x509 certificate using cert-manager for the console
295        /// to present to incoming connections.
296        /// The `dnsNames` and `issuerRef` fields are required.
297        /// Not yet implemented.
298        pub console_external_certificate_spec: Option<MaterializeCertSpec>,
299        /// The cert-manager Issuer or ClusterIssuer to use for database internal communication.
300        /// The `issuerRef` field is required.
301        /// This currently is only used for environmentd, but will eventually support clusterd.
302        /// Not yet implemented.
303        pub internal_certificate_spec: Option<MaterializeCertSpec>,
304    }
305
306    impl Materialize {
307        pub fn backend_secret_name(&self) -> String {
308            self.spec.backend_secret_name.clone()
309        }
310
311        pub fn namespace(&self) -> String {
312            self.meta().namespace.clone().unwrap()
313        }
314
315        pub fn create_service_account(&self) -> bool {
316            self.spec.service_account_name.is_none()
317        }
318
319        pub fn service_account_name(&self) -> String {
320            self.spec
321                .service_account_name
322                .clone()
323                .unwrap_or_else(|| self.name_unchecked())
324        }
325
326        pub fn role_name(&self) -> String {
327            self.name_unchecked()
328        }
329
330        pub fn role_binding_name(&self) -> String {
331            self.name_unchecked()
332        }
333
334        pub fn environmentd_statefulset_name(&self, generation: u64) -> String {
335            self.name_prefixed(&format!("environmentd-{generation}"))
336        }
337
338        pub fn environmentd_app_name(&self) -> String {
339            "environmentd".to_owned()
340        }
341
342        pub fn environmentd_service_name(&self) -> String {
343            self.name_prefixed("environmentd")
344        }
345
346        pub fn environmentd_service_internal_fqdn(&self) -> String {
347            format!(
348                "{}.{}.svc.cluster.local",
349                self.environmentd_service_name(),
350                self.meta().namespace.as_ref().unwrap()
351            )
352        }
353
354        pub fn environmentd_generation_service_name(&self, generation: u64) -> String {
355            self.name_prefixed(&format!("environmentd-{generation}"))
356        }
357
358        pub fn balancerd_app_name(&self) -> String {
359            "balancerd".to_owned()
360        }
361
362        pub fn environmentd_certificate_name(&self) -> String {
363            self.name_prefixed("environmentd-external")
364        }
365
366        pub fn environmentd_certificate_secret_name(&self) -> String {
367            self.name_prefixed("environmentd-tls")
368        }
369
370        pub fn balancerd_deployment_name(&self) -> String {
371            self.name_prefixed("balancerd")
372        }
373
374        pub fn balancerd_service_name(&self) -> String {
375            self.name_prefixed("balancerd")
376        }
377
378        pub fn console_app_name(&self) -> String {
379            "console".to_owned()
380        }
381
382        pub fn balancerd_external_certificate_name(&self) -> String {
383            self.name_prefixed("balancerd-external")
384        }
385
386        pub fn balancerd_external_certificate_secret_name(&self) -> String {
387            self.name_prefixed("balancerd-external-tls")
388        }
389
390        pub fn balancerd_replicas(&self) -> i32 {
391            self.spec.balancerd_replicas.unwrap_or(2)
392        }
393
394        pub fn console_replicas(&self) -> i32 {
395            self.spec.console_replicas.unwrap_or(2)
396        }
397
398        pub fn console_configmap_name(&self) -> String {
399            self.name_prefixed("console")
400        }
401
402        pub fn console_deployment_name(&self) -> String {
403            self.name_prefixed("console")
404        }
405
406        pub fn console_service_name(&self) -> String {
407            self.name_prefixed("console")
408        }
409
410        pub fn console_external_certificate_name(&self) -> String {
411            self.name_prefixed("console-external")
412        }
413
414        pub fn console_external_certificate_secret_name(&self) -> String {
415            self.name_prefixed("console-external-tls")
416        }
417
418        pub fn persist_pubsub_service_name(&self, generation: u64) -> String {
419            self.name_prefixed(&format!("persist-pubsub-{generation}"))
420        }
421
422        pub fn listeners_configmap_name(&self, generation: u64) -> String {
423            self.name_prefixed(&format!("listeners-{generation}"))
424        }
425
426        pub fn name_prefixed(&self, suffix: &str) -> String {
427            format!("mz{}-{}", self.resource_id(), suffix)
428        }
429
430        pub fn resource_id(&self) -> &str {
431            &self.status.as_ref().unwrap().resource_id
432        }
433
434        pub fn system_parameter_configmap_name(&self) -> Option<String> {
435            self.spec.system_parameter_configmap_name.clone()
436        }
437
438        pub fn environmentd_scratch_volume_storage_requirement(&self) -> Quantity {
439            self.spec
440                .environmentd_scratch_volume_storage_requirement
441                .clone()
442                .unwrap_or_else(|| {
443                    self.spec
444                        .environmentd_resource_requirements
445                        .as_ref()
446                        .and_then(|requirements| {
447                            requirements
448                                .requests
449                                .as_ref()
450                                .or(requirements.limits.as_ref())
451                        })
452                        // TODO: in cloud, we've been defaulting to twice the
453                        // memory limit, but k8s-openapi doesn't seem to
454                        // provide any way to parse Quantity values, so there
455                        // isn't an easy way to do arithmetic on it
456                        .and_then(|requirements| requirements.get("memory").cloned())
457                        // TODO: is there a better default to use here?
458                        .unwrap_or_else(|| Quantity("4096Mi".to_string()))
459                })
460        }
461
462        pub fn environment_id(&self, cloud_provider: &str, region: &str) -> String {
463            format!(
464                "{}-{}-{}-0",
465                cloud_provider, region, self.spec.environment_id,
466            )
467        }
468
469        pub fn requested_reconciliation_id(&self) -> Uuid {
470            self.spec.request_rollout
471        }
472
473        /// The value used to force the generated per-generation resources to
474        /// be detected as changed even when nothing else in the spec changed,
475        /// so that a requested rollout actually creates a new generation of
476        /// pods rather than completing as a no-op.
477        ///
478        /// Combines `spec.forceRollout` with the
479        /// [`FORCE_ROLLOUT_ANNOTATION`] annotation; changing either forces a
480        /// new generation. The annotation exists so that automation (e.g. the
481        /// GCP node upgrade watcher in orchestratord) can force a rollout
482        /// without touching spec fields that may be managed by tools like
483        /// Terraform.
484        pub fn force_rollout_value(&self) -> String {
485            match self
486                .meta()
487                .annotations
488                .as_ref()
489                .and_then(|annotations| annotations.get(FORCE_ROLLOUT_ANNOTATION))
490            {
491                Some(annotation) => format!("{}/{}", self.spec.force_rollout, annotation),
492                None => self.spec.force_rollout.to_string(),
493            }
494        }
495
496        pub fn rollout_requested(&self) -> bool {
497            self.requested_reconciliation_id()
498                != self
499                    .status
500                    .as_ref()
501                    .map_or_else(Uuid::nil, |status| status.last_completed_rollout_request)
502        }
503
504        /// The maximum amount of time a rollout may remain in progress before
505        /// it is automatically cancelled. Parsed from
506        /// [`MaterializeSpec::rollout_request_timeout`], falling back to
507        /// [`DEFAULT_ROLLOUT_REQUEST_TIMEOUT`] when unset or unparseable.
508        pub fn rollout_request_timeout(&self) -> Duration {
509            let timeout = &self.spec.rollout_request_timeout.0;
510            humantime::parse_duration(timeout)
511                .or_else(|e| {
512                    tracing::warn!(
513                        rollout_request_timeout = %timeout,
514                        "failed to parse rolloutRequestTimeout, using default: {e}",
515                    );
516                    humantime::parse_duration(DEFAULT_ROLLOUT_REQUEST_TIMEOUT)
517                })
518                .expect("DEFAULT_ROLLOUT_REQUEST_TIMEOUT must be a valid duration")
519        }
520
521        /// If a timeout-eligible rollout is currently in progress, returns the
522        /// time at which it entered the in-progress (`Unknown`) state. Used to
523        /// enforce the rollout timeout.
524        ///
525        /// The `Applying` and `ReadyToPromote` phases are both reported as a
526        /// single in-progress window: [`Self::up_to_date_transition_time`]
527        /// carries the timestamp forward across them (they share the `Unknown`
528        /// status), so the timeout spans the whole pre-promotion rollout rather
529        /// than resetting at each phase.
530        ///
531        /// The `Promoting` phase is deliberately excluded even though it is
532        /// also `Unknown`: once a rollout has reached promotion it must never
533        /// be cancelled by the timeout, since the previously-active generation
534        /// may already be torn down, leaving nothing to fall back to. (The
535        /// controller also never reaches the timeout check while promoting,
536        /// because `is_promoting` takes priority; this is belt-and-suspenders.)
537        pub fn rollout_in_progress_since(&self) -> Option<Timestamp> {
538            self.status
539                .as_ref()?
540                .conditions
541                .iter()
542                .find_map(|condition| {
543                    if condition.type_ == "UpToDate"
544                        && condition.status == "Unknown"
545                        && condition.reason != "Promoting"
546                    {
547                        Some(condition.last_transition_time.0)
548                    } else {
549                        None
550                    }
551                })
552        }
553
554        /// The `last_transition_time` to record for a new `UpToDate` condition
555        /// with `new_status`, following the Kubernetes convention that
556        /// `last_transition_time` marks when the condition's *status* last
557        /// changed — not its reason or message. While the status is unchanged
558        /// the existing timestamp is carried forward; it only resets to `now`
559        /// when the status actually changes (or there is no prior condition).
560        ///
561        /// This is what lets a rollout that moves through several same-status
562        /// phases (`Applying` -> `ReadyToPromote`, both `Unknown`) be measured
563        /// from when it first entered that status, so the rollout timeout
564        /// covers the phases together instead of restarting at each one.
565        pub fn up_to_date_transition_time(&self, new_status: &str, now: Timestamp) -> Timestamp {
566            self.status
567                .as_ref()
568                .and_then(|status| {
569                    status
570                        .conditions
571                        .iter()
572                        .find(|condition| condition.type_ == "UpToDate")
573                })
574                .filter(|condition| condition.status == new_status)
575                .map_or(now, |condition| condition.last_transition_time.0)
576        }
577
578        /// Returns the environmentd image ref of the currently-active
579        /// generation: the image of the last completed rollout, falling back
580        /// to the spec image when no rollout has completed yet. Downstream
581        /// resources (balancerd, console) should track this rather than
582        /// [`MaterializeSpec::environmentd_image_ref`] so they stay aligned
583        /// with the running environmentd when the spec is mid-rollout or has
584        /// been partially reverted (DEP-42).
585        pub fn active_environmentd_image_ref(&self) -> &str {
586            self.status
587                .as_ref()
588                .and_then(|s| s.last_completed_rollout_environmentd_image_ref.as_deref())
589                .unwrap_or(&self.spec.environmentd_image_ref)
590        }
591
592        pub fn set_force_promote(&mut self) {
593            self.spec.force_promote = self.spec.request_rollout.hyphenated().to_string();
594        }
595
596        pub fn should_force_promote(&self) -> bool {
597            self.spec.force_promote == self.spec.request_rollout.hyphenated().to_string()
598                || self.spec.force_promote
599                    == super::v1::Materialize::from(self.clone()).generate_rollout_hash()
600                || self.spec.rollout_strategy
601                    == MaterializeRolloutStrategy::ImmediatelyPromoteCausingDowntime
602        }
603
604        pub fn conditions_need_update(&self) -> bool {
605            let Some(status) = self.status.as_ref() else {
606                return true;
607            };
608            if status.conditions.is_empty() {
609                return true;
610            }
611            for condition in &status.conditions {
612                if condition.observed_generation != self.meta().generation {
613                    return true;
614                }
615            }
616            false
617        }
618
619        pub fn is_ready_to_promote(&self, resources_hash: &str) -> bool {
620            let Some(status) = self.status.as_ref() else {
621                return false;
622            };
623            if status.conditions.is_empty() {
624                return false;
625            }
626            status
627                .conditions
628                .iter()
629                .any(|condition| condition.reason == "ReadyToPromote")
630                && &status.resources_hash == resources_hash
631        }
632
633        pub fn is_promoting(&self) -> bool {
634            let Some(status) = self.status.as_ref() else {
635                return false;
636            };
637            if status.conditions.is_empty() {
638                return false;
639            }
640            status
641                .conditions
642                .iter()
643                .any(|condition| condition.reason == "Promoting")
644        }
645
646        pub fn update_in_progress(&self) -> bool {
647            let Some(status) = self.status.as_ref() else {
648                return false;
649            };
650            if status.conditions.is_empty() {
651                return false;
652            }
653            for condition in &status.conditions {
654                if condition.type_ == "UpToDate" && condition.status == "Unknown" {
655                    return true;
656                }
657            }
658            false
659        }
660
661        /// Checks that the given version is greater than or equal
662        /// to the existing version, if the existing version
663        /// can be parsed.
664        pub fn meets_minimum_version(&self, minimum: &Version) -> bool {
665            let version = parse_image_ref(&self.spec.environmentd_image_ref);
666            match version {
667                // Use cmp_precedence() to ignore build metadata per SemVer 2.0.0 spec
668                Some(version) => version.cmp_precedence(minimum).is_ge(),
669                // In the rare case that we see an image reference
670                // that we can't parse, we assume that it satisfies all
671                // version checks. Usually these are custom images that have
672                // been by a developer on a branch forked from a recent copy
673                // of main, and so this works out reasonably well in practice.
674                None => {
675                    tracing::warn!(
676                        image_ref = %self.spec.environmentd_image_ref,
677                        "failed to parse image ref",
678                    );
679                    true
680                }
681            }
682        }
683
684        /// This check isn't strictly required since environmentd will still be able to determine
685        /// if the upgrade is allowed or not. However, doing this check allows us to provide
686        /// the error as soon as possible and in a more user friendly way.
687        pub fn is_valid_upgrade_version(active_version: &Version, next_version: &Version) -> bool {
688            // Don't allow rolling back
689            // Note: semver comparison handles RC versions correctly:
690            // v26.0.0-rc.1 < v26.0.0-rc.2 < v26.0.0
691            // Use cmp_precedence() to ignore build metadata
692            if next_version.cmp_precedence(active_version) == std::cmp::Ordering::Less {
693                return false;
694            }
695
696            if active_version.major == 0 {
697                if next_version.major != active_version.major {
698                    if next_version.major == 26 {
699                        // We require customers to upgrade from 0.147.20 (Self Managed 25.2) or v0.164.X (Cloud)
700                        // before upgrading to 26.0.0
701                        return (active_version.minor == 147 && active_version.patch >= 20)
702                            || active_version.minor >= 164;
703                    } else {
704                        return false;
705                    }
706                }
707                // Self managed 25.1 to 25.2
708                if next_version.minor == 147 && active_version.minor == 130 {
709                    return true;
710                }
711                // only allow upgrading a single minor version at a time
712                return next_version.minor <= active_version.minor + 1;
713            } else if active_version.major >= 26 {
714                // For versions 26.X.X and onwards, we deny upgrades past 1 major version of the active version
715                return next_version.major <= active_version.major + 1;
716            }
717
718            true
719        }
720
721        /// Checks if the current environmentd image ref is within the upgrade window of the last
722        /// successful rollout.
723        pub fn within_upgrade_window(&self) -> bool {
724            let active_environmentd_version = self
725                .status
726                .as_ref()
727                .and_then(|status| {
728                    status
729                        .last_completed_rollout_environmentd_image_ref
730                        .as_ref()
731                })
732                .and_then(|image_ref| parse_image_ref(image_ref));
733
734            if let (Some(next_environmentd_version), Some(active_environmentd_version)) = (
735                parse_image_ref(&self.spec.environmentd_image_ref),
736                active_environmentd_version,
737            ) {
738                Self::is_valid_upgrade_version(
739                    &active_environmentd_version,
740                    &next_environmentd_version,
741                )
742            } else {
743                // If we fail to parse either version,
744                // we still allow the upgrade since environmentd will still error if the upgrade is not allowed.
745                true
746            }
747        }
748
749        pub fn status(&self) -> MaterializeStatus {
750            self.status.clone().unwrap_or_else(|| {
751                let mut status = MaterializeStatus::default();
752
753                status.resource_id = new_resource_id();
754
755                // If we're creating the initial status on an un-soft-deleted
756                // Environment we need to ensure that the last active generation
757                // is restored, otherwise the env will crash loop indefinitely
758                // as its catalog would have durably recorded a greater generation
759                if let Some(last_active_generation) = self
760                    .annotations()
761                    .get(LAST_KNOWN_ACTIVE_GENERATION_ANNOTATION)
762                {
763                    status.active_generation = last_active_generation
764                        .parse()
765                        .expect("valid int generation");
766                }
767
768                // Initialize the last completed rollout environmentd image ref to
769                // the current image ref if not already set.
770                status.last_completed_rollout_environmentd_image_ref =
771                    Some(self.spec.environmentd_image_ref.clone());
772
773                status
774            })
775        }
776    }
777
778    #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema, PartialEq)]
779    #[serde(rename_all = "camelCase")]
780    pub struct MaterializeStatus {
781        /// Resource identifier used as a name prefix to avoid pod name collisions.
782        pub resource_id: String,
783        /// The generation of Materialize pods actively capable of servicing requests.
784        pub active_generation: u64,
785        /// The UUID of the last successfully completed rollout.
786        pub last_completed_rollout_request: Uuid,
787        /// The image ref of the environmentd image that was last successfully rolled out.
788        /// Used to deny upgrades past 1 major version from the last successful rollout.
789        /// When None, we upgrade anyways.
790        pub last_completed_rollout_environmentd_image_ref: Option<String>,
791        /// A hash calculated from the spec of resources to be created based on this Materialize
792        /// spec. This is used for detecting when the existing resources are up to date.
793        /// If you want to trigger a rollout without making other changes that would cause this
794        /// hash to change, you must set forceRollout to the same UUID as requestRollout.
795        pub resources_hash: String,
796        /// The last completed rollout hash from v1.
797        /// This exists on this older version only for round-trip conversion support.
798        pub last_completed_rollout_hash: Option<String>,
799        pub conditions: Vec<Condition>,
800    }
801
802    impl MaterializeStatus {
803        pub fn needs_update(&self, other: &Self) -> bool {
804            let now = Timestamp::now();
805            let mut a = self.clone();
806            for condition in &mut a.conditions {
807                condition.last_transition_time = Time(now);
808            }
809            let mut b = other.clone();
810            for condition in &mut b.conditions {
811                condition.last_transition_time = Time(now);
812            }
813            a != b
814        }
815    }
816
817    impl ManagedResource for Materialize {
818        fn default_labels(&self) -> BTreeMap<String, String> {
819            BTreeMap::from_iter([
820                (
821                    "materialize.cloud/organization-name".to_owned(),
822                    self.name_unchecked(),
823                ),
824                (
825                    "materialize.cloud/organization-namespace".to_owned(),
826                    self.namespace(),
827                ),
828                (
829                    "materialize.cloud/mz-resource-id".to_owned(),
830                    self.resource_id().to_owned(),
831                ),
832            ])
833        }
834
835        fn app_name(&self) -> Option<&str> {
836            Some("environmentd")
837        }
838    }
839
840    impl From<v1::Materialize> for Materialize {
841        fn from(value: v1::Materialize) -> Self {
842            let rollout_hash = value.generate_rollout_hash();
843            // Derive a deterministic UUID from the rollout hash so that the
844            // same v1 spec always produces the same requestRollout,
845            // making re-applies of an unchanged spec idempotent.
846            let request_rollout = Uuid::new_v5(&Uuid::NAMESPACE_OID, rollout_hash.as_bytes());
847            Materialize {
848                metadata: value.metadata,
849                spec: MaterializeSpec {
850                    environmentd_image_ref: value.spec.environmentd_image_ref,
851                    environmentd_extra_args: value.spec.environmentd_extra_args,
852                    environmentd_extra_env: value.spec.environmentd_extra_env,
853                    environmentd_iam_role_arn: None,
854                    environmentd_connection_role_arn: value.spec.environmentd_connection_role_arn,
855                    environmentd_resource_requirements: value
856                        .spec
857                        .environmentd_resource_requirements,
858                    environmentd_scratch_volume_storage_requirement: value
859                        .spec
860                        .environmentd_scratch_volume_storage_requirement,
861                    balancerd_resource_requirements: value.spec.balancerd_resource_requirements,
862                    console_resource_requirements: value.spec.console_resource_requirements,
863                    balancerd_replicas: value.spec.balancerd_replicas,
864                    console_replicas: value.spec.console_replicas,
865                    service_account_name: value.spec.service_account_name,
866                    service_account_annotations: value.spec.service_account_annotations,
867                    service_account_labels: value.spec.service_account_labels,
868                    pod_annotations: value.spec.pod_annotations,
869                    pod_labels: value.spec.pod_labels,
870                    force_promote: value.spec.force_promote.unwrap_or_default(),
871                    force_rollout: value.spec.force_rollout,
872                    rollout_strategy: value.spec.rollout_strategy,
873                    rollout_request_timeout: value.spec.rollout_request_timeout,
874                    backend_secret_name: value.spec.backend_secret_name,
875                    authenticator_kind: value.spec.authenticator_kind,
876                    enable_rbac: value.spec.enable_rbac,
877                    environment_id: value.spec.environment_id,
878                    system_parameter_configmap_name: value.spec.system_parameter_configmap_name,
879                    balancerd_external_certificate_spec: value
880                        .spec
881                        .balancerd_external_certificate_spec,
882                    console_external_certificate_spec: value.spec.console_external_certificate_spec,
883                    internal_certificate_spec: value.spec.internal_certificate_spec,
884                    request_rollout,
885                    in_place_rollout: false,
886                },
887                status: value.status.map(|status| MaterializeStatus {
888                    resource_id: status.resource_id,
889                    active_generation: status.active_generation,
890                    last_completed_rollout_environmentd_image_ref: status
891                        .last_completed_rollout_environmentd_image_ref,
892                    conditions: status.conditions,
893                    // Derive the same deterministic UUID from the last
894                    // completed hash so that request_rollout == this value
895                    // when the spec hasn't changed (no rollout needed).
896                    last_completed_rollout_request: status
897                        .last_completed_rollout_hash
898                        .as_ref()
899                        .map(|hash| Uuid::new_v5(&Uuid::NAMESPACE_OID, hash.as_bytes()))
900                        .unwrap_or(Uuid::nil()),
901                    last_completed_rollout_hash: status.last_completed_rollout_hash,
902                    resources_hash: "".to_owned(),
903                }),
904            }
905        }
906    }
907
908    /// Partial mirror of [`MaterializeSpec`] for field-wise conversion of
909    /// objects that may be incomplete. See [`super::convert_v1_to_v1alpha1`].
910    ///
911    /// Each field is a [`PartialField`], distinguishing absent from explicit
912    /// null from a value, and unknown fields pass through via `extra`, so
913    /// serializing reproduces exactly what was deserialized. Adding a field
914    /// to [`MaterializeSpec`] breaks the exhaustive destructures in the
915    /// `From` impls below until its conversion is decided.
916    #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
917    #[serde(rename_all = "camelCase")]
918    pub struct PartialMaterializeSpec {
919        #[serde(
920            default,
921            with = "double_option",
922            skip_serializing_if = "Option::is_none"
923        )]
924        pub environmentd_image_ref: PartialField,
925        #[serde(
926            default,
927            with = "double_option",
928            skip_serializing_if = "Option::is_none"
929        )]
930        pub environmentd_extra_args: PartialField,
931        #[serde(
932            default,
933            with = "double_option",
934            skip_serializing_if = "Option::is_none"
935        )]
936        pub environmentd_extra_env: PartialField,
937        #[serde(
938            default,
939            with = "double_option",
940            skip_serializing_if = "Option::is_none"
941        )]
942        pub environmentd_iam_role_arn: PartialField,
943        #[serde(
944            default,
945            with = "double_option",
946            skip_serializing_if = "Option::is_none"
947        )]
948        pub environmentd_connection_role_arn: PartialField,
949        #[serde(
950            default,
951            with = "double_option",
952            skip_serializing_if = "Option::is_none"
953        )]
954        pub environmentd_resource_requirements: PartialField,
955        #[serde(
956            default,
957            with = "double_option",
958            skip_serializing_if = "Option::is_none"
959        )]
960        pub environmentd_scratch_volume_storage_requirement: PartialField,
961        #[serde(
962            default,
963            with = "double_option",
964            skip_serializing_if = "Option::is_none"
965        )]
966        pub balancerd_resource_requirements: PartialField,
967        #[serde(
968            default,
969            with = "double_option",
970            skip_serializing_if = "Option::is_none"
971        )]
972        pub console_resource_requirements: PartialField,
973        #[serde(
974            default,
975            with = "double_option",
976            skip_serializing_if = "Option::is_none"
977        )]
978        pub balancerd_replicas: PartialField,
979        #[serde(
980            default,
981            with = "double_option",
982            skip_serializing_if = "Option::is_none"
983        )]
984        pub console_replicas: PartialField,
985        #[serde(
986            default,
987            with = "double_option",
988            skip_serializing_if = "Option::is_none"
989        )]
990        pub service_account_name: PartialField,
991        #[serde(
992            default,
993            with = "double_option",
994            skip_serializing_if = "Option::is_none"
995        )]
996        pub service_account_annotations: PartialField,
997        #[serde(
998            default,
999            with = "double_option",
1000            skip_serializing_if = "Option::is_none"
1001        )]
1002        pub service_account_labels: PartialField,
1003        #[serde(
1004            default,
1005            with = "double_option",
1006            skip_serializing_if = "Option::is_none"
1007        )]
1008        pub pod_annotations: PartialField,
1009        #[serde(
1010            default,
1011            with = "double_option",
1012            skip_serializing_if = "Option::is_none"
1013        )]
1014        pub pod_labels: PartialField,
1015        #[serde(
1016            default,
1017            with = "double_option",
1018            skip_serializing_if = "Option::is_none"
1019        )]
1020        pub request_rollout: PartialField,
1021        #[serde(
1022            default,
1023            with = "double_option",
1024            skip_serializing_if = "Option::is_none"
1025        )]
1026        pub force_promote: PartialField,
1027        #[serde(
1028            default,
1029            with = "double_option",
1030            skip_serializing_if = "Option::is_none"
1031        )]
1032        pub force_rollout: PartialField,
1033        #[serde(
1034            default,
1035            with = "double_option",
1036            skip_serializing_if = "Option::is_none"
1037        )]
1038        pub in_place_rollout: PartialField,
1039        #[serde(
1040            default,
1041            with = "double_option",
1042            skip_serializing_if = "Option::is_none"
1043        )]
1044        pub rollout_strategy: PartialField,
1045        #[serde(
1046            default,
1047            with = "double_option",
1048            skip_serializing_if = "Option::is_none"
1049        )]
1050        pub rollout_request_timeout: PartialField,
1051        #[serde(
1052            default,
1053            with = "double_option",
1054            skip_serializing_if = "Option::is_none"
1055        )]
1056        pub backend_secret_name: PartialField,
1057        #[serde(
1058            default,
1059            with = "double_option",
1060            skip_serializing_if = "Option::is_none"
1061        )]
1062        pub authenticator_kind: PartialField,
1063        #[serde(
1064            default,
1065            with = "double_option",
1066            skip_serializing_if = "Option::is_none"
1067        )]
1068        pub enable_rbac: PartialField,
1069        #[serde(
1070            default,
1071            with = "double_option",
1072            skip_serializing_if = "Option::is_none"
1073        )]
1074        pub environment_id: PartialField,
1075        #[serde(
1076            default,
1077            with = "double_option",
1078            skip_serializing_if = "Option::is_none"
1079        )]
1080        pub system_parameter_configmap_name: PartialField,
1081        #[serde(
1082            default,
1083            with = "double_option",
1084            skip_serializing_if = "Option::is_none"
1085        )]
1086        pub balancerd_external_certificate_spec: PartialField,
1087        #[serde(
1088            default,
1089            with = "double_option",
1090            skip_serializing_if = "Option::is_none"
1091        )]
1092        pub console_external_certificate_spec: PartialField,
1093        #[serde(
1094            default,
1095            with = "double_option",
1096            skip_serializing_if = "Option::is_none"
1097        )]
1098        pub internal_certificate_spec: PartialField,
1099        #[serde(flatten)]
1100        pub extra: serde_json::Map<String, serde_json::Value>,
1101    }
1102
1103    /// Partial mirror of [`MaterializeStatus`], see [`PartialMaterializeSpec`].
1104    #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
1105    #[serde(rename_all = "camelCase")]
1106    pub struct PartialMaterializeStatus {
1107        #[serde(
1108            default,
1109            with = "double_option",
1110            skip_serializing_if = "Option::is_none"
1111        )]
1112        pub resource_id: PartialField,
1113        #[serde(
1114            default,
1115            with = "double_option",
1116            skip_serializing_if = "Option::is_none"
1117        )]
1118        pub active_generation: PartialField,
1119        #[serde(
1120            default,
1121            with = "double_option",
1122            skip_serializing_if = "Option::is_none"
1123        )]
1124        pub last_completed_rollout_request: PartialField,
1125        #[serde(
1126            default,
1127            with = "double_option",
1128            skip_serializing_if = "Option::is_none"
1129        )]
1130        pub last_completed_rollout_environmentd_image_ref: PartialField,
1131        #[serde(
1132            default,
1133            with = "double_option",
1134            skip_serializing_if = "Option::is_none"
1135        )]
1136        pub resources_hash: PartialField,
1137        #[serde(
1138            default,
1139            with = "double_option",
1140            skip_serializing_if = "Option::is_none"
1141        )]
1142        pub last_completed_rollout_hash: PartialField,
1143        #[serde(
1144            default,
1145            with = "double_option",
1146            skip_serializing_if = "Option::is_none"
1147        )]
1148        pub conditions: PartialField,
1149        #[serde(flatten)]
1150        pub extra: serde_json::Map<String, serde_json::Value>,
1151    }
1152
1153    /// Partial mirror of [`Materialize`], see [`PartialMaterializeSpec`].
1154    #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
1155    #[serde(rename_all = "camelCase")]
1156    pub struct PartialMaterialize {
1157        #[serde(default, skip_serializing_if = "Option::is_none")]
1158        pub api_version: Option<String>,
1159        #[serde(default, skip_serializing_if = "Option::is_none")]
1160        pub kind: Option<serde_json::Value>,
1161        #[serde(default, skip_serializing_if = "Option::is_none")]
1162        pub metadata: Option<serde_json::Value>,
1163        #[serde(default, skip_serializing_if = "Option::is_none")]
1164        pub spec: Option<PartialMaterializeSpec>,
1165        #[serde(default, skip_serializing_if = "Option::is_none")]
1166        pub status: Option<PartialMaterializeStatus>,
1167        #[serde(flatten)]
1168        pub extra: serde_json::Map<String, serde_json::Value>,
1169    }
1170
1171    impl From<MaterializeSpec> for PartialMaterializeSpec {
1172        fn from(spec: MaterializeSpec) -> Self {
1173            let MaterializeSpec {
1174                environmentd_image_ref,
1175                environmentd_extra_args,
1176                environmentd_extra_env,
1177                environmentd_iam_role_arn,
1178                environmentd_connection_role_arn,
1179                environmentd_resource_requirements,
1180                environmentd_scratch_volume_storage_requirement,
1181                balancerd_resource_requirements,
1182                console_resource_requirements,
1183                balancerd_replicas,
1184                console_replicas,
1185                service_account_name,
1186                service_account_annotations,
1187                service_account_labels,
1188                pod_annotations,
1189                pod_labels,
1190                request_rollout,
1191                force_promote,
1192                force_rollout,
1193                in_place_rollout,
1194                rollout_strategy,
1195                rollout_request_timeout,
1196                backend_secret_name,
1197                authenticator_kind,
1198                enable_rbac,
1199                environment_id,
1200                system_parameter_configmap_name,
1201                balancerd_external_certificate_spec,
1202                console_external_certificate_spec,
1203                internal_certificate_spec,
1204            } = spec;
1205            Self {
1206                environmentd_image_ref: present(environmentd_image_ref),
1207                environmentd_extra_args: present_opt(environmentd_extra_args),
1208                environmentd_extra_env: present_opt(environmentd_extra_env),
1209                environmentd_iam_role_arn: present_opt(environmentd_iam_role_arn),
1210                environmentd_connection_role_arn: present_opt(environmentd_connection_role_arn),
1211                environmentd_resource_requirements: present_opt(environmentd_resource_requirements),
1212                environmentd_scratch_volume_storage_requirement: present_opt(
1213                    environmentd_scratch_volume_storage_requirement,
1214                ),
1215                balancerd_resource_requirements: present_opt(balancerd_resource_requirements),
1216                console_resource_requirements: present_opt(console_resource_requirements),
1217                balancerd_replicas: present_opt(balancerd_replicas),
1218                console_replicas: present_opt(console_replicas),
1219                service_account_name: present_opt(service_account_name),
1220                service_account_annotations: present_opt(service_account_annotations),
1221                service_account_labels: present_opt(service_account_labels),
1222                pod_annotations: present_opt(pod_annotations),
1223                pod_labels: present_opt(pod_labels),
1224                request_rollout: present(request_rollout),
1225                force_promote: present(force_promote),
1226                force_rollout: present(force_rollout),
1227                in_place_rollout: present(in_place_rollout),
1228                rollout_strategy: present(rollout_strategy),
1229                rollout_request_timeout: present(rollout_request_timeout),
1230                backend_secret_name: present(backend_secret_name),
1231                authenticator_kind: present(authenticator_kind),
1232                enable_rbac: present(enable_rbac),
1233                environment_id: present(environment_id),
1234                system_parameter_configmap_name: present_opt(system_parameter_configmap_name),
1235                balancerd_external_certificate_spec: present_opt(
1236                    balancerd_external_certificate_spec,
1237                ),
1238                console_external_certificate_spec: present_opt(console_external_certificate_spec),
1239                internal_certificate_spec: present_opt(internal_certificate_spec),
1240                extra: serde_json::Map::new(),
1241            }
1242        }
1243    }
1244
1245    impl From<MaterializeStatus> for PartialMaterializeStatus {
1246        fn from(status: MaterializeStatus) -> Self {
1247            let MaterializeStatus {
1248                resource_id,
1249                active_generation,
1250                last_completed_rollout_request,
1251                last_completed_rollout_environmentd_image_ref,
1252                resources_hash,
1253                last_completed_rollout_hash,
1254                conditions,
1255            } = status;
1256            Self {
1257                resource_id: present(resource_id),
1258                active_generation: present(active_generation),
1259                last_completed_rollout_request: present(last_completed_rollout_request),
1260                last_completed_rollout_environmentd_image_ref: present_opt(
1261                    last_completed_rollout_environmentd_image_ref,
1262                ),
1263                resources_hash: present(resources_hash),
1264                last_completed_rollout_hash: present_opt(last_completed_rollout_hash),
1265                conditions: present(conditions),
1266                extra: serde_json::Map::new(),
1267            }
1268        }
1269    }
1270
1271    impl From<Materialize> for PartialMaterialize {
1272        fn from(mz: Materialize) -> Self {
1273            let Materialize {
1274                metadata,
1275                spec,
1276                status,
1277            } = mz;
1278            Self {
1279                api_version: Some("materialize.cloud/v1alpha1".to_owned()),
1280                kind: Some("Materialize".into()),
1281                metadata: Some(
1282                    serde_json::to_value(metadata).expect("ObjectMeta serializes to JSON"),
1283                ),
1284                spec: Some(spec.into()),
1285                status: status.map(Into::into),
1286                extra: serde_json::Map::new(),
1287            }
1288        }
1289    }
1290
1291    impl From<super::v1::PartialMaterializeSpec> for PartialMaterializeSpec {
1292        fn from(spec: super::v1::PartialMaterializeSpec) -> Self {
1293            let super::v1::PartialMaterializeSpec {
1294                environmentd_image_ref,
1295                environmentd_extra_args,
1296                environmentd_extra_env,
1297                environmentd_connection_role_arn,
1298                environmentd_resource_requirements,
1299                environmentd_scratch_volume_storage_requirement,
1300                balancerd_resource_requirements,
1301                console_resource_requirements,
1302                balancerd_replicas,
1303                console_replicas,
1304                service_account_name,
1305                service_account_annotations,
1306                service_account_labels,
1307                pod_annotations,
1308                pod_labels,
1309                force_promote,
1310                force_rollout,
1311                rollout_strategy,
1312                rollout_request_timeout,
1313                backend_secret_name,
1314                authenticator_kind,
1315                enable_rbac,
1316                environment_id,
1317                system_parameter_configmap_name,
1318                balancerd_external_certificate_spec,
1319                console_external_certificate_spec,
1320                internal_certificate_spec,
1321                extra,
1322            } = spec;
1323            Self {
1324                environmentd_image_ref,
1325                environmentd_extra_args,
1326                environmentd_extra_env,
1327                environmentd_iam_role_arn: None,
1328                environmentd_connection_role_arn,
1329                environmentd_resource_requirements,
1330                environmentd_scratch_volume_storage_requirement,
1331                balancerd_resource_requirements,
1332                console_resource_requirements,
1333                balancerd_replicas,
1334                console_replicas,
1335                service_account_name,
1336                service_account_annotations,
1337                service_account_labels,
1338                pod_annotations,
1339                pod_labels,
1340                // Derived from the complete spec. Spliced in by
1341                // `convert_v1_to_v1alpha1` when the input is complete, left
1342                // absent for partial objects so a field manager cannot gain
1343                // ownership of a field it never set.
1344                request_rollout: None,
1345                force_promote,
1346                force_rollout,
1347                in_place_rollout: None,
1348                rollout_strategy,
1349                rollout_request_timeout,
1350                backend_secret_name,
1351                authenticator_kind,
1352                enable_rbac,
1353                environment_id,
1354                system_parameter_configmap_name,
1355                balancerd_external_certificate_spec,
1356                console_external_certificate_spec,
1357                internal_certificate_spec,
1358                extra,
1359            }
1360        }
1361    }
1362
1363    impl From<super::v1::PartialMaterializeStatus> for PartialMaterializeStatus {
1364        fn from(status: super::v1::PartialMaterializeStatus) -> Self {
1365            let super::v1::PartialMaterializeStatus {
1366                resource_id,
1367                active_generation,
1368                last_completed_rollout_environmentd_image_ref,
1369                last_completed_rollout_hash,
1370                requested_rollout_hash: _,
1371                conditions,
1372                extra,
1373            } = status;
1374            Self {
1375                resource_id,
1376                active_generation,
1377                // Derived from the complete object, spliced in by
1378                // `convert_v1_to_v1alpha1` when the input is complete, left
1379                // absent for partial objects so a field manager cannot gain
1380                // ownership of fields it never set.
1381                last_completed_rollout_request: None,
1382                resources_hash: None,
1383                last_completed_rollout_environmentd_image_ref,
1384                last_completed_rollout_hash,
1385                conditions,
1386                extra,
1387            }
1388        }
1389    }
1390
1391    impl From<super::v1::PartialMaterialize> for PartialMaterialize {
1392        fn from(mz: super::v1::PartialMaterialize) -> Self {
1393            let super::v1::PartialMaterialize {
1394                api_version: _,
1395                kind,
1396                metadata,
1397                spec,
1398                status,
1399                extra,
1400            } = mz;
1401            Self {
1402                api_version: Some("materialize.cloud/v1alpha1".to_owned()),
1403                kind,
1404                metadata,
1405                spec: spec.map(Into::into),
1406                status: status.map(Into::into),
1407                extra,
1408            }
1409        }
1410    }
1411}
1412
1413pub mod v1 {
1414    use super::*;
1415
1416    #[derive(
1417        CustomResource,
1418        Clone,
1419        Debug,
1420        Default,
1421        PartialEq,
1422        Deserialize,
1423        Serialize,
1424        JsonSchema
1425    )]
1426    #[serde(rename_all = "camelCase")]
1427    #[kube(
1428        namespaced,
1429        group = "materialize.cloud",
1430        version = "v1",
1431        kind = "Materialize",
1432        singular = "materialize",
1433        plural = "materializes",
1434        shortname = "mzs",
1435        status = "MaterializeStatus",
1436        printcolumn = r#"{"name": "ImageRefRunning", "type": "string", "description": "Reference to the Docker image that is currently in use.", "jsonPath": ".status.lastCompletedRolloutEnvironmentdImageRef", "priority": 1}"#,
1437        printcolumn = r#"{"name": "ImageRefToDeploy", "type": "string", "description": "Reference to the Docker image which will be deployed on the next rollout.", "jsonPath": ".spec.environmentdImageRef", "priority": 1}"#,
1438        printcolumn = r#"{"name": "UpToDate", "type": "string", "description": "Whether the spec has been applied", "jsonPath": ".status.conditions[?(@.type==\"UpToDate\")].status", "priority": 1}"#
1439    )]
1440    pub struct MaterializeSpec {
1441        /// The environmentd image to run.
1442        pub environmentd_image_ref: String,
1443        /// Extra args to pass to the environmentd binary.
1444        pub environmentd_extra_args: Option<Vec<String>>,
1445        /// Extra environment variables to pass to the environmentd binary.
1446        pub environmentd_extra_env: Option<Vec<EnvVar>>,
1447        /// If running in AWS, override the IAM role to use to support
1448        /// the CREATE CONNECTION feature.
1449        pub environmentd_connection_role_arn: Option<String>,
1450        /// Resource requirements for the environmentd pod.
1451        pub environmentd_resource_requirements: Option<ResourceRequirements>,
1452        /// Amount of disk to allocate, if a storage class is provided.
1453        pub environmentd_scratch_volume_storage_requirement: Option<Quantity>,
1454        /// Resource requirements for the balancerd pod.
1455        ///
1456        /// This field is excluded from the rollout hash and changes will not trigger a rollout.
1457        pub balancerd_resource_requirements: Option<ResourceRequirements>,
1458        /// Resource requirements for the console pod.
1459        ///
1460        /// This field is excluded from the rollout hash and changes will not trigger a rollout.
1461        pub console_resource_requirements: Option<ResourceRequirements>,
1462        /// Number of balancerd pods to create.
1463        ///
1464        /// This field is excluded from the rollout hash and changes will not trigger a rollout.
1465        pub balancerd_replicas: Option<i32>,
1466        /// Number of console pods to create.
1467        ///
1468        /// This field is excluded from the rollout hash and changes will not trigger a rollout.
1469        pub console_replicas: Option<i32>,
1470
1471        /// Name of the kubernetes service account to use.
1472        /// If not set, we will create one with the same name as this Materialize object.
1473        pub service_account_name: Option<String>,
1474        /// Annotations to apply to the service account.
1475        ///
1476        /// Annotations on service accounts are commonly used by cloud providers for IAM.
1477        /// AWS uses "eks.amazonaws.com/role-arn".
1478        /// Azure uses "azure.workload.identity/client-id", but
1479        /// additionally requires "azure.workload.identity/use": "true" on the pods.
1480        pub service_account_annotations: Option<BTreeMap<String, String>>,
1481        /// Labels to apply to the service account.
1482        pub service_account_labels: Option<BTreeMap<String, String>>,
1483        /// Annotations to apply to the pods.
1484        pub pod_annotations: Option<BTreeMap<String, String>>,
1485        /// Labels to apply to the pods.
1486        pub pod_labels: Option<BTreeMap<String, String>>,
1487
1488        /// If `forcePromote` is set to the same value as the `status.requestedRolloutHash`,
1489        /// current rollout will skip waiting for clusters in the new
1490        /// generation to rehydrate before promoting the new environmentd to
1491        /// leader.
1492        ///
1493        /// This field is excluded from the rollout hash and changes will not trigger a rollout.
1494        pub force_promote: Option<String>,
1495        /// This value will force the controller to detect the spec as changed
1496        /// even if no other changes happened. This can be used to force a rollout
1497        /// to a new generation even without making any meaningful changes.
1498        #[serde(default)]
1499        pub force_rollout: Uuid,
1500        /// Rollout strategy to use when upgrading this Materialize instance.
1501        #[serde(default)]
1502        pub rollout_strategy: MaterializeRolloutStrategy,
1503        /// The maximum amount of time a rollout may remain in progress before
1504        /// it is automatically cancelled.
1505        ///
1506        /// While a rollout is in progress, the new generation of `environmentd`
1507        /// runs in a read-only, un-promoted state and holds back compaction via
1508        /// read holds. Leaving it in this state for too long can cause
1509        /// incident-inducing load when it is eventually promoted, so the
1510        /// operator cancels the rollout once this timeout is exceeded: the new
1511        /// generation is torn down and the previously-active generation
1512        /// continues serving. A new rollout can then be triggered by setting
1513        /// `forceRollout` to a new value.
1514        ///
1515        /// This does not apply to the `ImmediatelyPromoteCausingDowntime`
1516        /// rollout strategy or to force-promoted rollouts, since by the time
1517        /// those are in progress the old generation may already be gone.
1518        ///
1519        /// The value is parsed as a human-readable duration, e.g. `24h`,
1520        /// `90m`, or `1h 30m`. Defaults to [`DEFAULT_ROLLOUT_REQUEST_TIMEOUT`]
1521        /// when omitted (the API server fills it in); an unparseable value also
1522        /// falls back to that default.
1523        #[serde(default)]
1524        pub rollout_request_timeout: RolloutRequestTimeout,
1525        /// The name of a secret containing `metadata_backend_url` and `persist_backend_url`.
1526        /// It may also contain `external_login_password_mz_system`, which will be used as
1527        /// the password for the `mz_system` user if `authenticatorKind` is `Password`.
1528        pub backend_secret_name: String,
1529        /// How to authenticate with Materialize.
1530        #[serde(default)]
1531        pub authenticator_kind: AuthenticatorKind,
1532        /// Whether to enable role based access control. Defaults to false.
1533        #[serde(default)]
1534        pub enable_rbac: bool,
1535
1536        /// The value used by environmentd (via the --environment-id flag) to
1537        /// uniquely identify this instance. Must be globally unique, and
1538        /// is required if a license key is not provided.
1539        /// NOTE: This value MUST NOT be changed in an existing instance,
1540        /// since it affects things like the way data is stored in the persist
1541        /// backend.
1542        #[serde(default)]
1543        pub environment_id: Uuid,
1544
1545        /// The name of a ConfigMap containing system parameters in JSON format.
1546        /// The ConfigMap must contain a `system-params.json` key whose value
1547        /// is a valid JSON object containing valid system parameters.
1548        ///
1549        /// Run `SHOW ALL` in SQL to see a subset of configurable system parameters.
1550        ///
1551        /// Example ConfigMap:
1552        /// ```yaml
1553        /// data:
1554        ///   system-params.json: |
1555        ///     {
1556        ///       "max_connections": 1000
1557        ///     }
1558        /// ```
1559        pub system_parameter_configmap_name: Option<String>,
1560
1561        /// The configuration for generating an x509 certificate using cert-manager for balancerd
1562        /// to present to incoming connections.
1563        /// The `dnsNames` and `issuerRef` fields are required.
1564        ///
1565        /// This field is excluded from the rollout hash and changes will not trigger a rollout.
1566        pub balancerd_external_certificate_spec: Option<MaterializeCertSpec>,
1567        /// The configuration for generating an x509 certificate using cert-manager for the console
1568        /// to present to incoming connections.
1569        /// The `dnsNames` and `issuerRef` fields are required.
1570        /// Not yet implemented.
1571        ///
1572        /// This field is excluded from the rollout hash and changes will not trigger a rollout.
1573        pub console_external_certificate_spec: Option<MaterializeCertSpec>,
1574        /// The cert-manager Issuer or ClusterIssuer to use for database internal communication.
1575        /// The `issuerRef` field is required.
1576        /// This currently is only used for environmentd, but will eventually support clusterd.
1577        /// Not yet implemented.
1578        pub internal_certificate_spec: Option<MaterializeCertSpec>,
1579    }
1580
1581    impl Materialize {
1582        pub fn generate_rollout_hash(&self) -> String {
1583            let mut hasher = Sha256::new();
1584            // Remove fields that don't affect the resources generated per generation,
1585            // and we don't want to trigger a rollout from.
1586            let spec = MaterializeSpec {
1587                environmentd_image_ref: self.spec.environmentd_image_ref.clone(),
1588                environmentd_extra_args: self.spec.environmentd_extra_args.clone(),
1589                environmentd_extra_env: self.spec.environmentd_extra_env.clone(),
1590                environmentd_connection_role_arn: self
1591                    .spec
1592                    .environmentd_connection_role_arn
1593                    .clone(),
1594                environmentd_resource_requirements: self
1595                    .spec
1596                    .environmentd_resource_requirements
1597                    .clone(),
1598                environmentd_scratch_volume_storage_requirement: self
1599                    .spec
1600                    .environmentd_scratch_volume_storage_requirement
1601                    .clone(),
1602                balancerd_resource_requirements: None,
1603                console_resource_requirements: None,
1604                balancerd_replicas: None,
1605                console_replicas: None,
1606                service_account_name: self.spec.service_account_name.clone(),
1607                service_account_annotations: self.spec.service_account_annotations.clone(),
1608                service_account_labels: self.spec.service_account_labels.clone(),
1609                pod_annotations: self.spec.pod_annotations.clone(),
1610                pod_labels: self.spec.pod_labels.clone(),
1611                force_promote: None,
1612                force_rollout: self.spec.force_rollout,
1613                rollout_strategy: self.spec.rollout_strategy.clone(),
1614                rollout_request_timeout: self.spec.rollout_request_timeout.clone(),
1615                backend_secret_name: self.spec.backend_secret_name.clone(),
1616                authenticator_kind: self.spec.authenticator_kind,
1617                enable_rbac: self.spec.enable_rbac,
1618                environment_id: self.spec.environment_id,
1619                system_parameter_configmap_name: self.spec.system_parameter_configmap_name.clone(),
1620                balancerd_external_certificate_spec: None,
1621                console_external_certificate_spec: None,
1622                internal_certificate_spec: self.spec.internal_certificate_spec.clone(),
1623            };
1624            hasher.update(&serde_json::to_vec(&spec).unwrap());
1625            if let Some(annotation) = self
1626                .metadata
1627                .annotations
1628                .as_ref()
1629                .and_then(|annotations| annotations.get(FORCE_ROLLOUT_ANNOTATION))
1630            {
1631                hasher.update(annotation);
1632            }
1633            format!("{:x}", hasher.finalize())
1634        }
1635
1636        pub fn backend_secret_name(&self) -> String {
1637            self.spec.backend_secret_name.clone()
1638        }
1639
1640        pub fn namespace(&self) -> String {
1641            self.meta().namespace.clone().unwrap()
1642        }
1643
1644        pub fn create_service_account(&self) -> bool {
1645            self.spec.service_account_name.is_none()
1646        }
1647
1648        pub fn service_account_name(&self) -> String {
1649            self.spec
1650                .service_account_name
1651                .clone()
1652                .unwrap_or_else(|| self.name_unchecked())
1653        }
1654
1655        pub fn role_name(&self) -> String {
1656            self.name_unchecked()
1657        }
1658
1659        pub fn role_binding_name(&self) -> String {
1660            self.name_unchecked()
1661        }
1662
1663        pub fn environmentd_statefulset_name(&self, generation: u64) -> String {
1664            self.name_prefixed(&format!("environmentd-{generation}"))
1665        }
1666
1667        pub fn environmentd_app_name(&self) -> String {
1668            "environmentd".to_owned()
1669        }
1670
1671        pub fn environmentd_service_name(&self) -> String {
1672            self.name_prefixed("environmentd")
1673        }
1674
1675        pub fn environmentd_service_internal_fqdn(&self) -> String {
1676            format!(
1677                "{}.{}.svc.cluster.local",
1678                self.environmentd_service_name(),
1679                self.meta().namespace.as_ref().unwrap()
1680            )
1681        }
1682
1683        pub fn environmentd_generation_service_name(&self, generation: u64) -> String {
1684            self.name_prefixed(&format!("environmentd-{generation}"))
1685        }
1686
1687        pub fn balancerd_app_name(&self) -> String {
1688            "balancerd".to_owned()
1689        }
1690
1691        pub fn environmentd_certificate_name(&self) -> String {
1692            self.name_prefixed("environmentd-external")
1693        }
1694
1695        pub fn environmentd_certificate_secret_name(&self) -> String {
1696            self.name_prefixed("environmentd-tls")
1697        }
1698
1699        pub fn balancerd_deployment_name(&self) -> String {
1700            self.name_prefixed("balancerd")
1701        }
1702
1703        pub fn balancerd_service_name(&self) -> String {
1704            self.name_prefixed("balancerd")
1705        }
1706
1707        pub fn console_app_name(&self) -> String {
1708            "console".to_owned()
1709        }
1710
1711        pub fn balancerd_external_certificate_name(&self) -> String {
1712            self.name_prefixed("balancerd-external")
1713        }
1714
1715        pub fn balancerd_external_certificate_secret_name(&self) -> String {
1716            self.name_prefixed("balancerd-external-tls")
1717        }
1718
1719        pub fn balancerd_replicas(&self) -> i32 {
1720            self.spec.balancerd_replicas.unwrap_or(2)
1721        }
1722
1723        pub fn console_replicas(&self) -> i32 {
1724            self.spec.console_replicas.unwrap_or(2)
1725        }
1726
1727        pub fn console_configmap_name(&self) -> String {
1728            self.name_prefixed("console")
1729        }
1730
1731        pub fn console_deployment_name(&self) -> String {
1732            self.name_prefixed("console")
1733        }
1734
1735        pub fn console_service_name(&self) -> String {
1736            self.name_prefixed("console")
1737        }
1738
1739        pub fn console_external_certificate_name(&self) -> String {
1740            self.name_prefixed("console-external")
1741        }
1742
1743        pub fn console_external_certificate_secret_name(&self) -> String {
1744            self.name_prefixed("console-external-tls")
1745        }
1746
1747        pub fn persist_pubsub_service_name(&self, generation: u64) -> String {
1748            self.name_prefixed(&format!("persist-pubsub-{generation}"))
1749        }
1750
1751        pub fn listeners_configmap_name(&self, generation: u64) -> String {
1752            self.name_prefixed(&format!("listeners-{generation}"))
1753        }
1754
1755        pub fn name_prefixed(&self, suffix: &str) -> String {
1756            format!("mz{}-{}", self.resource_id(), suffix)
1757        }
1758
1759        pub fn resource_id(&self) -> &str {
1760            &self.status.as_ref().unwrap().resource_id
1761        }
1762
1763        pub fn system_parameter_configmap_name(&self) -> Option<String> {
1764            self.spec.system_parameter_configmap_name.clone()
1765        }
1766
1767        pub fn environmentd_scratch_volume_storage_requirement(&self) -> Quantity {
1768            self.spec
1769                .environmentd_scratch_volume_storage_requirement
1770                .clone()
1771                .unwrap_or_else(|| {
1772                    self.spec
1773                        .environmentd_resource_requirements
1774                        .as_ref()
1775                        .and_then(|requirements| {
1776                            requirements
1777                                .requests
1778                                .as_ref()
1779                                .or(requirements.limits.as_ref())
1780                        })
1781                        // TODO: in cloud, we've been defaulting to twice the
1782                        // memory limit, but k8s-openapi doesn't seem to
1783                        // provide any way to parse Quantity values, so there
1784                        // isn't an easy way to do arithmetic on it
1785                        .and_then(|requirements| requirements.get("memory").cloned())
1786                        // TODO: is there a better default to use here?
1787                        .unwrap_or_else(|| Quantity("4096Mi".to_string()))
1788                })
1789        }
1790
1791        pub fn environment_id(&self, cloud_provider: &str, region: &str) -> String {
1792            format!(
1793                "{}-{}-{}-0",
1794                cloud_provider, region, self.spec.environment_id,
1795            )
1796        }
1797
1798        pub fn rollout_requested(&self) -> bool {
1799            self.status
1800                .as_ref()
1801                .map(|status| status.last_completed_rollout_hash != status.requested_rollout_hash)
1802                .unwrap_or(false)
1803        }
1804
1805        pub fn set_force_promote(&mut self) {
1806            self.spec.force_promote = Some(self.generate_rollout_hash());
1807        }
1808
1809        pub fn should_force_promote(&self) -> bool {
1810            self.spec.force_promote.as_ref()
1811                == self
1812                    .status
1813                    .as_ref()
1814                    .and_then(|status| status.requested_rollout_hash.as_ref())
1815                || self.spec.rollout_strategy
1816                    == MaterializeRolloutStrategy::ImmediatelyPromoteCausingDowntime
1817        }
1818
1819        pub fn conditions_need_update(&self) -> bool {
1820            let Some(status) = self.status.as_ref() else {
1821                return true;
1822            };
1823            if status.conditions.is_empty() {
1824                return true;
1825            }
1826            for condition in &status.conditions {
1827                if condition.observed_generation != self.meta().generation {
1828                    return true;
1829                }
1830            }
1831            false
1832        }
1833
1834        pub fn is_ready_to_promote(&self, rollout_hash: &str) -> bool {
1835            let Some(status) = self.status.as_ref() else {
1836                return false;
1837            };
1838            if status.conditions.is_empty() {
1839                return false;
1840            }
1841            status
1842                .conditions
1843                .iter()
1844                .any(|condition| condition.reason == "ReadyToPromote")
1845                && status.requested_rollout_hash.as_deref() == Some(rollout_hash)
1846        }
1847
1848        pub fn is_promoting(&self) -> bool {
1849            let Some(status) = self.status.as_ref() else {
1850                return false;
1851            };
1852            if status.conditions.is_empty() {
1853                return false;
1854            }
1855            status
1856                .conditions
1857                .iter()
1858                .any(|condition| condition.reason == "Promoting")
1859        }
1860
1861        pub fn update_in_progress(&self) -> bool {
1862            let Some(status) = self.status.as_ref() else {
1863                return false;
1864            };
1865            if status.conditions.is_empty() {
1866                return false;
1867            }
1868            for condition in &status.conditions {
1869                if condition.type_ == "UpToDate" && condition.status == "Unknown" {
1870                    return true;
1871                }
1872            }
1873            false
1874        }
1875
1876        /// Checks that the given version is greater than or equal
1877        /// to the existing version, if the existing version
1878        /// can be parsed.
1879        pub fn meets_minimum_version(&self, minimum: &Version) -> bool {
1880            let version = parse_image_ref(&self.spec.environmentd_image_ref);
1881            match version {
1882                // Use cmp_precedence() to ignore build metadata per SemVer 2.0.0 spec
1883                Some(version) => version.cmp_precedence(minimum).is_ge(),
1884                // In the rare case that we see an image reference
1885                // that we can't parse, we assume that it satisfies all
1886                // version checks. Usually these are custom images that have
1887                // been by a developer on a branch forked from a recent copy
1888                // of main, and so this works out reasonably well in practice.
1889                None => {
1890                    tracing::warn!(
1891                        image_ref = %self.spec.environmentd_image_ref,
1892                        "failed to parse image ref",
1893                    );
1894                    true
1895                }
1896            }
1897        }
1898
1899        /// This check isn't strictly required since environmentd will still be able to determine
1900        /// if the upgrade is allowed or not. However, doing this check allows us to provide
1901        /// the error as soon as possible and in a more user friendly way.
1902        pub fn is_valid_upgrade_version(active_version: &Version, next_version: &Version) -> bool {
1903            // Don't allow rolling back
1904            // Note: semver comparison handles RC versions correctly:
1905            // v26.0.0-rc.1 < v26.0.0-rc.2 < v26.0.0
1906            // Use cmp_precedence() to ignore build metadata
1907            if next_version.cmp_precedence(active_version) == std::cmp::Ordering::Less {
1908                return false;
1909            }
1910
1911            if active_version.major == 0 {
1912                if next_version.major != active_version.major {
1913                    if next_version.major == 26 {
1914                        // We require customers to upgrade from 0.147.20 (Self Managed 25.2) or v0.164.X (Cloud)
1915                        // before upgrading to 26.0.0
1916
1917                        return (active_version.minor == 147 && active_version.patch >= 20)
1918                            || active_version.minor >= 164;
1919                    } else {
1920                        return false;
1921                    }
1922                }
1923                // Self managed 25.1 to 25.2
1924                if next_version.minor == 147 && active_version.minor == 130 {
1925                    return true;
1926                }
1927                // only allow upgrading a single minor version at a time
1928                return next_version.minor <= active_version.minor + 1;
1929            } else if active_version.major >= 26 {
1930                // For versions 26.X.X and onwards, we deny upgrades past 1 major version of the active version
1931                return next_version.major <= active_version.major + 1;
1932            }
1933
1934            true
1935        }
1936
1937        /// Checks if the current environmentd image ref is within the upgrade window of the last
1938        /// successful rollout.
1939        pub fn within_upgrade_window(&self) -> bool {
1940            let active_environmentd_version = self
1941                .status
1942                .as_ref()
1943                .and_then(|status| {
1944                    status
1945                        .last_completed_rollout_environmentd_image_ref
1946                        .as_ref()
1947                })
1948                .and_then(|image_ref| parse_image_ref(image_ref));
1949
1950            if let (Some(next_environmentd_version), Some(active_environmentd_version)) = (
1951                parse_image_ref(&self.spec.environmentd_image_ref),
1952                active_environmentd_version,
1953            ) {
1954                Self::is_valid_upgrade_version(
1955                    &active_environmentd_version,
1956                    &next_environmentd_version,
1957                )
1958            } else {
1959                // If we fail to parse either version,
1960                // we still allow the upgrade since environmentd will still error if the upgrade is not allowed.
1961                true
1962            }
1963        }
1964
1965        pub fn status(&self) -> MaterializeStatus {
1966            self.status.clone().unwrap_or_else(|| {
1967                let mut status = MaterializeStatus::default();
1968
1969                status.resource_id = new_resource_id();
1970
1971                // If we're creating the initial status on an un-soft-deleted
1972                // Environment we need to ensure that the last active generation
1973                // is restored, otherwise the env will crash loop indefinitely
1974                // as its catalog would have durably recorded a greater generation
1975                if let Some(last_active_generation) = self
1976                    .annotations()
1977                    .get(LAST_KNOWN_ACTIVE_GENERATION_ANNOTATION)
1978                {
1979                    status.active_generation = last_active_generation
1980                        .parse()
1981                        .expect("valid int generation");
1982                }
1983
1984                // Initialize the last completed rollout environmentd image ref to
1985                // the current image ref if not already set.
1986                status.last_completed_rollout_environmentd_image_ref =
1987                    Some(self.spec.environmentd_image_ref.clone());
1988
1989                status
1990            })
1991        }
1992    }
1993
1994    #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema, PartialEq)]
1995    #[serde(rename_all = "camelCase")]
1996    pub struct MaterializeStatus {
1997        /// Resource identifier used as a name prefix to avoid pod name collisions.
1998        pub resource_id: String,
1999        /// The generation of Materialize pods actively capable of servicing requests.
2000        pub active_generation: u64,
2001        /// The image ref of the environmentd image that was last successfully rolled out.
2002        /// Used to deny upgrades past 1 major version from the last successful rollout.
2003        /// When None, we upgrade anyways.
2004        pub last_completed_rollout_environmentd_image_ref: Option<String>,
2005        /// The last completed rollout's requestedRolloutHash.
2006        pub last_completed_rollout_hash: Option<String>,
2007        /// Hash of a subset of the Materialize spec and other fields.
2008        /// This is used to determine when the spec has changed and we need to rollout.
2009        pub requested_rollout_hash: Option<String>,
2010        pub conditions: Vec<Condition>,
2011    }
2012
2013    impl MaterializeStatus {
2014        pub fn needs_update(&self, other: &Self) -> bool {
2015            let now = Timestamp::now();
2016            let mut a = self.clone();
2017            for condition in &mut a.conditions {
2018                condition.last_transition_time = Time(now);
2019            }
2020            let mut b = other.clone();
2021            for condition in &mut b.conditions {
2022                condition.last_transition_time = Time(now);
2023            }
2024            a != b
2025        }
2026    }
2027
2028    impl ManagedResource for Materialize {
2029        fn default_labels(&self) -> BTreeMap<String, String> {
2030            BTreeMap::from_iter([
2031                (
2032                    "materialize.cloud/organization-name".to_owned(),
2033                    self.name_unchecked(),
2034                ),
2035                (
2036                    "materialize.cloud/organization-namespace".to_owned(),
2037                    self.namespace(),
2038                ),
2039                (
2040                    "materialize.cloud/mz-resource-id".to_owned(),
2041                    self.resource_id().to_owned(),
2042                ),
2043            ])
2044        }
2045
2046        fn app_name(&self) -> Option<&str> {
2047            Some("environmentd")
2048        }
2049    }
2050
2051    impl From<v1alpha1::Materialize> for Materialize {
2052        fn from(value: v1alpha1::Materialize) -> Self {
2053            let is_promoting = value.is_promoting();
2054            let service_account_annotations = if let Some(environmentd_iam_role_arn) =
2055                value.spec.environmentd_iam_role_arn
2056            {
2057                let mut annotations = value.spec.service_account_annotations.unwrap_or_default();
2058                annotations
2059                    .entry("eks.amazonaws.com/role-arn".to_owned())
2060                    .or_insert(environmentd_iam_role_arn);
2061                Some(annotations)
2062            } else {
2063                value.spec.service_account_annotations
2064            };
2065            let mut mz = Materialize {
2066                metadata: value.metadata,
2067                spec: MaterializeSpec {
2068                    environmentd_image_ref: value.spec.environmentd_image_ref,
2069                    environmentd_extra_args: value.spec.environmentd_extra_args,
2070                    environmentd_extra_env: value.spec.environmentd_extra_env,
2071                    environmentd_connection_role_arn: value.spec.environmentd_connection_role_arn,
2072                    environmentd_resource_requirements: value
2073                        .spec
2074                        .environmentd_resource_requirements,
2075                    environmentd_scratch_volume_storage_requirement: value
2076                        .spec
2077                        .environmentd_scratch_volume_storage_requirement,
2078                    balancerd_resource_requirements: value.spec.balancerd_resource_requirements,
2079                    console_resource_requirements: value.spec.console_resource_requirements,
2080                    balancerd_replicas: value.spec.balancerd_replicas,
2081                    console_replicas: value.spec.console_replicas,
2082                    service_account_name: value.spec.service_account_name,
2083                    service_account_annotations,
2084                    service_account_labels: value.spec.service_account_labels,
2085                    pod_annotations: value.spec.pod_annotations,
2086                    pod_labels: value.spec.pod_labels,
2087                    force_promote: if value.spec.force_promote.is_empty()
2088                        || &value.spec.force_promote == "00000000-0000-0000-0000-000000000000"
2089                    {
2090                        None
2091                    } else {
2092                        Some(value.spec.force_promote.to_string())
2093                    },
2094                    force_rollout: value.spec.force_rollout,
2095                    rollout_strategy: value.spec.rollout_strategy,
2096                    rollout_request_timeout: value.spec.rollout_request_timeout,
2097                    backend_secret_name: value.spec.backend_secret_name,
2098                    authenticator_kind: value.spec.authenticator_kind,
2099                    enable_rbac: value.spec.enable_rbac,
2100                    environment_id: value.spec.environment_id,
2101                    system_parameter_configmap_name: value.spec.system_parameter_configmap_name,
2102                    balancerd_external_certificate_spec: value
2103                        .spec
2104                        .balancerd_external_certificate_spec,
2105                    console_external_certificate_spec: value.spec.console_external_certificate_spec,
2106                    internal_certificate_spec: value.spec.internal_certificate_spec,
2107                },
2108                status: None,
2109            };
2110            let calculated_rollout_hash = mz.generate_rollout_hash();
2111            let last_completed_rollout_hash = match value
2112                .status
2113                .as_ref()
2114                .and_then(|status| status.last_completed_rollout_hash.to_owned())
2115            {
2116                Some(last_completed_rollout_hash) => Some(last_completed_rollout_hash),
2117                None => {
2118                    let currently_rolling_out = value
2119                        .status
2120                        .as_ref()
2121                        .map(|status| {
2122                            status.last_completed_rollout_request != value.spec.request_rollout
2123                                // If this is the first apply,
2124                                // these could both be nil and we still need to do a rollout.
2125                                || status.last_completed_rollout_request.is_nil()
2126                        })
2127                        .unwrap_or(true);
2128                    if currently_rolling_out {
2129                        // If they store a change, we're going to start over on a new rollout.
2130                        None
2131                    } else {
2132                        Some(calculated_rollout_hash.clone())
2133                    }
2134                }
2135            };
2136            let requested_rollout_hash = if is_promoting {
2137                None
2138            } else {
2139                Some(calculated_rollout_hash)
2140            };
2141            mz.status = value.status.map(|status| MaterializeStatus {
2142                resource_id: status.resource_id,
2143                active_generation: status.active_generation,
2144                last_completed_rollout_environmentd_image_ref: status
2145                    .last_completed_rollout_environmentd_image_ref,
2146                last_completed_rollout_hash,
2147                requested_rollout_hash,
2148                conditions: status.conditions,
2149            });
2150            mz
2151        }
2152    }
2153
2154    /// Partial mirror of [`MaterializeSpec`] for field-wise conversion of
2155    /// objects that may be incomplete. See [`super::convert_v1alpha1_to_v1`].
2156    ///
2157    /// Each field is a [`PartialField`], distinguishing absent from explicit
2158    /// null from a value, and unknown fields pass through via `extra`, so
2159    /// serializing reproduces exactly what was deserialized. Adding a field
2160    /// to [`MaterializeSpec`] breaks the exhaustive destructures in the
2161    /// `From` impls below until its conversion is decided.
2162    #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
2163    #[serde(rename_all = "camelCase")]
2164    pub struct PartialMaterializeSpec {
2165        #[serde(
2166            default,
2167            with = "double_option",
2168            skip_serializing_if = "Option::is_none"
2169        )]
2170        pub environmentd_image_ref: PartialField,
2171        #[serde(
2172            default,
2173            with = "double_option",
2174            skip_serializing_if = "Option::is_none"
2175        )]
2176        pub environmentd_extra_args: PartialField,
2177        #[serde(
2178            default,
2179            with = "double_option",
2180            skip_serializing_if = "Option::is_none"
2181        )]
2182        pub environmentd_extra_env: PartialField,
2183        #[serde(
2184            default,
2185            with = "double_option",
2186            skip_serializing_if = "Option::is_none"
2187        )]
2188        pub environmentd_connection_role_arn: PartialField,
2189        #[serde(
2190            default,
2191            with = "double_option",
2192            skip_serializing_if = "Option::is_none"
2193        )]
2194        pub environmentd_resource_requirements: PartialField,
2195        #[serde(
2196            default,
2197            with = "double_option",
2198            skip_serializing_if = "Option::is_none"
2199        )]
2200        pub environmentd_scratch_volume_storage_requirement: PartialField,
2201        #[serde(
2202            default,
2203            with = "double_option",
2204            skip_serializing_if = "Option::is_none"
2205        )]
2206        pub balancerd_resource_requirements: PartialField,
2207        #[serde(
2208            default,
2209            with = "double_option",
2210            skip_serializing_if = "Option::is_none"
2211        )]
2212        pub console_resource_requirements: PartialField,
2213        #[serde(
2214            default,
2215            with = "double_option",
2216            skip_serializing_if = "Option::is_none"
2217        )]
2218        pub balancerd_replicas: PartialField,
2219        #[serde(
2220            default,
2221            with = "double_option",
2222            skip_serializing_if = "Option::is_none"
2223        )]
2224        pub console_replicas: PartialField,
2225        #[serde(
2226            default,
2227            with = "double_option",
2228            skip_serializing_if = "Option::is_none"
2229        )]
2230        pub service_account_name: PartialField,
2231        #[serde(
2232            default,
2233            with = "double_option",
2234            skip_serializing_if = "Option::is_none"
2235        )]
2236        pub service_account_annotations: PartialField,
2237        #[serde(
2238            default,
2239            with = "double_option",
2240            skip_serializing_if = "Option::is_none"
2241        )]
2242        pub service_account_labels: PartialField,
2243        #[serde(
2244            default,
2245            with = "double_option",
2246            skip_serializing_if = "Option::is_none"
2247        )]
2248        pub pod_annotations: PartialField,
2249        #[serde(
2250            default,
2251            with = "double_option",
2252            skip_serializing_if = "Option::is_none"
2253        )]
2254        pub pod_labels: PartialField,
2255        #[serde(
2256            default,
2257            with = "double_option",
2258            skip_serializing_if = "Option::is_none"
2259        )]
2260        pub force_promote: PartialField,
2261        #[serde(
2262            default,
2263            with = "double_option",
2264            skip_serializing_if = "Option::is_none"
2265        )]
2266        pub force_rollout: PartialField,
2267        #[serde(
2268            default,
2269            with = "double_option",
2270            skip_serializing_if = "Option::is_none"
2271        )]
2272        pub rollout_strategy: PartialField,
2273        #[serde(
2274            default,
2275            with = "double_option",
2276            skip_serializing_if = "Option::is_none"
2277        )]
2278        pub rollout_request_timeout: PartialField,
2279        #[serde(
2280            default,
2281            with = "double_option",
2282            skip_serializing_if = "Option::is_none"
2283        )]
2284        pub backend_secret_name: PartialField,
2285        #[serde(
2286            default,
2287            with = "double_option",
2288            skip_serializing_if = "Option::is_none"
2289        )]
2290        pub authenticator_kind: PartialField,
2291        #[serde(
2292            default,
2293            with = "double_option",
2294            skip_serializing_if = "Option::is_none"
2295        )]
2296        pub enable_rbac: PartialField,
2297        #[serde(
2298            default,
2299            with = "double_option",
2300            skip_serializing_if = "Option::is_none"
2301        )]
2302        pub environment_id: PartialField,
2303        #[serde(
2304            default,
2305            with = "double_option",
2306            skip_serializing_if = "Option::is_none"
2307        )]
2308        pub system_parameter_configmap_name: PartialField,
2309        #[serde(
2310            default,
2311            with = "double_option",
2312            skip_serializing_if = "Option::is_none"
2313        )]
2314        pub balancerd_external_certificate_spec: PartialField,
2315        #[serde(
2316            default,
2317            with = "double_option",
2318            skip_serializing_if = "Option::is_none"
2319        )]
2320        pub console_external_certificate_spec: PartialField,
2321        #[serde(
2322            default,
2323            with = "double_option",
2324            skip_serializing_if = "Option::is_none"
2325        )]
2326        pub internal_certificate_spec: PartialField,
2327        #[serde(flatten)]
2328        pub extra: serde_json::Map<String, serde_json::Value>,
2329    }
2330
2331    /// Partial mirror of [`MaterializeStatus`], see [`PartialMaterializeSpec`].
2332    #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
2333    #[serde(rename_all = "camelCase")]
2334    pub struct PartialMaterializeStatus {
2335        #[serde(
2336            default,
2337            with = "double_option",
2338            skip_serializing_if = "Option::is_none"
2339        )]
2340        pub resource_id: PartialField,
2341        #[serde(
2342            default,
2343            with = "double_option",
2344            skip_serializing_if = "Option::is_none"
2345        )]
2346        pub active_generation: PartialField,
2347        #[serde(
2348            default,
2349            with = "double_option",
2350            skip_serializing_if = "Option::is_none"
2351        )]
2352        pub last_completed_rollout_environmentd_image_ref: PartialField,
2353        #[serde(
2354            default,
2355            with = "double_option",
2356            skip_serializing_if = "Option::is_none"
2357        )]
2358        pub last_completed_rollout_hash: PartialField,
2359        #[serde(
2360            default,
2361            with = "double_option",
2362            skip_serializing_if = "Option::is_none"
2363        )]
2364        pub requested_rollout_hash: PartialField,
2365        #[serde(
2366            default,
2367            with = "double_option",
2368            skip_serializing_if = "Option::is_none"
2369        )]
2370        pub conditions: PartialField,
2371        #[serde(flatten)]
2372        pub extra: serde_json::Map<String, serde_json::Value>,
2373    }
2374
2375    /// Partial mirror of [`Materialize`], see [`PartialMaterializeSpec`].
2376    #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
2377    #[serde(rename_all = "camelCase")]
2378    pub struct PartialMaterialize {
2379        #[serde(default, skip_serializing_if = "Option::is_none")]
2380        pub api_version: Option<String>,
2381        #[serde(default, skip_serializing_if = "Option::is_none")]
2382        pub kind: Option<serde_json::Value>,
2383        #[serde(default, skip_serializing_if = "Option::is_none")]
2384        pub metadata: Option<serde_json::Value>,
2385        #[serde(default, skip_serializing_if = "Option::is_none")]
2386        pub spec: Option<PartialMaterializeSpec>,
2387        #[serde(default, skip_serializing_if = "Option::is_none")]
2388        pub status: Option<PartialMaterializeStatus>,
2389        #[serde(flatten)]
2390        pub extra: serde_json::Map<String, serde_json::Value>,
2391    }
2392
2393    impl From<MaterializeSpec> for PartialMaterializeSpec {
2394        fn from(spec: MaterializeSpec) -> Self {
2395            let MaterializeSpec {
2396                environmentd_image_ref,
2397                environmentd_extra_args,
2398                environmentd_extra_env,
2399                environmentd_connection_role_arn,
2400                environmentd_resource_requirements,
2401                environmentd_scratch_volume_storage_requirement,
2402                balancerd_resource_requirements,
2403                console_resource_requirements,
2404                balancerd_replicas,
2405                console_replicas,
2406                service_account_name,
2407                service_account_annotations,
2408                service_account_labels,
2409                pod_annotations,
2410                pod_labels,
2411                force_promote,
2412                force_rollout,
2413                rollout_strategy,
2414                rollout_request_timeout,
2415                backend_secret_name,
2416                authenticator_kind,
2417                enable_rbac,
2418                environment_id,
2419                system_parameter_configmap_name,
2420                balancerd_external_certificate_spec,
2421                console_external_certificate_spec,
2422                internal_certificate_spec,
2423            } = spec;
2424            Self {
2425                environmentd_image_ref: present(environmentd_image_ref),
2426                environmentd_extra_args: present_opt(environmentd_extra_args),
2427                environmentd_extra_env: present_opt(environmentd_extra_env),
2428                environmentd_connection_role_arn: present_opt(environmentd_connection_role_arn),
2429                environmentd_resource_requirements: present_opt(environmentd_resource_requirements),
2430                environmentd_scratch_volume_storage_requirement: present_opt(
2431                    environmentd_scratch_volume_storage_requirement,
2432                ),
2433                balancerd_resource_requirements: present_opt(balancerd_resource_requirements),
2434                console_resource_requirements: present_opt(console_resource_requirements),
2435                balancerd_replicas: present_opt(balancerd_replicas),
2436                console_replicas: present_opt(console_replicas),
2437                service_account_name: present_opt(service_account_name),
2438                service_account_annotations: present_opt(service_account_annotations),
2439                service_account_labels: present_opt(service_account_labels),
2440                pod_annotations: present_opt(pod_annotations),
2441                pod_labels: present_opt(pod_labels),
2442                force_promote: present_opt(force_promote),
2443                force_rollout: present(force_rollout),
2444                rollout_strategy: present(rollout_strategy),
2445                rollout_request_timeout: present(rollout_request_timeout),
2446                backend_secret_name: present(backend_secret_name),
2447                authenticator_kind: present(authenticator_kind),
2448                enable_rbac: present(enable_rbac),
2449                environment_id: present(environment_id),
2450                system_parameter_configmap_name: present_opt(system_parameter_configmap_name),
2451                balancerd_external_certificate_spec: present_opt(
2452                    balancerd_external_certificate_spec,
2453                ),
2454                console_external_certificate_spec: present_opt(console_external_certificate_spec),
2455                internal_certificate_spec: present_opt(internal_certificate_spec),
2456                extra: serde_json::Map::new(),
2457            }
2458        }
2459    }
2460
2461    impl From<MaterializeStatus> for PartialMaterializeStatus {
2462        fn from(status: MaterializeStatus) -> Self {
2463            let MaterializeStatus {
2464                resource_id,
2465                active_generation,
2466                last_completed_rollout_environmentd_image_ref,
2467                last_completed_rollout_hash,
2468                requested_rollout_hash,
2469                conditions,
2470            } = status;
2471            Self {
2472                resource_id: present(resource_id),
2473                active_generation: present(active_generation),
2474                last_completed_rollout_environmentd_image_ref: present_opt(
2475                    last_completed_rollout_environmentd_image_ref,
2476                ),
2477                last_completed_rollout_hash: present_opt(last_completed_rollout_hash),
2478                requested_rollout_hash: present_opt(requested_rollout_hash),
2479                conditions: present(conditions),
2480                extra: serde_json::Map::new(),
2481            }
2482        }
2483    }
2484
2485    impl From<Materialize> for PartialMaterialize {
2486        fn from(mz: Materialize) -> Self {
2487            let Materialize {
2488                metadata,
2489                spec,
2490                status,
2491            } = mz;
2492            Self {
2493                api_version: Some("materialize.cloud/v1".to_owned()),
2494                kind: Some("Materialize".into()),
2495                metadata: Some(
2496                    serde_json::to_value(metadata).expect("ObjectMeta serializes to JSON"),
2497                ),
2498                spec: Some(spec.into()),
2499                status: status.map(Into::into),
2500                extra: serde_json::Map::new(),
2501            }
2502        }
2503    }
2504
2505    impl From<super::v1alpha1::PartialMaterializeSpec> for PartialMaterializeSpec {
2506        fn from(spec: super::v1alpha1::PartialMaterializeSpec) -> Self {
2507            let super::v1alpha1::PartialMaterializeSpec {
2508                environmentd_image_ref,
2509                environmentd_extra_args,
2510                environmentd_extra_env,
2511                environmentd_iam_role_arn,
2512                environmentd_connection_role_arn,
2513                environmentd_resource_requirements,
2514                environmentd_scratch_volume_storage_requirement,
2515                balancerd_resource_requirements,
2516                console_resource_requirements,
2517                balancerd_replicas,
2518                console_replicas,
2519                service_account_name,
2520                service_account_annotations,
2521                service_account_labels,
2522                pod_annotations,
2523                pod_labels,
2524                request_rollout: _,
2525                force_promote,
2526                force_rollout,
2527                in_place_rollout: _,
2528                rollout_strategy,
2529                rollout_request_timeout,
2530                backend_secret_name,
2531                authenticator_kind,
2532                enable_rbac,
2533                environment_id,
2534                system_parameter_configmap_name,
2535                balancerd_external_certificate_spec,
2536                console_external_certificate_spec,
2537                internal_certificate_spec,
2538                extra,
2539            } = spec;
2540            let service_account_annotations = merge_environmentd_iam_role_arn(
2541                service_account_annotations,
2542                environmentd_iam_role_arn,
2543            );
2544            // "" and the nil UUID mean "not force promoting" in v1alpha1,
2545            // which v1 spells as an absent field.
2546            let force_promote = match force_promote {
2547                Some(Some(value)) if value == "" || value == NIL_UUID_STR => None,
2548                other => other,
2549            };
2550            Self {
2551                environmentd_image_ref,
2552                environmentd_extra_args,
2553                environmentd_extra_env,
2554                environmentd_connection_role_arn,
2555                environmentd_resource_requirements,
2556                environmentd_scratch_volume_storage_requirement,
2557                balancerd_resource_requirements,
2558                console_resource_requirements,
2559                balancerd_replicas,
2560                console_replicas,
2561                service_account_name,
2562                service_account_annotations,
2563                service_account_labels,
2564                pod_annotations,
2565                pod_labels,
2566                force_promote,
2567                force_rollout,
2568                rollout_strategy,
2569                rollout_request_timeout,
2570                backend_secret_name,
2571                authenticator_kind,
2572                enable_rbac,
2573                environment_id,
2574                system_parameter_configmap_name,
2575                balancerd_external_certificate_spec,
2576                console_external_certificate_spec,
2577                internal_certificate_spec,
2578                extra,
2579            }
2580        }
2581    }
2582
2583    impl From<super::v1alpha1::PartialMaterializeStatus> for PartialMaterializeStatus {
2584        fn from(status: super::v1alpha1::PartialMaterializeStatus) -> Self {
2585            let super::v1alpha1::PartialMaterializeStatus {
2586                resource_id,
2587                active_generation,
2588                last_completed_rollout_request: _,
2589                last_completed_rollout_environmentd_image_ref,
2590                resources_hash: _,
2591                last_completed_rollout_hash,
2592                conditions,
2593                extra,
2594            } = status;
2595            Self {
2596                resource_id,
2597                active_generation,
2598                last_completed_rollout_environmentd_image_ref,
2599                last_completed_rollout_hash,
2600                // Derived from the complete object, spliced in by
2601                // `convert_v1alpha1_to_v1` when the input is complete, left
2602                // absent for partial objects so a field manager cannot gain
2603                // ownership of a field it never set.
2604                requested_rollout_hash: None,
2605                conditions,
2606                extra,
2607            }
2608        }
2609    }
2610
2611    impl From<super::v1alpha1::PartialMaterialize> for PartialMaterialize {
2612        fn from(mz: super::v1alpha1::PartialMaterialize) -> Self {
2613            let super::v1alpha1::PartialMaterialize {
2614                api_version: _,
2615                kind,
2616                metadata,
2617                spec,
2618                status,
2619                extra,
2620            } = mz;
2621            Self {
2622                api_version: Some("materialize.cloud/v1".to_owned()),
2623                kind,
2624                metadata,
2625                spec: spec.map(Into::into),
2626                status: status.map(Into::into),
2627                extra,
2628            }
2629        }
2630    }
2631}
2632
2633/// The nil UUID rendered the way it appears in JSON-encoded specs.
2634const NIL_UUID_STR: &str = "00000000-0000-0000-0000-000000000000";
2635
2636/// One field of a partial object: absent (`None`), explicit null
2637/// (`Some(None)`), or a value (`Some(Some(_))`).
2638///
2639/// Values are untyped [`serde_json::Value`]s because conversion must be a
2640/// total function over anything schema-shaped, including values that do not
2641/// validate. The type system is used for field *names*: the partial mirror
2642/// structs convert via exhaustive destructures, so adding a field to a spec
2643/// without deciding its conversion does not compile.
2644pub type PartialField = Option<Option<serde_json::Value>>;
2645
2646/// Serde adapter for [`PartialField`] distinguishing explicit null from an
2647/// absent field. Use with `#[serde(default, with = "double_option",
2648/// skip_serializing_if = "Option::is_none")]`.
2649mod double_option {
2650    use serde::{Deserialize, Deserializer, Serialize, Serializer};
2651
2652    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
2653    where
2654        T: Deserialize<'de>,
2655        D: Deserializer<'de>,
2656    {
2657        Option::<T>::deserialize(deserializer).map(Some)
2658    }
2659
2660    pub fn serialize<T, S>(value: &Option<Option<T>>, serializer: S) -> Result<S::Ok, S::Error>
2661    where
2662        T: Serialize,
2663        S: Serializer,
2664    {
2665        match value {
2666            Some(inner) => inner.serialize(serializer),
2667            // Unreachable under skip_serializing_if = "Option::is_none".
2668            None => serializer.serialize_none(),
2669        }
2670    }
2671}
2672
2673/// A [`PartialField`] holding a value that is always present in the typed
2674/// struct.
2675fn present<T: Serialize>(value: T) -> PartialField {
2676    Some(Some(
2677        serde_json::to_value(value).expect("CRD field serializes to JSON"),
2678    ))
2679}
2680
2681/// A [`PartialField`] from a typed optional field, absent when `None`.
2682fn present_opt<T: Serialize>(value: Option<T>) -> PartialField {
2683    value.map(|value| Some(serde_json::to_value(value).expect("CRD field serializes to JSON")))
2684}
2685
2686/// Merges a v1alpha1 `environmentdIamRoleArn` value into the
2687/// `serviceAccountAnnotations` map as `eks.amazonaws.com/role-arn`, matching
2688/// the typed conversion. An existing annotation wins. Annotations that are
2689/// not an object pass through untouched.
2690fn merge_environmentd_iam_role_arn(
2691    annotations: PartialField,
2692    role_arn: PartialField,
2693) -> PartialField {
2694    let Some(Some(role_arn)) = role_arn else {
2695        return annotations;
2696    };
2697    let mut map = match annotations {
2698        Some(Some(serde_json::Value::Object(map))) => map,
2699        Some(Some(other)) => return Some(Some(other)),
2700        Some(None) | None => serde_json::Map::new(),
2701    };
2702    map.entry("eks.amazonaws.com/role-arn").or_insert(role_arn);
2703    Some(Some(serde_json::Value::Object(map)))
2704}
2705
2706/// Converts a JSON-encoded v1alpha1 Materialize object to v1, for use by the
2707/// CRD conversion webhook.
2708///
2709/// Output fields are exactly the input fields, mapped field-wise, plus
2710/// derived fields when the input is complete. Server-side apply round-trips
2711/// each field manager's owned subset of an object through the conversion
2712/// webhook when it reconciles managed fields recorded at another version.
2713/// Those subsets routinely lack required fields, so conversion must accept
2714/// partial objects, and it must not add fields the input did not carry,
2715/// since a field manager must not gain ownership of fields it never set.
2716///
2717/// Fields derived from the complete spec (`status.requestedRolloutHash`
2718/// here, `spec.requestRollout` in the other direction) cannot be computed
2719/// from a subset, so they are spliced in from the typed conversion only when
2720/// the input deserializes as a complete object. The request carries no
2721/// indicator of partialness, so a subset that happens to contain every
2722/// required field also gains the derived fields. That is the irreducible
2723/// ambiguity, and it is limited to exactly those fields.
2724pub fn convert_v1alpha1_to_v1(
2725    value: serde_json::Value,
2726) -> Result<serde_json::Value, anyhow::Error> {
2727    let complete = serde_json::from_value::<v1alpha1::Materialize>(value.clone()).ok();
2728    let partial: v1alpha1::PartialMaterialize = serde_json::from_value(value)?;
2729    let mut converted = v1::PartialMaterialize::from(partial);
2730    if let Some(complete) = complete {
2731        let typed = v1::Materialize::from(complete);
2732        if let (Some(status), Some(typed_status)) = (converted.status.as_mut(), typed.status) {
2733            status.requested_rollout_hash = present_opt(typed_status.requested_rollout_hash);
2734            status.last_completed_rollout_hash =
2735                present_opt(typed_status.last_completed_rollout_hash);
2736        }
2737    }
2738    Ok(serde_json::to_value(converted)?)
2739}
2740
2741/// Converts a JSON-encoded v1 Materialize object to v1alpha1, for use by the
2742/// CRD conversion webhook.
2743///
2744/// See [`convert_v1alpha1_to_v1`] for the conversion contract. In this
2745/// direction the derived fields are `spec.requestRollout` and
2746/// `status.lastCompletedRolloutRequest`, both deterministic UUIDs derived
2747/// from rollout hashes of the complete object, and `status.resourcesHash`,
2748/// which has no v1 counterpart but is required by the v1alpha1 schema
2749/// whenever a status is present, so complete conversions must carry the
2750/// typed conversion's placeholder to stay valid at the storage version.
2751pub fn convert_v1_to_v1alpha1(
2752    value: serde_json::Value,
2753) -> Result<serde_json::Value, anyhow::Error> {
2754    let complete = serde_json::from_value::<v1::Materialize>(value.clone()).ok();
2755    let partial: v1::PartialMaterialize = serde_json::from_value(value)?;
2756    let mut converted = v1alpha1::PartialMaterialize::from(partial);
2757    if let Some(complete) = complete {
2758        let typed = v1alpha1::Materialize::from(complete);
2759        if let Some(spec) = converted.spec.as_mut() {
2760            spec.request_rollout = present(typed.spec.request_rollout);
2761        }
2762        if let (Some(status), Some(typed_status)) = (converted.status.as_mut(), typed.status) {
2763            status.last_completed_rollout_request =
2764                present(typed_status.last_completed_rollout_request);
2765            status.resources_hash = present(typed_status.resources_hash);
2766        }
2767    }
2768    Ok(serde_json::to_value(converted)?)
2769}
2770
2771fn parse_image_ref(image_ref: &str) -> Option<Version> {
2772    image_ref
2773        .rsplit_once(':')
2774        .and_then(|(_repo, tag)| tag.strip_prefix('v'))
2775        .and_then(|tag| {
2776            // To work around Docker tag restrictions, build metadata in
2777            // a Docker tag is delimited by `--` rather than the SemVer
2778            // `+` delimiter. So we need to swap the delimiter back to
2779            // `+` before parsing it as SemVer.
2780            let tag = tag.replace("--", "+");
2781            Version::parse(&tag).ok()
2782        })
2783}
2784
2785#[cfg(test)]
2786mod tests {
2787    use std::time::Duration;
2788
2789    use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, Time};
2790    use k8s_openapi::jiff::Timestamp;
2791    use kube::core::ObjectMeta;
2792    use semver::Version;
2793
2794    use super::v1alpha1::{Materialize, MaterializeSpec, MaterializeStatus};
2795    use super::{DEFAULT_ROLLOUT_REQUEST_TIMEOUT, FORCE_ROLLOUT_ANNOTATION, RolloutRequestTimeout};
2796
2797    #[mz_ore::test]
2798    #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux`
2799    fn force_rollout_annotation_forces_new_generation() {
2800        // The force-rollout annotation must feed into both the v1 rollout
2801        // hash (so that a rollout is requested) and the v1alpha1 force
2802        // rollout value stamped onto the generated statefulset (so that the
2803        // requested rollout actually creates a new generation rather than
2804        // completing as a no-op).
2805        let mut mz = super::v1::Materialize {
2806            spec: super::v1::MaterializeSpec {
2807                environmentd_image_ref: "materialize/environmentd:v26.0.0".to_owned(),
2808                ..Default::default()
2809            },
2810            metadata: ObjectMeta::default(),
2811            status: None,
2812        };
2813        let hash_without_annotation = mz.generate_rollout_hash();
2814        let force_without_annotation = Materialize::from(mz.clone()).force_rollout_value();
2815
2816        mz.metadata.annotations = Some(std::collections::BTreeMap::from_iter([(
2817            FORCE_ROLLOUT_ANNOTATION.to_owned(),
2818            "a4b56cbb-a13e-4f95-8a9d-425c9ba28576".to_owned(),
2819        )]));
2820        assert_ne!(mz.generate_rollout_hash(), hash_without_annotation);
2821        assert_ne!(
2822            Materialize::from(mz.clone()).force_rollout_value(),
2823            force_without_annotation
2824        );
2825
2826        // Changing the annotation's value changes both again.
2827        let hash = mz.generate_rollout_hash();
2828        let force = Materialize::from(mz.clone()).force_rollout_value();
2829        mz.metadata.annotations.as_mut().unwrap().insert(
2830            FORCE_ROLLOUT_ANNOTATION.to_owned(),
2831            "3f61bf8d-0714-462c-8b3b-3d9a68d0bcba".to_owned(),
2832        );
2833        assert_ne!(mz.generate_rollout_hash(), hash);
2834        assert_ne!(Materialize::from(mz.clone()).force_rollout_value(), force);
2835    }
2836
2837    #[mz_ore::test]
2838    fn meets_minimum_version() {
2839        let mut mz = Materialize {
2840            spec: MaterializeSpec {
2841                environmentd_image_ref:
2842                    "materialize/environmentd:devel-47116c24b8d0df33d3f60a9ee476aa8d7bce5953"
2843                        .to_owned(),
2844                ..Default::default()
2845            },
2846            metadata: ObjectMeta {
2847                ..Default::default()
2848            },
2849            status: None,
2850        };
2851
2852        // true cases
2853        assert!(mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2854        mz.spec.environmentd_image_ref = "materialize/environmentd:v0.34.0".to_owned();
2855        assert!(mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2856        mz.spec.environmentd_image_ref = "materialize/environmentd:v0.35.0".to_owned();
2857        assert!(mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2858        mz.spec.environmentd_image_ref = "materialize/environmentd:v0.34.3".to_owned();
2859        assert!(mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2860        mz.spec.environmentd_image_ref = "materialize/environmentd@41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3".to_owned();
2861        assert!(mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2862        mz.spec.environmentd_image_ref = "my.private.registry:5000:v0.34.3".to_owned();
2863        assert!(mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2864        mz.spec.environmentd_image_ref = "materialize/environmentd:v0.asdf.0".to_owned();
2865        assert!(mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2866        mz.spec.environmentd_image_ref =
2867            "materialize/environmentd:v0.146.0-dev.0--pr.g5a05a9e4ba873be8adaa528644aaae6e4c7cd29b"
2868                .to_owned();
2869        assert!(mz.meets_minimum_version(&Version::parse("0.146.0-dev.0").unwrap()));
2870
2871        // false cases
2872        mz.spec.environmentd_image_ref = "materialize/environmentd:v0.34.0-dev".to_owned();
2873        assert!(!mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2874        mz.spec.environmentd_image_ref = "materialize/environmentd:v0.33.0".to_owned();
2875        assert!(!mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2876        mz.spec.environmentd_image_ref = "materialize/environmentd:v0.34.0".to_owned();
2877        assert!(!mz.meets_minimum_version(&Version::parse("1.0.0").unwrap()));
2878        mz.spec.environmentd_image_ref = "my.private.registry:5000:v0.33.3".to_owned();
2879        assert!(!mz.meets_minimum_version(&Version::parse("0.34.0").unwrap()));
2880    }
2881
2882    #[mz_ore::test]
2883    fn within_upgrade_window() {
2884        let mut mz = Materialize {
2885            spec: MaterializeSpec {
2886                environmentd_image_ref: "materialize/environmentd:v26.0.0".to_owned(),
2887                ..Default::default()
2888            },
2889            metadata: ObjectMeta {
2890                ..Default::default()
2891            },
2892            status: Some(MaterializeStatus {
2893                last_completed_rollout_environmentd_image_ref: Some(
2894                    "materialize/environmentd:v26.0.0".to_owned(),
2895                ),
2896                ..Default::default()
2897            }),
2898        };
2899
2900        // Pass: upgrading from 26.0.0 to 27.7.3 (within 1 major version)
2901        mz.spec.environmentd_image_ref = "materialize/environmentd:v27.7.3".to_owned();
2902        assert!(mz.within_upgrade_window());
2903
2904        // Pass: upgrading from 26.0.0 to 27.7.8-dev.0 (within 1 major version, pre-release)
2905        mz.spec.environmentd_image_ref = "materialize/environmentd:v27.7.8-dev.0".to_owned();
2906        assert!(mz.within_upgrade_window());
2907
2908        // Fail: upgrading from 26.0.0 to 28.0.1 (more than 1 major version)
2909        mz.spec.environmentd_image_ref = "materialize/environmentd:v28.0.1".to_owned();
2910        assert!(!mz.within_upgrade_window());
2911
2912        // Pass: upgrading from 26.0.0 to 28.0.1.not_a_valid_version (invalid version, defaults to true)
2913        mz.spec.environmentd_image_ref =
2914            "materialize/environmentd:v28.0.1.not_a_valid_version".to_owned();
2915        assert!(mz.within_upgrade_window());
2916
2917        // Pass: upgrading from 0.164.0 to 26.1.0 (self managed 25.2 to 26.0)
2918        mz.status
2919            .as_mut()
2920            .unwrap()
2921            .last_completed_rollout_environmentd_image_ref =
2922            Some("materialize/environmentd:v0.147.20".to_owned());
2923        mz.spec.environmentd_image_ref = "materialize/environmentd:v26.1.0".to_owned();
2924        assert!(mz.within_upgrade_window());
2925
2926        // Pass: upgrading from 26.11.0-dev.0+b to 26.11.0-dev.0+a (same major.minor.patch.prerelease, different build metadata)
2927        mz.status
2928            .as_mut()
2929            .unwrap()
2930            .last_completed_rollout_environmentd_image_ref =
2931            Some("materialize/environmentd:v26.11.0-dev.0+b".to_owned());
2932        mz.spec.environmentd_image_ref = "materialize/environmentd:v26.11.0-dev.0+a".to_owned();
2933        assert!(mz.within_upgrade_window());
2934    }
2935
2936    #[mz_ore::test]
2937    fn is_valid_upgrade_version() {
2938        let success_tests = [
2939            (Version::new(0, 83, 0), Version::new(0, 83, 0)),
2940            (Version::new(0, 83, 0), Version::new(0, 84, 0)),
2941            (Version::new(0, 9, 0), Version::new(0, 10, 0)),
2942            (Version::new(0, 99, 0), Version::new(0, 100, 0)),
2943            (Version::new(0, 83, 0), Version::new(0, 83, 1)),
2944            (Version::new(0, 83, 0), Version::new(0, 83, 2)),
2945            (Version::new(0, 83, 2), Version::new(0, 83, 10)),
2946            // 0.147.20 to 26.0.0 represents the Self Managed 25.2 to 26.0 upgrade
2947            (Version::new(0, 147, 20), Version::new(26, 0, 0)),
2948            (Version::new(0, 164, 0), Version::new(26, 0, 0)),
2949            (Version::new(26, 0, 0), Version::new(26, 1, 0)),
2950            (Version::new(26, 5, 3), Version::new(26, 10, 0)),
2951            (Version::new(0, 130, 0), Version::new(0, 147, 0)),
2952        ];
2953        for (active_version, next_version) in success_tests {
2954            assert!(
2955                Materialize::is_valid_upgrade_version(&active_version, &next_version),
2956                "v{active_version} can upgrade to v{next_version}"
2957            );
2958        }
2959
2960        let failure_tests = [
2961            (Version::new(0, 83, 0), Version::new(0, 82, 0)),
2962            (Version::new(0, 83, 3), Version::new(0, 83, 2)),
2963            (Version::new(0, 83, 3), Version::new(1, 83, 3)),
2964            (Version::new(0, 83, 0), Version::new(0, 85, 0)),
2965            (Version::new(26, 0, 0), Version::new(28, 0, 0)),
2966            (Version::new(0, 130, 0), Version::new(26, 1, 0)),
2967            // Disallow anything before 0.147.20 to upgrade
2968            (Version::new(0, 147, 1), Version::new(26, 0, 0)),
2969            // Disallow anything between 0.148.0 and 0.164.0 to upgrade
2970            (Version::new(0, 148, 0), Version::new(26, 0, 0)),
2971        ];
2972        for (active_version, next_version) in failure_tests {
2973            assert!(
2974                !Materialize::is_valid_upgrade_version(&active_version, &next_version),
2975                "v{active_version} can't upgrade to v{next_version}"
2976            );
2977        }
2978    }
2979
2980    #[mz_ore::test]
2981    fn rollout_request_timeout() {
2982        let mz_with = |timeout: &str| Materialize {
2983            spec: MaterializeSpec {
2984                rollout_request_timeout: RolloutRequestTimeout(timeout.to_owned()),
2985                ..Default::default()
2986            },
2987            metadata: ObjectMeta::default(),
2988            status: None,
2989        };
2990
2991        // The default const is a valid duration and resolves to 24h.
2992        let default = humantime::parse_duration(DEFAULT_ROLLOUT_REQUEST_TIMEOUT).unwrap();
2993        assert_eq!(default, Duration::from_secs(24 * 60 * 60));
2994
2995        // The field's Default (used by `MaterializeSpec::default()` and serde's
2996        // `#[serde(default)]`) is the 24h default, with no empty intermediate.
2997        assert_eq!(
2998            RolloutRequestTimeout::default().0,
2999            DEFAULT_ROLLOUT_REQUEST_TIMEOUT
3000        );
3001        assert_eq!(
3002            Materialize {
3003                spec: MaterializeSpec::default(),
3004                metadata: ObjectMeta::default(),
3005                status: None,
3006            }
3007            .rollout_request_timeout(),
3008            default
3009        );
3010
3011        // Parseable values are honored.
3012        assert_eq!(
3013            mz_with("1h").rollout_request_timeout(),
3014            Duration::from_secs(60 * 60)
3015        );
3016        assert_eq!(
3017            mz_with("90m").rollout_request_timeout(),
3018            Duration::from_secs(90 * 60)
3019        );
3020        assert_eq!(
3021            mz_with("1h 30m").rollout_request_timeout(),
3022            Duration::from_secs(90 * 60)
3023        );
3024        // Unparseable values fall back to the default.
3025        assert_eq!(mz_with("not a duration").rollout_request_timeout(), default);
3026    }
3027
3028    #[mz_ore::test]
3029    fn rollout_request_timeout_schema_default() {
3030        // The default must be surfaced in the generated CRD's OpenAPI schema
3031        // (not just in the Rust helper), so the Kubernetes API server defaults
3032        // omitted fields and `kubectl explain` shows it.
3033        let crd = serde_json::to_value(<Materialize as kube::CustomResourceExt>::crd())
3034            .expect("CRD serializes");
3035        let default = &crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
3036            ["properties"]["rolloutRequestTimeout"]["default"];
3037        assert_eq!(
3038            default,
3039            &serde_json::json!(DEFAULT_ROLLOUT_REQUEST_TIMEOUT),
3040            "rolloutRequestTimeout schema default missing/wrong in generated CRD",
3041        );
3042    }
3043
3044    #[mz_ore::test]
3045    fn rollout_in_progress_since() {
3046        let now = Timestamp::now();
3047        let condition = |type_: &str, status: &str| Condition {
3048            type_: type_.to_owned(),
3049            status: status.to_owned(),
3050            last_transition_time: Time(now),
3051            message: String::new(),
3052            observed_generation: None,
3053            reason: "Test".to_owned(),
3054        };
3055        let mz_with = |conditions: Vec<Condition>| Materialize {
3056            spec: MaterializeSpec::default(),
3057            metadata: ObjectMeta::default(),
3058            status: Some(MaterializeStatus {
3059                conditions,
3060                ..Default::default()
3061            }),
3062        };
3063
3064        // No status at all.
3065        let mz = Materialize {
3066            spec: MaterializeSpec::default(),
3067            metadata: ObjectMeta::default(),
3068            status: None,
3069        };
3070        assert_eq!(mz.rollout_in_progress_since(), None);
3071
3072        // A timeout-eligible rollout in progress is signalled by an `Unknown`
3073        // `UpToDate` condition (the Applying and ReadyToPromote phases).
3074        assert_eq!(
3075            mz_with(vec![condition("UpToDate", "Unknown")]).rollout_in_progress_since(),
3076            Some(now)
3077        );
3078
3079        // The `Promoting` phase is also `Unknown`, but must NOT be reported:
3080        // once promoting, the rollout can no longer be cancelled by the
3081        // timeout.
3082        assert_eq!(
3083            mz_with(vec![Condition {
3084                reason: "Promoting".to_owned(),
3085                ..condition("UpToDate", "Unknown")
3086            }])
3087            .rollout_in_progress_since(),
3088            None
3089        );
3090
3091        // A settled rollout (True/False) is not in progress.
3092        assert_eq!(
3093            mz_with(vec![condition("UpToDate", "True")]).rollout_in_progress_since(),
3094            None
3095        );
3096        assert_eq!(
3097            mz_with(vec![condition("UpToDate", "False")]).rollout_in_progress_since(),
3098            None
3099        );
3100    }
3101
3102    #[mz_ore::test]
3103    fn up_to_date_transition_time() {
3104        // Two distinct, fixed instants so we can tell "carried the old
3105        // timestamp" apart from "reset to now".
3106        let stored = Timestamp::from_second(1_000).unwrap();
3107        let now = Timestamp::from_second(2_000).unwrap();
3108
3109        let condition = |status: &str| Condition {
3110            type_: "UpToDate".to_owned(),
3111            status: status.to_owned(),
3112            last_transition_time: Time(stored),
3113            message: String::new(),
3114            observed_generation: None,
3115            reason: "Test".to_owned(),
3116        };
3117        let mz_with = |conditions: Vec<Condition>| Materialize {
3118            spec: MaterializeSpec::default(),
3119            metadata: ObjectMeta::default(),
3120            status: Some(MaterializeStatus {
3121                conditions,
3122                ..Default::default()
3123            }),
3124        };
3125
3126        // No prior condition: use `now`.
3127        let mz = Materialize {
3128            spec: MaterializeSpec::default(),
3129            metadata: ObjectMeta::default(),
3130            status: None,
3131        };
3132        assert_eq!(mz.up_to_date_transition_time("Unknown", now), now);
3133
3134        // Same status as the prior condition: carry its timestamp forward, so
3135        // consecutive same-status phases (Applying -> ReadyToPromote) share one
3136        // timer.
3137        assert_eq!(
3138            mz_with(vec![condition("Unknown")]).up_to_date_transition_time("Unknown", now),
3139            stored
3140        );
3141
3142        // Status changed: reset to `now`.
3143        assert_eq!(
3144            mz_with(vec![condition("Unknown")]).up_to_date_transition_time("True", now),
3145            now
3146        );
3147    }
3148
3149    #[mz_ore::test]
3150    fn active_environmentd_image_ref() {
3151        const OLD: &str = "materialize/environmentd:v26.0.0";
3152        const NEW: &str = "materialize/environmentd:v27.0.0";
3153
3154        let mz_with = |spec_image: &str, status: Option<MaterializeStatus>| Materialize {
3155            spec: MaterializeSpec {
3156                environmentd_image_ref: spec_image.to_owned(),
3157                ..Default::default()
3158            },
3159            metadata: ObjectMeta::default(),
3160            status,
3161        };
3162
3163        // No status yet (pre-initial-reconcile): fall back to spec.
3164        let mz = mz_with(NEW, None);
3165        assert_eq!(mz.active_environmentd_image_ref(), NEW);
3166
3167        // Status present but last_completed_rollout_environmentd_image_ref
3168        // unset (e.g. resource upgraded from older orchestratord that didn't
3169        // populate the field): fall back to spec.
3170        let mz = mz_with(
3171            NEW,
3172            Some(MaterializeStatus {
3173                last_completed_rollout_environmentd_image_ref: None,
3174                ..Default::default()
3175            }),
3176        );
3177        assert_eq!(mz.active_environmentd_image_ref(), NEW);
3178
3179        // Steady state: spec image == last completed image. Either source is
3180        // fine; the method must return that image.
3181        let mz = mz_with(
3182            NEW,
3183            Some(MaterializeStatus {
3184                last_completed_rollout_environmentd_image_ref: Some(NEW.to_owned()),
3185                ..Default::default()
3186            }),
3187        );
3188        assert_eq!(mz.active_environmentd_image_ref(), NEW);
3189
3190        // DEP-42 / mid-rollout: spec image == NEW but last_completed_* still
3191        // holds OLD — either because the user canceled the rollout by
3192        // reverting only requestRollout, or because the new generation has
3193        // not yet been promoted. The active environmentd is still OLD, so
3194        // downstream resources must track OLD. Without this method,
3195        // balancerd would inherit the spec's NEW image while environmentd
3196        // still runs OLD, leaving balancerd pods skewed from the running
3197        // env.
3198        let mz = mz_with(
3199            NEW,
3200            Some(MaterializeStatus {
3201                last_completed_rollout_environmentd_image_ref: Some(OLD.to_owned()),
3202                ..Default::default()
3203            }),
3204        );
3205        assert_eq!(mz.active_environmentd_image_ref(), OLD);
3206    }
3207
3208    // Server-side apply round-trips each field manager's owned subset of an
3209    // object through the conversion webhook. Such subsets lack required
3210    // fields, so conversion must map them field-wise instead of erroring.
3211    #[mz_ore::test]
3212    fn convert_partial_v1alpha1_to_v1() {
3213        let subset = serde_json::json!({
3214            "apiVersion": "materialize.cloud/v1alpha1",
3215            "kind": "Materialize",
3216            "metadata": {"name": "mz", "namespace": "materialize"},
3217            "spec": {
3218                "environmentdIamRoleArn": "arn:aws:iam::123456789012:role/mz",
3219                "requestRollout": "1e6ef7bc-bff2-4bd4-90dc-eda5697bd0e6",
3220                "inPlaceRollout": false,
3221                "forcePromote": "",
3222                "serviceAccountLabels": {"team": "data"},
3223            },
3224            "status": {
3225                "activeGeneration": 3,
3226                "lastCompletedRolloutRequest": "1e6ef7bc-bff2-4bd4-90dc-eda5697bd0e6",
3227                "resourcesHash": "abc123",
3228            },
3229        });
3230        let converted = super::convert_v1alpha1_to_v1(subset).unwrap();
3231        assert_eq!(converted["apiVersion"], "materialize.cloud/v1");
3232        assert_eq!(converted["kind"], "Materialize");
3233        assert_eq!(converted["metadata"]["name"], "mz");
3234        let spec = converted["spec"].as_object().unwrap();
3235        assert!(!spec.contains_key("requestRollout"));
3236        assert!(!spec.contains_key("inPlaceRollout"));
3237        assert!(!spec.contains_key("forcePromote"));
3238        assert!(!spec.contains_key("environmentdIamRoleArn"));
3239        assert_eq!(
3240            spec["serviceAccountAnnotations"]["eks.amazonaws.com/role-arn"],
3241            "arn:aws:iam::123456789012:role/mz"
3242        );
3243        assert_eq!(spec["serviceAccountLabels"]["team"], "data");
3244        let status = converted["status"].as_object().unwrap();
3245        assert_eq!(status["activeGeneration"], 3);
3246        assert!(!status.contains_key("lastCompletedRolloutRequest"));
3247        assert!(!status.contains_key("resourcesHash"));
3248        assert!(!status.contains_key("requestedRolloutHash"));
3249
3250        // A meaningful forcePromote value survives the conversion.
3251        let subset = serde_json::json!({
3252            "apiVersion": "materialize.cloud/v1alpha1",
3253            "kind": "Materialize",
3254            "metadata": {"name": "mz"},
3255            "spec": {"forcePromote": "1e6ef7bc-bff2-4bd4-90dc-eda5697bd0e6"},
3256        });
3257        let converted = super::convert_v1alpha1_to_v1(subset).unwrap();
3258        assert_eq!(
3259            converted["spec"]["forcePromote"],
3260            "1e6ef7bc-bff2-4bd4-90dc-eda5697bd0e6"
3261        );
3262    }
3263
3264    #[mz_ore::test]
3265    fn convert_partial_v1_to_v1alpha1() {
3266        let subset = serde_json::json!({
3267            "apiVersion": "materialize.cloud/v1",
3268            "kind": "Materialize",
3269            "metadata": {"name": "mz"},
3270            "spec": {"environmentdImageRef": "materialize/environmentd:v26.0.0"},
3271            "status": {"requestedRolloutHash": "abc123", "activeGeneration": 1},
3272        });
3273        let converted = super::convert_v1_to_v1alpha1(subset).unwrap();
3274        assert_eq!(converted["apiVersion"], "materialize.cloud/v1alpha1");
3275        assert_eq!(
3276            converted["spec"]["environmentdImageRef"],
3277            "materialize/environmentd:v26.0.0"
3278        );
3279        let status = converted["status"].as_object().unwrap();
3280        assert_eq!(status["activeGeneration"], 1);
3281        assert!(!status.contains_key("requestedRolloutHash"));
3282        assert!(!status.contains_key("resourcesHash"));
3283        // Derived fields must not be invented for partial objects, a field
3284        // manager must not gain ownership of fields it never set.
3285        assert!(
3286            !converted["spec"]
3287                .as_object()
3288                .unwrap()
3289                .contains_key("requestRollout")
3290        );
3291    }
3292
3293    #[mz_ore::test]
3294    #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux`
3295    fn convert_full_v1alpha1_to_v1_derives_fields() {
3296        let mz = Materialize {
3297            metadata: ObjectMeta {
3298                name: Some("mz".to_owned()),
3299                namespace: Some("materialize".to_owned()),
3300                ..Default::default()
3301            },
3302            spec: MaterializeSpec {
3303                environmentd_image_ref: "materialize/environmentd:v26.0.0".to_owned(),
3304                backend_secret_name: "mz-backend".to_owned(),
3305                ..Default::default()
3306            },
3307            status: Some(MaterializeStatus::default()),
3308        };
3309        let value = serde_json::to_value(&mz).unwrap();
3310        let converted = super::convert_v1alpha1_to_v1(value).unwrap();
3311        assert_eq!(converted["apiVersion"], "materialize.cloud/v1");
3312        // Complete objects take the typed conversion, which computes fields
3313        // derived from the whole spec.
3314        assert!(converted["status"]["requestedRolloutHash"].is_string());
3315    }
3316
3317    #[mz_ore::test]
3318    fn convert_rejects_non_objects() {
3319        assert!(super::convert_v1alpha1_to_v1(serde_json::json!("not an object")).is_err());
3320        assert!(super::convert_v1_to_v1alpha1(serde_json::json!(42)).is_err());
3321        assert!(
3322            super::convert_v1alpha1_to_v1(serde_json::json!({"spec": "not an object"})).is_err()
3323        );
3324    }
3325
3326    #[mz_ore::test]
3327    fn convert_preserves_null_vs_absent() {
3328        let subset = serde_json::json!({
3329            "apiVersion": "materialize.cloud/v1alpha1",
3330            "kind": "Materialize",
3331            "metadata": {"name": "mz"},
3332            "spec": {
3333                "environmentdExtraArgs": null,
3334                "backendSecretName": "mz-backend",
3335            },
3336        });
3337        let converted = super::convert_v1alpha1_to_v1(subset).unwrap();
3338        let spec = converted["spec"].as_object().unwrap();
3339        assert!(spec.contains_key("environmentdExtraArgs"));
3340        assert!(spec["environmentdExtraArgs"].is_null());
3341        assert!(!spec.contains_key("consoleReplicas"));
3342    }
3343
3344    // A subset containing every required field deserializes as a complete
3345    // object and takes the derived-field splice, but the conversion must
3346    // still not add defaults or nulls for fields the subset did not carry.
3347    // Field managers must not gain ownership of fields they never set.
3348    #[mz_ore::test]
3349    #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux`
3350    fn convert_faithful_output_for_required_field_subsets() {
3351        let subset = serde_json::json!({
3352            "apiVersion": "materialize.cloud/v1alpha1",
3353            "kind": "Materialize",
3354            "metadata": {"name": "mz"},
3355            "spec": {
3356                "environmentdImageRef": "materialize/environmentd:v26.0.0",
3357                "backendSecretName": "mz-backend",
3358            },
3359        });
3360        let converted = super::convert_v1alpha1_to_v1(subset).unwrap();
3361        let spec = converted["spec"].as_object().unwrap();
3362        let mut keys: Vec<_> = spec.keys().cloned().collect();
3363        keys.sort();
3364        assert_eq!(keys, ["backendSecretName", "environmentdImageRef"]);
3365    }
3366
3367    #[mz_ore::test]
3368    fn convert_passes_unknown_fields_through() {
3369        let subset = serde_json::json!({
3370            "apiVersion": "materialize.cloud/v1alpha1",
3371            "kind": "Materialize",
3372            "metadata": {"name": "mz"},
3373            "spec": {"someFutureField": {"a": 1}},
3374            "someTopLevelField": true,
3375        });
3376        let converted = super::convert_v1alpha1_to_v1(subset).unwrap();
3377        assert_eq!(converted["spec"]["someFutureField"]["a"], 1);
3378        assert_eq!(converted["someTopLevelField"], true);
3379    }
3380
3381    #[mz_ore::test]
3382    #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux`
3383    fn convert_full_v1_to_v1alpha1_derives_request_rollout() {
3384        let mz = super::v1::Materialize {
3385            metadata: ObjectMeta {
3386                name: Some("mz".to_owned()),
3387                namespace: Some("materialize".to_owned()),
3388                ..Default::default()
3389            },
3390            spec: super::v1::MaterializeSpec {
3391                environmentd_image_ref: "materialize/environmentd:v26.0.0".to_owned(),
3392                backend_secret_name: "mz-backend".to_owned(),
3393                ..Default::default()
3394            },
3395            status: Some(super::v1::MaterializeStatus::default()),
3396        };
3397        let expected = Materialize::from(mz.clone()).spec.request_rollout;
3398        let converted = super::convert_v1_to_v1alpha1(serde_json::to_value(&mz).unwrap()).unwrap();
3399        assert_eq!(converted["apiVersion"], "materialize.cloud/v1alpha1");
3400        assert_eq!(
3401            converted["spec"]["requestRollout"],
3402            expected.hyphenated().to_string()
3403        );
3404        // The status fields required by the v1alpha1 schema but absent from
3405        // v1 must be spliced in, else the converted object fails validation
3406        // at the storage version.
3407        let status = converted["status"].as_object().unwrap();
3408        assert!(status["resourcesHash"].is_string());
3409        assert!(status["lastCompletedRolloutRequest"].is_string());
3410    }
3411
3412    // The `From<Materialize> for PartialMaterialize` impls are the
3413    // compile-time guard tying the partial mirrors to the typed structs.
3414    // Their output must be faithful: unset optional fields stay absent
3415    // rather than becoming nulls.
3416    #[mz_ore::test]
3417    fn partial_mirror_from_typed_omits_unset_fields() {
3418        let mz = Materialize {
3419            metadata: ObjectMeta::default(),
3420            spec: MaterializeSpec {
3421                environmentd_image_ref: "materialize/environmentd:v26.0.0".to_owned(),
3422                backend_secret_name: "mz-backend".to_owned(),
3423                ..Default::default()
3424            },
3425            status: None,
3426        };
3427        let value = serde_json::to_value(super::v1alpha1::PartialMaterialize::from(mz)).unwrap();
3428        let spec = value["spec"].as_object().unwrap();
3429        assert!(!spec.contains_key("balancerdReplicas"));
3430        for (key, field_value) in spec {
3431            assert!(!field_value.is_null(), "unexpected null for {key}");
3432        }
3433        assert!(!value.as_object().unwrap().contains_key("status"));
3434    }
3435}