diff --git a/Cargo.lock b/Cargo.lock index b60ae3d7..a1be0ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1558,6 +1558,7 @@ dependencies = [ "tokio", "tracing", "url", + "utoipa", ] [[package]] diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index a5ae37f8..23a6971e 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2340,6 +2340,16 @@ "format": "int64", "description": "Total number of bytes used by objects in this bucket" }, + "corsRules": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/cors.Rule" + }, + "description": "CORS rules for this bucket" + }, "created": { "type": "string", "format": "date-time", @@ -2363,6 +2373,16 @@ }, "description": "List of access keys that have permissions granted on this bucket" }, + "lifecycleRules": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/lifecycle.Rule" + }, + "description": "Object lifecycle rules for this bucket" + }, "objects": { "type": "integer", "format": "int64", @@ -2423,6 +2443,12 @@ }, "indexDocument": { "type": "string" + }, + "routingRules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/website.RoutingRule" + } } } }, @@ -4545,6 +4571,205 @@ ] } ] + }, + "cors.Rule": { + "type": "object", + "required": [ + "AllowedOrigin", + "AllowedMethod" + ], + "properties": { + "AllowedHeader": { + "type": "array", + "items": {} + }, + "AllowedMethod": { + "type": "array", + "items": {} + }, + "AllowedOrigin": { + "type": "array", + "items": {} + }, + "ExposeHeader": { + "type": "array", + "items": {} + }, + "ID": {}, + "MaxAgeSeconds": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/xml.IntValue" + } + ] + } + } + }, + "lifecycle.AbortIncompleteMpu": { + "type": "object", + "required": [ + "DaysAfterInitiation" + ], + "properties": { + "DaysAfterInitiation": { + "$ref": "#/components/schemas/xml.IntValue" + } + } + }, + "lifecycle.Expiration": { + "type": "object", + "properties": { + "Date": {}, + "Days": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/xml.IntValue" + } + ] + } + } + }, + "lifecycle.Filter": { + "type": "object", + "properties": { + "And": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/lifecycle.Filter" + } + ] + }, + "ObjectSizeGreaterThan": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/xml.IntValue" + } + ] + }, + "ObjectSizeLessThan": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/xml.IntValue" + } + ] + }, + "Prefix": {} + } + }, + "lifecycle.Rule": { + "type": "object", + "required": [ + "Status" + ], + "properties": { + "AbortIncompleteMultipartUpload": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/lifecycle.AbortIncompleteMpu" + } + ] + }, + "Expiration": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/lifecycle.Expiration" + } + ] + }, + "Filter": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/lifecycle.Filter" + } + ] + }, + "ID": {}, + "Status": {} + } + }, + "website.Condition": { + "type": "object", + "properties": { + "HttpErrorCodeReturnedEquals": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/xml.IntValue" + } + ] + }, + "KeyPrefixEquals": {} + } + }, + "website.Redirect": { + "type": "object", + "properties": { + "HostName": {}, + "HttpRedirectCode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/xml.IntValue" + } + ] + }, + "Protocol": {}, + "ReplaceKeyPrefixWith": {}, + "ReplaceKeyWith": {} + } + }, + "website.RoutingRule": { + "type": "object", + "required": [ + "Redirect" + ], + "properties": { + "Condition": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/website.Condition" + } + ] + }, + "Redirect": { + "$ref": "#/components/schemas/website.Redirect" + } + } + }, + "xml.IntValue": { + "type": "integer", + "format": "int64" } }, "securitySchemes": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index b375ecb9..5d751fbe 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -12,7 +12,7 @@ use garage_rpc::*; use garage_model::garage::Garage; -use garage_api_common::{common_error::CommonError, helpers::is_default}; +use garage_api_common::{common_error::CommonError, helpers::is_default, xml}; use crate::api_server::{find_matching_nodes, AdminRpc, AdminRpcResponse}; use crate::error::Error; @@ -857,9 +857,15 @@ pub struct GetBucketInfoResponse { pub global_aliases: Vec, /// Whether website access is enabled for this bucket pub website_access: bool, - #[serde(default)] /// Website configuration for this bucket pub website_config: Option, + // FIXME for v3: remove serde(default) for the two fields below + /// CORS rules for this bucket + #[serde(default)] + pub cors_rules: Option>, + /// Object lifecycle rules for this bucket + #[serde(default)] + pub lifecycle_rules: Option>, /// List of access keys that have permissions granted on this bucket pub keys: Vec, /// Number of objects in this bucket @@ -883,6 +889,8 @@ pub struct GetBucketInfoResponse { pub struct GetBucketInfoWebsiteResponse { pub index_document: String, pub error_document: Option, + #[serde(default)] + pub routing_rules: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 61be02d9..2fc15f46 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -18,6 +18,7 @@ use garage_model::s3::mpu_table; use garage_model::s3::object_table::*; use garage_api_common::common_error::CommonError; +use garage_api_common::xml; use crate::api::*; use crate::error::*; @@ -693,8 +694,24 @@ async fn bucket_info_results( GetBucketInfoWebsiteResponse { index_document: wsc.index_document, error_document: wsc.error_document, + routing_rules: wsc + .routing_rules + .into_iter() + .map(xml::website::RoutingRule::from_garage_routing_rule) + .collect::>(), } }), + cors_rules: state.cors_config.get().as_ref().map(|rules| { + rules + .iter() + .map(xml::cors::CorsRule::from_garage_cors_rule) + .collect::>() + }), + lifecycle_rules: state.lifecycle_config.get().as_ref().map(|lc| { + lc.iter() + .map(xml::lifecycle::LifecycleRule::from_garage_lifecycle_rule) + .collect::>() + }), keys: relevant_keys .into_values() .filter_map(|key| { diff --git a/src/api/common/Cargo.toml b/src/api/common/Cargo.toml index 05c640db..8e9d074f 100644 --- a/src/api/common/Cargo.toml +++ b/src/api/common/Cargo.toml @@ -44,6 +44,7 @@ url.workspace = true quick-xml.workspace = true serde.workspace = true serde_json.workspace = true +utoipa.workspace = true opentelemetry.workspace = true diff --git a/src/api/common/xml/cors.rs b/src/api/common/xml/cors.rs index 63e3e618..ae1f9b72 100644 --- a/src/api/common/xml/cors.rs +++ b/src/api/common/xml/cors.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use hyper::{header::HeaderName, Method}; @@ -16,7 +17,8 @@ pub struct CorsConfiguration { pub cors_rules: Vec, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = cors::Rule)] pub struct CorsRule { #[serde(rename = "ID", skip_serializing_if = "Option::is_none")] pub id: Option, @@ -32,19 +34,22 @@ pub struct CorsRule { pub expose_headers: Vec, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = cors::AllowedMethod)] pub struct AllowedMethod { #[serde(rename = "AllowedMethod")] pub allowed_method: Value, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = cors::AllowedHeader)] pub struct AllowedHeader { #[serde(rename = "AllowedHeader")] pub allowed_header: Value, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = cors::ExposedHeader)] pub struct ExposeHeader { #[serde(rename = "ExposeHeader")] pub expose_header: Value, diff --git a/src/api/common/xml/lifecycle.rs b/src/api/common/xml/lifecycle.rs index 2997d070..61f12a51 100644 --- a/src/api/common/xml/lifecycle.rs +++ b/src/api/common/xml/lifecycle.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use garage_model::bucket_table::{ parse_lifecycle_date, LifecycleExpiration as GarageLifecycleExpiration, @@ -15,7 +16,8 @@ pub struct LifecycleConfiguration { pub lifecycle_rules: Vec, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[schema(as = lifecycle::Rule)] pub struct LifecycleRule { #[serde(rename = "ID", skip_serializing_if = "Option::is_none")] pub id: Option, @@ -37,9 +39,13 @@ pub struct LifecycleRule { pub abort_incomplete_mpu: Option, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)] +#[derive( + Debug, ToSchema, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default, +)] +#[schema(as = lifecycle::Filter)] pub struct Filter { #[serde(rename = "And", skip_serializing_if = "Option::is_none")] + #[schema(no_recursion)] pub and: Option>, #[serde(rename = "Prefix", skip_serializing_if = "Option::is_none")] pub prefix: Option, @@ -52,7 +58,8 @@ pub struct Filter { pub size_lt: Option, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[schema(as = lifecycle::Expiration)] pub struct Expiration { #[serde(rename = "Days", skip_serializing_if = "Option::is_none")] pub days: Option, @@ -60,7 +67,8 @@ pub struct Expiration { pub at_date: Option, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[schema(as = lifecycle::AbortIncompleteMpu)] pub struct AbortIncompleteMpu { #[serde(rename = "DaysAfterInitiation")] pub days: IntValue, diff --git a/src/api/common/xml/mod.rs b/src/api/common/xml/mod.rs index 701e2834..be07949c 100644 --- a/src/api/common/xml/mod.rs +++ b/src/api/common/xml/mod.rs @@ -3,6 +3,7 @@ pub mod lifecycle; pub mod website; use serde::{Deserialize, Serialize, Serializer}; +use utoipa::ToSchema; pub fn to_xml_with_header(x: &T) -> Result { use quick_xml::se::{self, EmptyElementHandling, QuoteLevel}; @@ -32,7 +33,8 @@ pub fn xmlns_xsi_tag(_v: &(), s: S) -> Result { s.serialize_str("http://www.w3.org/2001/XMLSchema-instance") } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = xml::Value)] pub struct Value(#[serde(rename = "$value")] pub String); impl From<&str> for Value { @@ -41,5 +43,6 @@ impl From<&str> for Value { } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = xml::IntValue)] pub struct IntValue(#[serde(rename = "$value")] pub i64); diff --git a/src/api/common/xml/website.rs b/src/api/common/xml/website.rs index fc9d0b27..7759aa2c 100644 --- a/src/api/common/xml/website.rs +++ b/src/api/common/xml/website.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -use garage_model::bucket_table::{self, WebsiteConfig}; +use garage_model::bucket_table::{self, RoutingRule as GarageRoutingRule, WebsiteConfig}; use crate::common_error::CommonError as Error; use crate::xml::{xmlns_tag, IntValue, Value}; @@ -35,7 +36,8 @@ impl RoutingRules { } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = website::RoutingRule)] pub struct RoutingRule { #[serde(rename = "Condition")] pub condition: Option, @@ -43,19 +45,22 @@ pub struct RoutingRule { pub redirect: Redirect, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = website::Key)] pub struct Key { #[serde(rename = "Key")] pub key: Value, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = website::Suffix)] pub struct Suffix { #[serde(rename = "Suffix")] pub suffix: Value, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = website::Target)] pub struct Target { #[serde(rename = "HostName")] pub hostname: Value, @@ -63,7 +68,8 @@ pub struct Target { pub protocol: Option, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = website::Condition)] pub struct Condition { #[serde( rename = "HttpErrorCodeReturnedEquals", @@ -74,7 +80,8 @@ pub struct Condition { pub prefix: Option, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[schema(as = website::Redirect)] pub struct Redirect { #[serde(rename = "HostName", skip_serializing_if = "Option::is_none")] pub hostname: Option, @@ -214,6 +221,22 @@ impl RoutingRule { } self.redirect.validate() } + + pub fn from_garage_routing_rule(rule: GarageRoutingRule) -> Self { + RoutingRule { + condition: rule.condition.map(|cond| Condition { + http_error_code: cond.http_error_code.map(|c| IntValue(c as i64)), + prefix: cond.prefix.map(Value), + }), + redirect: Redirect { + hostname: rule.redirect.hostname.map(Value), + http_redirect_code: Some(IntValue(rule.redirect.http_redirect_code as i64)), + protocol: rule.redirect.protocol.map(Value), + replace_full: rule.redirect.replace_key.map(Value), + replace_prefix: rule.redirect.replace_key_prefix.map(Value), + }, + } + } } impl Condition { diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index d1241805..0010a300 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -9,7 +9,7 @@ use garage_api_common::xml::website::*; use crate::api_server::{ReqBody, ResBody}; use crate::error::*; -use crate::xml::{to_xml_with_header, IntValue, Value}; +use crate::xml::{to_xml_with_header, Value}; pub const X_AMZ_WEBSITE_REDIRECT_LOCATION: HeaderName = HeaderName::from_static("x-amz-website-redirect-location"); @@ -31,21 +31,7 @@ pub async fn handle_get_website(ctx: ReqCtx) -> Result, Error> .routing_rules .clone() .into_iter() - .map(|rule| RoutingRule { - condition: rule.condition.map(|cond| Condition { - http_error_code: cond.http_error_code.map(|c| IntValue(c as i64)), - prefix: cond.prefix.map(Value), - }), - redirect: Redirect { - hostname: rule.redirect.hostname.map(Value), - http_redirect_code: Some(IntValue( - rule.redirect.http_redirect_code as i64, - )), - protocol: rule.redirect.protocol.map(Value), - replace_full: rule.redirect.replace_key.map(Value), - replace_prefix: rule.redirect.replace_key_prefix.map(Value), - }, - }) + .map(RoutingRule::from_garage_routing_rule) .collect(), }, };