Skip to main content

unitycatalog_server/api/
volumes.rs

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        // Empty unless a managed volume allocates its id below. An empty
37        // `volume_id` leaves id assignment to the store (UUID v7), matching every
38        // other resource; a managed volume sets it so the storage path can embed
39        // it and the store persists the row under that same id.
40        let mut volume_id = String::new();
41        let storage_location = match volume_type {
42            VolumeType::EXTERNAL => {
43                // External volumes MUST have an explicit storage location that
44                // lives within a registered external location and does not
45                // overlap any existing table or volume.
46                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                // Managed volumes derive their storage location from the managed
58                // parent location resolved for the schema/catalog (schema →
59                // catalog → metastore), exactly like managed tables. That parent
60                // already carries the `__unitystorage/{schemas,catalogs}/<id>`
61                // segment; we append a `volumes/{volume_id}` segment, mirroring
62                // the `tables/{uuid}` convention in `create_staging_table`. The id
63                // is allocated here and persisted as `volume_id` (the store honors
64                // a pre-set id), so the path segment equals the volume's id and
65                // survives renames. A caller cannot supply its own location for a
66                // managed volume.
67                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    /// Register a credential and an external location at `s3://bucket/ext` so
265    /// external volumes can be created beneath it.
266    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    /// Register a credential + external location at `url` so a managed
308    /// `storage_root` under it passes the coverage check. `tag` keeps the
309    /// credential/location names unique across multiple roots in one test.
310    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    /// Create catalog `cat` (rooted at `storage_root`) and schema `sch` so a
340    /// managed volume can resolve a managed storage root. Registers a covering
341    /// external location for the catalog root so create_catalog's coverage check
342    /// passes.
343    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        // The path segment equals the persisted volume id, not the name.
390        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        // The location nests under the catalog's materialized location, which
396        // embeds the catalog id: {root}/__unitystorage/catalogs/<cid>/volumes/<vid>.
397        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        // The id round-trips: a get returns the same id and location.
425        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        // Both the catalog and schema roots must be covered by registered external
443        // locations (they are disjoint, so two distinct locations).
444        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        // Schema location embeds the schema id and roots under the schema's own
473        // storage root, not the catalog's.
474        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        // Dropping and recreating a managed volume with the same name yields a
491        // fresh id and therefore a distinct path — name-based collisions are gone.
492        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        // Managed volumes resolve their parent location lazily at create time and
520        // validate it against the local-storage allowlist. The default handler has
521        // an empty allowlist, so a catalog whose stored location is a file:// path
522        // (written directly, bypassing create-time validation) is rejected.
523        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        // Uniqueness on the catalog.schema.name triplet is preserved even though
541        // the store now honors a pre-allocated id.
542        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        // Path not contained in any external location.
560        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        // An external volume whose path falls inside a reserved managed-storage
572        // (`__unitystorage`) region is rejected even though it is contained by a
573        // registered external location — managed and external storage must not
574        // be mixed.
575        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        // A nested path under an existing volume overlaps it.
611        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}