admin api: expose routing rules, cors rules and lifecycle rules

This commit is contained in:
Alex Auvolat
2026-03-06 11:10:30 +01:00
committed by Alex
parent 6c0bb1c9b6
commit 64087172ff
10 changed files with 312 additions and 35 deletions
Generated
+1
View File
@@ -1558,6 +1558,7 @@ dependencies = [
"tokio",
"tracing",
"url",
"utoipa",
]
[[package]]
+225
View File
@@ -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": {
+10 -2
View File
@@ -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<String>,
/// Whether website access is enabled for this bucket
pub website_access: bool,
#[serde(default)]
/// Website configuration for this bucket
pub website_config: Option<GetBucketInfoWebsiteResponse>,
// FIXME for v3: remove serde(default) for the two fields below
/// CORS rules for this bucket
#[serde(default)]
pub cors_rules: Option<Vec<xml::cors::CorsRule>>,
/// Object lifecycle rules for this bucket
#[serde(default)]
pub lifecycle_rules: Option<Vec<xml::lifecycle::LifecycleRule>>,
/// List of access keys that have permissions granted on this bucket
pub keys: Vec<GetBucketInfoKey>,
/// Number of objects in this bucket
@@ -883,6 +889,8 @@ pub struct GetBucketInfoResponse {
pub struct GetBucketInfoWebsiteResponse {
pub index_document: String,
pub error_document: Option<String>,
#[serde(default)]
pub routing_rules: Vec<xml::website::RoutingRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+17
View File
@@ -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::<Vec<_>>(),
}
}),
cors_rules: state.cors_config.get().as_ref().map(|rules| {
rules
.iter()
.map(xml::cors::CorsRule::from_garage_cors_rule)
.collect::<Vec<_>>()
}),
lifecycle_rules: state.lifecycle_config.get().as_ref().map(|lc| {
lc.iter()
.map(xml::lifecycle::LifecycleRule::from_garage_lifecycle_rule)
.collect::<Vec<_>>()
}),
keys: relevant_keys
.into_values()
.filter_map(|key| {
+1
View File
@@ -44,6 +44,7 @@ url.workspace = true
quick-xml.workspace = true
serde.workspace = true
serde_json.workspace = true
utoipa.workspace = true
opentelemetry.workspace = true
+9 -4
View File
@@ -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<CorsRule>,
}
#[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<Value>,
@@ -32,19 +34,22 @@ pub struct CorsRule {
pub expose_headers: Vec<Value>,
}
#[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,
+12 -4
View File
@@ -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<LifecycleRule>,
}
#[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<Value>,
@@ -37,9 +39,13 @@ pub struct LifecycleRule {
pub abort_incomplete_mpu: Option<AbortIncompleteMpu>,
}
#[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<Box<Filter>>,
#[serde(rename = "Prefix", skip_serializing_if = "Option::is_none")]
pub prefix: Option<Value>,
@@ -52,7 +58,8 @@ pub struct Filter {
pub size_lt: Option<IntValue>,
}
#[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<IntValue>,
@@ -60,7 +67,8 @@ pub struct Expiration {
pub at_date: Option<Value>,
}
#[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,
+5 -2
View File
@@ -3,6 +3,7 @@ pub mod lifecycle;
pub mod website;
use serde::{Deserialize, Serialize, Serializer};
use utoipa::ToSchema;
pub fn to_xml_with_header<T: Serialize>(x: &T) -> Result<String, quick_xml::se::SeError> {
use quick_xml::se::{self, EmptyElementHandling, QuoteLevel};
@@ -32,7 +33,8 @@ pub fn xmlns_xsi_tag<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> {
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);
+30 -7
View File
@@ -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<Condition>,
@@ -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<Value>,
}
#[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<Value>,
}
#[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<Value>,
@@ -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 {
+2 -16
View File
@@ -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<Response<ResBody>, 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(),
},
};