iceberg/io/storage/config/
s3.rs1use serde::{Deserialize, Serialize};
24use typed_builder::TypedBuilder;
25
26use super::StorageConfig;
27use crate::error::invalid_data;
28use crate::io::is_truthy;
29use crate::{Error, Result};
30
31pub const S3_ENDPOINT: &str = "s3.endpoint";
33pub const S3_ACCESS_KEY_ID: &str = "s3.access-key-id";
35pub const S3_SECRET_ACCESS_KEY: &str = "s3.secret-access-key";
37pub const S3_SESSION_TOKEN: &str = "s3.session-token";
39pub const S3_REGION: &str = "s3.region";
41pub const CLIENT_REGION: &str = "client.region";
43pub const S3_PATH_STYLE_ACCESS: &str = "s3.path-style-access";
45pub const S3_SSE_TYPE: &str = "s3.sse.type";
47pub const S3_SSE_KEY: &str = "s3.sse.key";
52pub const S3_SSE_MD5: &str = "s3.sse.md5";
54pub const S3_ASSUME_ROLE_ARN: &str = "client.assume-role.arn";
57pub const S3_ASSUME_ROLE_EXTERNAL_ID: &str = "client.assume-role.external-id";
59pub const S3_ASSUME_ROLE_SESSION_NAME: &str = "client.assume-role.session-name";
61pub const S3_ALLOW_ANONYMOUS: &str = "s3.allow-anonymous";
63pub const S3_DISABLE_EC2_METADATA: &str = "s3.disable-ec2-metadata";
66pub const S3_DISABLE_CONFIG_LOAD: &str = "s3.disable-config-load";
68
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TypedBuilder)]
81pub struct S3Config {
82 #[builder(default, setter(strip_option, into))]
84 pub endpoint: Option<String>,
85 #[builder(default, setter(strip_option, into))]
87 pub access_key_id: Option<String>,
88 #[builder(default, setter(strip_option, into))]
90 pub secret_access_key: Option<String>,
91 #[builder(default, setter(strip_option, into))]
93 pub session_token: Option<String>,
94 #[builder(default, setter(strip_option, into))]
96 pub region: Option<String>,
97 #[builder(default = true)]
101 pub enable_virtual_host_style: bool,
102 #[builder(default, setter(strip_option, into))]
104 pub server_side_encryption: Option<String>,
105 #[builder(default, setter(strip_option, into))]
107 pub server_side_encryption_aws_kms_key_id: Option<String>,
108 #[builder(default, setter(strip_option, into))]
110 pub server_side_encryption_customer_algorithm: Option<String>,
111 #[builder(default, setter(strip_option, into))]
113 pub server_side_encryption_customer_key: Option<String>,
114 #[builder(default, setter(strip_option, into))]
116 pub server_side_encryption_customer_key_md5: Option<String>,
117 #[builder(default, setter(strip_option, into))]
119 pub role_arn: Option<String>,
120 #[builder(default, setter(strip_option, into))]
122 pub external_id: Option<String>,
123 #[builder(default, setter(strip_option, into))]
125 pub role_session_name: Option<String>,
126 #[builder(default)]
128 pub allow_anonymous: bool,
129 #[builder(default)]
131 pub disable_ec2_metadata: bool,
132 #[builder(default)]
134 pub disable_config_load: bool,
135}
136
137impl Default for S3Config {
138 fn default() -> Self {
139 Self::builder().build()
140 }
141}
142
143impl TryFrom<&StorageConfig> for S3Config {
144 type Error = Error;
145
146 fn try_from(config: &StorageConfig) -> Result<Self> {
147 let props = config.props();
148
149 let mut cfg = S3Config::default();
150
151 if let Some(endpoint) = props.get(S3_ENDPOINT) {
152 cfg.endpoint = Some(endpoint.clone());
153 }
154 if let Some(access_key_id) = props.get(S3_ACCESS_KEY_ID) {
155 cfg.access_key_id = Some(access_key_id.clone());
156 }
157 if let Some(secret_access_key) = props.get(S3_SECRET_ACCESS_KEY) {
158 cfg.secret_access_key = Some(secret_access_key.clone());
159 }
160 if let Some(session_token) = props.get(S3_SESSION_TOKEN) {
161 cfg.session_token = Some(session_token.clone());
162 }
163 if let Some(region) = props.get(S3_REGION) {
164 cfg.region = Some(region.clone());
165 }
166 if let Some(region) = props.get(CLIENT_REGION) {
168 cfg.region = Some(region.clone());
169 }
170 if let Some(path_style_access) = props.get(S3_PATH_STYLE_ACCESS) {
171 cfg.enable_virtual_host_style = !is_truthy(path_style_access.to_lowercase().as_str());
172 }
173 if let Some(arn) = props.get(S3_ASSUME_ROLE_ARN) {
174 cfg.role_arn = Some(arn.clone());
175 }
176 if let Some(external_id) = props.get(S3_ASSUME_ROLE_EXTERNAL_ID) {
177 cfg.external_id = Some(external_id.clone());
178 }
179 if let Some(session_name) = props.get(S3_ASSUME_ROLE_SESSION_NAME) {
180 cfg.role_session_name = Some(session_name.clone());
181 }
182
183 let s3_sse_key = props.get(S3_SSE_KEY).cloned();
185 if let Some(sse_type) = props.get(S3_SSE_TYPE) {
186 match sse_type.to_lowercase().as_str() {
187 "none" => {}
189 "s3" => {
191 cfg.server_side_encryption = Some("AES256".to_string());
192 }
193 "kms" => {
195 cfg.server_side_encryption = Some("aws:kms".to_string());
196 cfg.server_side_encryption_aws_kms_key_id = s3_sse_key;
197 }
198 "custom" => {
200 cfg.server_side_encryption_customer_algorithm = Some("AES256".to_string());
201 cfg.server_side_encryption_customer_key = s3_sse_key;
202 cfg.server_side_encryption_customer_key_md5 = props.get(S3_SSE_MD5).cloned();
203 }
204 _ => {
205 return Err(invalid_data!(
206 "Invalid {S3_SSE_TYPE}: {sse_type}. Expected one of (custom, kms, s3, none)"
207 ));
208 }
209 }
210 }
211
212 if let Some(allow_anonymous) = props.get(S3_ALLOW_ANONYMOUS)
213 && is_truthy(allow_anonymous.to_lowercase().as_str())
214 {
215 cfg.allow_anonymous = true;
216 }
217 if let Some(disable_ec2_metadata) = props.get(S3_DISABLE_EC2_METADATA)
218 && is_truthy(disable_ec2_metadata.to_lowercase().as_str())
219 {
220 cfg.disable_ec2_metadata = true;
221 }
222 if let Some(disable_config_load) = props.get(S3_DISABLE_CONFIG_LOAD)
223 && is_truthy(disable_config_load.to_lowercase().as_str())
224 {
225 cfg.disable_config_load = true;
226 }
227
228 Ok(cfg)
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn test_s3_config_builder() {
238 let config = S3Config::builder()
239 .region("us-east-1")
240 .access_key_id("my-access-key")
241 .secret_access_key("my-secret-key")
242 .endpoint("http://localhost:9000")
243 .build();
244
245 assert_eq!(config.region.as_deref(), Some("us-east-1"));
246 assert_eq!(config.access_key_id.as_deref(), Some("my-access-key"));
247 assert_eq!(config.secret_access_key.as_deref(), Some("my-secret-key"));
248 assert_eq!(config.endpoint.as_deref(), Some("http://localhost:9000"));
249 }
250
251 #[test]
252 fn test_s3_config_from_storage_config() {
253 let storage_config = StorageConfig::new()
254 .with_prop(S3_REGION, "us-east-1")
255 .with_prop(S3_ACCESS_KEY_ID, "my-access-key")
256 .with_prop(S3_SECRET_ACCESS_KEY, "my-secret-key")
257 .with_prop(S3_ENDPOINT, "http://localhost:9000");
258
259 let s3_config = S3Config::try_from(&storage_config).unwrap();
260
261 assert_eq!(s3_config.region.as_deref(), Some("us-east-1"));
262 assert_eq!(s3_config.access_key_id.as_deref(), Some("my-access-key"));
263 assert_eq!(
264 s3_config.secret_access_key.as_deref(),
265 Some("my-secret-key")
266 );
267 assert_eq!(s3_config.endpoint.as_deref(), Some("http://localhost:9000"));
268 }
269
270 #[test]
271 fn test_s3_config_client_region_precedence() {
272 let storage_config = StorageConfig::new()
273 .with_prop(S3_REGION, "us-east-1")
274 .with_prop(CLIENT_REGION, "eu-west-1");
275
276 let s3_config = S3Config::try_from(&storage_config).unwrap();
277
278 assert_eq!(s3_config.region.as_deref(), Some("eu-west-1"));
280 }
281
282 #[test]
283 fn test_s3_config_default_is_virtual_host_style() {
284 assert!(S3Config::default().enable_virtual_host_style);
286 assert!(
287 S3Config::try_from(&StorageConfig::new())
288 .unwrap()
289 .enable_virtual_host_style
290 );
291 }
292
293 #[test]
294 fn test_s3_config_path_style_access() {
295 let storage_config = StorageConfig::new().with_prop(S3_PATH_STYLE_ACCESS, "true");
296
297 let s3_config = S3Config::try_from(&storage_config).unwrap();
298
299 assert!(!s3_config.enable_virtual_host_style);
301 }
302
303 #[test]
304 fn test_s3_config_sse_kms() {
305 let storage_config = StorageConfig::new()
306 .with_prop(S3_SSE_TYPE, "kms")
307 .with_prop(S3_SSE_KEY, "my-kms-key-id");
308
309 let s3_config = S3Config::try_from(&storage_config).unwrap();
310
311 assert_eq!(s3_config.server_side_encryption.as_deref(), Some("aws:kms"));
312 assert_eq!(
313 s3_config.server_side_encryption_aws_kms_key_id.as_deref(),
314 Some("my-kms-key-id")
315 );
316 }
317
318 #[test]
319 fn test_s3_config_allow_anonymous() {
320 let storage_config = StorageConfig::new().with_prop(S3_ALLOW_ANONYMOUS, "true");
321
322 let s3_config = S3Config::try_from(&storage_config).unwrap();
323
324 assert!(s3_config.allow_anonymous);
325 }
326}