Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
737 changes: 649 additions & 88 deletions crates/cli/src/commands/mb.rs

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions crates/cli/src/output/v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::exit_code::ExitCode;

const VERSIONED_OBJECTS_FAMILY: &str = "versioned_objects";
const LOCKS_FAMILY: &str = "locks";
const BUCKET_OPERATIONS_FAMILY: &str = "bucket_operations";

#[derive(Debug, Serialize)]
pub struct V3SuccessEnvelope<T> {
Expand Down Expand Up @@ -34,6 +35,15 @@ impl<T> V3SuccessEnvelope<T> {
data,
}
}

pub fn bucket_operations(data: T) -> Self {
Self {
schema_version: 3,
family: BUCKET_OPERATIONS_FAMILY,
status: "success",
data,
}
}
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -67,6 +77,19 @@ impl V3ErrorEnvelope {
error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
}
}

pub fn bucket_operations(
code: ExitCode,
message: impl Into<String>,
capability: Option<&str>,
) -> Self {
Self {
schema_version: 3,
family: BUCKET_OPERATIONS_FAMILY,
status: "error",
error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
}
}
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -94,6 +117,21 @@ impl<T> V3PartialErrorEnvelope<T> {
data,
}
}

pub fn bucket_operations(
code: ExitCode,
message: impl Into<String>,
capability: Option<&str>,
data: T,
) -> Self {
Self {
schema_version: 3,
family: BUCKET_OPERATIONS_FAMILY,
status: "error",
error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
data,
}
}
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -229,4 +267,23 @@ mod tests {
assert_eq!(error["type"], "locks");
assert_eq!(error["error"]["type"], "conflict");
}

#[test]
fn bucket_operation_envelopes_preserve_partial_stage_data() {
let success = serde_json::to_value(V3SuccessEnvelope::bucket_operations(
serde_json::json!({ "operation": "create" }),
))
.expect("serialize bucket operation success");
let partial = serde_json::to_value(V3PartialErrorEnvelope::bucket_operations(
ExitCode::Conflict,
"Versioning verification failed",
Some("bucket_versioning"),
serde_json::json!({ "failed_stage": "verify_versioning" }),
))
.expect("serialize bucket operation partial failure");

assert_eq!(success["type"], "bucket_operations");
assert_eq!(partial["type"], "bucket_operations");
assert_eq!(partial["data"]["failed_stage"], "verify_versioning");
}
}
16 changes: 16 additions & 0 deletions crates/cli/tests/fixtures/output_v3/bucket_operations/empty.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"schema_version": 3,
"type": "bucket_operations",
"status": "success",
"data": {
"operation": "create",
"bucket": "existing-bucket",
"outcome": "existing",
"created": false,
"requested": {
"versioning_enabled": false,
"object_lock_enabled": false
},
"completed_stages": ["existing_bucket_detected"]
}
}
24 changes: 24 additions & 0 deletions crates/cli/tests/fixtures/output_v3/bucket_operations/error.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"schema_version": 3,
"type": "bucket_operations",
"status": "error",
"error": {
"type": "conflict",
"message": "Object Lock is not enabled; it cannot be enabled retroactively",
"retryable": false
},
"data": {
"operation": "create",
"bucket": "archive",
"outcome": "partial",
"created": true,
"requested": {
"versioning_enabled": true,
"object_lock_enabled": true
},
"effective_versioning": true,
"effective_object_lock": false,
"completed_stages": ["bucket_created", "versioning_verified"],
"failed_stage": "verify_object_lock"
}
}
25 changes: 25 additions & 0 deletions crates/cli/tests/fixtures/output_v3/bucket_operations/success.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"schema_version": 3,
"type": "bucket_operations",
"status": "success",
"data": {
"operation": "create",
"bucket": "archive",
"outcome": "created",
"created": true,
"requested": {
"region": "us-east-1",
"versioning_enabled": true,
"object_lock_enabled": false
},
"effective_region": "us-east-1",
"region_semantics": "service_reported",
"effective_versioning": true,
"completed_stages": [
"bucket_created",
"region_verified",
"versioning_enabled",
"versioning_verified"
]
}
}
14 changes: 14 additions & 0 deletions crates/cli/tests/output_schema_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const V3_FAMILIES: &[&str] = &[
"admin_operations",
"replication",
"replication_operations",
"bucket_operations",
];

fn repository_root() -> PathBuf {
Expand Down Expand Up @@ -220,6 +221,19 @@ fn locks_contract_types_bucket_defaults_and_mutation_metadata() {
);
}

#[test]
fn bucket_creation_partial_contract_preserves_effective_state_and_failed_stage() {
let validator = load_validator(3);
let fixture = load_json(&fixture_path("bucket_operations", "error"));

assert_valid(&validator, &fixture, "partial bucket creation fixture");
assert_eq!(fixture["status"], "error");
assert_eq!(fixture["data"]["outcome"], "partial");
assert_eq!(fixture["data"]["created"], true);
assert_eq!(fixture["data"]["effective_versioning"], true);
assert_eq!(fixture["data"]["failed_stage"], "verify_object_lock");
}

