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