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