diff --git a/CHANGELOG.md b/CHANGELOG.md index e0bb5d7..3331562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ All notable changes to RustFS Operator are documented in this file. The format i - Tenant `spec.hostUsers` and OpenShift `hostUsers: false` defaults for `restricted-v3`. - Tenant bucket canned anonymous access and ConfigMap-sourced bucket policies. - Declarative Tenant bucket lifecycle rules with drift-aware ownership and explicit removal. +- Bucket lifecycle `transition` and `noncurrentVersionTransition` actions that move objects to a + remote tier registered in RustFS. - Declarative Tenant bucket versioning and Object Lock default retention with drift-aware ownership. - Helm chart `cosiDriver.enabled` to deploy the RustFS COSI (`rustfs.objectstorage.k8s.io`) driver alongside the upstream provisioner sidecar, with dedicated ServiceAccount/RBAC. diff --git a/console-web/types/api.ts b/console-web/types/api.ts index d005a5f..78a860f 100644 --- a/console-web/types/api.ts +++ b/console-web/types/api.ts @@ -157,6 +157,14 @@ export interface BucketLifecycleRule { abortIncompleteMultipartUpload?: { daysAfterInitiation: number } + transition?: { + days: number + storageClass: string + } + noncurrentVersionTransition?: { + noncurrentDays: number + storageClass: string + } } export interface ProvisioningItemStatus { diff --git a/crates/rustfs-admin/src/admin_ops.rs b/crates/rustfs-admin/src/admin_ops.rs index 6f09dd6..1ac7079 100644 --- a/crates/rustfs-admin/src/admin_ops.rs +++ b/crates/rustfs-admin/src/admin_ops.rs @@ -16,17 +16,17 @@ //! - user CRUD and policy queries/expression on `/rustfs/admin/v3/*` //! - only admin protocol semantics live here; transport/signing is delegated. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use super::helpers::{ body_mentions_not_found, build_canonical_query, extract_canned_policy_document, - is_absent_resource, + is_absent_resource, parse_tier_names, }; use super::{ ADD_CANNED_POLICY_PATH, ADD_USER_PATH, ADMIN_SIGNING_SERVICE, INFO_CANNED_POLICY_PATH, - JSON_CONTENT_TYPE, LIST_CANNED_POLICIES_PATH, REMOVE_USER_PATH, RustfsAdminClient, - RustfsClientError, RustfsServerInfo, RustfsServerInfoResponse, RustfsUserInfo, - SERVER_INFO_PATH, SET_POLICY_PATH, USER_INFO_PATH, + JSON_CONTENT_TYPE, LIST_CANNED_POLICIES_PATH, LIST_TIERS_PATH, REMOVE_USER_PATH, + RustfsAdminClient, RustfsClientError, RustfsServerInfo, RustfsServerInfoResponse, + RustfsUserInfo, SERVER_INFO_PATH, SET_POLICY_PATH, USER_INFO_PATH, }; use reqwest::StatusCode; use serde_json::Value; @@ -175,6 +175,15 @@ impl RustfsAdminClient { .collect() } + /// Names of the remote tiers registered in RustFS. Only names are kept; the response also + /// carries tier endpoints and credentials, which are dropped while parsing. + pub async fn list_tier_names(&self) -> Result, RustfsClientError> { + let body = self + .send_admin_request("GET", LIST_TIERS_PATH, "", "", None) + .await?; + parse_tier_names(&body) + } + pub async fn server_info(&self) -> Result { let body = self .send_admin_request("GET", SERVER_INFO_PATH, "", "", None) diff --git a/crates/rustfs-admin/src/helpers.rs b/crates/rustfs-admin/src/helpers.rs index 0a94465..fb5b008 100644 --- a/crates/rustfs-admin/src/helpers.rs +++ b/crates/rustfs-admin/src/helpers.rs @@ -13,6 +13,8 @@ // limitations under the License. //! Internal helper duties: shared credential parsing, signature/hash utilities, and parsers. +use std::collections::BTreeSet; + use hmac::{Hmac, Mac}; use reqwest::StatusCode; use serde_json::Value; @@ -150,6 +152,33 @@ pub(super) fn extract_canned_policy_document(body: &str) -> Result", "": {"name": ..}}` +/// entries where the backend object is keyed by the tier type. +pub(super) fn parse_tier_names(body: &str) -> Result, RustfsClientError> { + let tiers = match serde_json::from_str::(body) + .map_err(|_| RustfsClientError::ParseResponseFailed)? + { + Value::Null => return Ok(BTreeSet::new()), + Value::Array(tiers) => tiers, + _ => return Err(RustfsClientError::ParseResponseFailed), + }; + tiers + .iter() + .map(|tier| { + let tier_type = tier + .get("type") + .and_then(Value::as_str) + .ok_or(RustfsClientError::ParseResponseFailed)?; + tier.get(tier_type) + .and_then(|backend| backend.get("name")) + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + .ok_or(RustfsClientError::ParseResponseFailed) + }) + .collect() +} + pub(super) fn sha256_hex(payload: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(payload); diff --git a/crates/rustfs-admin/src/lib.rs b/crates/rustfs-admin/src/lib.rs index deda0e6..09a673b 100644 --- a/crates/rustfs-admin/src/lib.rs +++ b/crates/rustfs-admin/src/lib.rs @@ -34,8 +34,9 @@ mod pool_ops; mod s3_ops; pub use s3_ops::{ BucketLifecycleAbortIncompleteMultipartUpload, BucketLifecycleConfiguration, - BucketLifecycleExpiration, BucketLifecycleRule, BucketLifecycleRuleStatus, - BucketObjectLockConfiguration, BucketObjectLockDefaultRetention, BucketObjectLockRetentionMode, + BucketLifecycleExpiration, BucketLifecycleNoncurrentVersionTransition, BucketLifecycleRule, + BucketLifecycleRuleStatus, BucketLifecycleTransition, BucketObjectLockConfiguration, + BucketObjectLockDefaultRetention, BucketObjectLockRetentionMode, BucketObjectLockRetentionPeriod, BucketVersioningState, BucketVersioningUpdate, canonicalize_bucket_lifecycle_xml, }; @@ -54,6 +55,7 @@ const LIST_CANNED_POLICIES_PATH: &str = "/rustfs/admin/v3/list-canned-policies"; const ADD_CANNED_POLICY_PATH: &str = "/rustfs/admin/v3/add-canned-policy"; const INFO_CANNED_POLICY_PATH: &str = "/rustfs/admin/v3/info-canned-policy"; const SERVER_INFO_PATH: &str = "/rustfs/admin/v3/info"; +const LIST_TIERS_PATH: &str = "/rustfs/admin/v3/tier"; const POOLS_LIST_PATH: &str = "/rustfs/admin/v3/pools/list"; const POOLS_STATUS_PATH: &str = "/rustfs/admin/v3/pools/status"; const POOLS_DECOMMISSION_PATH: &str = "/rustfs/admin/v3/pools/decommission"; @@ -557,9 +559,46 @@ impl RustfsAdminClient { #[cfg(test)] mod tests { - use super::helpers::build_canonical_query; + use super::helpers::{build_canonical_query, parse_tier_names}; use super::{RustfsClientError, StatusCode}; + #[test] + fn tier_names_are_read_from_the_backend_object_and_secrets_are_ignored() { + // Shape of GET /rustfs/admin/v3/tier from RustFS 1.0.2-preview.2 (credentials replaced). + let body = r#"[ + {"type":"s3","s3":{"name":"COLD","endpoint":"https://s3.example.com","accessKey":"AK","secretKey":"SK","bucket":"cold","prefix":"hot/","region":"us-1","storageClass":""}}, + {"type":"rustfs","rustfs":{"name":"ARCHIVE","endpoint":"https://archive.example.com","accessKey":"AK","secretKey":"SK","bucket":"archive"}} + ]"#; + + let names = parse_tier_names(body).expect("tier list parses"); + assert_eq!( + names.into_iter().collect::>(), + vec!["ARCHIVE".to_string(), "COLD".to_string()] + ); + assert!(parse_tier_names("[]").expect("empty list").is_empty()); + assert!(parse_tier_names("null").expect("null list").is_empty()); + } + + #[test] + fn tier_list_without_a_backend_name_is_rejected() { + for body in [ + r#"{"type":"s3"}"#, + r#"[{"s3":{"name":"COLD"}}]"#, + r#"[{"type":"s3","s3":{}}]"#, + r#"[{"type":"s3","s3":{"name":""}}]"#, + r#"[{"type":"s3","minio":{"name":"COLD"}}]"#, + "not json", + ] { + assert!( + matches!( + parse_tier_names(body), + Err(RustfsClientError::ParseResponseFailed) + ), + "{body} must be rejected" + ); + } + } + #[test] fn canonical_query_uses_sigv4_uri_encoding_and_encoded_sort_order() { let query = build_canonical_query(&[ diff --git a/crates/rustfs-admin/src/s3_ops.rs b/crates/rustfs-admin/src/s3_ops.rs index d4080b0..ff43c3e 100644 --- a/crates/rustfs-admin/src/s3_ops.rs +++ b/crates/rustfs-admin/src/s3_ops.rs @@ -392,6 +392,18 @@ pub struct BucketLifecycleAbortIncompleteMultipartUpload { pub days_after_initiation: i32, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BucketLifecycleTransition { + pub days: i32, + pub storage_class: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BucketLifecycleNoncurrentVersionTransition { + pub noncurrent_days: i32, + pub storage_class: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BucketLifecycleRule { pub id: String, @@ -399,6 +411,8 @@ pub struct BucketLifecycleRule { pub prefix: String, pub expiration: Option, pub abort_incomplete_multipart_upload: Option, + pub transition: Option, + pub noncurrent_version_transition: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -432,9 +446,27 @@ impl BucketLifecycleConfiguration { } xml.push_str(""); xml.push_str(&escape_xml(&rule.id)); - xml.push_str(""); + xml.push_str(""); + // Element order follows RustFS's GetBucketLifecycleConfiguration response, which the + // ownership hash compares against after canonicalization. + if let Some(transition) = &rule.noncurrent_version_transition { + xml.push_str(""); + xml.push_str(&transition.noncurrent_days.to_string()); + xml.push_str(""); + xml.push_str(&escape_xml(&transition.storage_class)); + xml.push_str(""); + } + xml.push_str(""); xml.push_str(rule.status.as_str()); - xml.push_str(""); + xml.push_str(""); + if let Some(transition) = &rule.transition { + xml.push_str(""); + xml.push_str(&transition.days.to_string()); + xml.push_str(""); + xml.push_str(&escape_xml(&transition.storage_class)); + xml.push_str(""); + } + xml.push_str(""); } xml.push_str(""); xml @@ -1393,6 +1425,8 @@ mod tests { prefix: "logs/<".to_string(), expiration: Some(BucketLifecycleExpiration { days: 30 }), abort_incomplete_multipart_upload: None, + transition: None, + noncurrent_version_transition: None, }, BucketLifecycleRule { id: "a".to_string(), @@ -1404,6 +1438,8 @@ mod tests { days_after_initiation: 1, }, ), + transition: None, + noncurrent_version_transition: None, }, ], }; @@ -1424,6 +1460,8 @@ mod tests { prefix: "logs/".to_string(), expiration: Some(BucketLifecycleExpiration { days: 30 }), abort_incomplete_multipart_upload: None, + transition: None, + noncurrent_version_transition: None, }, BucketLifecycleRule { id: "cleanup-incomplete-uploads".to_string(), @@ -1435,6 +1473,8 @@ mod tests { days_after_initiation: 1, }, ), + transition: None, + noncurrent_version_transition: None, }, ], }; @@ -1454,6 +1494,79 @@ mod tests { ); } + #[test] + fn lifecycle_xml_with_transitions_matches_rustfs_get_response_shape() { + let configuration = BucketLifecycleConfiguration { + rules: vec![ + BucketLifecycleRule { + id: "to-cold".to_string(), + status: BucketLifecycleRuleStatus::Enabled, + prefix: "logs/".to_string(), + expiration: Some(BucketLifecycleExpiration { days: 30 }), + abort_incomplete_multipart_upload: None, + transition: Some(BucketLifecycleTransition { + days: 1, + storage_class: "COLD".to_string(), + }), + noncurrent_version_transition: None, + }, + BucketLifecycleRule { + id: "noncurrent-to-cold".to_string(), + status: BucketLifecycleRuleStatus::Enabled, + prefix: String::new(), + expiration: None, + abort_incomplete_multipart_upload: None, + transition: None, + noncurrent_version_transition: Some( + BucketLifecycleNoncurrentVersionTransition { + noncurrent_days: 7, + storage_class: "COLD".to_string(), + }, + ), + }, + ], + }; + // RustFS re-serializes stored rules in this element order regardless of the order sent. + let rustfs_get_fixture = concat!( + "", + "noncurrent-to-cold", + "7", + "COLD", + "Enabled", + "30", + "logs/to-coldEnabled", + "1COLD", + "" + ); + + assert_eq!( + canonicalize_bucket_lifecycle_xml(&configuration.to_xml()).unwrap(), + canonicalize_bucket_lifecycle_xml(rustfs_get_fixture).unwrap() + ); + } + + #[test] + fn lifecycle_xml_transition_allows_zero_days_and_escapes_storage_class() { + let configuration = BucketLifecycleConfiguration { + rules: vec![BucketLifecycleRule { + id: "now".to_string(), + status: BucketLifecycleRuleStatus::Enabled, + prefix: String::new(), + expiration: None, + abort_incomplete_multipart_upload: None, + transition: Some(BucketLifecycleTransition { + days: 0, + storage_class: "A&B".to_string(), + }), + noncurrent_version_transition: None, + }], + }; + + assert!(configuration.to_xml().contains( + "Enabled0A&B" + )); + } + #[test] fn lifecycle_canonicalization_normalizes_transport_formatting() { let compact = concat!( @@ -1482,8 +1595,8 @@ mod tests { ); let live = concat!( "all", - "Enabled7", - "WARM" + "7", + "Enabled" ); assert_ne!( diff --git a/deploy/rustfs-operator/README.md b/deploy/rustfs-operator/README.md index 6f3f8d4..908a986 100755 --- a/deploy/rustfs-operator/README.md +++ b/deploy/rustfs-operator/README.md @@ -336,7 +336,7 @@ spec: days: 30 ``` -Policy ConfigMaps and user Secrets must live in the Tenant namespace. `users[].credsSecret.name` selects the credentials Secret; when omitted, the operator falls back to a Secret named after `users[].name` for compatibility with existing manifests. Bucket versioning can be enabled or suspended. Object Lock automatically requires enabled versioning and cannot be disabled after activation; `objectLockConfiguration` manages an optional default `Governance` or `Compliance` retention period in days. Omitting the configuration leaves an existing rule unmanaged, while `state: Absent` removes only an operator-owned default rule. Bucket lifecycle supports expiration and incomplete multipart upload cleanup rules. Omission leaves lifecycle unmanaged; `state: Absent` deletes only a configuration whose live hash still matches the Operator's ownership record. The operator indexes references from Tenant specs, so creating or updating a referenced object enqueues every referencing Tenant without requiring or mutating labels or requiring write access to that object. Provisioned resources are retained when removed from the Tenant spec. +Policy ConfigMaps and user Secrets must live in the Tenant namespace. `users[].credsSecret.name` selects the credentials Secret; when omitted, the operator falls back to a Secret named after `users[].name` for compatibility with existing manifests. Bucket versioning can be enabled or suspended. Object Lock automatically requires enabled versioning and cannot be disabled after activation; `objectLockConfiguration` manages an optional default `Governance` or `Compliance` retention period in days. Omitting the configuration leaves an existing rule unmanaged, while `state: Absent` removes only an operator-owned default rule. Bucket lifecycle supports expiration, incomplete multipart upload cleanup, and transition of current or noncurrent versions to a remote tier registered in RustFS. Omission leaves lifecycle unmanaged; `state: Absent` deletes only a configuration whose live hash still matches the Operator's ownership record. The operator indexes references from Tenant specs, so creating or updating a referenced object enqueues every referencing Tenant without requiring or mutating labels or requiring write access to that object. Provisioned resources are retained when removed from the Tenant spec. ### RBAC Configuration diff --git a/deploy/rustfs-operator/crds/tenant-crd.yaml b/deploy/rustfs-operator/crds/tenant-crd.yaml index 1949502..f7cbbf9 100644 --- a/deploy/rustfs-operator/crds/tenant-crd.yaml +++ b/deploy/rustfs-operator/crds/tenant-crd.yaml @@ -1277,19 +1277,53 @@ spec: maxLength: 255 minLength: 1 type: string + noncurrentVersionTransition: + description: |- + Moves noncurrent object versions to a remote tier `noncurrentDays` after they become + noncurrent. Only meaningful on versioned buckets. + nullable: true + properties: + noncurrentDays: + format: int32 + minimum: 0.0 + type: integer + storageClass: + minLength: 1 + type: string + required: + - noncurrentDays + - storageClass + type: object status: enum: - Enabled - Disabled type: string + transition: + description: |- + Moves current object versions to a remote tier after `days`. `storageClass` names a tier + registered in RustFS (uppercase); RustFS rejects a lifecycle that references an unknown tier. + nullable: true + properties: + days: + format: int32 + minimum: 0.0 + type: integer + storageClass: + minLength: 1 + type: string + required: + - days + - storageClass + type: object required: - filter - id - status type: object x-kubernetes-validations: - - message: lifecycle rule must configure expiration or abortIncompleteMultipartUpload - rule: has(self.expiration) || has(self.abortIncompleteMultipartUpload) + - message: lifecycle rule must configure expiration, abortIncompleteMultipartUpload, transition, or noncurrentVersionTransition + rule: has(self.expiration) || has(self.abortIncompleteMultipartUpload) || has(self.transition) || has(self.noncurrentVersionTransition) maxItems: 1000 type: array x-kubernetes-list-map-keys: diff --git a/docs/operator-user-guide.md b/docs/operator-user-guide.md index 1f40b9d..f5350cf 100644 --- a/docs/operator-user-guide.md +++ b/docs/operator-user-guide.md @@ -961,7 +961,22 @@ The operator can create RustFS policies, users, and buckets after the Tenant wor Bucket versioning and Object Lock are declarative but conservative. `versioning: true` enables versioning. `versioning: false` keeps a never-versioned bucket unchanged or requests S3 `Suspended` after versioning has been enabled; omitting the field leaves versioning unmanaged unless Object Lock requires it. `objectLock: true` enables Object Lock after versioning is enabled. Object Lock cannot be disabled, and the Operator refuses `objectLock: false` or versioning suspension when the live bucket has Object Lock enabled. `objectLockConfiguration` manages the default retention rule: omit `state` for `Present`, specify `mode: Governance` or `Compliance` with `days`, or use `state: Absent` without `mode` and `days` to remove only the default rule. Omitting the entire field leaves a live default-retention rule unmanaged. The Operator adopts an identical live rule, but does not replace or remove a different rule unless its hash matches the last configuration recorded in status. Default retention applies to new object versions; it does not retroactively lock existing versions. -Each bucket may also declare `lifecycle.state: Present` with one or more S3 lifecycle rules. The current schema supports expiration after a positive number of days and aborting incomplete multipart uploads after a positive number of days; every rule has a unique ID, an `Enabled` or `Disabled` status, and an explicit prefix filter (use `prefix: ""` for all objects). `lifecycle.state: Absent` requests deletion and cannot include rules. The Operator adopts an identical live configuration, but never replaces or deletes a different configuration without a matching ownership hash in `status.provisioning.buckets`. Omitting `lifecycle` performs no lifecycle API calls and leaves the live configuration untouched. +Each bucket may also declare `lifecycle.state: Present` with one or more S3 lifecycle rules. The current schema supports expiration after a positive number of days, aborting incomplete multipart uploads after a positive number of days, `transition` of current versions after zero or more days, and `noncurrentVersionTransition` of noncurrent versions after zero or more days; every rule has a unique ID, an `Enabled` or `Disabled` status, and an explicit prefix filter (use `prefix: ""` for all objects). `lifecycle.state: Absent` requests deletion and cannot include rules. The Operator adopts an identical live configuration, but never replaces or deletes a different configuration without a matching ownership hash in `status.provisioning.buckets`. Omitting `lifecycle` performs no lifecycle API calls and leaves the live configuration untouched. + +Transition actions move data to a remote tier. `storageClass` is the name of a tier already registered in RustFS (for example with `rc ilm tier add`); tier names are uppercase, and `STANDARD` and `REDUCED_REDUNDANCY` are reserved. The Operator does not register tiers. Before applying a lifecycle with transition actions it checks that every referenced tier exists; while one is missing, the bucket stays `Pending` with a message naming the tier and the Operator retries, so the tier can be registered after the rule is added: + +```yaml +lifecycle: + state: Present + rules: + - id: to-cold + status: Enabled + filter: + prefix: "" + transition: + days: 7 + storageClass: COLD +``` Transient Kubernetes and RustFS admin/S3 failures (timeouts, 429, 5xx, connection errors, TLS not ready) leave provisioning items `Pending` and requeue instead of marking the Tenant `Failed`. Permanent 4xx configuration errors still fail and wait for a spec or object change. diff --git a/docs/operator-user-guide.zh-CN.md b/docs/operator-user-guide.zh-CN.md index 02f224b..f24e35d 100644 --- a/docs/operator-user-guide.zh-CN.md +++ b/docs/operator-user-guide.zh-CN.md @@ -905,7 +905,22 @@ Operator 可以在 Tenant workload Ready 后自动创建 RustFS policy、user Bucket 版本控制和 Object Lock 采用声明式但保守的调和策略。`versioning: true` 启用版本控制。`versioning: false` 会让从未启用版本控制的 bucket 保持原状,或在版本控制已启用后请求 S3 `Suspended`;省略该字段时保持现状,除非 Object Lock 要求先启用版本控制。`objectLock: true` 会在版本控制启用后开启 Object Lock。Object Lock 不可关闭;当线上 bucket 已启用 Object Lock 时,Operator 会拒绝 `objectLock: false` 或暂停版本控制。`objectLockConfiguration` 管理默认保留规则:省略 `state` 等同于 `Present`,此时必须设置 `mode: Governance` 或 `Compliance` 以及 `days`;`state: Absent` 必须省略 `mode` 和 `days`,仅移除默认规则。完全省略 `objectLockConfiguration` 时,不管理已有默认保留规则。Operator 可以接管与声明完全相同的线上规则,但只有线上配置哈希仍与 status 中最后一次应用的配置一致时,才会替换或移除不同规则。默认保留策略只作用于之后创建的对象版本,不会追溯锁定已有版本。 -每个 bucket 还可以通过 `lifecycle.state: Present` 声明一个或多个 S3 生命周期规则。当前 schema 支持按正整数天数过期对象,以及按正整数天数清理未完成的分片上传;每条规则必须有唯一 ID、`Enabled` 或 `Disabled` 状态,并显式指定 prefix filter(`prefix: ""` 表示全部对象)。`lifecycle.state: Absent` 表示请求删除,且不能包含规则。Operator 可以接管与声明完全相同的现有配置,但没有 `status.provisioning.buckets` 中匹配的所有权哈希时,不会覆盖或删除不同的现有配置。省略 `lifecycle` 时不会调用 Lifecycle API,也不会改动现有配置。 +每个 bucket 还可以通过 `lifecycle.state: Present` 声明一个或多个 S3 生命周期规则。当前 schema 支持按正整数天数过期对象、按正整数天数清理未完成的分片上传、通过 `transition` 在零天或更多天后迁移当前版本,以及通过 `noncurrentVersionTransition` 在零天或更多天后迁移非当前版本;每条规则必须有唯一 ID、`Enabled` 或 `Disabled` 状态,并显式指定 prefix filter(`prefix: ""` 表示全部对象)。`lifecycle.state: Absent` 表示请求删除,且不能包含规则。Operator 可以接管与声明完全相同的现有配置,但没有 `status.provisioning.buckets` 中匹配的所有权哈希时,不会覆盖或删除不同的现有配置。省略 `lifecycle` 时不会调用 Lifecycle API,也不会改动现有配置。 + +迁移类操作会把数据移动到远程层(tier)。`storageClass` 是 RustFS 中已注册的 tier 名称(例如通过 `rc ilm tier add` 注册);tier 名称必须为大写,`STANDARD` 和 `REDUCED_REDUNDANCY` 为保留名称。Operator 不负责注册 tier。在应用包含迁移操作的生命周期配置之前,Operator 会检查所有被引用的 tier 是否存在;如果有 tier 尚未注册,bucket 会保持 `Pending` 状态并在消息中列出该 tier,Operator 会持续重试,因此可以在添加规则之后再注册 tier: + +```yaml +lifecycle: + state: Present + rules: + - id: to-cold + status: Enabled + filter: + prefix: "" + transition: + days: 7 + storageClass: COLD +``` Kubernetes 或 RustFS 管理/S3 API 的瞬时失败(超时、429、5xx、连接错误、TLS 未就绪)会把 provisioning 条目保持为 `Pending` 并重新入队,而不会把 Tenant 标为 `Failed`。永久性 4xx 配置错误仍会失败并等待 spec 或对象变更。 diff --git a/src/console/openapi.rs b/src/console/openapi.rs index ea7437e..23581f6 100644 --- a/src/console/openapi.rs +++ b/src/console/openapi.rs @@ -56,11 +56,12 @@ use crate::console::models::topology::{ }; use crate::types::v1alpha1::provisioning::{ BucketAnonymousAccess, BucketLifecycleAbortIncompleteMultipartUpload, - BucketLifecycleExpiration, BucketLifecycleRule, BucketLifecycleRuleFilter, - BucketLifecycleRuleStatus, BucketLifecycleSpec, BucketLifecycleState, - BucketObjectLockConfiguration, BucketObjectLockConfigurationState, - BucketObjectLockRetentionMode, ConfigMapKeyReference, PolicyDocumentSource, ProvisioningBucket, - ProvisioningDeletionPolicy, ProvisioningPolicy, ProvisioningUser, UserCredentialsSecretRef, + BucketLifecycleExpiration, BucketLifecycleNoncurrentVersionTransition, BucketLifecycleRule, + BucketLifecycleRuleFilter, BucketLifecycleRuleStatus, BucketLifecycleSpec, + BucketLifecycleState, BucketLifecycleTransition, BucketObjectLockConfiguration, + BucketObjectLockConfigurationState, BucketObjectLockRetentionMode, ConfigMapKeyReference, + PolicyDocumentSource, ProvisioningBucket, ProvisioningDeletionPolicy, ProvisioningPolicy, + ProvisioningUser, UserCredentialsSecretRef, }; use crate::types::v1alpha1::status::provisioning::{ BucketVersioningStatus, ProvisioningBucketStatus, ProvisioningItemState, @@ -143,6 +144,8 @@ use crate::types::v1alpha1::status::provisioning::{ BucketLifecycleRuleFilter, BucketLifecycleExpiration, BucketLifecycleAbortIncompleteMultipartUpload, + BucketLifecycleTransition, + BucketLifecycleNoncurrentVersionTransition, CreateTenantRequest, CreatePoolRequest, PoolInfo, @@ -792,6 +795,8 @@ mod tests { assert!(schemas.contains_key("ProvisioningStatus")); assert!(schemas.contains_key("ProvisioningBucketStatus")); assert!(schemas.contains_key("BucketLifecycleSpec")); + assert!(schemas.contains_key("BucketLifecycleTransition")); + assert!(schemas.contains_key("BucketLifecycleNoncurrentVersionTransition")); assert!(schemas.contains_key("BucketObjectLockConfiguration")); assert!(schemas.contains_key("UserCredentialsSecretRef")); assert_eq!( diff --git a/src/reconcile/provisioning.rs b/src/reconcile/provisioning.rs index 9909f0a..6380c48 100644 --- a/src/reconcile/provisioning.rs +++ b/src/reconcile/provisioning.rs @@ -17,8 +17,10 @@ use crate::sts::rustfs_client::{ BucketLifecycleAbortIncompleteMultipartUpload as AdminLifecycleAbort, BucketLifecycleConfiguration as AdminLifecycleConfiguration, BucketLifecycleExpiration as AdminLifecycleExpiration, + BucketLifecycleNoncurrentVersionTransition as AdminLifecycleNoncurrentVersionTransition, BucketLifecycleRule as AdminLifecycleRule, BucketLifecycleRuleStatus as AdminLifecycleRuleStatus, + BucketLifecycleTransition as AdminLifecycleTransition, BucketObjectLockConfiguration as AdminObjectLockConfiguration, BucketObjectLockDefaultRetention as AdminObjectLockDefaultRetention, BucketObjectLockRetentionMode as AdminObjectLockRetentionMode, @@ -2784,6 +2786,16 @@ async fn reconcile_bucket_lifecycle( ), LifecycleReconcileAction::Apply => { if let Some(configuration) = desired.as_ref() { + if let Some(waiting) = + lifecycle_tiers_pending(run, client, previous, &base_item, configuration).await + { + return finalize_bucket_lifecycle_status( + waiting, + previous, + desired_hash, + false, + ); + } match client .put_bucket_lifecycle_configuration(&bucket.name, configuration) .await @@ -2876,6 +2888,71 @@ async fn reconcile_bucket_lifecycle( finalize_bucket_lifecycle_status(item, previous, desired_hash, applied) } +/// Transition rules name tiers that are registered outside the operator, often after the +/// Tenant is provisioned. RustFS rejects a lifecycle that references an unknown tier with a +/// generic 400, which would leave the bucket `Failed` until the spec changes, so a missing tier +/// is reported as `Pending` and retried instead. +async fn lifecycle_tiers_pending( + run: &mut ProvisioningRun<'_>, + client: &RustfsAdminClient, + previous: Option<&ProvisioningBucketStatus>, + base_item: &ProvisioningItemStatus, + configuration: &AdminLifecycleConfiguration, +) -> Option { + let referenced = configuration + .rules + .iter() + .flat_map(|rule| { + rule.transition + .iter() + .map(|transition| transition.storage_class.as_str()) + .chain( + rule.noncurrent_version_transition + .iter() + .map(|transition| transition.storage_class.as_str()), + ) + }) + .collect::>(); + if referenced.is_empty() { + return None; + } + + let registered = match client.list_tier_names().await { + Ok(names) => names, + Err(error) => { + return Some(bucket_item_from_admin_error( + run, + previous, + base_item, + Reason::BucketLifecycleApplyFailed, + error, + "failed to list RustFS tiers referenced by bucket lifecycle", + )); + } + }; + let missing = referenced + .into_iter() + .filter(|name| !registered.contains(*name)) + .collect::>(); + if missing.is_empty() { + return None; + } + + let message = format!( + "waiting for RustFS tier(s) {} referenced by bucket lifecycle", + missing.join(", ") + ); + run.request_retry(message.clone()); + Some(replace_bucket_item_state( + run, + previous, + base_item, + ProvisioningItemState::Pending, + Reason::ProvisioningPending, + message, + )) +} + fn lifecycle_configuration( spec: &BucketLifecycleSpec, ) -> Result, String> { @@ -2924,9 +3001,13 @@ fn lifecycle_configuration( rule.id )); } - if rule.expiration.is_none() && rule.abort_incomplete_multipart_upload.is_none() { + if rule.expiration.is_none() + && rule.abort_incomplete_multipart_upload.is_none() + && rule.transition.is_none() + && rule.noncurrent_version_transition.is_none() + { return Err(format!( - "bucket lifecycle rule '{}' must configure expiration or abortIncompleteMultipartUpload", + "bucket lifecycle rule '{}' must configure expiration, abortIncompleteMultipartUpload, transition, or noncurrentVersionTransition", rule.id )); } @@ -2951,6 +3032,29 @@ fn lifecycle_configuration( )); } + if let Some(transition) = rule.transition.as_ref() { + if transition.days < 0 { + return Err(format!( + "bucket lifecycle rule '{}' transition.days must not be negative", + rule.id + )); + } + validate_lifecycle_storage_class(&rule.id, "transition", &transition.storage_class)?; + } + if let Some(transition) = rule.noncurrent_version_transition.as_ref() { + if transition.noncurrent_days < 0 { + return Err(format!( + "bucket lifecycle rule '{}' noncurrentVersionTransition.noncurrentDays must not be negative", + rule.id + )); + } + validate_lifecycle_storage_class( + &rule.id, + "noncurrentVersionTransition", + &transition.storage_class, + )?; + } + rules.push(AdminLifecycleRule { id: rule.id.clone(), status: match rule.status { @@ -2967,11 +3071,56 @@ fn lifecycle_configuration( days_after_initiation: value.days_after_initiation, }, ), + transition: rule + .transition + .as_ref() + .map(|value| AdminLifecycleTransition { + days: value.days, + storage_class: value.storage_class.clone(), + }), + noncurrent_version_transition: rule.noncurrent_version_transition.as_ref().map( + |value| AdminLifecycleNoncurrentVersionTransition { + noncurrent_days: value.noncurrent_days, + storage_class: value.storage_class.clone(), + }, + ), }); } Ok(Some(AdminLifecycleConfiguration { rules })) } +/// Mirrors RustFS tier-name rules so a misspelled tier fails with a clear message instead of a +/// generic `invalid tier` response from PutBucketLifecycleConfiguration. +fn validate_lifecycle_storage_class( + rule_id: &str, + field: &str, + storage_class: &str, +) -> Result<(), String> { + if storage_class.is_empty() { + return Err(format!( + "bucket lifecycle rule '{rule_id}' {field}.storageClass must name a RustFS tier" + )); + } + if !is_valid_xml_text(storage_class) { + return Err(format!( + "bucket lifecycle rule '{rule_id}' {field}.storageClass contains characters that cannot be represented in S3 XML" + )); + } + if storage_class != storage_class.to_uppercase() { + return Err(format!( + "bucket lifecycle rule '{rule_id}' {field}.storageClass '{storage_class}' must be uppercase like RustFS tier names" + )); + } + if storage_class.eq_ignore_ascii_case("STANDARD") + || storage_class.eq_ignore_ascii_case("REDUCED_REDUNDANCY") + { + return Err(format!( + "bucket lifecycle rule '{rule_id}' {field}.storageClass '{storage_class}' is a reserved storage class, not a tier" + )); + } + Ok(()) +} + fn lifecycle_desired_hash(spec: &BucketLifecycleSpec) -> Option { lifecycle_configuration(spec) .ok() @@ -3603,7 +3752,9 @@ fn reason_from_str(reason: &str) -> Reason { #[cfg(test)] mod tests { use super::*; - use crate::types::v1alpha1::provisioning::ConfigMapKeyReference; + use crate::types::v1alpha1::provisioning::{ + BucketLifecycleNoncurrentVersionTransition, ConfigMapKeyReference, + }; use axum::{ Router, body::Body, @@ -3637,6 +3788,8 @@ mod tests { #[derive(Clone, Default)] struct LifecycleCapture { live: Arc>>, + tiers: Arc>, + tier_count: Arc, put_body: Arc>, get_count: Arc, put_count: Arc, @@ -3910,11 +4063,17 @@ mod tests { } } + async fn lifecycle_tier_handler(State(capture): State) -> Response { + capture.tier_count.fetch_add(1, Ordering::SeqCst); + (StatusCode::OK, capture.tiers.lock().await.clone()).into_response() + } + async fn lifecycle_test_client( capture: LifecycleCapture, ) -> (RustfsAdminClient, tokio::task::JoinHandle<()>) { let router = Router::new() .route("/app-data", any(lifecycle_bucket_handler)) + .route("/rustfs/admin/v3/tier", get(lifecycle_tier_handler)) .with_state(capture); let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) .await @@ -3947,6 +4106,22 @@ mod tests { .expect("lifecycle bucket should deserialize") } + fn transition_lifecycle_bucket(days: i32, storage_class: &str) -> ProvisioningBucket { + serde_json::from_value(serde_json::json!({ + "name": "app-data", + "lifecycle": { + "state": "Present", + "rules": [{ + "id": "to-cold", + "status": "Enabled", + "filter": {"prefix": ""}, + "transition": {"days": days, "storageClass": storage_class} + }] + } + })) + .expect("transition lifecycle bucket should deserialize") + } + fn absent_lifecycle_bucket() -> ProvisioningBucket { serde_json::from_value(serde_json::json!({ "name": "app-data", @@ -6407,6 +6582,11 @@ mod tests { assert_eq!(capture.get_count.load(Ordering::SeqCst), 1); assert_eq!(capture.put_count.load(Ordering::SeqCst), 1); assert_eq!(capture.delete_count.load(Ordering::SeqCst), 0); + assert_eq!( + capture.tier_count.load(Ordering::SeqCst), + 0, + "rules without transitions must not query RustFS tiers" + ); assert_eq!( item.lifecycle_desired_hash, item.lifecycle_last_applied_hash @@ -6676,6 +6856,150 @@ mod tests { assert!(lifecycle_configuration(zero_days.lifecycle.as_ref().expect("lifecycle")).is_err()); } + #[test] + fn lifecycle_configuration_validates_transition_fields() { + let valid = transition_lifecycle_bucket(0, "COLD"); + let configuration = lifecycle_configuration(valid.lifecycle.as_ref().expect("lifecycle")) + .expect("zero-day transition is valid") + .expect("Present lifecycle"); + assert_eq!( + configuration.rules[0].transition, + Some(AdminLifecycleTransition { + days: 0, + storage_class: "COLD".to_string(), + }) + ); + + for (days, storage_class) in [ + (-1, "COLD"), + (1, ""), + (1, "cold"), + (1, "STANDARD"), + (1, "reduced_redundancy"), + (1, "BAD\u{0}TIER"), + ] { + let bucket = transition_lifecycle_bucket(days, storage_class); + assert!( + lifecycle_configuration(bucket.lifecycle.as_ref().expect("lifecycle")).is_err(), + "transition days={days} storageClass={storage_class:?} must be rejected" + ); + } + + let mut noncurrent = transition_lifecycle_bucket(1, "COLD"); + let rule = &mut noncurrent.lifecycle.as_mut().expect("lifecycle").rules[0]; + rule.transition = None; + rule.noncurrent_version_transition = Some(BucketLifecycleNoncurrentVersionTransition { + noncurrent_days: -1, + storage_class: "COLD".to_string(), + }); + assert!( + lifecycle_configuration(noncurrent.lifecycle.as_ref().expect("lifecycle")).is_err() + ); + let rule = &mut noncurrent.lifecycle.as_mut().expect("lifecycle").rules[0]; + rule.noncurrent_version_transition = Some(BucketLifecycleNoncurrentVersionTransition { + noncurrent_days: 0, + storage_class: "COLD".to_string(), + }); + assert!( + lifecycle_configuration(noncurrent.lifecycle.as_ref().expect("lifecycle")).is_ok(), + "RustFS accepts a zero-day noncurrent transition" + ); + let rule = &mut noncurrent.lifecycle.as_mut().expect("lifecycle").rules[0]; + rule.noncurrent_version_transition = Some(BucketLifecycleNoncurrentVersionTransition { + noncurrent_days: 7, + storage_class: "COLD".to_string(), + }); + let configuration = + lifecycle_configuration(noncurrent.lifecycle.as_ref().expect("lifecycle")) + .expect("noncurrent transition is valid") + .expect("Present lifecycle"); + assert_eq!( + configuration.rules[0].noncurrent_version_transition, + Some(AdminLifecycleNoncurrentVersionTransition { + noncurrent_days: 7, + storage_class: "COLD".to_string(), + }) + ); + } + + #[tokio::test] + async fn transition_bucket_lifecycle_is_applied_when_live_configuration_is_missing() { + let capture = LifecycleCapture::default(); + *capture.tiers.lock().await = r#"[{"type":"s3","s3":{"name":"COLD"}}]"#.to_string(); + let (client, server) = lifecycle_test_client(capture.clone()).await; + let ctx = empty_kube_context(); + let user = provisioning_user("app-user", "app-user-secret", "readwrite"); + let tenant = provisioning_test_tenant(user, ProvisioningStatus::default()); + let mut run = empty_run(&ctx, &tenant); + + let item = + reconcile_bucket(&mut run, &client, &transition_lifecycle_bucket(1, "COLD")).await; + + assert_eq!(item.state, ProvisioningItemState::Ready.as_str()); + assert_eq!(capture.tier_count.load(Ordering::SeqCst), 1); + assert_eq!(capture.put_count.load(Ordering::SeqCst), 1); + let body = capture.put_body.lock().await; + assert!(body.contains( + "Enabled1COLD" + )); + server.abort(); + } + + #[tokio::test] + async fn transition_bucket_lifecycle_waits_for_an_unregistered_tier() { + let capture = LifecycleCapture::default(); + *capture.tiers.lock().await = r#"[{"type":"s3","s3":{"name":"WARM"}}]"#.to_string(); + let (client, server) = lifecycle_test_client(capture.clone()).await; + let ctx = empty_kube_context(); + let user = provisioning_user("app-user", "app-user-secret", "readwrite"); + let tenant = provisioning_test_tenant(user, ProvisioningStatus::default()); + let mut run = empty_run(&ctx, &tenant); + + let item = + reconcile_bucket(&mut run, &client, &transition_lifecycle_bucket(1, "COLD")).await; + + assert_eq!(item.state, ProvisioningItemState::Pending.as_str()); + assert!( + item.message + .as_deref() + .is_some_and(|message| message.contains("COLD")) + ); + assert_eq!(capture.put_count.load(Ordering::SeqCst), 0); + assert!(run.retry.is_some(), "a missing tier must requeue"); + assert!(item.lifecycle_last_applied_hash.is_none()); + server.abort(); + } + + #[tokio::test] + async fn rustfs_shaped_live_transition_lifecycle_is_adopted_without_write() { + // Shape returned by RustFS GetBucketLifecycleConfiguration for an operator-applied rule. + let live = concat!( + "", + "", + "to-coldEnabled", + "1COLD", + "" + ); + let capture = LifecycleCapture::default(); + *capture.live.lock().await = Some(live.to_string()); + let (client, server) = lifecycle_test_client(capture.clone()).await; + let ctx = empty_kube_context(); + let user = provisioning_user("app-user", "app-user-secret", "readwrite"); + let tenant = provisioning_test_tenant(user, ProvisioningStatus::default()); + let mut run = empty_run(&ctx, &tenant); + + let item = + reconcile_bucket(&mut run, &client, &transition_lifecycle_bucket(1, "COLD")).await; + + assert_eq!(item.state, ProvisioningItemState::Ready.as_str()); + assert_eq!(capture.put_count.load(Ordering::SeqCst), 0); + assert_eq!( + item.lifecycle_desired_hash, + item.lifecycle_last_applied_hash + ); + server.abort(); + } + #[tokio::test] async fn anonymous_download_policy_is_applied_when_live_policy_is_missing() { let capture = PolicyApplyCapture::default(); diff --git a/src/sts/rustfs_client.rs b/src/sts/rustfs_client.rs index e7a03b1..b758226 100644 --- a/src/sts/rustfs_client.rs +++ b/src/sts/rustfs_client.rs @@ -22,8 +22,9 @@ use crate::cluster_dns; pub use rustfs_admin::{ BucketLifecycleAbortIncompleteMultipartUpload, BucketLifecycleConfiguration, - BucketLifecycleExpiration, BucketLifecycleRule, BucketLifecycleRuleStatus, - BucketObjectLockConfiguration, BucketObjectLockDefaultRetention, BucketObjectLockRetentionMode, + BucketLifecycleExpiration, BucketLifecycleNoncurrentVersionTransition, BucketLifecycleRule, + BucketLifecycleRuleStatus, BucketLifecycleTransition, BucketObjectLockConfiguration, + BucketObjectLockDefaultRetention, BucketObjectLockRetentionMode, BucketObjectLockRetentionPeriod, BucketVersioningState, BucketVersioningUpdate, CreateBucketResult, RustfsClientError, RustfsCredentials, RustfsErasureBackend, RustfsErasureSetInfo, RustfsPoolDecommissionInfo, RustfsPoolListItem, RustfsPoolStatus, diff --git a/src/types/v1alpha1.rs b/src/types/v1alpha1.rs index 1601fae..ec2051d 100755 --- a/src/types/v1alpha1.rs +++ b/src/types/v1alpha1.rs @@ -429,9 +429,23 @@ mod tenant_provisioning_crd_tests { lifecycle["x-kubernetes-validations"][0]["message"], json!("Present lifecycle requires rules and Absent lifecycle forbids rules") ); + assert_eq!( + lifecycle["properties"]["rules"]["items"]["properties"]["transition"]["properties"] + ["days"]["minimum"] + .as_f64(), + Some(0.0) + ); + assert_eq!( + lifecycle["properties"]["rules"]["items"]["properties"]["noncurrentVersionTransition"] + ["properties"]["noncurrentDays"]["minimum"] + .as_f64(), + Some(0.0) + ); assert_eq!( lifecycle["properties"]["rules"]["items"]["x-kubernetes-validations"][0]["message"], - json!("lifecycle rule must configure expiration or abortIncompleteMultipartUpload") + json!( + "lifecycle rule must configure expiration, abortIncompleteMultipartUpload, transition, or noncurrentVersionTransition" + ) ); let object_lock = &spec["properties"]["buckets"]["items"]["properties"]["objectLockConfiguration"]; diff --git a/src/types/v1alpha1/provisioning.rs b/src/types/v1alpha1/provisioning.rs index e141ba4..69b8260 100644 --- a/src/types/v1alpha1/provisioning.rs +++ b/src/types/v1alpha1/provisioning.rs @@ -208,9 +208,33 @@ pub struct BucketLifecycleAbortIncompleteMultipartUpload { pub days_after_initiation: i32, } +/// Moves current object versions to a remote tier after `days`. `storageClass` names a tier +/// registered in RustFS (uppercase); RustFS rejects a lifecycle that references an unknown tier. #[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, ToSchema, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -#[x_kube(validation = Rule::new("has(self.expiration) || has(self.abortIncompleteMultipartUpload)").message("lifecycle rule must configure expiration or abortIncompleteMultipartUpload"))] +pub struct BucketLifecycleTransition { + #[schemars(range(min = 0))] + pub days: i32, + + #[schemars(length(min = 1))] + pub storage_class: String, +} + +/// Moves noncurrent object versions to a remote tier `noncurrentDays` after they become +/// noncurrent. Only meaningful on versioned buckets. +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BucketLifecycleNoncurrentVersionTransition { + #[schemars(range(min = 0))] + pub noncurrent_days: i32, + + #[schemars(length(min = 1))] + pub storage_class: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[x_kube(validation = Rule::new("has(self.expiration) || has(self.abortIncompleteMultipartUpload) || has(self.transition) || has(self.noncurrentVersionTransition)").message("lifecycle rule must configure expiration, abortIncompleteMultipartUpload, transition, or noncurrentVersionTransition"))] pub struct BucketLifecycleRule { #[schemars(length(min = 1, max = MAX_BUCKET_LIFECYCLE_RULE_ID_LENGTH))] pub id: String, @@ -224,6 +248,12 @@ pub struct BucketLifecycleRule { #[serde(default, skip_serializing_if = "Option::is_none")] pub abort_incomplete_multipart_upload: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transition: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub noncurrent_version_transition: Option, } #[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, ToSchema, PartialEq, Eq)] @@ -457,6 +487,40 @@ mod tests { ); } + #[test] + fn bucket_lifecycle_deserializes_transition_actions() { + let bucket: super::ProvisioningBucket = serde_json::from_value(serde_json::json!({ + "name": "app-data", + "lifecycle": { + "state": "Present", + "rules": [{ + "id": "to-cold", + "status": "Enabled", + "filter": {"prefix": ""}, + "transition": {"days": 1, "storageClass": "COLD"}, + "noncurrentVersionTransition": {"noncurrentDays": 7, "storageClass": "COLD"} + }] + } + })) + .expect("transition lifecycle should deserialize"); + let rule = &bucket.lifecycle.expect("lifecycle is present").rules[0]; + assert_eq!( + rule.transition, + Some(super::BucketLifecycleTransition { + days: 1, + storage_class: "COLD".to_string(), + }) + ); + assert_eq!( + rule.noncurrent_version_transition, + Some(super::BucketLifecycleNoncurrentVersionTransition { + noncurrent_days: 7, + storage_class: "COLD".to_string(), + }) + ); + assert!(rule.expiration.is_none()); + } + #[test] fn bucket_object_lock_configuration_preserves_issue_yaml_shape() { let bucket: super::ProvisioningBucket = serde_json::from_value(serde_json::json!({