1use itertools::Itertools;
2
3use unitycatalog_common::models::ObjectLabel;
4use unitycatalog_common::models::volumes::v1::*;
5use unitycatalog_common::models::{ResourceIdent, ResourceName, ResourceRef};
6
7use super::staging_tables::{child_location, resolve_managed_parent_location};
8use super::{RequestContext, SecuredAction};
9pub use crate::codegen::volumes::VolumeHandler;
10use crate::policy::{Permission, Policy, process_resources};
11use crate::services::location::StorageLocationUrl;
12use crate::services::object_store::validate_external_storage_location;
13use crate::services::{ProvidesLocalStoragePolicy, ProvidesManagedStorageRoot};
14use crate::store::ResourceStore;
15use crate::{Error, Result};
16
17#[async_trait::async_trait]
18impl<
19 T: ResourceStore
20 + Policy<RequestContext>
21 + ProvidesLocalStoragePolicy
22 + ProvidesManagedStorageRoot,
23> VolumeHandler<RequestContext> for T
24{
25 #[tracing::instrument(skip(self, context), fields(resource_name))]
26 async fn create_volume(
27 &self,
28 request: CreateVolumeRequest,
29 context: RequestContext,
30 ) -> Result<Volume> {
31 tracing::Span::current().record("resource_name", &request.name);
32 self.check_required(&request, &context).await?;
33
34 let volume_type = request.volume_type.as_known().unwrap_or_default();
35
36 let mut volume_id = String::new();
41 let storage_location = match volume_type {
42 VolumeType::EXTERNAL => {
43 let location = request
47 .storage_location
48 .filter(|s| !s.is_empty())
49 .ok_or_else(|| {
50 Error::invalid_argument("storage_location is required for EXTERNAL volumes")
51 })?;
52 let parsed = StorageLocationUrl::parse(&location)?;
53 validate_external_storage_location(self, &parsed).await?;
54 location
55 }
56 VolumeType::MANAGED => {
57 let parent = resolve_managed_parent_location(
68 self,
69 &request.catalog_name,
70 &request.schema_name,
71 )
72 .await?;
73 volume_id = uuid::Uuid::now_v7().hyphenated().to_string();
74 child_location(&parent, "volumes", &volume_id)
75 }
76 VolumeType::VOLUME_TYPE_UNSPECIFIED => {
77 return Err(Error::invalid_argument(
78 "volume_type must be specified (EXTERNAL or MANAGED)",
79 ));
80 }
81 };
82
83 let full_name = format!(
84 "{}.{}.{}",
85 request.catalog_name, request.schema_name, request.name
86 );
87 let resource = Volume {
88 full_name,
89 name: request.name,
90 catalog_name: request.catalog_name,
91 schema_name: request.schema_name,
92 volume_type: request.volume_type,
93 storage_location,
94 volume_id,
95 comment: request.comment,
96 ..Default::default()
97 };
98 Ok(self.create(resource.into()).await?.0.try_into()?)
99 }
100
101 #[tracing::instrument(skip(self, context))]
102 async fn list_volumes(
103 &self,
104 request: ListVolumesRequest,
105 context: RequestContext,
106 ) -> Result<ListVolumesResponse> {
107 self.check_required(&request, &context).await?;
108 let (mut resources, next_page_token) = self
109 .list(
110 &ObjectLabel::Volume,
111 Some(&ResourceName::new([
112 &request.catalog_name,
113 &request.schema_name,
114 ])),
115 request.max_results.map(|v| v as usize),
116 request.page_token,
117 )
118 .await?;
119 process_resources(self, &context, &Permission::Read, &mut resources).await?;
120 Ok(ListVolumesResponse {
121 volumes: resources.into_iter().map(|r| r.try_into()).try_collect()?,
122 next_page_token,
123 ..Default::default()
124 })
125 }
126
127 #[tracing::instrument(skip(self, context), fields(resource_name))]
128 async fn get_volume(
129 &self,
130 request: GetVolumeRequest,
131 context: RequestContext,
132 ) -> Result<Volume> {
133 tracing::Span::current().record("resource_name", &request.name);
134 self.check_required(&request, &context).await?;
135 Ok(self.get(&request.resource()).await?.0.try_into()?)
136 }
137
138 #[tracing::instrument(skip(self, context), fields(resource_name))]
139 async fn update_volume(
140 &self,
141 request: UpdateVolumeRequest,
142 context: RequestContext,
143 ) -> Result<Volume> {
144 tracing::Span::current().record("resource_name", &request.name);
145 self.check_required(&request, &context).await?;
146 let ident = request.resource();
147 let name = ResourceName::from_naive_str_split(request.name.as_str());
148 let [catalog_name, schema_name, volume_name] = name.as_ref() else {
149 return Err(Error::invalid_argument(
150 "Invalid volume name - expected <catalog_name>.<schema_name>.<volume_name>",
151 ));
152 };
153 let new_name = request.new_name.as_deref().unwrap_or(volume_name);
154 let resource = Volume {
155 name: new_name.to_owned(),
156 comment: request.comment,
157 owner: request.owner,
158 catalog_name: catalog_name.to_owned(),
159 schema_name: schema_name.to_owned(),
160 full_name: format!("{}.{}.{}", catalog_name, schema_name, new_name),
161 ..Default::default()
162 };
163 Ok(self.update(&ident, resource.into()).await?.0.try_into()?)
164 }
165
166 #[tracing::instrument(skip(self, context), fields(resource_name))]
167 async fn delete_volume(
168 &self,
169 request: DeleteVolumeRequest,
170 context: RequestContext,
171 ) -> Result<()> {
172 tracing::Span::current().record("resource_name", &request.name);
173 self.check_required(&request, &context).await?;
174 Ok(self.delete(&request.resource()).await?)
175 }
176}
177
178impl SecuredAction for CreateVolumeRequest {
179 fn resource(&self) -> ResourceIdent {
180 ResourceIdent::volume(ResourceName::new([
181 self.catalog_name.as_str(),
182 self.schema_name.as_str(),
183 self.name.as_str(),
184 ]))
185 }
186
187 fn permission(&self) -> &'static Permission {
188 &Permission::Create
189 }
190}
191
192impl SecuredAction for ListVolumesRequest {
193 fn resource(&self) -> ResourceIdent {
194 ResourceIdent::volume(ResourceRef::Undefined)
195 }
196
197 fn permission(&self) -> &'static Permission {
198 &Permission::Read
199 }
200}
201
202impl SecuredAction for GetVolumeRequest {
203 fn resource(&self) -> ResourceIdent {
204 ResourceIdent::volume(ResourceName::from_naive_str_split(self.name.as_str()))
205 }
206
207 fn permission(&self) -> &'static Permission {
208 &Permission::Read
209 }
210}
211
212impl SecuredAction for UpdateVolumeRequest {
213 fn resource(&self) -> ResourceIdent {
214 ResourceIdent::volume(ResourceName::from_naive_str_split(self.name.as_str()))
215 }
216
217 fn permission(&self) -> &'static Permission {
218 &Permission::Manage
219 }
220}
221
222impl SecuredAction for DeleteVolumeRequest {
223 fn resource(&self) -> ResourceIdent {
224 ResourceIdent::volume(ResourceName::from_naive_str_split(self.name.as_str()))
225 }
226
227 fn permission(&self) -> &'static Permission {
228 &Permission::Manage
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use std::sync::Arc;
235
236 use unitycatalog_common::models::catalogs::v1::{CreateCatalogRequest, GetCatalogRequest};
237 use unitycatalog_common::models::credentials::v1::{
238 AwsIamRoleConfig, CreateCredentialRequest, Purpose,
239 };
240 use unitycatalog_common::models::external_locations::v1::CreateExternalLocationRequest;
241 use unitycatalog_common::models::schemas::v1::CreateSchemaRequest;
242 use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
243
244 use super::*;
245 use crate::api::{CatalogHandler, CredentialHandler, ExternalLocationHandler, SchemaHandler};
246 use crate::memory::InMemoryResourceStore;
247 use crate::policy::ConstantPolicy;
248 use crate::services::ServerHandler;
249
250 fn handler() -> ServerHandler<RequestContext> {
251 let encryptor =
252 EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
253 let store = Arc::new(InMemoryResourceStore::new(encryptor));
254 let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
255 ServerHandler::try_new_tokio(policy, store).unwrap()
256 }
257
258 fn ctx() -> RequestContext {
259 RequestContext {
260 recipient: crate::policy::Principal::anonymous(),
261 }
262 }
263
264 async fn setup_external_location(h: &ServerHandler<RequestContext>) {
267 h.create_credential(
268 CreateCredentialRequest {
269 name: "cred".to_string(),
270 purpose: Purpose::Storage.into(),
271 aws_iam_role: Some(AwsIamRoleConfig {
272 role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
273 ..Default::default()
274 })
275 .into(),
276 ..Default::default()
277 },
278 ctx(),
279 )
280 .await
281 .unwrap();
282 h.create_external_location(
283 CreateExternalLocationRequest {
284 name: "ext".to_string(),
285 url: "s3://bucket/ext".to_string(),
286 credential_name: "cred".to_string(),
287 ..Default::default()
288 },
289 ctx(),
290 )
291 .await
292 .unwrap();
293 }
294
295 fn create_external_volume(name: &str, location: Option<&str>) -> CreateVolumeRequest {
296 CreateVolumeRequest {
297 catalog_name: "cat".to_string(),
298 schema_name: "sch".to_string(),
299 name: name.to_string(),
300 volume_type: VolumeType::External.into(),
301 storage_location: location.map(str::to_string),
302 comment: None,
303 ..Default::default()
304 }
305 }
306
307 async fn make_covering_location(h: &ServerHandler<RequestContext>, tag: &str, url: &str) {
311 h.create_credential(
312 CreateCredentialRequest {
313 name: format!("{tag}-cred"),
314 purpose: Purpose::Storage.into(),
315 aws_iam_role: Some(AwsIamRoleConfig {
316 role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
317 ..Default::default()
318 })
319 .into(),
320 ..Default::default()
321 },
322 ctx(),
323 )
324 .await
325 .unwrap();
326 h.create_external_location(
327 CreateExternalLocationRequest {
328 name: format!("{tag}-el"),
329 url: url.to_string(),
330 credential_name: format!("{tag}-cred"),
331 ..Default::default()
332 },
333 ctx(),
334 )
335 .await
336 .unwrap();
337 }
338
339 async fn setup_managed_namespace(h: &ServerHandler<RequestContext>, storage_root: &str) {
344 make_covering_location(h, "cat", storage_root).await;
345 h.create_catalog(
346 CreateCatalogRequest {
347 name: "cat".to_string(),
348 storage_root: Some(storage_root.to_string()),
349 ..Default::default()
350 },
351 ctx(),
352 )
353 .await
354 .unwrap();
355 h.create_schema(
356 CreateSchemaRequest {
357 name: "sch".to_string(),
358 catalog_name: "cat".to_string(),
359 ..Default::default()
360 },
361 ctx(),
362 )
363 .await
364 .unwrap();
365 }
366
367 fn create_managed_volume(name: &str) -> CreateVolumeRequest {
368 CreateVolumeRequest {
369 catalog_name: "cat".to_string(),
370 schema_name: "sch".to_string(),
371 name: name.to_string(),
372 volume_type: VolumeType::Managed.into(),
373 storage_location: None,
374 comment: None,
375 ..Default::default()
376 }
377 }
378
379 #[tokio::test]
380 async fn managed_volume_location_uses_volume_id() {
381 let h = handler();
382 setup_managed_namespace(&h, "s3://bucket/cat").await;
383
384 let v = h
385 .create_volume(create_managed_volume("vol"), ctx())
386 .await
387 .unwrap();
388
389 assert!(
391 uuid::Uuid::parse_str(&v.volume_id).is_ok(),
392 "volume_id should be a uuid, got {}",
393 v.volume_id
394 );
395 let catalog = h
398 .get_catalog(
399 GetCatalogRequest {
400 name: "cat".to_string(),
401 ..Default::default()
402 },
403 ctx(),
404 )
405 .await
406 .unwrap();
407 let cat_loc = catalog.storage_location.unwrap();
408 assert_eq!(
409 v.storage_location,
410 format!("{cat_loc}/volumes/{}", v.volume_id)
411 );
412 assert!(
413 v.storage_location
414 .starts_with("s3://bucket/cat/__unitystorage/catalogs/"),
415 "got {}",
416 v.storage_location
417 );
418 assert!(
419 !v.storage_location.ends_with("/volumes/vol"),
420 "managed volume should not use its name in the path: {}",
421 v.storage_location
422 );
423
424 let got = h
426 .get_volume(
427 GetVolumeRequest {
428 name: "cat.sch.vol".to_string(),
429 ..Default::default()
430 },
431 ctx(),
432 )
433 .await
434 .unwrap();
435 assert_eq!(got.volume_id, v.volume_id);
436 assert_eq!(got.storage_location, v.storage_location);
437 }
438
439 #[tokio::test]
440 async fn managed_volume_schema_location_takes_precedence() {
441 let h = handler();
442 make_covering_location(&h, "cat", "s3://bucket/cat").await;
445 make_covering_location(&h, "sch", "s3://other/sch").await;
446 h.create_catalog(
447 CreateCatalogRequest {
448 name: "cat".to_string(),
449 storage_root: Some("s3://bucket/cat".to_string()),
450 ..Default::default()
451 },
452 ctx(),
453 )
454 .await
455 .unwrap();
456 h.create_schema(
457 CreateSchemaRequest {
458 name: "sch".to_string(),
459 catalog_name: "cat".to_string(),
460 storage_root: Some("s3://other/sch".to_string()),
461 ..Default::default()
462 },
463 ctx(),
464 )
465 .await
466 .unwrap();
467
468 let v = h
469 .create_volume(create_managed_volume("vol"), ctx())
470 .await
471 .unwrap();
472 assert!(
475 v.storage_location
476 .starts_with("s3://other/sch/__unitystorage/schemas/"),
477 "got {}",
478 v.storage_location
479 );
480 assert!(
481 v.storage_location
482 .ends_with(&format!("/volumes/{}", v.volume_id)),
483 "got {}",
484 v.storage_location
485 );
486 }
487
488 #[tokio::test]
489 async fn managed_volume_recreate_yields_new_path() {
490 let h = handler();
493 setup_managed_namespace(&h, "s3://bucket/cat").await;
494
495 let first = h
496 .create_volume(create_managed_volume("vol"), ctx())
497 .await
498 .unwrap();
499 h.delete_volume(
500 DeleteVolumeRequest {
501 name: "cat.sch.vol".to_string(),
502 ..Default::default()
503 },
504 ctx(),
505 )
506 .await
507 .unwrap();
508 let second = h
509 .create_volume(create_managed_volume("vol"), ctx())
510 .await
511 .unwrap();
512
513 assert_ne!(first.volume_id, second.volume_id);
514 assert_ne!(first.storage_location, second.storage_location);
515 }
516
517 #[tokio::test]
518 async fn managed_volume_resolved_local_location_outside_allowlist_is_rejected() {
519 use unitycatalog_common::models::catalogs::v1::Catalog;
524 let h = handler();
525 setup_managed_namespace(&h, "s3://bucket/cat").await;
526 let cat_ident = ResourceIdent::catalog(ResourceName::new(["cat"]));
527 let local = Catalog {
528 name: "cat".to_string(),
529 storage_location: Some("file:///forbidden/cat/__unitystorage/catalogs/cid".to_string()),
530 ..Default::default()
531 };
532 h.update(&cat_ident, local.into()).await.unwrap();
533
534 let res = h.create_volume(create_managed_volume("vol"), ctx()).await;
535 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
536 }
537
538 #[tokio::test]
539 async fn managed_volume_duplicate_name_is_rejected() {
540 let h = handler();
543 setup_managed_namespace(&h, "s3://bucket/cat").await;
544
545 h.create_volume(create_managed_volume("vol"), ctx())
546 .await
547 .unwrap();
548 let err = h
549 .create_volume(create_managed_volume("vol"), ctx())
550 .await
551 .expect_err("duplicate triplet must be rejected");
552 assert_eq!(err.error_code(), "RESOURCE_ALREADY_EXISTS", "{err:?}");
553 }
554
555 #[tokio::test]
556 async fn external_volume_outside_external_location_is_rejected() {
557 let h = handler();
558 setup_external_location(&h).await;
559 let res = h
561 .create_volume(
562 create_external_volume("v", Some("s3://bucket/other/vol")),
563 ctx(),
564 )
565 .await;
566 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
567 }
568
569 #[tokio::test]
570 async fn external_volume_inside_managed_region_is_rejected() {
571 let h = handler();
576 setup_external_location(&h).await;
577 let res = h
578 .create_volume(
579 create_external_volume("v", Some("s3://bucket/ext/__unitystorage/volumes/x")),
580 ctx(),
581 )
582 .await;
583 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
584 }
585
586 #[tokio::test]
587 async fn external_volume_within_external_location_succeeds() {
588 let h = handler();
589 setup_external_location(&h).await;
590 let created = h
591 .create_volume(
592 create_external_volume("v", Some("s3://bucket/ext/vol")),
593 ctx(),
594 )
595 .await
596 .unwrap();
597 assert_eq!(created.storage_location, "s3://bucket/ext/vol");
598 }
599
600 #[tokio::test]
601 async fn external_volume_overlapping_existing_volume_is_rejected() {
602 let h = handler();
603 setup_external_location(&h).await;
604 h.create_volume(
605 create_external_volume("v1", Some("s3://bucket/ext/vol")),
606 ctx(),
607 )
608 .await
609 .unwrap();
610 let res = h
612 .create_volume(
613 create_external_volume("v2", Some("s3://bucket/ext/vol/inner")),
614 ctx(),
615 )
616 .await;
617 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
618 }
619}