#[test]
fn legacy_schemas_compile_and_existing_v1_golden_snapshots_remain_valid() {
let v1_validator = load_validator(1);
Expand Down
8 changes: 4 additions & 4 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ pub use select::{
SelectSseCustomerOptions,
};
pub use traits::{
BucketNotification, Capabilities, DeleteObjectFailure, DeleteObjectsResult,
DeleteRequestOptions, DeletedObject, ListObjectVersionsOptions, ListOptions, ListResult,
NotificationTarget, ObjectInfo, ObjectReadOptions, ObjectStore, ObjectVersion,
ObjectVersionIdentifier, ObjectVersionListResult,
BucketNotification, Capabilities, CreateBucketOptions, DeleteObjectFailure,
DeleteObjectsResult, DeleteRequestOptions, DeletedObject, ListObjectVersionsOptions,
ListOptions, ListResult, NotificationTarget, ObjectInfo, ObjectReadOptions, ObjectStore,
ObjectVersion, ObjectVersionIdentifier, ObjectVersionListResult,
};
pub use transfer::{
TransferCandidate, TransferControls, TransferExecutor, TransferOutcome, TransferOutcomeState,
Expand Down
99 changes: 99 additions & 0 deletions crates/core/src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,53 @@ use crate::replication::{
};
use crate::select::SelectOptions;

/// Requested behavior for bucket creation.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateBucketOptions {
/// Explicit S3 location constraint. `None` omits the request body.
pub region: Option<String>,
/// Whether the resulting bucket must have versioning enabled.
pub versioning_enabled: bool,
/// Whether Object Lock must be enabled in the create request.
pub object_lock_enabled: bool,
}

impl CreateBucketOptions {
/// Build CLI options while applying Object Lock's required versioning invariant.
pub fn for_cli(
region: Option<String>,
versioning_enabled: bool,
object_lock_enabled: bool,
) -> Result<Self> {
let options = Self {
region,
versioning_enabled: versioning_enabled || object_lock_enabled,
object_lock_enabled,
};
options.validate()?;
Ok(options)
}

/// Reject request states that cannot produce the promised bucket state.
pub fn validate(&self) -> Result<()> {
if self.object_lock_enabled && !self.versioning_enabled {
return Err(Error::InvalidPath(
"Bucket Object Lock requires versioning to be enabled".to_string(),
));
}
if self
.region
.as_deref()
.is_some_and(|region| region.trim().is_empty())
{
return Err(Error::InvalidPath(
"Bucket region cannot be empty".to_string(),
));
}
Ok(())
}
}

/// Metadata for an object version
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectVersion {
Expand Down Expand Up @@ -397,6 +444,33 @@ pub trait ObjectStore: Send + Sync {
/// Create a bucket
async fn create_bucket(&self, bucket: &str) -> Result<()>;

/// Create a bucket with explicit region, versioning, and Object Lock intent.
///
/// The default preserves existing implementations for the option-free request and rejects
/// advanced behavior instead of silently ignoring it.
async fn create_bucket_with_options(
&self,
bucket: &str,
options: &CreateBucketOptions,
) -> Result<()> {
options.validate()?;
if options != &CreateBucketOptions::default() {
return Err(Error::UnsupportedFeature(
"Bucket creation options are not implemented by this object store".to_string(),
));
}
self.create_bucket(bucket).await
}

/// Return the effective location reported by the service.
///
/// `None` is the S3 representation for the default `us-east-1` location.
async fn get_bucket_location(&self, _bucket: &str) -> Result<Option<String>> {
Err(Error::UnsupportedFeature(
"Bucket location inspection is not implemented by this object store".to_string(),
))
}

/// Delete a bucket
async fn delete_bucket(&self, bucket: &str) -> Result<()>;

Expand Down Expand Up @@ -735,6 +809,31 @@ pub trait ObjectStore: Send + Sync {
mod tests {
use super::*;

#[test]
fn create_bucket_options_reject_lock_without_versioning() {
let options = CreateBucketOptions {
region: Some("us-east-1".to_string()),
versioning_enabled: false,
object_lock_enabled: true,
};

let error = options
.validate()
.expect_err("Object Lock without versioning must be rejected");

assert!(matches!(error, crate::Error::InvalidPath(_)));
}

#[test]
fn create_bucket_options_normalize_cli_lock_to_versioning() {
let options = CreateBucketOptions::for_cli(Some("us-east-1".to_string()), false, true)
.expect("CLI Object Lock options should be valid");

assert!(options.object_lock_enabled);
assert!(options.versioning_enabled);
assert_eq!(options.region.as_deref(), Some("us-east-1"));
}

#[test]
fn test_object_info_file() {
let info = ObjectInfo::file("test.txt", 1024);
Expand Down
Loading
Loading