From 933772f7ec6ea3cb9808d5d74cec34810e2173be Mon Sep 17 00:00:00 2001 From: Qi Yu Date: Fri, 3 Jan 2025 14:50:50 +0800 Subject: [PATCH 01/36] [#5979] fix(docs): Fix incorrect description in document how-to-use-gvfs.md (#6068) ### What changes were proposed in this pull request? Fix a vague description about customized file system usage in file how-to-use-gvfs.md. ### Why are the changes needed? To make documents more accurate. Fix: #5979 ### Does this PR introduce _any_ user-facing change? N/A ### How was this patch tested? N/A --- docs/how-to-use-gvfs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-to-use-gvfs.md b/docs/how-to-use-gvfs.md index 102ec082a76..31ede3a5374 100644 --- a/docs/how-to-use-gvfs.md +++ b/docs/how-to-use-gvfs.md @@ -518,7 +518,7 @@ fs = gvfs.GravitinoVirtualFileSystem(server_uri="http://localhost:8090", metalak :::note -Gravitino python client does not support customized filesets defined by users due to the limit of `fsspec` library. +Gravitino python client does not support [customized file systems](hadoop-catalog.md#how-to-custom-your-own-hcfs-file-system-fileset) defined by users due to the limit of `fsspec` library. ::: From f4f07186b34c5d2e145783622a0d6f90b3004e69 Mon Sep 17 00:00:00 2001 From: Cheng-Yi Shih <48374270+cool9850311@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:30:00 +0800 Subject: [PATCH 02/36] [#6080] fix(docs): Fix the wrong possible values. (#6084) What changes were proposed in this pull request? List role names for metadata object, "COLUMN" and "ROLE" value for "metadataObjectType" are meaningless currently. Remove it. Why are the changes needed? Fix: #6080 Does this PR introduce any user-facing change? No. How was this patch tested? Just documents. --- docs/open-api/roles.yaml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/open-api/roles.yaml b/docs/open-api/roles.yaml index 986d0fdc6f1..5ce9c26eec5 100644 --- a/docs/open-api/roles.yaml +++ b/docs/open-api/roles.yaml @@ -148,7 +148,7 @@ paths: /metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/roles: parameters: - $ref: "./openapi.yaml#/components/parameters/metalake" - - $ref: "./openapi.yaml#/components/parameters/metadataObjectType" + - $ref: "#/components/parameters/metadataObjectTypeOfRole" - $ref: "./openapi.yaml#/components/parameters/metadataObjectFullName" get: @@ -386,4 +386,19 @@ components: value: { "code": 0, "names": [ "user1", "user2" ] - } \ No newline at end of file + } + parameters: + metadataObjectTypeOfRole: + name: metadataObjectType + in: path + description: The type of the metadata object + required: true + schema: + type: string + enum: + - "METALAKE" + - "CATALOG" + - "SCHEMA" + - "TABLE" + - "FILESET" + - "TOPIC" \ No newline at end of file From e8241a96701a2e135db31a94474e3758464f4427 Mon Sep 17 00:00:00 2001 From: Xun Date: Fri, 3 Jan 2025 15:58:20 +0800 Subject: [PATCH 03/36] [#6044] improve(lock): optimization tree lock when drop and load Table/Schema (#6063) ### What changes were proposed in this pull request? Modify Schema and Table RESTful interface lock operations. ### Why are the changes needed? Fix: #6044 ### Does this PR introduce _any_ user-facing change? N/A ### How was this patch tested? CI Passed. --- .../catalog/SchemaOperationDispatcher.java | 64 +++++---- .../catalog/TableOperationDispatcher.java | 131 ++++++++++-------- .../gravitino/utils/NameIdentifierUtil.java | 31 ++++- .../server/web/rest/SchemaOperations.java | 6 +- .../server/web/rest/TableOperations.java | 6 +- 5 files changed, 139 insertions(+), 99 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java b/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java index 789e5e47155..8f36ce0d957 100644 --- a/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java +++ b/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java @@ -277,36 +277,40 @@ public Schema alterSchema(NameIdentifier ident, SchemaChange... changes) @Override public boolean dropSchema(NameIdentifier ident, boolean cascade) throws NonEmptySchemaException { NameIdentifier catalogIdent = getCatalogIdentifier(ident); - boolean droppedFromCatalog = - doWithCatalog( - catalogIdent, - c -> c.doWithSchemaOps(s -> s.dropSchema(ident, cascade)), - NonEmptySchemaException.class, - RuntimeException.class); - - // For managed schema, we don't need to drop the schema from the store again. - boolean isManagedSchema = isManagedEntity(catalogIdent, Capability.Scope.SCHEMA); - if (isManagedSchema) { - return droppedFromCatalog; - } - - // For unmanaged schema, it could happen that the schema: - // 1. Is not found in the catalog (dropped directly from underlying sources) - // 2. Is found in the catalog but not in the store (not managed by Gravitino) - // 3. Is found in the catalog and the store (managed by Gravitino) - // 4. Neither found in the catalog nor in the store. - // In all situations, we try to delete the schema from the store, but we don't take the - // return value of the store operation into account. We only take the return value of the - // catalog into account. - try { - store.delete(ident, SCHEMA, cascade); - } catch (NoSuchEntityException e) { - LOG.warn("The schema to be dropped does not exist in the store: {}", ident, e); - } catch (Exception e) { - throw new RuntimeException(e); - } - - return droppedFromCatalog; + return TreeLockUtils.doWithTreeLock( + catalogIdent, + LockType.WRITE, + () -> { + boolean droppedFromCatalog = + doWithCatalog( + catalogIdent, + c -> c.doWithSchemaOps(s -> s.dropSchema(ident, cascade)), + NonEmptySchemaException.class, + RuntimeException.class); + + // For managed schema, we don't need to drop the schema from the store again. + boolean isManagedSchema = isManagedEntity(catalogIdent, Capability.Scope.SCHEMA); + if (isManagedSchema) { + return droppedFromCatalog; + } + + // For unmanaged schema, it could happen that the schema: + // 1. Is not found in the catalog (dropped directly from underlying sources) + // 2. Is found in the catalog but not in the store (not managed by Gravitino) + // 3. Is found in the catalog and the store (managed by Gravitino) + // 4. Neither found in the catalog nor in the store. + // In all situations, we try to delete the schema from the store, but we don't take the + // return value of the store operation into account. We only take the return value of the + // catalog into account. + try { + store.delete(ident, SCHEMA, cascade); + } catch (NoSuchEntityException e) { + LOG.warn("The schema to be dropped does not exist in the store: {}", ident, e); + } catch (Exception e) { + throw new RuntimeException(e); + } + return droppedFromCatalog; + }); } private void importSchema(NameIdentifier identifier) { diff --git a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java index 7a4c5a5655b..3e6aa2abbef 100644 --- a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java +++ b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java @@ -62,6 +62,7 @@ import org.apache.gravitino.rel.indexes.Index; import org.apache.gravitino.rel.indexes.Indexes; import org.apache.gravitino.storage.IdGenerator; +import org.apache.gravitino.utils.NameIdentifierUtil; import org.apache.gravitino.utils.PrincipalUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -269,33 +270,41 @@ public Table alterTable(NameIdentifier ident, TableChange... changes) */ @Override public boolean dropTable(NameIdentifier ident) { - NameIdentifier catalogIdent = getCatalogIdentifier(ident); - boolean droppedFromCatalog = - doWithCatalog( - catalogIdent, c -> c.doWithTableOps(t -> t.dropTable(ident)), RuntimeException.class); - - // For unmanaged table, it could happen that the table: - // 1. Is not found in the catalog (dropped directly from underlying sources) - // 2. Is found in the catalog but not in the store (not managed by Gravitino) - // 3. Is found in the catalog and the store (managed by Gravitino) - // 4. Neither found in the catalog nor in the store. - // In all situations, we try to delete the schema from the store, but we don't take the - // return value of the store operation into account. We only take the return value of the - // catalog into account. - // - // For managed table, we should take the return value of the store operation into account. - boolean droppedFromStore = false; - try { - droppedFromStore = store.delete(ident, TABLE); - } catch (NoSuchEntityException e) { - LOG.warn("The table to be dropped does not exist in the store: {}", ident, e); - } catch (Exception e) { - throw new RuntimeException(e); - } - - return isManagedEntity(catalogIdent, Capability.Scope.TABLE) - ? droppedFromStore - : droppedFromCatalog; + NameIdentifier schemaIdentifier = NameIdentifierUtil.getSchemaIdentifier(ident); + return TreeLockUtils.doWithTreeLock( + schemaIdentifier, + LockType.WRITE, + () -> { + NameIdentifier catalogIdent = getCatalogIdentifier(ident); + boolean droppedFromCatalog = + doWithCatalog( + catalogIdent, + c -> c.doWithTableOps(t -> t.dropTable(ident)), + RuntimeException.class); + + // For unmanaged table, it could happen that the table: + // 1. Is not found in the catalog (dropped directly from underlying sources) + // 2. Is found in the catalog but not in the store (not managed by Gravitino) + // 3. Is found in the catalog and the store (managed by Gravitino) + // 4. Neither found in the catalog nor in the store. + // In all situations, we try to delete the schema from the store, but we don't take the + // return value of the store operation into account. We only take the return value of the + // catalog into account. + // + // For managed table, we should take the return value of the store operation into account. + boolean droppedFromStore = false; + try { + droppedFromStore = store.delete(ident, TABLE); + } catch (NoSuchEntityException e) { + LOG.warn("The table to be dropped does not exist in the store: {}", ident, e); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return isManagedEntity(catalogIdent, Capability.Scope.TABLE) + ? droppedFromStore + : droppedFromCatalog; + }); } /** @@ -314,37 +323,43 @@ public boolean dropTable(NameIdentifier ident) { */ @Override public boolean purgeTable(NameIdentifier ident) throws UnsupportedOperationException { - NameIdentifier catalogIdent = getCatalogIdentifier(ident); - boolean droppedFromCatalog = - doWithCatalog( - catalogIdent, - c -> c.doWithTableOps(t -> t.purgeTable(ident)), - RuntimeException.class, - UnsupportedOperationException.class); - - // For unmanaged table, it could happen that the table: - // 1. Is not found in the catalog (dropped directly from underlying sources) - // 2. Is found in the catalog but not in the store (not managed by Gravitino) - // 3. Is found in the catalog and the store (managed by Gravitino) - // 4. Neither found in the catalog nor in the store. - // In all situations, we try to delete the schema from the store, but we don't take the - // return value of the store operation into account. We only take the return value of the - // catalog into account. - // - // For managed table, we should take the return value of the store operation into account. - boolean droppedFromStore = false; - try { - droppedFromStore = store.delete(ident, TABLE); - } catch (NoSuchEntityException e) { - LOG.warn("The table to be purged does not exist in the store: {}", ident, e); - return false; - } catch (Exception e) { - throw new RuntimeException(e); - } - - return isManagedEntity(catalogIdent, Capability.Scope.TABLE) - ? droppedFromStore - : droppedFromCatalog; + NameIdentifier schemaIdentifier = NameIdentifierUtil.getSchemaIdentifier(ident); + return TreeLockUtils.doWithTreeLock( + schemaIdentifier, + LockType.WRITE, + () -> { + NameIdentifier catalogIdent = getCatalogIdentifier(ident); + boolean droppedFromCatalog = + doWithCatalog( + catalogIdent, + c -> c.doWithTableOps(t -> t.purgeTable(ident)), + RuntimeException.class, + UnsupportedOperationException.class); + + // For unmanaged table, it could happen that the table: + // 1. Is not found in the catalog (dropped directly from underlying sources) + // 2. Is found in the catalog but not in the store (not managed by Gravitino) + // 3. Is found in the catalog and the store (managed by Gravitino) + // 4. Neither found in the catalog nor in the store. + // In all situations, we try to delete the schema from the store, but we don't take the + // return value of the store operation into account. We only take the return value of the + // catalog into account. + // + // For managed table, we should take the return value of the store operation into account. + boolean droppedFromStore = false; + try { + droppedFromStore = store.delete(ident, TABLE); + } catch (NoSuchEntityException e) { + LOG.warn("The table to be purged does not exist in the store: {}", ident, e); + return false; + } catch (Exception e) { + throw new RuntimeException(e); + } + + return isManagedEntity(catalogIdent, Capability.Scope.TABLE) + ? droppedFromStore + : droppedFromCatalog; + }); } private EntityCombinedTable importTable(NameIdentifier identifier) { diff --git a/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java b/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java index b656bfa95da..2b7e69ebee0 100644 --- a/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java +++ b/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java @@ -249,7 +249,8 @@ public static NameIdentifier toModelVersionIdentifier(NameIdentifier modelIdent, public static NameIdentifier getCatalogIdentifier(NameIdentifier ident) throws IllegalNameIdentifierException { NameIdentifier.check( - ident.name() != null, "The name variable in the NameIdentifier must have value."); + ident.name() != null && !ident.name().isEmpty(), + "The name variable in the NameIdentifier must have value."); Namespace.check( ident.namespace() != null && !ident.namespace().isEmpty(), "Catalog namespace must be non-null and have 1 level, the input namespace is %s", @@ -265,6 +266,34 @@ public static NameIdentifier getCatalogIdentifier(NameIdentifier ident) return NameIdentifier.of(allElems.get(0), allElems.get(1)); } + /** + * Try to get the schema {@link NameIdentifier} from the given {@link NameIdentifier}. + * + * @param ident The {@link NameIdentifier} to check. + * @return The schema {@link NameIdentifier} + * @throws IllegalNameIdentifierException If the given {@link NameIdentifier} does not include + * schema name + */ + public static NameIdentifier getSchemaIdentifier(NameIdentifier ident) + throws IllegalNameIdentifierException { + NameIdentifier.check( + ident.name() != null && !ident.name().isEmpty(), + "The name variable in the NameIdentifier must have value."); + Namespace.check( + ident.namespace() != null && !ident.namespace().isEmpty() && ident.namespace().length() > 1, + "Schema namespace must be non-null and at least 1 level, the input namespace is %s", + ident.namespace()); + + List allElems = + Stream.concat(Arrays.stream(ident.namespace().levels()), Stream.of(ident.name())) + .collect(Collectors.toList()); + if (allElems.size() < 3) { + throw new IllegalNameIdentifierException( + "Cannot create a schema NameIdentifier less than three elements."); + } + return NameIdentifier.of(allElems.get(0), allElems.get(1), allElems.get(2)); + } + /** * Check the given {@link NameIdentifier} is a metalake identifier. Throw an {@link * IllegalNameIdentifierException} if it's not. diff --git a/server/src/main/java/org/apache/gravitino/server/web/rest/SchemaOperations.java b/server/src/main/java/org/apache/gravitino/server/web/rest/SchemaOperations.java index 8093da7ef79..55341627b91 100644 --- a/server/src/main/java/org/apache/gravitino/server/web/rest/SchemaOperations.java +++ b/server/src/main/java/org/apache/gravitino/server/web/rest/SchemaOperations.java @@ -210,11 +210,7 @@ public Response dropSchema( httpRequest, () -> { NameIdentifier ident = NameIdentifierUtil.ofSchema(metalake, catalog, schema); - boolean dropped = - TreeLockUtils.doWithTreeLock( - NameIdentifierUtil.ofCatalog(metalake, catalog), - LockType.WRITE, - () -> dispatcher.dropSchema(ident, cascade)); + boolean dropped = dispatcher.dropSchema(ident, cascade); if (!dropped) { LOG.warn("Fail to drop schema {} under namespace {}", schema, ident.namespace()); } diff --git a/server/src/main/java/org/apache/gravitino/server/web/rest/TableOperations.java b/server/src/main/java/org/apache/gravitino/server/web/rest/TableOperations.java index d5cf1ffc7be..3d9d863e985 100644 --- a/server/src/main/java/org/apache/gravitino/server/web/rest/TableOperations.java +++ b/server/src/main/java/org/apache/gravitino/server/web/rest/TableOperations.java @@ -228,11 +228,7 @@ public Response dropTable( httpRequest, () -> { NameIdentifier ident = NameIdentifierUtil.ofTable(metalake, catalog, schema, table); - boolean dropped = - TreeLockUtils.doWithTreeLock( - NameIdentifier.of(metalake, catalog, schema), - LockType.WRITE, - () -> purge ? dispatcher.purgeTable(ident) : dispatcher.dropTable(ident)); + boolean dropped = purge ? dispatcher.purgeTable(ident) : dispatcher.dropTable(ident); if (!dropped) { LOG.warn("Failed to drop table {} under schema {}", table, schema); } From 6f6343038bab3dde69f6f9a519ee997a2fd6cb88 Mon Sep 17 00:00:00 2001 From: Qi Yu Date: Fri, 3 Jan 2025 16:27:04 +0800 Subject: [PATCH 04/36] [#6049] fix(bundles): Fix scheme gs not found problem (#6050) ### What changes were proposed in this pull request? Add `mergeServiceFiles()` when build the bundle jars ### Why are the changes needed? After add HDFS client to gravitino bundle jar, we should add make sure both Gravitino and HDFS `Filesystem` are merged into resource file Fix: #6049 ### Does this PR introduce _any_ user-facing change? N/A ### How was this patch tested? Existing test. --------- Co-authored-by: Jerry Shao --- .github/workflows/backend-integration-test.yml | 1 + bundles/aws-bundle/build.gradle.kts | 1 + bundles/azure-bundle/build.gradle.kts | 1 + bundles/gcp-bundle/build.gradle.kts | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/workflows/backend-integration-test.yml b/.github/workflows/backend-integration-test.yml index 1958163f863..085c508ad39 100644 --- a/.github/workflows/backend-integration-test.yml +++ b/.github/workflows/backend-integration-test.yml @@ -39,6 +39,7 @@ jobs: - meta/** - scripts/** - server/** + - bundles/** - server-common/** - build.gradle.kts - gradle.properties diff --git a/bundles/aws-bundle/build.gradle.kts b/bundles/aws-bundle/build.gradle.kts index 35b1e22a4f6..a5765fb0641 100644 --- a/bundles/aws-bundle/build.gradle.kts +++ b/bundles/aws-bundle/build.gradle.kts @@ -39,6 +39,7 @@ tasks.withType(ShadowJar::class.java) { relocate("org.apache.commons.lang3", "org.apache.gravitino.aws.shaded.org.apache.commons.lang3") relocate("com.google.common", "org.apache.gravitino.aws.shaded.com.google.common") relocate("com.fasterxml.jackson", "org.apache.gravitino.aws.shaded.com.fasterxml.jackson") + mergeServiceFiles() } tasks.jar { diff --git a/bundles/azure-bundle/build.gradle.kts b/bundles/azure-bundle/build.gradle.kts index 7d9e253ac8a..fd57d33e105 100644 --- a/bundles/azure-bundle/build.gradle.kts +++ b/bundles/azure-bundle/build.gradle.kts @@ -42,6 +42,7 @@ tasks.withType(ShadowJar::class.java) { relocate("com.fasterxml", "org.apache.gravitino.azure.shaded.com.fasterxml") relocate("com.google.common", "org.apache.gravitino.azure.shaded.com.google.common") relocate("org.eclipse.jetty", "org.apache.gravitino.azure.shaded.org.eclipse.jetty") + mergeServiceFiles() } tasks.jar { diff --git a/bundles/gcp-bundle/build.gradle.kts b/bundles/gcp-bundle/build.gradle.kts index 73efaf9f22c..50300fafe05 100644 --- a/bundles/gcp-bundle/build.gradle.kts +++ b/bundles/gcp-bundle/build.gradle.kts @@ -42,6 +42,7 @@ tasks.withType(ShadowJar::class.java) { relocate("com.google.common", "org.apache.gravitino.gcp.shaded.com.google.common") relocate("com.fasterxml", "org.apache.gravitino.gcp.shaded.com.fasterxml") relocate("org.eclipse.jetty", "org.apache.gravitino.gcp.shaded.org.eclipse.jetty") + mergeServiceFiles() } tasks.jar { From f893b5f903c3bd9616bb2be2e849716e9054fe83 Mon Sep 17 00:00:00 2001 From: roryqi Date: Fri, 3 Jan 2025 16:39:22 +0800 Subject: [PATCH 05/36] [#6082] fix: Fix error code of creating role operation (#6085) ### What changes were proposed in this pull request? We should return 400, if the role contains an error metalake metadata object. We should return 400, if the catalog doesn't exist. ### Why are the changes needed? Fix: #6082 ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? Added UT. --- .../gravitino/utils/MetadataObjectUtil.java | 4 +++ .../server/web/rest/RoleOperations.java | 6 ++--- .../server/web/rest/TestRoleOperations.java | 26 ++++++++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java b/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java index 44b9d30a0a7..eb963182bf3 100644 --- a/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java +++ b/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java @@ -32,6 +32,7 @@ import org.apache.gravitino.MetadataObject; import org.apache.gravitino.NameIdentifier; import org.apache.gravitino.authorization.AuthorizationUtils; +import org.apache.gravitino.exceptions.IllegalMetadataObjectException; import org.apache.gravitino.exceptions.NoSuchMetadataObjectException; import org.apache.gravitino.exceptions.NoSuchRoleException; @@ -125,6 +126,9 @@ public static void checkMetadataObject(String metalake, MetadataObject object) { switch (object.type()) { case METALAKE: + if (!metalake.equals(object.name())) { + throw new IllegalMetadataObjectException("The metalake object name must be %s", metalake); + } NameIdentifierUtil.checkMetalake(identifier); check(env.metalakeDispatcher().metalakeExists(identifier), exceptionToThrowSupplier); break; diff --git a/server/src/main/java/org/apache/gravitino/server/web/rest/RoleOperations.java b/server/src/main/java/org/apache/gravitino/server/web/rest/RoleOperations.java index e986753d0ce..bf82d78b676 100644 --- a/server/src/main/java/org/apache/gravitino/server/web/rest/RoleOperations.java +++ b/server/src/main/java/org/apache/gravitino/server/web/rest/RoleOperations.java @@ -142,10 +142,10 @@ public Response createRole(@PathParam("metalake") String metalake, RoleCreateReq Set privileges = Sets.newHashSet(object.privileges()); AuthorizationUtils.checkDuplicatedNamePrivilege(privileges); - for (Privilege privilege : object.privileges()) { - AuthorizationUtils.checkPrivilege((PrivilegeDTO) privilege, object, metalake); - } try { + for (Privilege privilege : object.privileges()) { + AuthorizationUtils.checkPrivilege((PrivilegeDTO) privilege, object, metalake); + } MetadataObjectUtil.checkMetadataObject(metalake, object); } catch (NoSuchMetadataObjectException nsm) { throw new IllegalMetadataObjectException(nsm); diff --git a/server/src/test/java/org/apache/gravitino/server/web/rest/TestRoleOperations.java b/server/src/test/java/org/apache/gravitino/server/web/rest/TestRoleOperations.java index 5a53ec5f9f0..6edd3339398 100644 --- a/server/src/test/java/org/apache/gravitino/server/web/rest/TestRoleOperations.java +++ b/server/src/test/java/org/apache/gravitino/server/web/rest/TestRoleOperations.java @@ -63,6 +63,7 @@ import org.apache.gravitino.dto.util.DTOConverters; import org.apache.gravitino.exceptions.IllegalNamespaceException; import org.apache.gravitino.exceptions.IllegalPrivilegeException; +import org.apache.gravitino.exceptions.NoSuchCatalogException; import org.apache.gravitino.exceptions.NoSuchMetadataObjectException; import org.apache.gravitino.exceptions.NoSuchMetalakeException; import org.apache.gravitino.exceptions.NoSuchRoleException; @@ -199,8 +200,31 @@ public void testCreateRole() { Privileges.UseCatalog.allow().condition(), roleDTO.securableObjects().get(0).privileges().get(0).condition()); + // Test with a wrong metalake name + RoleCreateRequest reqWithWrongMetalake = + new RoleCreateRequest( + "role", + Collections.emptyMap(), + new SecurableObjectDTO[] { + DTOConverters.toDTO( + SecurableObjects.ofMetalake( + "unknown", Lists.newArrayList(Privileges.UseCatalog.allow()))), + }); + Response respWithWrongMetalake = + target("/metalakes/metalake1/roles") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(reqWithWrongMetalake, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals( + Response.Status.BAD_REQUEST.getStatusCode(), respWithWrongMetalake.getStatus()); + Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, respWithWrongMetalake.getMediaType()); + ErrorResponse withWrongMetalakeResponse = respWithWrongMetalake.readEntity(ErrorResponse.class); + Assertions.assertEquals( + ErrorConstants.ILLEGAL_ARGUMENTS_CODE, withWrongMetalakeResponse.getCode()); + // Test to a catalog which doesn't exist - when(catalogDispatcher.catalogExists(any())).thenReturn(false); + reset(catalogDispatcher); + when(catalogDispatcher.loadCatalog(any())).thenThrow(new NoSuchCatalogException("mock error")); Response respNotExist = target("/metalakes/metalake1/roles") .request(MediaType.APPLICATION_JSON_TYPE) From c3172952c7d70671641eb36a020c3cd12a84f0a8 Mon Sep 17 00:00:00 2001 From: roryqi Date: Fri, 3 Jan 2025 17:36:57 +0800 Subject: [PATCH 06/36] [#6060] fix(core): Add the check of requests related to authorization (#6065) ### What changes were proposed in this pull request? Add the check of requests related to authorization ### Why are the changes needed? Fix: #6060 ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? Added UT. --- .../dto/requests/RoleCreateRequest.java | 9 +++ .../server/web/rest/GroupOperations.java | 12 ++-- .../server/web/rest/OwnerOperations.java | 1 + .../server/web/rest/PermissionOperations.java | 60 +++++++++++-------- .../server/web/rest/RoleOperations.java | 1 + .../server/web/rest/UserOperations.java | 12 ++-- .../server/web/rest/TestGroupOperations.java | 9 +++ .../server/web/rest/TestOwnerOperations.java | 8 +++ .../web/rest/TestPermissionOperations.java | 47 ++++++++++++++- .../server/web/rest/TestRoleOperations.java | 31 +++++++++- .../server/web/rest/TestUserOperations.java | 9 +++ 11 files changed, 163 insertions(+), 36 deletions(-) diff --git a/common/src/main/java/org/apache/gravitino/dto/requests/RoleCreateRequest.java b/common/src/main/java/org/apache/gravitino/dto/requests/RoleCreateRequest.java index 9d85c0c6e14..0466d7d3105 100644 --- a/common/src/main/java/org/apache/gravitino/dto/requests/RoleCreateRequest.java +++ b/common/src/main/java/org/apache/gravitino/dto/requests/RoleCreateRequest.java @@ -79,5 +79,14 @@ public void validate() throws IllegalArgumentException { Preconditions.checkArgument( StringUtils.isNotBlank(name), "\"name\" field is required and cannot be empty"); Preconditions.checkArgument(securableObjects != null, "\"securableObjects\" can't null "); + for (SecurableObjectDTO objectDTO : securableObjects) { + Preconditions.checkArgument( + StringUtils.isNotBlank(objectDTO.name()), "\" securable object name\" can't be blank"); + Preconditions.checkArgument( + objectDTO.type() != null, "\" securable object type\" can't be null"); + Preconditions.checkArgument( + objectDTO.privileges() != null && !objectDTO.privileges().isEmpty(), + "\"securable object privileges\" can't be null or empty"); + } } } diff --git a/server/src/main/java/org/apache/gravitino/server/web/rest/GroupOperations.java b/server/src/main/java/org/apache/gravitino/server/web/rest/GroupOperations.java index 12cf769932e..95db0ca67f3 100644 --- a/server/src/main/java/org/apache/gravitino/server/web/rest/GroupOperations.java +++ b/server/src/main/java/org/apache/gravitino/server/web/rest/GroupOperations.java @@ -103,11 +103,13 @@ public Response addGroup(@PathParam("metalake") String metalake, GroupAddRequest TreeLockUtils.doWithTreeLock( NameIdentifier.of(AuthorizationUtils.ofGroupNamespace(metalake).levels()), LockType.WRITE, - () -> - Utils.ok( - new GroupResponse( - DTOConverters.toDTO( - accessControlManager.addGroup(metalake, request.getName())))))); + () -> { + request.validate(); + return Utils.ok( + new GroupResponse( + DTOConverters.toDTO( + accessControlManager.addGroup(metalake, request.getName())))); + })); } catch (Exception e) { return ExceptionHandlers.handleGroupException( OperationType.ADD, request.getName(), metalake, e); diff --git a/server/src/main/java/org/apache/gravitino/server/web/rest/OwnerOperations.java b/server/src/main/java/org/apache/gravitino/server/web/rest/OwnerOperations.java index ea5684b55f9..7dcfcfd0674 100644 --- a/server/src/main/java/org/apache/gravitino/server/web/rest/OwnerOperations.java +++ b/server/src/main/java/org/apache/gravitino/server/web/rest/OwnerOperations.java @@ -113,6 +113,7 @@ public Response setOwnerForObject( return Utils.doAs( httpRequest, () -> { + request.validate(); MetadataObjectUtil.checkMetadataObject(metalake, object); NameIdentifier objectIdent = MetadataObjectUtil.toEntityIdent(metalake, object); TreeLockUtils.doWithTreeLock( diff --git a/server/src/main/java/org/apache/gravitino/server/web/rest/PermissionOperations.java b/server/src/main/java/org/apache/gravitino/server/web/rest/PermissionOperations.java index 38fcd7380e6..3ce1517a46a 100644 --- a/server/src/main/java/org/apache/gravitino/server/web/rest/PermissionOperations.java +++ b/server/src/main/java/org/apache/gravitino/server/web/rest/PermissionOperations.java @@ -87,12 +87,14 @@ public Response grantRolesToUser( TreeLockUtils.doWithTreeLock( NameIdentifier.of(AuthorizationUtils.ofRoleNamespace(metalake).levels()), LockType.READ, - () -> - Utils.ok( - new UserResponse( - DTOConverters.toDTO( - accessControlManager.grantRolesToUser( - metalake, request.getRoleNames(), user))))))); + () -> { + request.validate(); + return Utils.ok( + new UserResponse( + DTOConverters.toDTO( + accessControlManager.grantRolesToUser( + metalake, request.getRoleNames(), user)))); + }))); } catch (Exception e) { return ExceptionHandlers.handleUserPermissionOperationException( OperationType.GRANT, StringUtils.join(request.getRoleNames(), ","), user, e); @@ -119,12 +121,14 @@ public Response grantRolesToGroup( TreeLockUtils.doWithTreeLock( NameIdentifier.of(AuthorizationUtils.ofRoleNamespace(metalake).levels()), LockType.READ, - () -> - Utils.ok( - new GroupResponse( - DTOConverters.toDTO( - accessControlManager.grantRolesToGroup( - metalake, request.getRoleNames(), group))))))); + () -> { + request.validate(); + return Utils.ok( + new GroupResponse( + DTOConverters.toDTO( + accessControlManager.grantRolesToGroup( + metalake, request.getRoleNames(), group)))); + }))); } catch (Exception e) { return ExceptionHandlers.handleGroupPermissionOperationException( OperationType.GRANT, StringUtils.join(request.getRoleNames(), ","), group, e); @@ -151,12 +155,14 @@ public Response revokeRolesFromUser( TreeLockUtils.doWithTreeLock( NameIdentifier.of(AuthorizationUtils.ofRoleNamespace(metalake).levels()), LockType.READ, - () -> - Utils.ok( - new UserResponse( - DTOConverters.toDTO( - accessControlManager.revokeRolesFromUser( - metalake, request.getRoleNames(), user))))))); + () -> { + request.validate(); + return Utils.ok( + new UserResponse( + DTOConverters.toDTO( + accessControlManager.revokeRolesFromUser( + metalake, request.getRoleNames(), user)))); + }))); } catch (Exception e) { return ExceptionHandlers.handleUserPermissionOperationException( OperationType.REVOKE, StringUtils.join(request.getRoleNames(), ","), user, e); @@ -183,12 +189,14 @@ public Response revokeRolesFromGroup( TreeLockUtils.doWithTreeLock( NameIdentifier.of(AuthorizationUtils.ofRoleNamespace(metalake).levels()), LockType.READ, - () -> - Utils.ok( - new GroupResponse( - DTOConverters.toDTO( - accessControlManager.revokeRolesFromGroup( - metalake, request.getRoleNames(), group))))))); + () -> { + request.validate(); + return Utils.ok( + new GroupResponse( + DTOConverters.toDTO( + accessControlManager.revokeRolesFromGroup( + metalake, request.getRoleNames(), group)))); + }))); } catch (Exception e) { return ExceptionHandlers.handleGroupPermissionOperationException( OperationType.REVOKE, StringUtils.join(request.getRoleNames()), group, e); @@ -214,6 +222,8 @@ public Response grantPrivilegeToRole( return Utils.doAs( httpRequest, () -> { + privilegeGrantRequest.validate(); + for (PrivilegeDTO privilegeDTO : privilegeGrantRequest.getPrivileges()) { AuthorizationUtils.checkPrivilege(privilegeDTO, object, metalake); } @@ -259,6 +269,8 @@ public Response revokePrivilegeFromRole( return Utils.doAs( httpRequest, () -> { + privilegeRevokeRequest.validate(); + for (PrivilegeDTO privilegeDTO : privilegeRevokeRequest.getPrivileges()) { AuthorizationUtils.checkPrivilege(privilegeDTO, object, metalake); } diff --git a/server/src/main/java/org/apache/gravitino/server/web/rest/RoleOperations.java b/server/src/main/java/org/apache/gravitino/server/web/rest/RoleOperations.java index bf82d78b676..9690afe13f1 100644 --- a/server/src/main/java/org/apache/gravitino/server/web/rest/RoleOperations.java +++ b/server/src/main/java/org/apache/gravitino/server/web/rest/RoleOperations.java @@ -127,6 +127,7 @@ public Response createRole(@PathParam("metalake") String metalake, RoleCreateReq return Utils.doAs( httpRequest, () -> { + request.validate(); Set metadataObjects = Sets.newHashSet(); for (SecurableObjectDTO object : request.getSecurableObjects()) { MetadataObject metadataObject = diff --git a/server/src/main/java/org/apache/gravitino/server/web/rest/UserOperations.java b/server/src/main/java/org/apache/gravitino/server/web/rest/UserOperations.java index 24f34d652ab..518178cd325 100644 --- a/server/src/main/java/org/apache/gravitino/server/web/rest/UserOperations.java +++ b/server/src/main/java/org/apache/gravitino/server/web/rest/UserOperations.java @@ -129,11 +129,13 @@ public Response addUser(@PathParam("metalake") String metalake, UserAddRequest r TreeLockUtils.doWithTreeLock( NameIdentifier.of(AuthorizationUtils.ofGroupNamespace(metalake).levels()), LockType.WRITE, - () -> - Utils.ok( - new UserResponse( - DTOConverters.toDTO( - accessControlManager.addUser(metalake, request.getName())))))); + () -> { + request.validate(); + return Utils.ok( + new UserResponse( + DTOConverters.toDTO( + accessControlManager.addUser(metalake, request.getName())))); + })); } catch (Exception e) { return ExceptionHandlers.handleUserException( OperationType.ADD, request.getName(), metalake, e); diff --git a/server/src/test/java/org/apache/gravitino/server/web/rest/TestGroupOperations.java b/server/src/test/java/org/apache/gravitino/server/web/rest/TestGroupOperations.java index 77f0cf97988..ac4f8c66a8d 100644 --- a/server/src/test/java/org/apache/gravitino/server/web/rest/TestGroupOperations.java +++ b/server/src/test/java/org/apache/gravitino/server/web/rest/TestGroupOperations.java @@ -116,6 +116,15 @@ public void testAddGroup() { when(manager.addGroup(any(), any())).thenReturn(group); + // test with IllegalRequest + GroupAddRequest illegalReq = new GroupAddRequest(""); + Response illegalResp = + target("/metalakes/metalake1/groups") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(illegalReq, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), illegalResp.getStatus()); + Response resp = target("/metalakes/metalake1/groups") .request(MediaType.APPLICATION_JSON_TYPE) diff --git a/server/src/test/java/org/apache/gravitino/server/web/rest/TestOwnerOperations.java b/server/src/test/java/org/apache/gravitino/server/web/rest/TestOwnerOperations.java index 0643ed9bf1a..dc7451a538c 100644 --- a/server/src/test/java/org/apache/gravitino/server/web/rest/TestOwnerOperations.java +++ b/server/src/test/java/org/apache/gravitino/server/web/rest/TestOwnerOperations.java @@ -202,6 +202,14 @@ public Type type() { @Test void testSetOwnerForObject() { when(metalakeDispatcher.metalakeExists(any())).thenReturn(true); + OwnerSetRequest invalidRequest = new OwnerSetRequest(null, Owner.Type.USER); + Response invalidResp = + target("/metalakes/metalake1/owners/metalake/metalake1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .put(Entity.entity(invalidRequest, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), invalidResp.getStatus()); + OwnerSetRequest request = new OwnerSetRequest("test", Owner.Type.USER); Response resp = target("/metalakes/metalake1/owners/metalake/metalake1") diff --git a/server/src/test/java/org/apache/gravitino/server/web/rest/TestPermissionOperations.java b/server/src/test/java/org/apache/gravitino/server/web/rest/TestPermissionOperations.java index 8876e9035f4..1f507cbbcc1 100644 --- a/server/src/test/java/org/apache/gravitino/server/web/rest/TestPermissionOperations.java +++ b/server/src/test/java/org/apache/gravitino/server/web/rest/TestPermissionOperations.java @@ -135,8 +135,15 @@ public void testGrantRolesToUser() { .build(); when(manager.grantRolesToUser(any(), any(), any())).thenReturn(userEntity); - RoleGrantRequest request = new RoleGrantRequest(Lists.newArrayList("role1")); + RoleGrantRequest illegalReq = new RoleGrantRequest(null); + Response illegalResp = + target("/metalakes/metalake1/permissions/users/user/grant") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .put(Entity.entity(illegalReq, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), illegalResp.getStatus()); + RoleGrantRequest request = new RoleGrantRequest(Lists.newArrayList("role1")); Response resp = target("/metalakes/metalake1/permissions/users/user/grant") .request(MediaType.APPLICATION_JSON_TYPE) @@ -232,6 +239,15 @@ public void testGrantRolesToGroup() { .build(); when(manager.grantRolesToGroup(any(), any(), any())).thenReturn(groupEntity); + // Test with Illegal request + RoleGrantRequest illegalReq = new RoleGrantRequest(null); + Response illegalResp = + target("/metalakes/metalake1/permissions/groups/group/grant") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .put(Entity.entity(illegalReq, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), illegalResp.getStatus()); + RoleGrantRequest request = new RoleGrantRequest(Lists.newArrayList("role1")); Response resp = @@ -331,6 +347,16 @@ public void testRevokeRolesFromUser() { AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build()) .build(); when(manager.revokeRolesFromUser(any(), any(), any())).thenReturn(userEntity); + + // Test with illegal request + RoleRevokeRequest illegalReq = new RoleRevokeRequest(null); + Response illegalResp = + target("/metalakes/metalake1/permissions/users/user1/revoke") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .put(Entity.entity(illegalReq, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), illegalResp.getStatus()); + RoleRevokeRequest request = new RoleRevokeRequest(Lists.newArrayList("role1")); Response resp = @@ -393,6 +419,15 @@ public void testRevokeRolesFromGroup() { AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build()) .build(); when(manager.revokeRolesFromGroup(any(), any(), any())).thenReturn(groupEntity); + // Test with illegal request + RoleRevokeRequest illegalReq = new RoleRevokeRequest(null); + Response illegalResp = + target("/metalakes/metalake1/permissions/groups/group1/revoke") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .put(Entity.entity(illegalReq, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), illegalResp.getStatus()); + RoleRevokeRequest request = new RoleRevokeRequest(Lists.newArrayList("role1")); Response resp = @@ -538,6 +573,16 @@ public void testRevokePrivilegesFromRole() { .build(); when(manager.revokePrivilegesFromRole(any(), any(), any(), any())).thenReturn(roleEntity); when(metalakeDispatcher.metalakeExists(any())).thenReturn(true); + + // Test with illegal request + PrivilegeRevokeRequest illegalReq = new PrivilegeRevokeRequest(null); + Response illegalResp = + target("/metalakes/metalake1/permissions/roles/role1/metalake/metalake1/revoke") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .put(Entity.entity(illegalReq, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), illegalResp.getStatus()); + PrivilegeRevokeRequest request = new PrivilegeRevokeRequest( Lists.newArrayList( diff --git a/server/src/test/java/org/apache/gravitino/server/web/rest/TestRoleOperations.java b/server/src/test/java/org/apache/gravitino/server/web/rest/TestRoleOperations.java index 6edd3339398..06d9fcc27e9 100644 --- a/server/src/test/java/org/apache/gravitino/server/web/rest/TestRoleOperations.java +++ b/server/src/test/java/org/apache/gravitino/server/web/rest/TestRoleOperations.java @@ -29,6 +29,7 @@ import com.google.common.collect.Lists; import java.io.IOException; +import java.lang.reflect.Field; import java.time.Instant; import java.util.Collections; import java.util.concurrent.atomic.AtomicReference; @@ -52,6 +53,7 @@ import org.apache.gravitino.catalog.SchemaDispatcher; import org.apache.gravitino.catalog.TableDispatcher; import org.apache.gravitino.catalog.TopicDispatcher; +import org.apache.gravitino.dto.authorization.PrivilegeDTO; import org.apache.gravitino.dto.authorization.RoleDTO; import org.apache.gravitino.dto.authorization.SecurableObjectDTO; import org.apache.gravitino.dto.requests.RoleCreateRequest; @@ -142,7 +144,7 @@ protected void configure() { } @Test - public void testCreateRole() { + public void testCreateRole() throws IllegalAccessException, NoSuchFieldException { SecurableObject securableObject = SecurableObjects.ofCatalog("catalog", Lists.newArrayList(Privileges.UseCatalog.allow())); SecurableObject anotherSecurableObject = @@ -161,6 +163,33 @@ public void testCreateRole() { when(manager.createRole(any(), any(), any(), any())).thenReturn(role); when(catalogDispatcher.catalogExists(any())).thenReturn(true); + // Test with IllegalRequest + RoleCreateRequest illegalRequest = new RoleCreateRequest("role", Collections.emptyMap(), null); + Response illegalResp = + target("/metalakes/metalake1/roles") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(illegalRequest, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), illegalResp.getStatus()); + + SecurableObjectDTO illegalObject = + DTOConverters.toDTO( + SecurableObjects.ofCatalog( + "illegal_catalog", Lists.newArrayList(Privileges.CreateSchema.deny()))); + Field field = illegalObject.getClass().getDeclaredField("privileges"); + field.setAccessible(true); + field.set(illegalObject, new PrivilegeDTO[] {}); + + illegalRequest = + new RoleCreateRequest( + "role", Collections.emptyMap(), new SecurableObjectDTO[] {illegalObject}); + illegalResp = + target("/metalakes/metalake1/roles") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(illegalRequest, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), illegalResp.getStatus()); + Response resp = target("/metalakes/metalake1/roles") .request(MediaType.APPLICATION_JSON_TYPE) diff --git a/server/src/test/java/org/apache/gravitino/server/web/rest/TestUserOperations.java b/server/src/test/java/org/apache/gravitino/server/web/rest/TestUserOperations.java index 7f570e779f4..82bc59155ba 100644 --- a/server/src/test/java/org/apache/gravitino/server/web/rest/TestUserOperations.java +++ b/server/src/test/java/org/apache/gravitino/server/web/rest/TestUserOperations.java @@ -115,6 +115,15 @@ public void testAddUser() { when(manager.addUser(any(), any())).thenReturn(user); + // test with IllegalRequest + UserAddRequest illegalReq = new UserAddRequest(""); + Response illegalResp = + target("/metalakes/metalake1/users") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(illegalReq, MediaType.APPLICATION_JSON_TYPE)); + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), illegalResp.getStatus()); + Response resp = target("/metalakes/metalake1/users") .request(MediaType.APPLICATION_JSON_TYPE) From 2732792e07e73b539c5905df95f18f0bf6a858f3 Mon Sep 17 00:00:00 2001 From: roryqi Date: Fri, 3 Jan 2025 18:23:41 +0800 Subject: [PATCH 07/36] [#6061] fix(core): Fix the issues of list user or group details (#6067) ### What changes were proposed in this pull request? Fix the issues of list user or group details ### Why are the changes needed? Fix: #6061 ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? Added UT. --- .../test/authorization/AccessControlIT.java | 8 ++++++ .../base/GroupMetaBaseSQLProvider.java | 15 ++++++---- .../base/UserMetaBaseSQLProvider.java | 15 ++++++---- .../provider/h2/GroupMetaH2Provider.java | 15 ++++++---- .../provider/h2/UserMetaH2Provider.java | 15 ++++++---- .../GroupMetaPostgreSQLProvider.java | 15 ++++++---- .../UserMetaPostgreSQLProvider.java | 15 ++++++---- .../service/TestGroupMetaService.java | 28 +++++++++++++++++++ .../service/TestUserMetaService.java | 28 +++++++++++++++++++ 9 files changed, 118 insertions(+), 36 deletions(-) diff --git a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/AccessControlIT.java b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/AccessControlIT.java index 78c29433439..268ed20f3ce 100644 --- a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/AccessControlIT.java +++ b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/AccessControlIT.java @@ -121,6 +121,10 @@ void testManageUsers() { users.stream().map(User::name).collect(Collectors.toList())); Assertions.assertEquals(Lists.newArrayList("role1"), users.get(2).roles()); + // ISSUE-6061: Test listUsers with revoked users + metalake.revokeRolesFromUser(Lists.newArrayList("role1"), username); + Assertions.assertEquals(3, metalake.listUsers().length); + // Get a not-existed user Assertions.assertThrows(NoSuchUserException.class, () -> metalake.getUser("not-existed")); @@ -176,6 +180,10 @@ void testManageGroups() { groups.stream().map(Group::name).collect(Collectors.toList())); Assertions.assertEquals(Lists.newArrayList("role2"), groups.get(0).roles()); + // ISSUE-6061: Test listGroups with revoked groups + metalake.revokeRolesFromGroup(Lists.newArrayList("role2"), groupName); + Assertions.assertEquals(2, metalake.listGroups().length); + Assertions.assertTrue(metalake.removeGroup(groupName)); Assertions.assertFalse(metalake.removeGroup(groupName)); diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java index a52e1b86144..1f28b771c2d 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java @@ -57,16 +57,19 @@ public String listExtendedGroupPOsByMetalakeId(Long metalakeId) { + " JSON_ARRAYAGG(rot.role_id) as roleIds" + " FROM " + GROUP_TABLE_NAME - + " gt LEFT OUTER JOIN " + + " gt LEFT OUTER JOIN (" + + " SELECT * FROM " + GROUP_ROLE_RELATION_TABLE_NAME - + " rt ON rt.group_id = gt.group_id" - + " LEFT OUTER JOIN " + + " WHERE deleted_at = 0)" + + " AS rt ON rt.group_id = gt.group_id" + + " LEFT OUTER JOIN (" + + " SELECT * FROM " + ROLE_TABLE_NAME - + " rot ON rot.role_id = rt.role_id" + + " WHERE deleted_at = 0)" + + " AS rot ON rot.role_id = rt.role_id" + " WHERE " + " gt.deleted_at = 0 AND" - + " (rot.deleted_at = 0 OR rot.deleted_at is NULL) AND" - + " (rt.deleted_at = 0 OR rt.deleted_at is NULL) AND gt.metalake_id = #{metalakeId}" + + " gt.metalake_id = #{metalakeId}" + " GROUP BY gt.group_id"; } diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java index 2a211c24f5e..4e81ae35df9 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java @@ -165,16 +165,19 @@ public String listExtendedUserPOsByMetalakeId(@Param("metalakeId") Long metalake + " JSON_ARRAYAGG(rot.role_id) as roleIds" + " FROM " + USER_TABLE_NAME - + " ut LEFT OUTER JOIN " + + " ut LEFT OUTER JOIN (" + + " SELECT * FROM " + USER_ROLE_RELATION_TABLE_NAME - + " rt ON rt.user_id = ut.user_id" - + " LEFT OUTER JOIN " + + " WHERE deleted_at = 0)" + + " AS rt ON rt.user_id = ut.user_id" + + " LEFT OUTER JOIN (" + + " SELECT * FROM " + ROLE_TABLE_NAME - + " rot ON rot.role_id = rt.role_id" + + " WHERE deleted_at = 0)" + + " AS rot ON rot.role_id = rt.role_id" + " WHERE " + " ut.deleted_at = 0 AND" - + " (rot.deleted_at = 0 OR rot.deleted_at is NULL) AND" - + " (rt.deleted_at = 0 OR rt.deleted_at is NULL) AND ut.metalake_id = #{metalakeId}" + + " ut.metalake_id = #{metalakeId}" + " GROUP BY ut.user_id"; } diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/GroupMetaH2Provider.java b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/GroupMetaH2Provider.java index 175d9d8ae9a..e975131e090 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/GroupMetaH2Provider.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/GroupMetaH2Provider.java @@ -37,16 +37,19 @@ public String listExtendedGroupPOsByMetalakeId(@Param("metalakeId") Long metalak + " '[' || GROUP_CONCAT('\"' || rot.role_id || '\"') || ']' as roleIds" + " FROM " + GROUP_TABLE_NAME - + " gt LEFT OUTER JOIN " + + " gt LEFT OUTER JOIN (" + + " SELECT * FROM " + GROUP_ROLE_RELATION_TABLE_NAME - + " rt ON rt.group_id = gt.group_id" - + " LEFT OUTER JOIN " + + " WHERE deleted_at = 0)" + + " AS rt ON rt.group_id = gt.group_id" + + " LEFT OUTER JOIN (" + + " SELECT * FROM " + ROLE_TABLE_NAME - + " rot ON rot.role_id = rt.role_id" + + " WHERE deleted_at = 0)" + + " AS rot ON rot.role_id = rt.role_id" + " WHERE " + " gt.deleted_at = 0 AND" - + " (rot.deleted_at = 0 OR rot.deleted_at is NULL) AND" - + " (rt.deleted_at = 0 OR rt.deleted_at is NULL) AND gt.metalake_id = #{metalakeId}" + + " gt.metalake_id = #{metalakeId}" + " GROUP BY gt.group_id"; } } diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java index be17138ce49..b4fb1614904 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java @@ -37,16 +37,19 @@ public String listExtendedUserPOsByMetalakeId(@Param("metalakeId") Long metalake + " '[' || GROUP_CONCAT('\"' || rot.role_id || '\"') || ']' as roleIds" + " FROM " + USER_TABLE_NAME - + " ut LEFT OUTER JOIN " + + " ut LEFT OUTER JOIN (" + + " SELECT * FROM " + USER_ROLE_RELATION_TABLE_NAME - + " rt ON rt.user_id = ut.user_id" - + " LEFT OUTER JOIN " + + " WHERE deleted_at = 0)" + + " AS rt ON rt.user_id = ut.user_id" + + " LEFT OUTER JOIN (" + + " SELECT * FROM " + ROLE_TABLE_NAME - + " rot ON rot.role_id = rt.role_id" + + " WHERE deleted_at = 0)" + + " AS rot ON rot.role_id = rt.role_id" + " WHERE " + " ut.deleted_at = 0 AND " - + "(rot.deleted_at = 0 OR rot.deleted_at is NULL) AND " - + "(rt.deleted_at = 0 OR rt.deleted_at is NULL) AND ut.metalake_id = #{metalakeId}" + + " ut.metalake_id = #{metalakeId}" + " GROUP BY ut.user_id"; } } diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java index 51cf47bf7d7..3ace33f6f84 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java @@ -80,16 +80,19 @@ public String listExtendedGroupPOsByMetalakeId(Long metalakeId) { + " JSON_AGG(rot.role_id) as roleIds" + " FROM " + GROUP_TABLE_NAME - + " gt LEFT OUTER JOIN " + + " gt LEFT OUTER JOIN (" + + " SELECT * FROM " + GROUP_ROLE_RELATION_TABLE_NAME - + " rt ON rt.group_id = gt.group_id" - + " LEFT OUTER JOIN " + + " WHERE deleted_at = 0)" + + " AS rt ON rt.group_id = gt.group_id" + + " LEFT OUTER JOIN (" + + " SELECT * FROM " + ROLE_TABLE_NAME - + " rot ON rot.role_id = rt.role_id" + + " WHERE deleted_at = 0)" + + " AS rot ON rot.role_id = rt.role_id" + " WHERE " + " gt.deleted_at = 0 AND" - + " (rot.deleted_at = 0 OR rot.deleted_at is NULL) AND" - + " (rt.deleted_at = 0 OR rt.deleted_at is NULL) AND gt.metalake_id = #{metalakeId}" + + " gt.metalake_id = #{metalakeId}" + " GROUP BY gt.group_id"; } } diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java index b6ac62b2b87..84ab965582c 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java @@ -80,16 +80,19 @@ public String listExtendedUserPOsByMetalakeId(Long metalakeId) { + " JSON_AGG(rot.role_id) as roleIds" + " FROM " + USER_TABLE_NAME - + " ut LEFT OUTER JOIN " + + " ut LEFT OUTER JOIN (" + + " SELECT * FROM " + USER_ROLE_RELATION_TABLE_NAME - + " rt ON rt.user_id = ut.user_id" - + " LEFT OUTER JOIN " + + " WHERE deleted_at = 0)" + + " AS rt ON rt.user_id = ut.user_id" + + " LEFT OUTER JOIN (" + + " SELECT * FROM " + ROLE_TABLE_NAME - + " rot ON rot.role_id = rt.role_id" + + " WHERE deleted_at = 0)" + + " AS rot ON rot.role_id = rt.role_id" + " WHERE " + " ut.deleted_at = 0 AND" - + " (rot.deleted_at = 0 OR rot.deleted_at is NULL) AND" - + " (rt.deleted_at = 0 OR rt.deleted_at is NULL) AND ut.metalake_id = #{metalakeId}" + + " ut.metalake_id = #{metalakeId}" + " GROUP BY ut.user_id"; } } diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java index 77cd9d110bc..5e90f0eb89f 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java @@ -27,6 +27,7 @@ import java.sql.SQLException; import java.sql.Statement; import java.time.Instant; +import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -189,6 +190,33 @@ void testListGroups() throws IOException { } } } + + // ISSUE-6061: Test listGroupsByNamespace with revoked users + Function revokeUpdater = + group -> { + AuditInfo updateAuditInfo = + AuditInfo.builder() + .withCreator(group.auditInfo().creator()) + .withCreateTime(group.auditInfo().createTime()) + .withLastModifier("revokeGroup") + .withLastModifiedTime(Instant.now()) + .build(); + + return GroupEntity.builder() + .withNamespace(group.namespace()) + .withId(group.id()) + .withName(group.name()) + .withRoleNames(Collections.emptyList()) + .withRoleIds(Collections.emptyList()) + .withAuditInfo(updateAuditInfo) + .build(); + }; + + Assertions.assertNotNull(groupMetaService.updateGroup(group2.nameIdentifier(), revokeUpdater)); + actualGroups = + groupMetaService.listGroupsByNamespace( + AuthorizationUtils.ofGroupNamespace(metalakeName), true); + Assertions.assertEquals(expectGroups.size(), actualGroups.size()); } @Test diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java index 0efd886ee4d..e93a83bafd6 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java @@ -27,6 +27,7 @@ import java.sql.SQLException; import java.sql.Statement; import java.time.Instant; +import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -188,6 +189,33 @@ void testListUsers() throws IOException { } } } + + // ISSUE-6061: Test listUsersByNamespace with revoked users + Function revokeUpdater = + user -> { + AuditInfo updateAuditInfo = + AuditInfo.builder() + .withCreator(user.auditInfo().creator()) + .withCreateTime(user.auditInfo().createTime()) + .withLastModifier("revokeUser") + .withLastModifiedTime(Instant.now()) + .build(); + + return UserEntity.builder() + .withNamespace(user.namespace()) + .withId(user.id()) + .withName(user.name()) + .withRoleNames(Collections.emptyList()) + .withRoleIds(Collections.emptyList()) + .withAuditInfo(updateAuditInfo) + .build(); + }; + + Assertions.assertNotNull(userMetaService.updateUser(user1.nameIdentifier(), revokeUpdater)); + actualUsers = + userMetaService.listUsersByNamespace( + AuthorizationUtils.ofUserNamespace(metalakeName), true); + Assertions.assertEquals(expectUsers.size(), actualUsers.size()); } @Test From d1e2890c4b61b6813518e5fa6eeae992ea047a08 Mon Sep 17 00:00:00 2001 From: Yuhui Date: Mon, 16 Dec 2024 11:56:59 +0800 Subject: [PATCH 08/36] [#5734] feat (gvfs-fuse): Gvfs-fuse basic FUSE-level implementation and code structure layout (#5835) ### What changes were proposed in this pull request? 1. Implement basic FUSE interfaces. 2. Implement filesystem trait and relation structures. ### Why are the changes needed? Fix: #5734 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? No --- .github/workflows/gvfs-fuse-build-test.yml | 89 +++ clients/filesystem-fuse/.cargo/config.toml | 2 +- clients/filesystem-fuse/Cargo.toml | 10 +- clients/filesystem-fuse/rust-toolchain.toml | 21 + clients/filesystem-fuse/src/filesystem.rs | 241 +++++++++ .../filesystem-fuse/src/fuse_api_handle.rs | 507 ++++++++++++++++++ clients/filesystem-fuse/src/lib.rs | 20 + clients/filesystem-fuse/src/main.rs | 4 +- 8 files changed, 890 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/gvfs-fuse-build-test.yml create mode 100644 clients/filesystem-fuse/rust-toolchain.toml create mode 100644 clients/filesystem-fuse/src/filesystem.rs create mode 100644 clients/filesystem-fuse/src/fuse_api_handle.rs create mode 100644 clients/filesystem-fuse/src/lib.rs diff --git a/.github/workflows/gvfs-fuse-build-test.yml b/.github/workflows/gvfs-fuse-build-test.yml new file mode 100644 index 00000000000..4af01d82da3 --- /dev/null +++ b/.github/workflows/gvfs-fuse-build-test.yml @@ -0,0 +1,89 @@ +name: Build gvfs-fuse and testing + +# Controls when the workflow will run +on: + push: + branches: [ "main", "branch-*" ] + pull_request: + branches: [ "main", "branch-*" ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + source_changes: + - .github/** + - api/** + - bin/** + - catalogs/hadoop/** + - clients/filesystem-fuse/** + - common/** + - conf/** + - core/** + - dev/** + - gradle/** + - meta/** + - scripts/** + - server/** + - server-common/** + - build.gradle.kts + - gradle.properties + - gradlew + - setting.gradle.kts + outputs: + source_changes: ${{ steps.filter.outputs.source_changes }} + + # Build for AMD64 architecture + Gvfs-Build: + needs: changes + if: needs.changes.outputs.source_changes == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + matrix: + architecture: [linux/amd64] + java-version: [ 17 ] + env: + PLATFORM: ${{ matrix.architecture }} + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java-version }} + distribution: 'temurin' + cache: 'gradle' + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Check required command + run: | + dev/ci/check_commands.sh + + - name: Build and test Gravitino + run: | + ./gradlew :clients:filesystem-fuse:build -PenableFuse=true + + - name: Free up disk space + run: | + dev/ci/util_free_space.sh + + - name: Upload tests reports + uses: actions/upload-artifact@v3 + if: ${{ (failure() && steps.integrationTest.outcome == 'failure') || contains(github.event.pull_request.labels.*.name, 'upload log') }} + with: + name: Gvfs-fuse integrate-test-reports-${{ matrix.java-version }} + path: | + clients/filesystem-fuse/build/test/log/*.log + diff --git a/clients/filesystem-fuse/.cargo/config.toml b/clients/filesystem-fuse/.cargo/config.toml index 37751e880c3..78bc9f7fe48 100644 --- a/clients/filesystem-fuse/.cargo/config.toml +++ b/clients/filesystem-fuse/.cargo/config.toml @@ -17,4 +17,4 @@ [build] target-dir = "build" - +rustflags = ["-Adead_code", "-Aclippy::redundant-field-names"] diff --git a/clients/filesystem-fuse/Cargo.toml b/clients/filesystem-fuse/Cargo.toml index 1b186d61cb1..2883cecc656 100644 --- a/clients/filesystem-fuse/Cargo.toml +++ b/clients/filesystem-fuse/Cargo.toml @@ -29,9 +29,15 @@ repository = "https://github.com/apache/gravitino" name = "gvfs-fuse" path = "src/main.rs" +[lib] +name="gvfs_fuse" + [dependencies] +async-trait = "0.1" +bytes = "1.6.0" futures-util = "0.3.30" -libc = "0.2.164" +fuse3 = { version = "0.8.1", "features" = ["tokio-runtime", "unprivileged"] } log = "0.4.22" tokio = { version = "1.38.0", features = ["full"] } -tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } \ No newline at end of file +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } + diff --git a/clients/filesystem-fuse/rust-toolchain.toml b/clients/filesystem-fuse/rust-toolchain.toml new file mode 100644 index 00000000000..a7cf737871d --- /dev/null +++ b/clients/filesystem-fuse/rust-toolchain.toml @@ -0,0 +1,21 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[toolchain] +channel = "1.82.0" +components = ["rustfmt", "clippy", "rust-src"] +profile = "default" diff --git a/clients/filesystem-fuse/src/filesystem.rs b/clients/filesystem-fuse/src/filesystem.rs new file mode 100644 index 00000000000..6d1d8fa2538 --- /dev/null +++ b/clients/filesystem-fuse/src/filesystem.rs @@ -0,0 +1,241 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use async_trait::async_trait; +use bytes::Bytes; +use fuse3::{Errno, FileType, Timestamp}; + +pub(crate) type Result = std::result::Result; + +/// RawFileSystem interface for the file system implementation. it use by FuseApiHandle, +/// it ues the file id to operate the file system apis +/// the `file_id` and `parent_file_id` it is the unique identifier for the file system, +/// it is used to identify the file or directory +/// the `handle_id` it is the file handle, it is used to identify the opened file, +/// it is used to read or write the file content +/// the `file id` and `handle_id` need to mapping the `ino`/`inode` and `fh` in the fuse3 +#[async_trait] +pub(crate) trait RawFileSystem: Send + Sync { + /// Init the file system + async fn init(&self) -> Result<()>; + + /// Get the file path by file id, if the file id is valid, return the file path + async fn get_file_path(&self, file_id: u64) -> String; + + /// Validate the file id and file handle, if file id and file handle is valid and it associated, return Ok + async fn valid_file_id(&self, file_id: u64, fh: u64) -> Result<()>; + + /// Get the file stat by file id. if the file id is valid, return the file stat + async fn stat(&self, file_id: u64) -> Result; + + /// Lookup the file by parent file id and file name, if the file is exist, return the file stat + async fn lookup(&self, parent_file_id: u64, name: &str) -> Result; + + /// Read the directory by file id, if the file id is a valid directory, return the file stat list + async fn read_dir(&self, dir_file_id: u64) -> Result>; + + /// Open the file by file id and flags, if the file id is a valid file, return the file handle + async fn open_file(&self, file_id: u64, flags: u32) -> Result; + + /// Open the directory by file id and flags, if successful, return the file handle + async fn open_dir(&self, file_id: u64, flags: u32) -> Result; + + /// Create the file by parent file id and file name and flags, if successful, return the file handle + async fn create_file(&self, parent_file_id: u64, name: &str, flags: u32) -> Result; + + /// Create the directory by parent file id and file name, if successful, return the file id + async fn create_dir(&self, parent_file_id: u64, name: &str) -> Result; + + /// Set the file attribute by file id and file stat + async fn set_attr(&self, file_id: u64, file_stat: &FileStat) -> Result<()>; + + /// Remove the file by parent file id and file name + async fn remove_file(&self, parent_file_id: u64, name: &str) -> Result<()>; + + /// Remove the directory by parent file id and file name + async fn remove_dir(&self, parent_file_id: u64, name: &str) -> Result<()>; + + /// Close the file by file id and file handle, if successful + async fn close_file(&self, file_id: u64, fh: u64) -> Result<()>; + + /// Read the file content by file id, file handle, offset and size, if successful, return the read result + async fn read(&self, file_id: u64, fh: u64, offset: u64, size: u32) -> Result; + + /// Write the file content by file id, file handle, offset and data, if successful, return the written size + async fn write(&self, file_id: u64, fh: u64, offset: u64, data: &[u8]) -> Result; +} + +/// PathFileSystem is the interface for the file system implementation, it use to interact with other file system +/// it is used file path to operate the file system +#[async_trait] +pub(crate) trait PathFileSystem: Send + Sync { + /// Init the file system + async fn init(&self) -> Result<()>; + + /// Get the file stat by file path, if the file is exist, return the file stat + async fn stat(&self, name: &str) -> Result; + + /// Get the file stat by parent file path and file name, if the file is exist, return the file stat + async fn lookup(&self, parent: &str, name: &str) -> Result; + + /// Read the directory by file path, if the file is a valid directory, return the file stat list + async fn read_dir(&self, name: &str) -> Result>; + + /// Open the file by file path and flags, if the file is exist, return the opened file + async fn open_file(&self, name: &str, flags: OpenFileFlags) -> Result; + + /// Open the directory by file path and flags, if the file is exist, return the opened file + async fn open_dir(&self, name: &str, flags: OpenFileFlags) -> Result; + + /// Create the file by parent file path and file name and flags, if successful, return the opened file + async fn create_file( + &self, + parent: &str, + name: &str, + flags: OpenFileFlags, + ) -> Result; + + /// Create the directory by parent file path and file name, if successful, return the file stat + async fn create_dir(&self, parent: &str, name: &str) -> Result; + + /// Set the file attribute by file path and file stat + async fn set_attr(&self, name: &str, file_stat: &FileStat, flush: bool) -> Result<()>; + + /// Remove the file by parent file path and file name + async fn remove_file(&self, parent: &str, name: &str) -> Result<()>; + + /// Remove the directory by parent file path and file name + async fn remove_dir(&self, parent: &str, name: &str) -> Result<()>; +} + +// FileSystemContext is the system environment for the fuse file system. +pub(crate) struct FileSystemContext { + // system user id + pub(crate) uid: u32, + + // system group id + pub(crate) gid: u32, + + // default file permission + pub(crate) default_file_perm: u16, + + // default idr permission + pub(crate) default_dir_perm: u16, + + // io block size + pub(crate) block_size: u32, +} + +impl FileSystemContext { + pub(crate) fn new(uid: u32, gid: u32) -> Self { + FileSystemContext { + uid, + gid, + default_file_perm: 0o644, + default_dir_perm: 0o755, + block_size: 4 * 1024, + } + } +} + +// FileStat is the file metadata of the file +#[derive(Clone, Debug)] +pub struct FileStat { + // file id for the file system. + pub(crate) file_id: u64, + + // parent file id + pub(crate) parent_file_id: u64, + + // file name + pub(crate) name: String, + + // file path of the fuse file system root + pub(crate) path: String, + + // file size + pub(crate) size: u64, + + // file type like regular file or directory and so on + pub(crate) kind: FileType, + + // file permission + pub(crate) perm: u16, + + // file access time + pub(crate) atime: Timestamp, + + // file modify time + pub(crate) mtime: Timestamp, + + // file create time + pub(crate) ctime: Timestamp, + + // file link count + pub(crate) nlink: u32, +} + +/// Opened file for read or write, it is used to read or write the file content. +pub(crate) struct OpenedFile { + pub(crate) file_stat: FileStat, + + pub(crate) handle_id: u64, + + pub reader: Option>, + + pub writer: Option>, +} + +// FileHandle is the file handle for the opened file. +pub(crate) struct FileHandle { + pub(crate) file_id: u64, + + pub(crate) handle_id: u64, +} + +// OpenFileFlags is the open file flags for the file system. +pub struct OpenFileFlags(u32); + +/// File reader interface for read file content +#[async_trait] +pub(crate) trait FileReader: Sync + Send { + /// read the file content by offset and size, if successful, return the read result + async fn read(&mut self, offset: u64, size: u32) -> Result; + + /// close the file + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} + +/// File writer interface for write file content +#[async_trait] +pub trait FileWriter: Sync + Send { + /// write the file content by offset and data, if successful, return the written size + async fn write(&mut self, offset: u64, data: &[u8]) -> Result; + + /// close the file + async fn close(&mut self) -> Result<()> { + Ok(()) + } + + /// flush the file + async fn flush(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/clients/filesystem-fuse/src/fuse_api_handle.rs b/clients/filesystem-fuse/src/fuse_api_handle.rs new file mode 100644 index 00000000000..8c065df0227 --- /dev/null +++ b/clients/filesystem-fuse/src/fuse_api_handle.rs @@ -0,0 +1,507 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use crate::filesystem::{FileStat, FileSystemContext, RawFileSystem}; +use fuse3::path::prelude::{ReplyData, ReplyOpen, ReplyStatFs, ReplyWrite}; +use fuse3::path::Request; +use fuse3::raw::prelude::{ + FileAttr, ReplyAttr, ReplyCreated, ReplyDirectory, ReplyDirectoryPlus, ReplyEntry, ReplyInit, +}; +use fuse3::raw::reply::{DirectoryEntry, DirectoryEntryPlus}; +use fuse3::raw::Filesystem; +use fuse3::FileType::{Directory, RegularFile}; +use fuse3::{Errno, FileType, Inode, SetAttr, Timestamp}; +use futures_util::stream; +use futures_util::stream::BoxStream; +use futures_util::StreamExt; +use std::ffi::{OsStr, OsString}; +use std::num::NonZeroU32; +use std::time::{Duration, SystemTime}; + +pub(crate) struct FuseApiHandle { + fs: T, + default_ttl: Duration, + fs_context: FileSystemContext, +} + +impl FuseApiHandle { + const DEFAULT_ATTR_TTL: Duration = Duration::from_secs(1); + const DEFAULT_MAX_WRITE_SIZE: u32 = 16 * 1024; + + pub fn new(fs: T, context: FileSystemContext) -> Self { + Self { + fs: fs, + default_ttl: Self::DEFAULT_ATTR_TTL, + fs_context: context, + } + } + + pub async fn get_file_path(&self, file_id: u64) -> String { + self.fs.get_file_path(file_id).await + } + + async fn get_modified_file_stat( + &self, + file_id: u64, + size: Option, + atime: Option, + mtime: Option, + ) -> Result { + let mut file_stat = self.fs.stat(file_id).await?; + + if let Some(size) = size { + file_stat.size = size; + }; + + if let Some(atime) = atime { + file_stat.atime = atime; + }; + + if let Some(mtime) = mtime { + file_stat.mtime = mtime; + }; + + Ok(file_stat) + } +} + +impl Filesystem for FuseApiHandle { + async fn init(&self, _req: Request) -> fuse3::Result { + self.fs.init().await?; + Ok(ReplyInit { + max_write: NonZeroU32::new(Self::DEFAULT_MAX_WRITE_SIZE).unwrap(), + }) + } + + async fn destroy(&self, _req: Request) { + //TODO need to call the destroy method of the local_fs + } + + async fn lookup( + &self, + _req: Request, + parent: Inode, + name: &OsStr, + ) -> fuse3::Result { + let name = name.to_string_lossy(); + let file_stat = self.fs.lookup(parent, &name).await?; + Ok(ReplyEntry { + ttl: self.default_ttl, + attr: fstat_to_file_attr(&file_stat, &self.fs_context), + generation: 0, + }) + } + + async fn getattr( + &self, + _req: Request, + inode: Inode, + fh: Option, + _flags: u32, + ) -> fuse3::Result { + // check the fh is associated with the file_id + if let Some(fh) = fh { + self.fs.valid_file_id(inode, fh).await?; + } + + let file_stat = self.fs.stat(inode).await?; + Ok(ReplyAttr { + ttl: self.default_ttl, + attr: fstat_to_file_attr(&file_stat, &self.fs_context), + }) + } + + async fn setattr( + &self, + _req: Request, + inode: Inode, + fh: Option, + set_attr: SetAttr, + ) -> fuse3::Result { + // check the fh is associated with the file_id + if let Some(fh) = fh { + self.fs.valid_file_id(inode, fh).await?; + } + + let new_file_stat = self + .get_modified_file_stat(inode, set_attr.size, set_attr.atime, set_attr.mtime) + .await?; + let attr = fstat_to_file_attr(&new_file_stat, &self.fs_context); + self.fs.set_attr(inode, &new_file_stat).await?; + Ok(ReplyAttr { + ttl: self.default_ttl, + attr: attr, + }) + } + + async fn mkdir( + &self, + _req: Request, + parent: Inode, + name: &OsStr, + _mode: u32, + _umask: u32, + ) -> fuse3::Result { + let name = name.to_string_lossy(); + let handle_id = self.fs.create_dir(parent, &name).await?; + Ok(ReplyEntry { + ttl: self.default_ttl, + attr: dummy_file_attr( + handle_id, + Directory, + Timestamp::from(SystemTime::now()), + &self.fs_context, + ), + generation: 0, + }) + } + + async fn unlink(&self, _req: Request, parent: Inode, name: &OsStr) -> fuse3::Result<()> { + let name = name.to_string_lossy(); + self.fs.remove_file(parent, &name).await?; + Ok(()) + } + + async fn rmdir(&self, _req: Request, parent: Inode, name: &OsStr) -> fuse3::Result<()> { + let name = name.to_string_lossy(); + self.fs.remove_dir(parent, &name).await?; + Ok(()) + } + + async fn open(&self, _req: Request, inode: Inode, flags: u32) -> fuse3::Result { + let file_handle = self.fs.open_file(inode, flags).await?; + Ok(ReplyOpen { + fh: file_handle.handle_id, + flags: flags, + }) + } + + async fn read( + &self, + _req: Request, + inode: Inode, + fh: u64, + offset: u64, + size: u32, + ) -> fuse3::Result { + let data = self.fs.read(inode, fh, offset, size).await?; + Ok(ReplyData { data: data }) + } + + async fn write( + &self, + _req: Request, + inode: Inode, + fh: u64, + offset: u64, + data: &[u8], + _write_flags: u32, + _flags: u32, + ) -> fuse3::Result { + let written = self.fs.write(inode, fh, offset, data).await?; + Ok(ReplyWrite { written: written }) + } + + async fn statfs(&self, _req: Request, _inode: Inode) -> fuse3::Result { + //TODO: Implement statfs for the filesystem + Ok(ReplyStatFs { + blocks: 1000000, + bfree: 1000000, + bavail: 1000000, + files: 1000000, + ffree: 1000000, + bsize: 4096, + namelen: 255, + frsize: 4096, + }) + } + + async fn release( + &self, + _eq: Request, + inode: Inode, + fh: u64, + _flags: u32, + _lock_owner: u64, + _flush: bool, + ) -> fuse3::Result<()> { + self.fs.close_file(inode, fh).await + } + + async fn opendir(&self, _req: Request, inode: Inode, flags: u32) -> fuse3::Result { + let file_handle = self.fs.open_dir(inode, flags).await?; + Ok(ReplyOpen { + fh: file_handle.handle_id, + flags: flags, + }) + } + + type DirEntryStream<'a> + = BoxStream<'a, fuse3::Result> + where + T: 'a; + + #[allow(clippy::needless_lifetimes)] + async fn readdir<'a>( + &'a self, + _req: Request, + parent: Inode, + _fh: u64, + offset: i64, + ) -> fuse3::Result>> { + let current = self.fs.stat(parent).await?; + let files = self.fs.read_dir(parent).await?; + let entries_stream = + stream::iter(files.into_iter().enumerate().map(|(index, file_stat)| { + Ok(DirectoryEntry { + inode: file_stat.file_id, + name: file_stat.name.clone().into(), + kind: file_stat.kind, + offset: (index + 3) as i64, + }) + })); + + let relative_paths = stream::iter([ + Ok(DirectoryEntry { + inode: current.file_id, + name: ".".into(), + kind: Directory, + offset: 1, + }), + Ok(DirectoryEntry { + inode: current.parent_file_id, + name: "..".into(), + kind: Directory, + offset: 2, + }), + ]); + + //TODO Need to improve the read dir operation + let combined_stream = relative_paths.chain(entries_stream); + Ok(ReplyDirectory { + entries: combined_stream.skip(offset as usize).boxed(), + }) + } + + async fn releasedir( + &self, + _req: Request, + inode: Inode, + fh: u64, + _flags: u32, + ) -> fuse3::Result<()> { + self.fs.close_file(inode, fh).await + } + + async fn create( + &self, + _req: Request, + parent: Inode, + name: &OsStr, + _mode: u32, + flags: u32, + ) -> fuse3::Result { + let name = name.to_string_lossy(); + let file_handle = self.fs.create_file(parent, &name, flags).await?; + Ok(ReplyCreated { + ttl: self.default_ttl, + attr: dummy_file_attr( + file_handle.file_id, + RegularFile, + Timestamp::from(SystemTime::now()), + &self.fs_context, + ), + generation: 0, + fh: file_handle.handle_id, + flags: flags, + }) + } + + type DirEntryPlusStream<'a> + = BoxStream<'a, fuse3::Result> + where + T: 'a; + + #[allow(clippy::needless_lifetimes)] + async fn readdirplus<'a>( + &'a self, + _req: Request, + parent: Inode, + _fh: u64, + offset: u64, + _lock_owner: u64, + ) -> fuse3::Result>> { + let current = self.fs.stat(parent).await?; + let files = self.fs.read_dir(parent).await?; + let entries_stream = + stream::iter(files.into_iter().enumerate().map(|(index, file_stat)| { + Ok(DirectoryEntryPlus { + inode: file_stat.file_id, + name: file_stat.name.clone().into(), + kind: file_stat.kind, + offset: (index + 3) as i64, + attr: fstat_to_file_attr(&file_stat, &self.fs_context), + generation: 0, + entry_ttl: self.default_ttl, + attr_ttl: self.default_ttl, + }) + })); + + let relative_paths = stream::iter([ + Ok(DirectoryEntryPlus { + inode: current.file_id, + name: OsString::from("."), + kind: Directory, + offset: 1, + attr: fstat_to_file_attr(¤t, &self.fs_context), + generation: 0, + entry_ttl: self.default_ttl, + attr_ttl: self.default_ttl, + }), + Ok(DirectoryEntryPlus { + inode: current.parent_file_id, + name: OsString::from(".."), + kind: Directory, + offset: 2, + attr: dummy_file_attr( + current.parent_file_id, + Directory, + Timestamp::from(SystemTime::now()), + &self.fs_context, + ), + generation: 0, + entry_ttl: self.default_ttl, + attr_ttl: self.default_ttl, + }), + ]); + + //TODO Need to improve the read dir operation + let combined_stream = relative_paths.chain(entries_stream); + Ok(ReplyDirectoryPlus { + entries: combined_stream.skip(offset as usize).boxed(), + }) + } +} + +const fn fstat_to_file_attr(file_st: &FileStat, context: &FileSystemContext) -> FileAttr { + debug_assert!(file_st.file_id != 0 && file_st.parent_file_id != 0); + FileAttr { + ino: file_st.file_id, + size: file_st.size, + blocks: (file_st.size + context.block_size as u64 - 1) / context.block_size as u64, + atime: file_st.atime, + mtime: file_st.mtime, + ctime: file_st.ctime, + kind: file_st.kind, + perm: file_st.perm, + nlink: file_st.nlink, + uid: context.uid, + gid: context.gid, + rdev: 0, + blksize: context.block_size, + #[cfg(target_os = "macos")] + crtime: file_st.ctime, + #[cfg(target_os = "macos")] + flags: 0, + } +} + +const fn dummy_file_attr( + file_id: u64, + kind: FileType, + now: Timestamp, + context: &FileSystemContext, +) -> FileAttr { + debug_assert!(file_id != 0); + let mode = match kind { + Directory => context.default_dir_perm, + _ => context.default_file_perm, + }; + FileAttr { + ino: file_id, + size: 0, + blocks: 1, + atime: now, + mtime: now, + ctime: now, + kind, + perm: mode, + nlink: 0, + uid: context.uid, + gid: context.gid, + rdev: 0, + blksize: context.block_size, + #[cfg(target_os = "macos")] + crtime: now, + #[cfg(target_os = "macos")] + flags: 0, + } +} + +#[cfg(test)] +mod test { + use crate::filesystem::{FileStat, FileSystemContext}; + use crate::fuse_api_handle::fstat_to_file_attr; + use fuse3::{FileType, Timestamp}; + + #[test] + fn test_fstat_to_file_attr() { + let file_stat = FileStat { + file_id: 1, + parent_file_id: 3, + name: "test".to_string(), + path: "".to_string(), + size: 10032, + kind: FileType::RegularFile, + perm: 0, + atime: Timestamp { sec: 10, nsec: 3 }, + mtime: Timestamp { sec: 12, nsec: 5 }, + ctime: Timestamp { sec: 15, nsec: 7 }, + nlink: 0, + }; + + let context = FileSystemContext { + uid: 1, + gid: 2, + default_file_perm: 0o644, + default_dir_perm: 0o755, + block_size: 4 * 1024, + }; + + let file_attr = fstat_to_file_attr(&file_stat, &context); + + assert_eq!(file_attr.ino, 1); + assert_eq!(file_attr.size, 10032); + assert_eq!(file_attr.blocks, 3); + assert_eq!(file_attr.atime, Timestamp { sec: 10, nsec: 3 }); + assert_eq!(file_attr.mtime, Timestamp { sec: 12, nsec: 5 }); + assert_eq!(file_attr.ctime, Timestamp { sec: 15, nsec: 7 }); + assert_eq!(file_attr.kind, FileType::RegularFile); + assert_eq!(file_attr.perm, 0); + assert_eq!(file_attr.nlink, 0); + assert_eq!(file_attr.uid, 1); + assert_eq!(file_attr.gid, 2); + assert_eq!(file_attr.rdev, 0); + assert_eq!(file_attr.blksize, 4 * 1024); + #[cfg(target_os = "macos")] + assert_eq!(file_attr.crtime, Timestamp { sec: 15, nsec: 7 }); + #[cfg(target_os = "macos")] + assert_eq!(file_attr.flags, 0); + } +} diff --git a/clients/filesystem-fuse/src/lib.rs b/clients/filesystem-fuse/src/lib.rs new file mode 100644 index 00000000000..54fb59a5107 --- /dev/null +++ b/clients/filesystem-fuse/src/lib.rs @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +mod filesystem; +mod fuse_api_handle; diff --git a/clients/filesystem-fuse/src/main.rs b/clients/filesystem-fuse/src/main.rs index 48b6ab5517e..f6a7e69ec67 100644 --- a/clients/filesystem-fuse/src/main.rs +++ b/clients/filesystem-fuse/src/main.rs @@ -16,6 +16,8 @@ * specific language governing permissions and limitations * under the License. */ +mod filesystem; +mod fuse_api_handle; use log::debug; use log::info; @@ -23,7 +25,7 @@ use std::process::exit; #[tokio::main] async fn main() { - tracing_subscriber::fmt().with_env_filter("debug").init(); + tracing_subscriber::fmt().init(); info!("Starting filesystem..."); debug!("Shutdown filesystem..."); exit(0); From 7d01ba619e0616922cb2ea9fc76818a59a90a30a Mon Sep 17 00:00:00 2001 From: Yuhui Date: Tue, 24 Dec 2024 11:21:51 +0800 Subject: [PATCH 09/36] [#5877] feat (gvfs-fuse): Implement a common filesystem layer (#5878) ### What changes were proposed in this pull request? Implement a common filesystem layer to handle manage file ids, file name mappings, and file relationships. and delegate filesystem APIs to PathFilesystem. ### Why are the changes needed? Fix: #5877 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? Uts --- clients/filesystem-fuse/.cargo/config.toml | 1 - clients/filesystem-fuse/Cargo.toml | 6 +- clients/filesystem-fuse/Makefile | 69 +++ clients/filesystem-fuse/build.gradle.kts | 36 +- .../src/default_raw_filesystem.rs | 394 ++++++++++++++++++ clients/filesystem-fuse/src/filesystem.rs | 134 ++++-- .../filesystem-fuse/src/fuse_api_handle.rs | 17 +- clients/filesystem-fuse/src/lib.rs | 4 + clients/filesystem-fuse/src/main.rs | 52 +++ clients/filesystem-fuse/src/opened_file.rs | 141 +++++++ .../src/opened_file_manager.rs | 111 +++++ clients/filesystem-fuse/src/utils.rs | 67 +++ 12 files changed, 969 insertions(+), 63 deletions(-) create mode 100644 clients/filesystem-fuse/Makefile create mode 100644 clients/filesystem-fuse/src/default_raw_filesystem.rs create mode 100644 clients/filesystem-fuse/src/opened_file.rs create mode 100644 clients/filesystem-fuse/src/opened_file_manager.rs create mode 100644 clients/filesystem-fuse/src/utils.rs diff --git a/clients/filesystem-fuse/.cargo/config.toml b/clients/filesystem-fuse/.cargo/config.toml index 78bc9f7fe48..9d5bb048edc 100644 --- a/clients/filesystem-fuse/.cargo/config.toml +++ b/clients/filesystem-fuse/.cargo/config.toml @@ -16,5 +16,4 @@ # under the License. [build] -target-dir = "build" rustflags = ["-Adead_code", "-Aclippy::redundant-field-names"] diff --git a/clients/filesystem-fuse/Cargo.toml b/clients/filesystem-fuse/Cargo.toml index 2883cecc656..3bcf20f37ef 100644 --- a/clients/filesystem-fuse/Cargo.toml +++ b/clients/filesystem-fuse/Cargo.toml @@ -30,13 +30,15 @@ name = "gvfs-fuse" path = "src/main.rs" [lib] -name="gvfs_fuse" +name = "gvfs_fuse" [dependencies] async-trait = "0.1" bytes = "1.6.0" -futures-util = "0.3.30" +dashmap = "6.1.0" fuse3 = { version = "0.8.1", "features" = ["tokio-runtime", "unprivileged"] } +futures-util = "0.3.30" +libc = "0.2.168" log = "0.4.22" tokio = { version = "1.38.0", features = ["full"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } diff --git a/clients/filesystem-fuse/Makefile b/clients/filesystem-fuse/Makefile new file mode 100644 index 00000000000..f4a4cef20ae --- /dev/null +++ b/clients/filesystem-fuse/Makefile @@ -0,0 +1,69 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +.EXPORT_ALL_VARIABLES: + +.PHONY: build +build: + cargo build --all-features --workspace + +fmt: + cargo fmt --all + +cargo-sort: install-cargo-sort + cargo sort -w + +fix-toml: install-taplo-cli + taplo fmt + +check-fmt: + cargo fmt --all -- --check + +check-clippy: + cargo clippy --all-targets --all-features --workspace -- -D warnings + +install-cargo-sort: + cargo install cargo-sort@1.0.9 + +check-cargo-sort: install-cargo-sort + cargo sort -c + +install-cargo-machete: + cargo install cargo-machete + +cargo-machete: install-cargo-machete + cargo machete + +install-taplo-cli: + cargo install taplo-cli@0.9.0 + +check-toml: install-taplo-cli + taplo check + +check: check-fmt check-clippy check-cargo-sort check-toml cargo-machete + +doc-test: + cargo test --no-fail-fast --doc --all-features --workspace + +unit-test: doc-test + cargo test --no-fail-fast --lib --all-features --workspace + +test: doc-test + cargo test --no-fail-fast --all-targets --all-features --workspace + +clean: + cargo clean diff --git a/clients/filesystem-fuse/build.gradle.kts b/clients/filesystem-fuse/build.gradle.kts index 08693ddc5bd..7d24c86a5b0 100644 --- a/clients/filesystem-fuse/build.gradle.kts +++ b/clients/filesystem-fuse/build.gradle.kts @@ -20,8 +20,6 @@ import org.gradle.api.tasks.Exec val checkRustEnvironment by tasks.registering(Exec::class) { - description = "Check if Rust environment." - group = "verification" commandLine("bash", "-c", "cargo --version") standardOutput = System.out errorOutput = System.err @@ -30,36 +28,30 @@ val checkRustEnvironment by tasks.registering(Exec::class) { val buildRustProject by tasks.registering(Exec::class) { dependsOn(checkRustEnvironment) - description = "Compile the Rust project" workingDir = file("$projectDir") - commandLine("bash", "-c", "cargo build --release") + commandLine("bash", "-c", "make build") } val checkRustProject by tasks.registering(Exec::class) { dependsOn(checkRustEnvironment) - description = "Check the Rust project" workingDir = file("$projectDir") - commandLine( - "bash", - "-c", - """ - set -e - echo "Checking the code format" - cargo fmt --all -- --check - - echo "Running clippy" - cargo clippy --all-targets --all-features --workspace -- -D warnings - """.trimIndent() - ) + commandLine("bash", "-c", "make check") } val testRustProject by tasks.registering(Exec::class) { dependsOn(checkRustEnvironment) - description = "Run tests in the Rust project" - group = "verification" workingDir = file("$projectDir") - commandLine("bash", "-c", "cargo test --release") + commandLine("bash", "-c", "make test") + + standardOutput = System.out + errorOutput = System.err +} + +val cleanRustProject by tasks.registering(Exec::class) { + dependsOn(checkRustEnvironment) + workingDir = file("$projectDir") + commandLine("bash", "-c", "make clean") standardOutput = System.out errorOutput = System.err @@ -85,3 +77,7 @@ tasks.named("check") { tasks.named("test") { dependsOn(testRustProject) } + +tasks.named("clean") { + dependsOn(cleanRustProject) +} diff --git a/clients/filesystem-fuse/src/default_raw_filesystem.rs b/clients/filesystem-fuse/src/default_raw_filesystem.rs new file mode 100644 index 00000000000..9a66cd551f0 --- /dev/null +++ b/clients/filesystem-fuse/src/default_raw_filesystem.rs @@ -0,0 +1,394 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::filesystem::{FileStat, PathFileSystem, RawFileSystem, Result}; +use crate::opened_file::{FileHandle, OpenFileFlags}; +use crate::opened_file_manager::OpenedFileManager; +use crate::utils::join_file_path; +use async_trait::async_trait; +use bytes::Bytes; +use fuse3::{Errno, FileType}; +use std::collections::HashMap; +use std::sync::atomic::AtomicU64; +use tokio::sync::RwLock; + +/// DefaultRawFileSystem is a simple implementation for the file system. +/// it is used to manage the file metadata and file handle. +/// The operations of the file system are implemented by the PathFileSystem. +pub struct DefaultRawFileSystem { + /// file entries + file_entry_manager: RwLock, + /// opened files + opened_file_manager: OpenedFileManager, + /// file id generator + file_id_generator: AtomicU64, + + /// real filesystem + fs: T, +} + +impl DefaultRawFileSystem { + const INITIAL_FILE_ID: u64 = 10000; + const ROOT_DIR_PARENT_FILE_ID: u64 = 1; + const ROOT_DIR_FILE_ID: u64 = 1; + const ROOT_DIR_NAME: &'static str = ""; + + pub(crate) fn new(fs: T) -> Self { + Self { + file_entry_manager: RwLock::new(FileEntryManager::new()), + opened_file_manager: OpenedFileManager::new(), + file_id_generator: AtomicU64::new(Self::INITIAL_FILE_ID), + fs, + } + } + + fn next_file_id(&self) -> u64 { + self.file_id_generator + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + } + + async fn get_file_entry(&self, file_id: u64) -> Result { + self.file_entry_manager + .read() + .await + .get_file_entry_by_id(file_id) + .ok_or(Errno::from(libc::ENOENT)) + } + + async fn get_file_entry_by_path(&self, path: &str) -> Option { + self.file_entry_manager + .read() + .await + .get_file_entry_by_path(path) + } + + async fn resolve_file_id_to_filestat(&self, file_stat: &mut FileStat, parent_file_id: u64) { + let mut file_manager = self.file_entry_manager.write().await; + let file_entry = file_manager.get_file_entry_by_path(&file_stat.path); + match file_entry { + None => { + // allocate new file id + file_stat.set_file_id(parent_file_id, self.next_file_id()); + file_manager.insert(file_stat.parent_file_id, file_stat.file_id, &file_stat.path); + } + Some(file) => { + // use the exist file id + file_stat.set_file_id(file.parent_file_id, file.file_id); + } + } + } + + async fn open_file_internal( + &self, + file_id: u64, + flags: u32, + kind: FileType, + ) -> Result { + let file_entry = self.get_file_entry(file_id).await?; + + let mut opened_file = { + match kind { + FileType::Directory => { + self.fs + .open_dir(&file_entry.path, OpenFileFlags(flags)) + .await? + } + FileType::RegularFile => { + self.fs + .open_file(&file_entry.path, OpenFileFlags(flags)) + .await? + } + _ => return Err(Errno::from(libc::EINVAL)), + } + }; + // set the exists file id + opened_file.set_file_id(file_entry.parent_file_id, file_id); + let file = self.opened_file_manager.put(opened_file); + let file = file.lock().await; + Ok(file.file_handle()) + } + + async fn remove_file_entry_locked(&self, path: &str) { + let mut file_manager = self.file_entry_manager.write().await; + file_manager.remove(path); + } + + async fn insert_file_entry_locked(&self, parent_file_id: u64, file_id: u64, path: &str) { + let mut file_manager = self.file_entry_manager.write().await; + file_manager.insert(parent_file_id, file_id, path); + } +} + +#[async_trait] +impl RawFileSystem for DefaultRawFileSystem { + async fn init(&self) -> Result<()> { + // init root directory + self.insert_file_entry_locked( + Self::ROOT_DIR_PARENT_FILE_ID, + Self::ROOT_DIR_FILE_ID, + Self::ROOT_DIR_NAME, + ) + .await; + self.fs.init().await + } + + async fn get_file_path(&self, file_id: u64) -> Result { + let file_entry = self.get_file_entry(file_id).await?; + Ok(file_entry.path) + } + + async fn valid_file_handle_id(&self, file_id: u64, fh: u64) -> Result<()> { + let fh_file_id = self + .opened_file_manager + .get(fh) + .ok_or(Errno::from(libc::EBADF))? + .lock() + .await + .file_stat + .file_id; + + (file_id == fh_file_id) + .then_some(()) + .ok_or(Errno::from(libc::EBADF)) + } + + async fn stat(&self, file_id: u64) -> Result { + let file_entry = self.get_file_entry(file_id).await?; + let mut file_stat = self.fs.stat(&file_entry.path).await?; + file_stat.set_file_id(file_entry.parent_file_id, file_entry.file_id); + Ok(file_stat) + } + + async fn lookup(&self, parent_file_id: u64, name: &str) -> Result { + let parent_file_entry = self.get_file_entry(parent_file_id).await?; + let mut file_stat = self.fs.lookup(&parent_file_entry.path, name).await?; + // fill the file id to file stat + self.resolve_file_id_to_filestat(&mut file_stat, parent_file_id) + .await; + Ok(file_stat) + } + + async fn read_dir(&self, file_id: u64) -> Result> { + let file_entry = self.get_file_entry(file_id).await?; + let mut child_filestats = self.fs.read_dir(&file_entry.path).await?; + for file_stat in child_filestats.iter_mut() { + self.resolve_file_id_to_filestat(file_stat, file_stat.file_id) + .await; + } + Ok(child_filestats) + } + + async fn open_file(&self, file_id: u64, flags: u32) -> Result { + self.open_file_internal(file_id, flags, FileType::RegularFile) + .await + } + + async fn open_dir(&self, file_id: u64, flags: u32) -> Result { + self.open_file_internal(file_id, flags, FileType::Directory) + .await + } + + async fn create_file(&self, parent_file_id: u64, name: &str, flags: u32) -> Result { + let parent_file_entry = self.get_file_entry(parent_file_id).await?; + let mut file_without_id = self + .fs + .create_file(&parent_file_entry.path, name, OpenFileFlags(flags)) + .await?; + + file_without_id.set_file_id(parent_file_id, self.next_file_id()); + + // insert the new file to file entry manager + self.insert_file_entry_locked( + parent_file_id, + file_without_id.file_stat.file_id, + &file_without_id.file_stat.path, + ) + .await; + + // put the openfile to the opened file manager and allocate a file handle id + let file_with_id = self.opened_file_manager.put(file_without_id); + let opened_file_with_file_handle_id = file_with_id.lock().await; + Ok(opened_file_with_file_handle_id.file_handle()) + } + + async fn create_dir(&self, parent_file_id: u64, name: &str) -> Result { + let parent_file_entry = self.get_file_entry(parent_file_id).await?; + let mut filestat = self.fs.create_dir(&parent_file_entry.path, name).await?; + + filestat.set_file_id(parent_file_id, self.next_file_id()); + + // insert the new file to file entry manager + self.insert_file_entry_locked(parent_file_id, filestat.file_id, &filestat.path) + .await; + Ok(filestat.file_id) + } + + async fn set_attr(&self, file_id: u64, file_stat: &FileStat) -> Result<()> { + let file_entry = self.get_file_entry(file_id).await?; + self.fs.set_attr(&file_entry.path, file_stat, true).await + } + + async fn remove_file(&self, parent_file_id: u64, name: &str) -> Result<()> { + let parent_file_entry = self.get_file_entry(parent_file_id).await?; + self.fs.remove_file(&parent_file_entry.path, name).await?; + + // remove the file from file entry manager + self.remove_file_entry_locked(&join_file_path(&parent_file_entry.path, name)) + .await; + Ok(()) + } + + async fn remove_dir(&self, parent_file_id: u64, name: &str) -> Result<()> { + let parent_file_entry = self.get_file_entry(parent_file_id).await?; + self.fs.remove_dir(&parent_file_entry.path, name).await?; + + // remove the dir from file entry manager + self.remove_file_entry_locked(&join_file_path(&parent_file_entry.path, name)) + .await; + Ok(()) + } + + async fn close_file(&self, _file_id: u64, fh: u64) -> Result<()> { + let opened_file = self + .opened_file_manager + .remove(fh) + .ok_or(Errno::from(libc::EBADF))?; + let mut file = opened_file.lock().await; + file.close().await + } + + async fn read( + &self, + _file_id: u64, + fh: u64, + offset: u64, + size: u32, + ) -> crate::filesystem::Result { + let (data, file_stat) = { + let opened_file = self + .opened_file_manager + .get(fh) + .ok_or(Errno::from(libc::EBADF))?; + let mut opened_file = opened_file.lock().await; + let data = opened_file.read(offset, size).await; + (data, opened_file.file_stat.clone()) + }; + + // update the file atime + self.fs.set_attr(&file_stat.path, &file_stat, false).await?; + + data + } + + async fn write( + &self, + _file_id: u64, + fh: u64, + offset: u64, + data: &[u8], + ) -> crate::filesystem::Result { + let (len, file_stat) = { + let opened_file = self + .opened_file_manager + .get(fh) + .ok_or(Errno::from(libc::EBADF))?; + let mut opened_file = opened_file.lock().await; + let len = opened_file.write(offset, data).await; + (len, opened_file.file_stat.clone()) + }; + + // update the file size, mtime and atime + self.fs.set_attr(&file_stat.path, &file_stat, false).await?; + + len + } +} + +/// File entry is represent the abstract file. +#[derive(Debug, Clone)] +struct FileEntry { + file_id: u64, + parent_file_id: u64, + path: String, +} + +/// FileEntryManager is manage all the file entries in memory. it is used manger the file relationship and name mapping. +struct FileEntryManager { + // file_id_map is a map of file_id to file entry. + file_id_map: HashMap, + + // file_path_map is a map of file path to file entry. + file_path_map: HashMap, +} + +impl FileEntryManager { + fn new() -> Self { + Self { + file_id_map: HashMap::new(), + file_path_map: HashMap::new(), + } + } + + fn get_file_entry_by_id(&self, file_id: u64) -> Option { + self.file_id_map.get(&file_id).cloned() + } + + fn get_file_entry_by_path(&self, path: &str) -> Option { + self.file_path_map.get(path).cloned() + } + + fn insert(&mut self, parent_file_id: u64, file_id: u64, path: &str) { + let file_entry = FileEntry { + file_id, + parent_file_id, + path: path.to_string(), + }; + self.file_id_map.insert(file_id, file_entry.clone()); + self.file_path_map.insert(path.to_string(), file_entry); + } + + fn remove(&mut self, path: &str) { + if let Some(file) = self.file_path_map.remove(path) { + self.file_id_map.remove(&file.file_id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_file_entry_manager() { + let mut manager = FileEntryManager::new(); + manager.insert(1, 2, "a/b"); + let file = manager.get_file_entry_by_id(2).unwrap(); + assert_eq!(file.file_id, 2); + assert_eq!(file.parent_file_id, 1); + assert_eq!(file.path, "a/b"); + + let file = manager.get_file_entry_by_path("a/b").unwrap(); + assert_eq!(file.file_id, 2); + assert_eq!(file.parent_file_id, 1); + assert_eq!(file.path, "a/b"); + + manager.remove("a/b"); + assert!(manager.get_file_entry_by_id(2).is_none()); + assert!(manager.get_file_entry_by_path("a/b").is_none()); + } +} diff --git a/clients/filesystem-fuse/src/filesystem.rs b/clients/filesystem-fuse/src/filesystem.rs index 6d1d8fa2538..b0d32ded233 100644 --- a/clients/filesystem-fuse/src/filesystem.rs +++ b/clients/filesystem-fuse/src/filesystem.rs @@ -16,9 +16,12 @@ * specific language governing permissions and limitations * under the License. */ +use crate::opened_file::{FileHandle, OpenFileFlags, OpenedFile}; +use crate::utils::{join_file_path, split_file_path}; use async_trait::async_trait; use bytes::Bytes; use fuse3::{Errno, FileType, Timestamp}; +use std::time::SystemTime; pub(crate) type Result = std::result::Result; @@ -35,15 +38,15 @@ pub(crate) trait RawFileSystem: Send + Sync { async fn init(&self) -> Result<()>; /// Get the file path by file id, if the file id is valid, return the file path - async fn get_file_path(&self, file_id: u64) -> String; + async fn get_file_path(&self, file_id: u64) -> Result; /// Validate the file id and file handle, if file id and file handle is valid and it associated, return Ok - async fn valid_file_id(&self, file_id: u64, fh: u64) -> Result<()>; + async fn valid_file_handle_id(&self, file_id: u64, fh: u64) -> Result<()>; /// Get the file stat by file id. if the file id is valid, return the file stat async fn stat(&self, file_id: u64) -> Result; - /// Lookup the file by parent file id and file name, if the file is exist, return the file stat + /// Lookup the file by parent file id and file name, if the file exists, return the file stat async fn lookup(&self, parent_file_id: u64, name: &str) -> Result; /// Read the directory by file id, if the file id is a valid directory, return the file stat list @@ -87,22 +90,22 @@ pub(crate) trait PathFileSystem: Send + Sync { /// Init the file system async fn init(&self) -> Result<()>; - /// Get the file stat by file path, if the file is exist, return the file stat - async fn stat(&self, name: &str) -> Result; + /// Get the file stat by file path, if the file exists, return the file stat + async fn stat(&self, path: &str) -> Result; - /// Get the file stat by parent file path and file name, if the file is exist, return the file stat + /// Get the file stat by parent file path and file name, if the file exists, return the file stat async fn lookup(&self, parent: &str, name: &str) -> Result; - /// Read the directory by file path, if the file is a valid directory, return the file stat list - async fn read_dir(&self, name: &str) -> Result>; + /// Read the directory by file path, if the directory exists, return the file stat list + async fn read_dir(&self, path: &str) -> Result>; - /// Open the file by file path and flags, if the file is exist, return the opened file - async fn open_file(&self, name: &str, flags: OpenFileFlags) -> Result; + /// Open the file by file path and flags, if the file exists, return the opened file + async fn open_file(&self, path: &str, flags: OpenFileFlags) -> Result; - /// Open the directory by file path and flags, if the file is exist, return the opened file - async fn open_dir(&self, name: &str, flags: OpenFileFlags) -> Result; + /// Open the directory by file path and flags, if the file exists, return the opened file + async fn open_dir(&self, path: &str, flags: OpenFileFlags) -> Result; - /// Create the file by parent file path and file name and flags, if successful, return the opened file + /// Create the file by parent file path and file name and flags, if successful return the opened file async fn create_file( &self, parent: &str, @@ -114,7 +117,7 @@ pub(crate) trait PathFileSystem: Send + Sync { async fn create_dir(&self, parent: &str, name: &str) -> Result; /// Set the file attribute by file path and file stat - async fn set_attr(&self, name: &str, file_stat: &FileStat, flush: bool) -> Result<()>; + async fn set_attr(&self, path: &str, file_stat: &FileStat, flush: bool) -> Result<()>; /// Remove the file by parent file path and file name async fn remove_file(&self, parent: &str, name: &str) -> Result<()>; @@ -174,9 +177,6 @@ pub struct FileStat { // file type like regular file or directory and so on pub(crate) kind: FileType, - // file permission - pub(crate) perm: u16, - // file access time pub(crate) atime: Timestamp, @@ -190,27 +190,48 @@ pub struct FileStat { pub(crate) nlink: u32, } -/// Opened file for read or write, it is used to read or write the file content. -pub(crate) struct OpenedFile { - pub(crate) file_stat: FileStat, +impl FileStat { + pub fn new_file_filestat_with_path(path: &str, size: u64) -> Self { + let (parent, name) = split_file_path(path); + Self::new_file_filestat(parent, name, size) + } - pub(crate) handle_id: u64, + pub fn new_dir_filestat_with_path(path: &str) -> Self { + let (parent, name) = split_file_path(path); + Self::new_dir_filestat(parent, name) + } - pub reader: Option>, + pub fn new_file_filestat(parent: &str, name: &str, size: u64) -> Self { + Self::new_filestat(parent, name, size, FileType::RegularFile) + } - pub writer: Option>, -} + pub fn new_dir_filestat(parent: &str, name: &str) -> Self { + Self::new_filestat(parent, name, 0, FileType::Directory) + } -// FileHandle is the file handle for the opened file. -pub(crate) struct FileHandle { - pub(crate) file_id: u64, + pub fn new_filestat(parent: &str, name: &str, size: u64, kind: FileType) -> Self { + let atime = Timestamp::from(SystemTime::now()); + Self { + file_id: 0, + parent_file_id: 0, + name: name.into(), + path: join_file_path(parent, name), + size: size, + kind: kind, + atime: atime, + mtime: atime, + ctime: atime, + nlink: 1, + } + } - pub(crate) handle_id: u64, + pub(crate) fn set_file_id(&mut self, parent_file_id: u64, file_id: u64) { + debug_assert!(file_id != 0 && parent_file_id != 0); + self.parent_file_id = parent_file_id; + self.file_id = file_id; + } } -// OpenFileFlags is the open file flags for the file system. -pub struct OpenFileFlags(u32); - /// File reader interface for read file content #[async_trait] pub(crate) trait FileReader: Sync + Send { @@ -239,3 +260,54 @@ pub trait FileWriter: Sync + Send { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_file_stat() { + //test new file + let file_stat = FileStat::new_file_filestat("a", "b", 10); + assert_eq!(file_stat.name, "b"); + assert_eq!(file_stat.path, "a/b"); + assert_eq!(file_stat.size, 10); + assert_eq!(file_stat.kind, FileType::RegularFile); + + //test new dir + let file_stat = FileStat::new_dir_filestat("a", "b"); + assert_eq!(file_stat.name, "b"); + assert_eq!(file_stat.path, "a/b"); + assert_eq!(file_stat.size, 0); + assert_eq!(file_stat.kind, FileType::Directory); + + //test new file with path + let file_stat = FileStat::new_file_filestat_with_path("a/b", 10); + assert_eq!(file_stat.name, "b"); + assert_eq!(file_stat.path, "a/b"); + assert_eq!(file_stat.size, 10); + assert_eq!(file_stat.kind, FileType::RegularFile); + + //test new dir with path + let file_stat = FileStat::new_dir_filestat_with_path("a/b"); + assert_eq!(file_stat.name, "b"); + assert_eq!(file_stat.path, "a/b"); + assert_eq!(file_stat.size, 0); + assert_eq!(file_stat.kind, FileType::Directory); + } + + #[test] + fn test_file_stat_set_file_id() { + let mut file_stat = FileStat::new_file_filestat("a", "b", 10); + file_stat.set_file_id(1, 2); + assert_eq!(file_stat.file_id, 2); + assert_eq!(file_stat.parent_file_id, 1); + } + + #[test] + #[should_panic(expected = "assertion failed: file_id != 0 && parent_file_id != 0")] + fn test_file_stat_set_file_id_panic() { + let mut file_stat = FileStat::new_file_filestat("a", "b", 10); + file_stat.set_file_id(1, 0); + } +} diff --git a/clients/filesystem-fuse/src/fuse_api_handle.rs b/clients/filesystem-fuse/src/fuse_api_handle.rs index 8c065df0227..7dc5461ce7f 100644 --- a/clients/filesystem-fuse/src/fuse_api_handle.rs +++ b/clients/filesystem-fuse/src/fuse_api_handle.rs @@ -52,10 +52,6 @@ impl FuseApiHandle { } } - pub async fn get_file_path(&self, file_id: u64) -> String { - self.fs.get_file_path(file_id).await - } - async fn get_modified_file_stat( &self, file_id: u64, @@ -117,7 +113,7 @@ impl Filesystem for FuseApiHandle { ) -> fuse3::Result { // check the fh is associated with the file_id if let Some(fh) = fh { - self.fs.valid_file_id(inode, fh).await?; + self.fs.valid_file_handle_id(inode, fh).await?; } let file_stat = self.fs.stat(inode).await?; @@ -136,7 +132,7 @@ impl Filesystem for FuseApiHandle { ) -> fuse3::Result { // check the fh is associated with the file_id if let Some(fh) = fh { - self.fs.valid_file_id(inode, fh).await?; + self.fs.valid_file_handle_id(inode, fh).await?; } let new_file_stat = self @@ -401,6 +397,10 @@ impl Filesystem for FuseApiHandle { const fn fstat_to_file_attr(file_st: &FileStat, context: &FileSystemContext) -> FileAttr { debug_assert!(file_st.file_id != 0 && file_st.parent_file_id != 0); + let perm = match file_st.kind { + Directory => context.default_dir_perm, + _ => context.default_file_perm, + }; FileAttr { ino: file_st.file_id, size: file_st.size, @@ -409,7 +409,7 @@ const fn fstat_to_file_attr(file_st: &FileStat, context: &FileSystemContext) -> mtime: file_st.mtime, ctime: file_st.ctime, kind: file_st.kind, - perm: file_st.perm, + perm: perm, nlink: file_st.nlink, uid: context.uid, gid: context.gid, @@ -469,7 +469,6 @@ mod test { path: "".to_string(), size: 10032, kind: FileType::RegularFile, - perm: 0, atime: Timestamp { sec: 10, nsec: 3 }, mtime: Timestamp { sec: 12, nsec: 5 }, ctime: Timestamp { sec: 15, nsec: 7 }, @@ -493,7 +492,7 @@ mod test { assert_eq!(file_attr.mtime, Timestamp { sec: 12, nsec: 5 }); assert_eq!(file_attr.ctime, Timestamp { sec: 15, nsec: 7 }); assert_eq!(file_attr.kind, FileType::RegularFile); - assert_eq!(file_attr.perm, 0); + assert_eq!(file_attr.perm, context.default_file_perm); assert_eq!(file_attr.nlink, 0); assert_eq!(file_attr.uid, 1); assert_eq!(file_attr.gid, 2); diff --git a/clients/filesystem-fuse/src/lib.rs b/clients/filesystem-fuse/src/lib.rs index 54fb59a5107..c1689bac476 100644 --- a/clients/filesystem-fuse/src/lib.rs +++ b/clients/filesystem-fuse/src/lib.rs @@ -16,5 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +mod default_raw_filesystem; mod filesystem; mod fuse_api_handle; +mod opened_file; +mod opened_file_manager; +mod utils; diff --git a/clients/filesystem-fuse/src/main.rs b/clients/filesystem-fuse/src/main.rs index f6a7e69ec67..3d8e9dbb953 100644 --- a/clients/filesystem-fuse/src/main.rs +++ b/clients/filesystem-fuse/src/main.rs @@ -16,8 +16,12 @@ * specific language governing permissions and limitations * under the License. */ +mod default_raw_filesystem; mod filesystem; mod fuse_api_handle; +mod opened_file; +mod opened_file_manager; +mod utils; use log::debug; use log::info; @@ -30,3 +34,51 @@ async fn main() { debug!("Shutdown filesystem..."); exit(0); } + +async fn create_gvfs_fuse_filesystem() { + // Gvfs-fuse filesystem structure: + // FuseApiHandle + // ├─ DefaultRawFileSystem (RawFileSystem) + // │ └─ FileSystemLog (PathFileSystem) + // │ ├─ GravitinoComposedFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ S3FileSystem (PathFileSystem) + // │ │ │ └─ OpenDALFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ HDFSFileSystem (PathFileSystem) + // │ │ │ └─ OpenDALFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ JuiceFileSystem (PathFileSystem) + // │ │ │ └─ NasFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ XXXFileSystem (PathFileSystem) + // + // `SimpleFileSystem` is a low-level filesystem designed to communicate with FUSE APIs. + // It manages file and directory relationships, as well as file mappings. + // It delegates file operations to the PathFileSystem + // + // `FileSystemLog` is a decorator that adds extra debug logging functionality to file system APIs. + // Similar implementations include permissions, caching, and metrics. + // + // `GravitinoComposeFileSystem` is a composite file system that can combine multiple `GravitinoFilesetFileSystem`. + // It use the part of catalog and schema of fileset path to a find actual GravitinoFilesetFileSystem. delegate the operation to the real storage. + // If the user only mounts a fileset, this layer is not present. There will only be one below layer. + // + // `GravitinoFilesetFileSystem` is a file system that can access a fileset.It translates the fileset path to the real storage path. + // and delegate the operation to the real storage. + // + // `OpenDALFileSystem` is a file system that use the OpenDAL to access real storage. + // it can assess the S3, HDFS, gcs, azblob and other storage. + // + // `S3FileSystem` is a file system that use `OpenDALFileSystem` to access S3 storage. + // + // `HDFSFileSystem` is a file system that use `OpenDALFileSystem` to access HDFS storage. + // + // `NasFileSystem` is a filesystem that uses a locally accessible path mounted by NAS tools, such as JuiceFS. + // + // `JuiceFileSystem` is a file that use `NasFileSystem` to access JuiceFS storage. + // + // `XXXFileSystem is a filesystem that allows you to implement file access through your own extensions. + + todo!("Implement the createGvfsFuseFileSystem function"); +} diff --git a/clients/filesystem-fuse/src/opened_file.rs b/clients/filesystem-fuse/src/opened_file.rs new file mode 100644 index 00000000000..ba3e41595da --- /dev/null +++ b/clients/filesystem-fuse/src/opened_file.rs @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::filesystem::{FileReader, FileStat, FileWriter, Result}; +use bytes::Bytes; +use fuse3::{Errno, Timestamp}; +use std::time::SystemTime; + +/// Opened file for read or write, it is used to read or write the file content. +pub(crate) struct OpenedFile { + pub(crate) file_stat: FileStat, + + pub(crate) handle_id: u64, + + pub reader: Option>, + + pub writer: Option>, +} + +impl OpenedFile { + pub(crate) fn new(file_stat: FileStat) -> Self { + OpenedFile { + file_stat: file_stat, + handle_id: 0, + reader: None, + writer: None, + } + } + + pub(crate) async fn read(&mut self, offset: u64, size: u32) -> Result { + let reader = self.reader.as_mut().ok_or(Errno::from(libc::EBADF))?; + let result = reader.read(offset, size).await?; + + // update the atime + self.file_stat.atime = Timestamp::from(SystemTime::now()); + + Ok(result) + } + + pub(crate) async fn write(&mut self, offset: u64, data: &[u8]) -> Result { + let writer = self.writer.as_mut().ok_or(Errno::from(libc::EBADF))?; + let written = writer.write(offset, data).await?; + + // update the file size ,mtime and atime + let end = offset + written as u64; + if end > self.file_stat.size { + self.file_stat.size = end; + } + self.file_stat.atime = Timestamp::from(SystemTime::now()); + self.file_stat.mtime = self.file_stat.atime; + + Ok(written) + } + + pub(crate) async fn close(&mut self) -> Result<()> { + let mut errors = Vec::new(); + if let Some(mut reader) = self.reader.take() { + if let Err(e) = reader.close().await { + errors.push(e); + } + } + + if let Some(mut writer) = self.writer.take() { + if let Err(e) = self.flush().await { + errors.push(e); + } + if let Err(e) = writer.close().await { + errors.push(e); + } + } + + if !errors.is_empty() { + return Err(errors.remove(0)); + } + Ok(()) + } + + pub(crate) async fn flush(&mut self) -> Result<()> { + if let Some(writer) = &mut self.writer { + writer.flush().await?; + } + Ok(()) + } + + pub(crate) fn file_handle(&self) -> FileHandle { + debug_assert!(self.handle_id != 0); + debug_assert!(self.file_stat.file_id != 0); + FileHandle { + file_id: self.file_stat.file_id, + handle_id: self.handle_id, + } + } + + pub(crate) fn set_file_id(&mut self, parent_file_id: u64, file_id: u64) { + debug_assert!(file_id != 0 && parent_file_id != 0); + self.file_stat.set_file_id(parent_file_id, file_id) + } +} + +// FileHandle is the file handle for the opened file. +pub(crate) struct FileHandle { + pub(crate) file_id: u64, + + pub(crate) handle_id: u64, +} + +// OpenFileFlags is the open file flags for the file system. +pub(crate) struct OpenFileFlags(pub(crate) u32); + +#[cfg(test)] +mod tests { + use super::*; + use crate::filesystem::FileStat; + + #[test] + fn test_open_file() { + let mut open_file = OpenedFile::new(FileStat::new_file_filestat("a", "b", 10)); + assert_eq!(open_file.file_stat.name, "b"); + assert_eq!(open_file.file_stat.size, 10); + + open_file.set_file_id(1, 2); + + assert_eq!(open_file.file_stat.file_id, 2); + assert_eq!(open_file.file_stat.parent_file_id, 1); + } +} diff --git a/clients/filesystem-fuse/src/opened_file_manager.rs b/clients/filesystem-fuse/src/opened_file_manager.rs new file mode 100644 index 00000000000..17bfe00a397 --- /dev/null +++ b/clients/filesystem-fuse/src/opened_file_manager.rs @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::opened_file::OpenedFile; +use dashmap::DashMap; +use std::sync::atomic::AtomicU64; +use std::sync::Arc; +use tokio::sync::Mutex; + +// OpenedFileManager is a manager all the opened files. and allocate a file handle id for the opened file. +pub(crate) struct OpenedFileManager { + // file_handle_map is a map of file_handle_id to opened file. + file_handle_map: DashMap>>, + + // file_handle_id_generator is used to generate unique file handle IDs. + handle_id_generator: AtomicU64, +} + +impl OpenedFileManager { + pub fn new() -> Self { + Self { + file_handle_map: Default::default(), + handle_id_generator: AtomicU64::new(1), + } + } + + pub(crate) fn next_handle_id(&self) -> u64 { + self.handle_id_generator + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + } + + pub(crate) fn put(&self, mut file: OpenedFile) -> Arc> { + // Put the file into the file handle map, and allocate a file handle id for the file. + let file_handle_id = self.next_handle_id(); + file.handle_id = file_handle_id; + let file_handle = Arc::new(Mutex::new(file)); + self.file_handle_map + .insert(file_handle_id, file_handle.clone()); + file_handle + } + + pub(crate) fn get(&self, handle_id: u64) -> Option>> { + self.file_handle_map + .get(&handle_id) + .map(|x| x.value().clone()) + } + + pub(crate) fn remove(&self, handle_id: u64) -> Option>> { + self.file_handle_map.remove(&handle_id).map(|x| x.1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::filesystem::FileStat; + + #[tokio::test] + async fn test_opened_file_manager() { + let manager = OpenedFileManager::new(); + + let file1_stat = FileStat::new_file_filestat("", "a.txt", 13); + let file2_stat = FileStat::new_file_filestat("", "b.txt", 18); + + let file1 = OpenedFile::new(file1_stat.clone()); + let file2 = OpenedFile::new(file2_stat.clone()); + + let handle_id1 = manager.put(file1).lock().await.handle_id; + let handle_id2 = manager.put(file2).lock().await.handle_id; + + // Test the file handle id is assigned. + assert!(handle_id1 > 0 && handle_id2 > 0); + assert_ne!(handle_id1, handle_id2); + + // test get file by handle id + assert_eq!( + manager.get(handle_id1).unwrap().lock().await.file_stat.name, + file1_stat.name + ); + + assert_eq!( + manager.get(handle_id2).unwrap().lock().await.file_stat.name, + file2_stat.name + ); + + // test remove file by handle id + assert_eq!( + manager.remove(handle_id1).unwrap().lock().await.handle_id, + handle_id1 + ); + + // test get file by handle id after remove + assert!(manager.get(handle_id1).is_none()); + assert!(manager.get(handle_id2).is_some()); + } +} diff --git a/clients/filesystem-fuse/src/utils.rs b/clients/filesystem-fuse/src/utils.rs new file mode 100644 index 00000000000..0c0cc80a162 --- /dev/null +++ b/clients/filesystem-fuse/src/utils.rs @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::filesystem::RawFileSystem; + +// join the parent and name to a path +pub fn join_file_path(parent: &str, name: &str) -> String { + //TODO handle corner cases + if parent.is_empty() { + name.to_string() + } else { + format!("{}/{}", parent, name) + } +} + +// split the path to parent and name +pub fn split_file_path(path: &str) -> (&str, &str) { + match path.rfind('/') { + Some(pos) => (&path[..pos], &path[pos + 1..]), + None => ("", path), + } +} + +// convert file id to file path string if file id is invalid return "Unknown" +pub async fn file_id_to_file_path_string(file_id: u64, fs: &impl RawFileSystem) -> String { + fs.get_file_path(file_id) + .await + .unwrap_or("Unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_join_file_path() { + assert_eq!(join_file_path("", "a"), "a"); + assert_eq!(join_file_path("", "a.txt"), "a.txt"); + assert_eq!(join_file_path("a", "b"), "a/b"); + assert_eq!(join_file_path("a/b", "c"), "a/b/c"); + assert_eq!(join_file_path("a/b", "c.txt"), "a/b/c.txt"); + } + + #[test] + fn test_split_file_path() { + assert_eq!(split_file_path("a"), ("", "a")); + assert_eq!(split_file_path("a.txt"), ("", "a.txt")); + assert_eq!(split_file_path("a/b"), ("a", "b")); + assert_eq!(split_file_path("a/b/c"), ("a/b", "c")); + assert_eq!(split_file_path("a/b/c.txt"), ("a/b", "c.txt")); + } +} From e7c86088b8d84a64904ff82143f8e846e6e41189 Mon Sep 17 00:00:00 2001 From: Yuhui Date: Wed, 25 Dec 2024 20:00:48 +0800 Subject: [PATCH 10/36] [#5886] feat (gvfs-fuse): Implement an in-memory file system (#5915) ### What changes were proposed in this pull request? Implement an in-memory filesystem for testing and validating the FUSE framework. You need to implement the PathFilesystem trait and support basic file and directory operations: ### Why are the changes needed? Fix: #5886 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? IT --- clients/filesystem-fuse/Cargo.toml | 2 +- .../src/default_raw_filesystem.rs | 103 ++-- clients/filesystem-fuse/src/filesystem.rs | 484 ++++++++++++++++-- .../filesystem-fuse/src/fuse_api_handle.rs | 23 +- clients/filesystem-fuse/src/fuse_server.rs | 93 ++++ clients/filesystem-fuse/src/lib.rs | 11 + clients/filesystem-fuse/src/main.rs | 67 +-- .../filesystem-fuse/src/memory_filesystem.rs | 281 ++++++++++ clients/filesystem-fuse/src/mount.rs | 118 +++++ clients/filesystem-fuse/src/opened_file.rs | 7 +- .../src/opened_file_manager.rs | 5 +- clients/filesystem-fuse/src/utils.rs | 48 +- clients/filesystem-fuse/tests/fuse_test.rs | 147 ++++++ clients/filesystem-fuse/tests/it.rs | 23 - 14 files changed, 1170 insertions(+), 242 deletions(-) create mode 100644 clients/filesystem-fuse/src/fuse_server.rs create mode 100644 clients/filesystem-fuse/src/memory_filesystem.rs create mode 100644 clients/filesystem-fuse/src/mount.rs create mode 100644 clients/filesystem-fuse/tests/fuse_test.rs delete mode 100644 clients/filesystem-fuse/tests/it.rs diff --git a/clients/filesystem-fuse/Cargo.toml b/clients/filesystem-fuse/Cargo.toml index 3bcf20f37ef..75a4dd71301 100644 --- a/clients/filesystem-fuse/Cargo.toml +++ b/clients/filesystem-fuse/Cargo.toml @@ -40,6 +40,6 @@ fuse3 = { version = "0.8.1", "features" = ["tokio-runtime", "unprivileged"] } futures-util = "0.3.30" libc = "0.2.168" log = "0.4.22" +once_cell = "1.20.2" tokio = { version = "1.38.0", features = ["full"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } - diff --git a/clients/filesystem-fuse/src/default_raw_filesystem.rs b/clients/filesystem-fuse/src/default_raw_filesystem.rs index 9a66cd551f0..0ab92e91640 100644 --- a/clients/filesystem-fuse/src/default_raw_filesystem.rs +++ b/clients/filesystem-fuse/src/default_raw_filesystem.rs @@ -16,14 +16,18 @@ * specific language governing permissions and limitations * under the License. */ -use crate::filesystem::{FileStat, PathFileSystem, RawFileSystem, Result}; +use crate::filesystem::{ + FileStat, PathFileSystem, RawFileSystem, Result, INITIAL_FILE_ID, ROOT_DIR_FILE_ID, + ROOT_DIR_PARENT_FILE_ID, ROOT_DIR_PATH, +}; use crate::opened_file::{FileHandle, OpenFileFlags}; use crate::opened_file_manager::OpenedFileManager; -use crate::utils::join_file_path; use async_trait::async_trait; use bytes::Bytes; use fuse3::{Errno, FileType}; use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicU64; use tokio::sync::RwLock; @@ -43,16 +47,11 @@ pub struct DefaultRawFileSystem { } impl DefaultRawFileSystem { - const INITIAL_FILE_ID: u64 = 10000; - const ROOT_DIR_PARENT_FILE_ID: u64 = 1; - const ROOT_DIR_FILE_ID: u64 = 1; - const ROOT_DIR_NAME: &'static str = ""; - pub(crate) fn new(fs: T) -> Self { Self { file_entry_manager: RwLock::new(FileEntryManager::new()), opened_file_manager: OpenedFileManager::new(), - file_id_generator: AtomicU64::new(Self::INITIAL_FILE_ID), + file_id_generator: AtomicU64::new(INITIAL_FILE_ID), fs, } } @@ -70,7 +69,7 @@ impl DefaultRawFileSystem { .ok_or(Errno::from(libc::ENOENT)) } - async fn get_file_entry_by_path(&self, path: &str) -> Option { + async fn get_file_entry_by_path(&self, path: &Path) -> Option { self.file_entry_manager .read() .await @@ -123,12 +122,12 @@ impl DefaultRawFileSystem { Ok(file.file_handle()) } - async fn remove_file_entry_locked(&self, path: &str) { + async fn remove_file_entry_locked(&self, path: &Path) { let mut file_manager = self.file_entry_manager.write().await; file_manager.remove(path); } - async fn insert_file_entry_locked(&self, parent_file_id: u64, file_id: u64, path: &str) { + async fn insert_file_entry_locked(&self, parent_file_id: u64, file_id: u64, path: &Path) { let mut file_manager = self.file_entry_manager.write().await; file_manager.insert(parent_file_id, file_id, path); } @@ -139,9 +138,9 @@ impl RawFileSystem for DefaultRawFileSystem { async fn init(&self) -> Result<()> { // init root directory self.insert_file_entry_locked( - Self::ROOT_DIR_PARENT_FILE_ID, - Self::ROOT_DIR_FILE_ID, - Self::ROOT_DIR_NAME, + ROOT_DIR_PARENT_FILE_ID, + ROOT_DIR_FILE_ID, + Path::new(ROOT_DIR_PATH), ) .await; self.fs.init().await @@ -149,7 +148,7 @@ impl RawFileSystem for DefaultRawFileSystem { async fn get_file_path(&self, file_id: u64) -> Result { let file_entry = self.get_file_entry(file_id).await?; - Ok(file_entry.path) + Ok(file_entry.path.to_string_lossy().to_string()) } async fn valid_file_handle_id(&self, file_id: u64, fh: u64) -> Result<()> { @@ -174,12 +173,15 @@ impl RawFileSystem for DefaultRawFileSystem { Ok(file_stat) } - async fn lookup(&self, parent_file_id: u64, name: &str) -> Result { + async fn lookup(&self, parent_file_id: u64, name: &OsStr) -> Result { let parent_file_entry = self.get_file_entry(parent_file_id).await?; - let mut file_stat = self.fs.lookup(&parent_file_entry.path, name).await?; + + let path = parent_file_entry.path.join(name); + let mut file_stat = self.fs.stat(&path).await?; // fill the file id to file stat self.resolve_file_id_to_filestat(&mut file_stat, parent_file_id) .await; + Ok(file_stat) } @@ -203,11 +205,16 @@ impl RawFileSystem for DefaultRawFileSystem { .await } - async fn create_file(&self, parent_file_id: u64, name: &str, flags: u32) -> Result { + async fn create_file( + &self, + parent_file_id: u64, + name: &OsStr, + flags: u32, + ) -> Result { let parent_file_entry = self.get_file_entry(parent_file_id).await?; let mut file_without_id = self .fs - .create_file(&parent_file_entry.path, name, OpenFileFlags(flags)) + .create_file(&parent_file_entry.path.join(name), OpenFileFlags(flags)) .await?; file_without_id.set_file_id(parent_file_id, self.next_file_id()); @@ -226,9 +233,10 @@ impl RawFileSystem for DefaultRawFileSystem { Ok(opened_file_with_file_handle_id.file_handle()) } - async fn create_dir(&self, parent_file_id: u64, name: &str) -> Result { + async fn create_dir(&self, parent_file_id: u64, name: &OsStr) -> Result { let parent_file_entry = self.get_file_entry(parent_file_id).await?; - let mut filestat = self.fs.create_dir(&parent_file_entry.path, name).await?; + let path = parent_file_entry.path.join(name); + let mut filestat = self.fs.create_dir(&path).await?; filestat.set_file_id(parent_file_id, self.next_file_id()); @@ -243,23 +251,23 @@ impl RawFileSystem for DefaultRawFileSystem { self.fs.set_attr(&file_entry.path, file_stat, true).await } - async fn remove_file(&self, parent_file_id: u64, name: &str) -> Result<()> { + async fn remove_file(&self, parent_file_id: u64, name: &OsStr) -> Result<()> { let parent_file_entry = self.get_file_entry(parent_file_id).await?; - self.fs.remove_file(&parent_file_entry.path, name).await?; + let path = parent_file_entry.path.join(name); + self.fs.remove_file(&path).await?; // remove the file from file entry manager - self.remove_file_entry_locked(&join_file_path(&parent_file_entry.path, name)) - .await; + self.remove_file_entry_locked(&path).await; Ok(()) } - async fn remove_dir(&self, parent_file_id: u64, name: &str) -> Result<()> { + async fn remove_dir(&self, parent_file_id: u64, name: &OsStr) -> Result<()> { let parent_file_entry = self.get_file_entry(parent_file_id).await?; - self.fs.remove_dir(&parent_file_entry.path, name).await?; + let path = parent_file_entry.path.join(name); + self.fs.remove_dir(&path).await?; // remove the dir from file entry manager - self.remove_file_entry_locked(&join_file_path(&parent_file_entry.path, name)) - .await; + self.remove_file_entry_locked(&path).await; Ok(()) } @@ -324,7 +332,7 @@ impl RawFileSystem for DefaultRawFileSystem { struct FileEntry { file_id: u64, parent_file_id: u64, - path: String, + path: PathBuf, } /// FileEntryManager is manage all the file entries in memory. it is used manger the file relationship and name mapping. @@ -333,7 +341,7 @@ struct FileEntryManager { file_id_map: HashMap, // file_path_map is a map of file path to file entry. - file_path_map: HashMap, + file_path_map: HashMap, } impl FileEntryManager { @@ -348,21 +356,21 @@ impl FileEntryManager { self.file_id_map.get(&file_id).cloned() } - fn get_file_entry_by_path(&self, path: &str) -> Option { + fn get_file_entry_by_path(&self, path: &Path) -> Option { self.file_path_map.get(path).cloned() } - fn insert(&mut self, parent_file_id: u64, file_id: u64, path: &str) { + fn insert(&mut self, parent_file_id: u64, file_id: u64, path: &Path) { let file_entry = FileEntry { file_id, parent_file_id, - path: path.to_string(), + path: path.into(), }; self.file_id_map.insert(file_id, file_entry.clone()); - self.file_path_map.insert(path.to_string(), file_entry); + self.file_path_map.insert(path.into(), file_entry); } - fn remove(&mut self, path: &str) { + fn remove(&mut self, path: &Path) { if let Some(file) = self.file_path_map.remove(path) { self.file_id_map.remove(&file.file_id); } @@ -372,23 +380,34 @@ impl FileEntryManager { #[cfg(test)] mod tests { use super::*; + use crate::filesystem::tests::TestRawFileSystem; + use crate::memory_filesystem::MemoryFileSystem; #[test] fn test_file_entry_manager() { let mut manager = FileEntryManager::new(); - manager.insert(1, 2, "a/b"); + manager.insert(1, 2, Path::new("a/b")); let file = manager.get_file_entry_by_id(2).unwrap(); assert_eq!(file.file_id, 2); assert_eq!(file.parent_file_id, 1); - assert_eq!(file.path, "a/b"); + assert_eq!(file.path, Path::new("a/b")); - let file = manager.get_file_entry_by_path("a/b").unwrap(); + let file = manager.get_file_entry_by_path(Path::new("a/b")).unwrap(); assert_eq!(file.file_id, 2); assert_eq!(file.parent_file_id, 1); - assert_eq!(file.path, "a/b"); + assert_eq!(file.path, Path::new("a/b")); - manager.remove("a/b"); + manager.remove(Path::new("a/b")); assert!(manager.get_file_entry_by_id(2).is_none()); - assert!(manager.get_file_entry_by_path("a/b").is_none()); + assert!(manager.get_file_entry_by_path(Path::new("a/b")).is_none()); + } + + #[tokio::test] + async fn test_default_raw_file_system() { + let memory_fs = MemoryFileSystem::new().await; + let raw_fs = DefaultRawFileSystem::new(memory_fs); + let _ = raw_fs.init().await; + let mut tester = TestRawFileSystem::new(raw_fs); + tester.test_raw_file_system().await; } } diff --git a/clients/filesystem-fuse/src/filesystem.rs b/clients/filesystem-fuse/src/filesystem.rs index b0d32ded233..d9440b0e652 100644 --- a/clients/filesystem-fuse/src/filesystem.rs +++ b/clients/filesystem-fuse/src/filesystem.rs @@ -17,14 +17,22 @@ * under the License. */ use crate::opened_file::{FileHandle, OpenFileFlags, OpenedFile}; -use crate::utils::{join_file_path, split_file_path}; use async_trait::async_trait; use bytes::Bytes; +use fuse3::FileType::{Directory, RegularFile}; use fuse3::{Errno, FileType, Timestamp}; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; use std::time::SystemTime; pub(crate) type Result = std::result::Result; +pub(crate) const ROOT_DIR_PARENT_FILE_ID: u64 = 1; +pub(crate) const ROOT_DIR_FILE_ID: u64 = 1; +pub(crate) const ROOT_DIR_NAME: &str = ""; +pub(crate) const ROOT_DIR_PATH: &str = "/"; +pub(crate) const INITIAL_FILE_ID: u64 = 10000; + /// RawFileSystem interface for the file system implementation. it use by FuseApiHandle, /// it ues the file id to operate the file system apis /// the `file_id` and `parent_file_id` it is the unique identifier for the file system, @@ -47,7 +55,7 @@ pub(crate) trait RawFileSystem: Send + Sync { async fn stat(&self, file_id: u64) -> Result; /// Lookup the file by parent file id and file name, if the file exists, return the file stat - async fn lookup(&self, parent_file_id: u64, name: &str) -> Result; + async fn lookup(&self, parent_file_id: u64, name: &OsStr) -> Result; /// Read the directory by file id, if the file id is a valid directory, return the file stat list async fn read_dir(&self, dir_file_id: u64) -> Result>; @@ -59,19 +67,24 @@ pub(crate) trait RawFileSystem: Send + Sync { async fn open_dir(&self, file_id: u64, flags: u32) -> Result; /// Create the file by parent file id and file name and flags, if successful, return the file handle - async fn create_file(&self, parent_file_id: u64, name: &str, flags: u32) -> Result; + async fn create_file( + &self, + parent_file_id: u64, + name: &OsStr, + flags: u32, + ) -> Result; /// Create the directory by parent file id and file name, if successful, return the file id - async fn create_dir(&self, parent_file_id: u64, name: &str) -> Result; + async fn create_dir(&self, parent_file_id: u64, name: &OsStr) -> Result; /// Set the file attribute by file id and file stat async fn set_attr(&self, file_id: u64, file_stat: &FileStat) -> Result<()>; /// Remove the file by parent file id and file name - async fn remove_file(&self, parent_file_id: u64, name: &str) -> Result<()>; + async fn remove_file(&self, parent_file_id: u64, name: &OsStr) -> Result<()>; /// Remove the directory by parent file id and file name - async fn remove_dir(&self, parent_file_id: u64, name: &str) -> Result<()>; + async fn remove_dir(&self, parent_file_id: u64, name: &OsStr) -> Result<()>; /// Close the file by file id and file handle, if successful async fn close_file(&self, file_id: u64, fh: u64) -> Result<()>; @@ -91,39 +104,31 @@ pub(crate) trait PathFileSystem: Send + Sync { async fn init(&self) -> Result<()>; /// Get the file stat by file path, if the file exists, return the file stat - async fn stat(&self, path: &str) -> Result; - - /// Get the file stat by parent file path and file name, if the file exists, return the file stat - async fn lookup(&self, parent: &str, name: &str) -> Result; + async fn stat(&self, path: &Path) -> Result; /// Read the directory by file path, if the directory exists, return the file stat list - async fn read_dir(&self, path: &str) -> Result>; + async fn read_dir(&self, path: &Path) -> Result>; /// Open the file by file path and flags, if the file exists, return the opened file - async fn open_file(&self, path: &str, flags: OpenFileFlags) -> Result; + async fn open_file(&self, path: &Path, flags: OpenFileFlags) -> Result; /// Open the directory by file path and flags, if the file exists, return the opened file - async fn open_dir(&self, path: &str, flags: OpenFileFlags) -> Result; + async fn open_dir(&self, path: &Path, flags: OpenFileFlags) -> Result; - /// Create the file by parent file path and file name and flags, if successful return the opened file - async fn create_file( - &self, - parent: &str, - name: &str, - flags: OpenFileFlags, - ) -> Result; + /// Create the file by file path and flags, if successful, return the opened file + async fn create_file(&self, path: &Path, flags: OpenFileFlags) -> Result; - /// Create the directory by parent file path and file name, if successful, return the file stat - async fn create_dir(&self, parent: &str, name: &str) -> Result; + /// Create the directory by file path , if successful, return the file stat + async fn create_dir(&self, path: &Path) -> Result; /// Set the file attribute by file path and file stat - async fn set_attr(&self, path: &str, file_stat: &FileStat, flush: bool) -> Result<()>; + async fn set_attr(&self, path: &Path, file_stat: &FileStat, flush: bool) -> Result<()>; - /// Remove the file by parent file path and file name - async fn remove_file(&self, parent: &str, name: &str) -> Result<()>; + /// Remove the file by file path + async fn remove_file(&self, path: &Path) -> Result<()>; - /// Remove the directory by parent file path and file name - async fn remove_dir(&self, parent: &str, name: &str) -> Result<()>; + /// Remove the directory by file path + async fn remove_dir(&self, path: &Path) -> Result<()>; } // FileSystemContext is the system environment for the fuse file system. @@ -166,10 +171,10 @@ pub struct FileStat { pub(crate) parent_file_id: u64, // file name - pub(crate) name: String, + pub(crate) name: OsString, // file path of the fuse file system root - pub(crate) path: String, + pub(crate) path: PathBuf, // file size pub(crate) size: u64, @@ -191,31 +196,33 @@ pub struct FileStat { } impl FileStat { - pub fn new_file_filestat_with_path(path: &str, size: u64) -> Self { - let (parent, name) = split_file_path(path); - Self::new_file_filestat(parent, name, size) + pub fn new_file_filestat_with_path(path: &Path, size: u64) -> Self { + Self::new_filestat(path, size, RegularFile) } - pub fn new_dir_filestat_with_path(path: &str) -> Self { - let (parent, name) = split_file_path(path); - Self::new_dir_filestat(parent, name) + pub fn new_dir_filestat_with_path(path: &Path) -> Self { + Self::new_filestat(path, 0, Directory) } - pub fn new_file_filestat(parent: &str, name: &str, size: u64) -> Self { - Self::new_filestat(parent, name, size, FileType::RegularFile) + pub fn new_file_filestat(parent: &Path, name: &OsStr, size: u64) -> Self { + let path = parent.join(name); + Self::new_filestat(&path, size, RegularFile) } - pub fn new_dir_filestat(parent: &str, name: &str) -> Self { - Self::new_filestat(parent, name, 0, FileType::Directory) + pub fn new_dir_filestat(parent: &Path, name: &OsStr) -> Self { + let path = parent.join(name); + Self::new_filestat(&path, 0, Directory) } - pub fn new_filestat(parent: &str, name: &str, size: u64, kind: FileType) -> Self { + pub fn new_filestat(path: &Path, size: u64, kind: FileType) -> Self { let atime = Timestamp::from(SystemTime::now()); + // root directory name is "" + let name = path.file_name().unwrap_or(OsStr::new(ROOT_DIR_NAME)); Self { file_id: 0, parent_file_id: 0, - name: name.into(), - path: join_file_path(parent, name), + name: name.to_os_string(), + path: path.into(), size: size, kind: kind, atime: atime, @@ -262,43 +269,414 @@ pub trait FileWriter: Sync + Send { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + use std::collections::HashMap; + + pub(crate) struct TestPathFileSystem { + files: HashMap, + fs: F, + } + + impl TestPathFileSystem { + pub(crate) fn new(fs: F) -> Self { + Self { + files: HashMap::new(), + fs, + } + } + + pub(crate) async fn test_path_file_system(&mut self) { + // Test root dir + self.test_root_dir().await; + + // Test stat file + self.test_stat_file(Path::new("/.gvfs_meta"), RegularFile, 0) + .await; + + // Test create file + self.test_create_file(Path::new("/file1.txt")).await; + + // Test create dir + self.test_create_dir(Path::new("/dir1")).await; + + // Test list dir + self.test_list_dir(Path::new("/")).await; + + // Test remove file + self.test_remove_file(Path::new("/file1.txt")).await; + + // Test remove dir + self.test_remove_dir(Path::new("/dir1")).await; + + // Test file not found + self.test_file_not_found(Path::new("unknown")).await; + + // Test list dir + self.test_list_dir(Path::new("/")).await; + } + + async fn test_root_dir(&mut self) { + let root_dir_path = Path::new("/"); + let root_file_stat = self.fs.stat(root_dir_path).await; + assert!(root_file_stat.is_ok()); + let root_file_stat = root_file_stat.unwrap(); + self.assert_file_stat(&root_file_stat, root_dir_path, Directory, 0); + } + + async fn test_stat_file(&mut self, path: &Path, expect_kind: FileType, expect_size: u64) { + let file_stat = self.fs.stat(path).await; + assert!(file_stat.is_ok()); + let file_stat = file_stat.unwrap(); + self.assert_file_stat(&file_stat, path, expect_kind, expect_size); + self.files.insert(file_stat.path.clone(), file_stat); + } + + async fn test_create_file(&mut self, path: &Path) { + let opened_file = self.fs.create_file(path, OpenFileFlags(0)).await; + assert!(opened_file.is_ok()); + let file = opened_file.unwrap(); + self.assert_file_stat(&file.file_stat, path, FileType::RegularFile, 0); + self.test_stat_file(path, RegularFile, 0).await; + } + + async fn test_create_dir(&mut self, path: &Path) { + let dir_stat = self.fs.create_dir(path).await; + assert!(dir_stat.is_ok()); + let dir_stat = dir_stat.unwrap(); + self.assert_file_stat(&dir_stat, path, Directory, 0); + self.test_stat_file(path, Directory, 0).await; + } + + async fn test_list_dir(&self, path: &Path) { + let list_dir = self.fs.read_dir(path).await; + assert!(list_dir.is_ok()); + let list_dir = list_dir.unwrap(); + assert_eq!(list_dir.len(), self.files.len()); + for file_stat in list_dir { + assert!(self.files.contains_key(&file_stat.path)); + let actual_file_stat = self.files.get(&file_stat.path).unwrap(); + self.assert_file_stat( + &file_stat, + &actual_file_stat.path, + actual_file_stat.kind, + actual_file_stat.size, + ); + } + } + + async fn test_remove_file(&mut self, path: &Path) { + let remove_file = self.fs.remove_file(path).await; + assert!(remove_file.is_ok()); + self.files.remove(path); + + self.test_file_not_found(path).await; + } + + async fn test_remove_dir(&mut self, path: &Path) { + let remove_dir = self.fs.remove_dir(path).await; + assert!(remove_dir.is_ok()); + self.files.remove(path); + + self.test_file_not_found(path).await; + } + + async fn test_file_not_found(&self, path: &Path) { + let not_found_file = self.fs.stat(path).await; + assert!(not_found_file.is_err()); + } + + fn assert_file_stat(&self, file_stat: &FileStat, path: &Path, kind: FileType, size: u64) { + assert_eq!(file_stat.path, path); + assert_eq!(file_stat.kind, kind); + assert_eq!(file_stat.size, size); + } + } + + pub(crate) struct TestRawFileSystem { + fs: F, + files: HashMap, + } + + impl TestRawFileSystem { + pub(crate) fn new(fs: F) -> Self { + Self { + fs, + files: HashMap::new(), + } + } + + pub(crate) async fn test_raw_file_system(&mut self) { + // Test root dir + self.test_root_dir().await; + + let parent_file_id = ROOT_DIR_FILE_ID; + // Test lookup file + let file_id = self + .test_lookup_file(parent_file_id, ".gvfs_meta".as_ref(), RegularFile, 0) + .await; + + // Test get file stat + self.test_stat_file(file_id, Path::new("/.gvfs_meta"), RegularFile, 0) + .await; + + // Test get file path + self.test_get_file_path(file_id, "/.gvfs_meta").await; + + // Test create file + self.test_create_file(parent_file_id, "file1.txt".as_ref()) + .await; + + // Test open file + let file_handle = self + .test_open_file(parent_file_id, "file1.txt".as_ref()) + .await; + + // Test write file + self.test_write_file(&file_handle, "test").await; + + // Test read file + self.test_read_file(&file_handle, "test").await; + + // Test close file + self.test_close_file(&file_handle).await; + + // Test create dir + self.test_create_dir(parent_file_id, "dir1".as_ref()).await; + + // Test list dir + self.test_list_dir(parent_file_id).await; + + // Test remove file + self.test_remove_file(parent_file_id, "file1.txt".as_ref()) + .await; + + // Test remove dir + self.test_remove_dir(parent_file_id, "dir1".as_ref()).await; + + // Test list dir again + self.test_list_dir(parent_file_id).await; + + // Test file not found + self.test_file_not_found(23).await; + } + + async fn test_root_dir(&self) { + let root_file_stat = self.fs.stat(ROOT_DIR_FILE_ID).await; + assert!(root_file_stat.is_ok()); + let root_file_stat = root_file_stat.unwrap(); + self.assert_file_stat( + &root_file_stat, + Path::new(ROOT_DIR_PATH), + FileType::Directory, + 0, + ); + } + + async fn test_lookup_file( + &mut self, + parent_file_id: u64, + expect_name: &OsStr, + expect_kind: FileType, + expect_size: u64, + ) -> u64 { + let file_stat = self.fs.lookup(parent_file_id, expect_name).await; + assert!(file_stat.is_ok()); + let file_stat = file_stat.unwrap(); + self.assert_file_stat(&file_stat, &file_stat.path, expect_kind, expect_size); + assert_eq!(file_stat.name, expect_name); + let file_id = file_stat.file_id; + self.files.insert(file_stat.file_id, file_stat); + file_id + } + + async fn test_get_file_path(&mut self, file_id: u64, expect_path: &str) { + let file_path = self.fs.get_file_path(file_id).await; + assert!(file_path.is_ok()); + assert_eq!(file_path.unwrap(), expect_path); + } + + async fn test_stat_file( + &mut self, + file_id: u64, + expect_path: &Path, + expect_kind: FileType, + expect_size: u64, + ) { + let file_stat = self.fs.stat(file_id).await; + assert!(file_stat.is_ok()); + let file_stat = file_stat.unwrap(); + self.assert_file_stat(&file_stat, expect_path, expect_kind, expect_size); + self.files.insert(file_stat.file_id, file_stat); + } + + async fn test_create_file(&mut self, root_file_id: u64, name: &OsStr) { + let file = self.fs.create_file(root_file_id, name, 0).await; + assert!(file.is_ok()); + let file = file.unwrap(); + assert!(file.handle_id > 0); + assert!(file.file_id >= INITIAL_FILE_ID); + let file_stat = self.fs.stat(file.file_id).await; + assert!(file_stat.is_ok()); + + self.test_stat_file(file.file_id, &file_stat.unwrap().path, RegularFile, 0) + .await; + } + + async fn test_open_file(&self, root_file_id: u64, name: &OsStr) -> FileHandle { + let file = self.fs.lookup(root_file_id, name).await.unwrap(); + let file_handle = self.fs.open_file(file.file_id, 0).await; + assert!(file_handle.is_ok()); + let file_handle = file_handle.unwrap(); + assert_eq!(file_handle.file_id, file.file_id); + file_handle + } + + async fn test_write_file(&mut self, file_handle: &FileHandle, content: &str) { + let write_size = self + .fs + .write( + file_handle.file_id, + file_handle.handle_id, + 0, + content.as_bytes(), + ) + .await; + assert!(write_size.is_ok()); + assert_eq!(write_size.unwrap(), content.len() as u32); + + self.files.get_mut(&file_handle.file_id).unwrap().size = content.len() as u64; + } + + async fn test_read_file(&self, file_handle: &FileHandle, expected_content: &str) { + let read_data = self + .fs + .read( + file_handle.file_id, + file_handle.handle_id, + 0, + expected_content.len() as u32, + ) + .await; + assert!(read_data.is_ok()); + assert_eq!(read_data.unwrap(), expected_content.as_bytes()); + } + + async fn test_close_file(&self, file_handle: &FileHandle) { + let close_file = self + .fs + .close_file(file_handle.file_id, file_handle.handle_id) + .await; + assert!(close_file.is_ok()); + } + + async fn test_create_dir(&mut self, parent_file_id: u64, name: &OsStr) { + let dir = self.fs.create_dir(parent_file_id, name).await; + assert!(dir.is_ok()); + let dir_file_id = dir.unwrap(); + assert!(dir_file_id >= INITIAL_FILE_ID); + let dir_stat = self.fs.stat(dir_file_id).await; + assert!(dir_stat.is_ok()); + + self.test_stat_file(dir_file_id, &dir_stat.unwrap().path, Directory, 0) + .await; + } + + async fn test_list_dir(&self, root_file_id: u64) { + let list_dir = self.fs.read_dir(root_file_id).await; + assert!(list_dir.is_ok()); + let list_dir = list_dir.unwrap(); + assert_eq!(list_dir.len(), self.files.len()); + for file_stat in list_dir { + assert!(self.files.contains_key(&file_stat.file_id)); + let actual_file_stat = self.files.get(&file_stat.file_id).unwrap(); + self.assert_file_stat( + &file_stat, + &actual_file_stat.path, + actual_file_stat.kind, + actual_file_stat.size, + ); + } + } + + async fn test_remove_file(&mut self, root_file_id: u64, name: &OsStr) { + let file_stat = self.fs.lookup(root_file_id, name).await; + assert!(file_stat.is_ok()); + let file_stat = file_stat.unwrap(); + + let remove_file = self.fs.remove_file(root_file_id, name).await; + assert!(remove_file.is_ok()); + self.files.remove(&file_stat.file_id); + + self.test_file_not_found(file_stat.file_id).await; + } + + async fn test_remove_dir(&mut self, root_file_id: u64, name: &OsStr) { + let file_stat = self.fs.lookup(root_file_id, name).await; + assert!(file_stat.is_ok()); + let file_stat = file_stat.unwrap(); + + let remove_dir = self.fs.remove_dir(root_file_id, name).await; + assert!(remove_dir.is_ok()); + self.files.remove(&file_stat.file_id); + + self.test_file_not_found(file_stat.file_id).await; + } + + async fn test_file_not_found(&self, file_id: u64) { + let not_found_file = self.fs.stat(file_id).await; + assert!(not_found_file.is_err()); + } + + fn assert_file_stat(&self, file_stat: &FileStat, path: &Path, kind: FileType, size: u64) { + assert_eq!(file_stat.path, path); + assert_eq!(file_stat.kind, kind); + assert_eq!(file_stat.size, size); + if file_stat.file_id == 1 { + assert_eq!(file_stat.parent_file_id, 1); + } else { + assert!(file_stat.file_id >= INITIAL_FILE_ID); + assert!( + file_stat.parent_file_id == 1 || file_stat.parent_file_id >= INITIAL_FILE_ID + ); + } + } + } #[test] fn test_create_file_stat() { //test new file - let file_stat = FileStat::new_file_filestat("a", "b", 10); + let file_stat = FileStat::new_file_filestat(Path::new("a"), "b".as_ref(), 10); assert_eq!(file_stat.name, "b"); - assert_eq!(file_stat.path, "a/b"); + assert_eq!(file_stat.path, Path::new("a/b")); assert_eq!(file_stat.size, 10); assert_eq!(file_stat.kind, FileType::RegularFile); //test new dir - let file_stat = FileStat::new_dir_filestat("a", "b"); + let file_stat = FileStat::new_dir_filestat("a".as_ref(), "b".as_ref()); assert_eq!(file_stat.name, "b"); - assert_eq!(file_stat.path, "a/b"); + assert_eq!(file_stat.path, Path::new("a/b")); assert_eq!(file_stat.size, 0); assert_eq!(file_stat.kind, FileType::Directory); //test new file with path - let file_stat = FileStat::new_file_filestat_with_path("a/b", 10); + let file_stat = FileStat::new_file_filestat_with_path("a/b".as_ref(), 10); assert_eq!(file_stat.name, "b"); - assert_eq!(file_stat.path, "a/b"); + assert_eq!(file_stat.path, Path::new("a/b")); assert_eq!(file_stat.size, 10); assert_eq!(file_stat.kind, FileType::RegularFile); //test new dir with path - let file_stat = FileStat::new_dir_filestat_with_path("a/b"); + let file_stat = FileStat::new_dir_filestat_with_path("a/b".as_ref()); assert_eq!(file_stat.name, "b"); - assert_eq!(file_stat.path, "a/b"); + assert_eq!(file_stat.path, Path::new("a/b")); assert_eq!(file_stat.size, 0); assert_eq!(file_stat.kind, FileType::Directory); } #[test] fn test_file_stat_set_file_id() { - let mut file_stat = FileStat::new_file_filestat("a", "b", 10); + let mut file_stat = FileStat::new_file_filestat("a".as_ref(), "b".as_ref(), 10); file_stat.set_file_id(1, 2); assert_eq!(file_stat.file_id, 2); assert_eq!(file_stat.parent_file_id, 1); @@ -307,7 +685,7 @@ mod tests { #[test] #[should_panic(expected = "assertion failed: file_id != 0 && parent_file_id != 0")] fn test_file_stat_set_file_id_panic() { - let mut file_stat = FileStat::new_file_filestat("a", "b", 10); + let mut file_stat = FileStat::new_file_filestat("a".as_ref(), "b".as_ref(), 10); file_stat.set_file_id(1, 0); } } diff --git a/clients/filesystem-fuse/src/fuse_api_handle.rs b/clients/filesystem-fuse/src/fuse_api_handle.rs index 7dc5461ce7f..1f24e94ee86 100644 --- a/clients/filesystem-fuse/src/fuse_api_handle.rs +++ b/clients/filesystem-fuse/src/fuse_api_handle.rs @@ -95,8 +95,7 @@ impl Filesystem for FuseApiHandle { parent: Inode, name: &OsStr, ) -> fuse3::Result { - let name = name.to_string_lossy(); - let file_stat = self.fs.lookup(parent, &name).await?; + let file_stat = self.fs.lookup(parent, name).await?; Ok(ReplyEntry { ttl: self.default_ttl, attr: fstat_to_file_attr(&file_stat, &self.fs_context), @@ -154,8 +153,7 @@ impl Filesystem for FuseApiHandle { _mode: u32, _umask: u32, ) -> fuse3::Result { - let name = name.to_string_lossy(); - let handle_id = self.fs.create_dir(parent, &name).await?; + let handle_id = self.fs.create_dir(parent, name).await?; Ok(ReplyEntry { ttl: self.default_ttl, attr: dummy_file_attr( @@ -169,14 +167,12 @@ impl Filesystem for FuseApiHandle { } async fn unlink(&self, _req: Request, parent: Inode, name: &OsStr) -> fuse3::Result<()> { - let name = name.to_string_lossy(); - self.fs.remove_file(parent, &name).await?; + self.fs.remove_file(parent, name).await?; Ok(()) } async fn rmdir(&self, _req: Request, parent: Inode, name: &OsStr) -> fuse3::Result<()> { - let name = name.to_string_lossy(); - self.fs.remove_dir(parent, &name).await?; + self.fs.remove_dir(parent, name).await?; Ok(()) } @@ -267,7 +263,7 @@ impl Filesystem for FuseApiHandle { stream::iter(files.into_iter().enumerate().map(|(index, file_stat)| { Ok(DirectoryEntry { inode: file_stat.file_id, - name: file_stat.name.clone().into(), + name: file_stat.name.clone(), kind: file_stat.kind, offset: (index + 3) as i64, }) @@ -313,8 +309,7 @@ impl Filesystem for FuseApiHandle { _mode: u32, flags: u32, ) -> fuse3::Result { - let name = name.to_string_lossy(); - let file_handle = self.fs.create_file(parent, &name, flags).await?; + let file_handle = self.fs.create_file(parent, name, flags).await?; Ok(ReplyCreated { ttl: self.default_ttl, attr: dummy_file_attr( @@ -349,7 +344,7 @@ impl Filesystem for FuseApiHandle { stream::iter(files.into_iter().enumerate().map(|(index, file_stat)| { Ok(DirectoryEntryPlus { inode: file_stat.file_id, - name: file_stat.name.clone().into(), + name: file_stat.name.clone(), kind: file_stat.kind, offset: (index + 3) as i64, attr: fstat_to_file_attr(&file_stat, &self.fs_context), @@ -465,8 +460,8 @@ mod test { let file_stat = FileStat { file_id: 1, parent_file_id: 3, - name: "test".to_string(), - path: "".to_string(), + name: "test".into(), + path: "".into(), size: 10032, kind: FileType::RegularFile, atime: Timestamp { sec: 10, nsec: 3 }, diff --git a/clients/filesystem-fuse/src/fuse_server.rs b/clients/filesystem-fuse/src/fuse_server.rs new file mode 100644 index 00000000000..dae7c28a631 --- /dev/null +++ b/clients/filesystem-fuse/src/fuse_server.rs @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use fuse3::raw::{Filesystem, Session}; +use fuse3::{MountOptions, Result}; +use log::{error, info}; +use std::process::exit; +use std::sync::Arc; +use tokio::select; +use tokio::sync::Notify; + +/// Represents a FUSE server capable of starting and stopping the FUSE filesystem. +pub struct FuseServer { + // Notification for stop + close_notify: Arc, + + // Mount point of the FUSE filesystem + mount_point: String, +} + +impl FuseServer { + /// Creates a new instance of `FuseServer`. + pub fn new(mount_point: &str) -> Self { + Self { + close_notify: Arc::new(Default::default()), + mount_point: mount_point.to_string(), + } + } + + /// Starts the FUSE filesystem and blocks until it is stopped. + pub async fn start(&self, fuse_fs: impl Filesystem + Sync + 'static) -> Result<()> { + //check if the mount point exists + if !std::path::Path::new(&self.mount_point).exists() { + error!("Mount point {} does not exist", self.mount_point); + exit(libc::ENOENT); + } + + info!( + "Starting FUSE filesystem and mounting at {}", + self.mount_point + ); + + let mount_options = MountOptions::default(); + let mut mount_handle = Session::new(mount_options) + .mount_with_unprivileged(fuse_fs, &self.mount_point) + .await?; + + let handle = &mut mount_handle; + + select! { + res = handle => { + if res.is_err() { + error!("Failed to mount FUSE filesystem: {:?}", res.err()); + } + }, + _ = self.close_notify.notified() => { + if let Err(e) = mount_handle.unmount().await { + error!("Failed to unmount FUSE filesystem: {:?}", e); + } else { + info!("FUSE filesystem unmounted successfully."); + } + } + } + + // notify that the filesystem is stopped + self.close_notify.notify_one(); + Ok(()) + } + + /// Stops the FUSE filesystem. + pub async fn stop(&self) { + info!("Stopping FUSE filesystem..."); + self.close_notify.notify_one(); + + // wait for the filesystem to stop + self.close_notify.notified().await; + } +} diff --git a/clients/filesystem-fuse/src/lib.rs b/clients/filesystem-fuse/src/lib.rs index c1689bac476..36e8c28d343 100644 --- a/clients/filesystem-fuse/src/lib.rs +++ b/clients/filesystem-fuse/src/lib.rs @@ -19,6 +19,17 @@ mod default_raw_filesystem; mod filesystem; mod fuse_api_handle; +mod fuse_server; +mod memory_filesystem; +mod mount; mod opened_file; mod opened_file_manager; mod utils; + +pub async fn gvfs_mount(mount_point: &str) -> fuse3::Result<()> { + mount::mount(mount_point).await +} + +pub async fn gvfs_unmount() { + mount::unmount().await; +} diff --git a/clients/filesystem-fuse/src/main.rs b/clients/filesystem-fuse/src/main.rs index 3d8e9dbb953..28866a9bb1c 100644 --- a/clients/filesystem-fuse/src/main.rs +++ b/clients/filesystem-fuse/src/main.rs @@ -16,69 +16,18 @@ * specific language governing permissions and limitations * under the License. */ -mod default_raw_filesystem; -mod filesystem; -mod fuse_api_handle; -mod opened_file; -mod opened_file_manager; -mod utils; - -use log::debug; +use gvfs_fuse::{gvfs_mount, gvfs_unmount}; use log::info; -use std::process::exit; +use tokio::signal; #[tokio::main] -async fn main() { +async fn main() -> fuse3::Result<()> { tracing_subscriber::fmt().init(); - info!("Starting filesystem..."); - debug!("Shutdown filesystem..."); - exit(0); -} + tokio::spawn(async { gvfs_mount("gvfs").await }); -async fn create_gvfs_fuse_filesystem() { - // Gvfs-fuse filesystem structure: - // FuseApiHandle - // ├─ DefaultRawFileSystem (RawFileSystem) - // │ └─ FileSystemLog (PathFileSystem) - // │ ├─ GravitinoComposedFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ S3FileSystem (PathFileSystem) - // │ │ │ └─ OpenDALFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ HDFSFileSystem (PathFileSystem) - // │ │ │ └─ OpenDALFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ JuiceFileSystem (PathFileSystem) - // │ │ │ └─ NasFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ XXXFileSystem (PathFileSystem) - // - // `SimpleFileSystem` is a low-level filesystem designed to communicate with FUSE APIs. - // It manages file and directory relationships, as well as file mappings. - // It delegates file operations to the PathFileSystem - // - // `FileSystemLog` is a decorator that adds extra debug logging functionality to file system APIs. - // Similar implementations include permissions, caching, and metrics. - // - // `GravitinoComposeFileSystem` is a composite file system that can combine multiple `GravitinoFilesetFileSystem`. - // It use the part of catalog and schema of fileset path to a find actual GravitinoFilesetFileSystem. delegate the operation to the real storage. - // If the user only mounts a fileset, this layer is not present. There will only be one below layer. - // - // `GravitinoFilesetFileSystem` is a file system that can access a fileset.It translates the fileset path to the real storage path. - // and delegate the operation to the real storage. - // - // `OpenDALFileSystem` is a file system that use the OpenDAL to access real storage. - // it can assess the S3, HDFS, gcs, azblob and other storage. - // - // `S3FileSystem` is a file system that use `OpenDALFileSystem` to access S3 storage. - // - // `HDFSFileSystem` is a file system that use `OpenDALFileSystem` to access HDFS storage. - // - // `NasFileSystem` is a filesystem that uses a locally accessible path mounted by NAS tools, such as JuiceFS. - // - // `JuiceFileSystem` is a file that use `NasFileSystem` to access JuiceFS storage. - // - // `XXXFileSystem is a filesystem that allows you to implement file access through your own extensions. + let _ = signal::ctrl_c().await; + info!("Received Ctrl+C, Unmounting gvfs..."); + gvfs_unmount().await; - todo!("Implement the createGvfsFuseFileSystem function"); + Ok(()) } diff --git a/clients/filesystem-fuse/src/memory_filesystem.rs b/clients/filesystem-fuse/src/memory_filesystem.rs new file mode 100644 index 00000000000..ca3f13fd9a6 --- /dev/null +++ b/clients/filesystem-fuse/src/memory_filesystem.rs @@ -0,0 +1,281 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::filesystem::{FileReader, FileStat, FileWriter, PathFileSystem, Result}; +use crate::opened_file::{OpenFileFlags, OpenedFile}; +use async_trait::async_trait; +use bytes::Bytes; +use fuse3::FileType::{Directory, RegularFile}; +use fuse3::{Errno, FileType}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; + +// Simple in-memory file implementation of MemoryFileSystem +struct MemoryFile { + kind: FileType, + data: Arc>>, +} + +// MemoryFileSystem is a simple in-memory filesystem implementation +// It is used for testing purposes +pub struct MemoryFileSystem { + // file_map is a map of file name to file size + file_map: RwLock>, +} + +impl MemoryFileSystem { + const FS_META_FILE_NAME: &'static str = "/.gvfs_meta"; + + pub(crate) async fn new() -> Self { + Self { + file_map: RwLock::new(Default::default()), + } + } + + fn create_file_stat(&self, path: &Path, file: &MemoryFile) -> FileStat { + match file.kind { + Directory => FileStat::new_dir_filestat_with_path(path), + _ => { + FileStat::new_file_filestat_with_path(path, file.data.lock().unwrap().len() as u64) + } + } + } +} + +#[async_trait] +impl PathFileSystem for MemoryFileSystem { + async fn init(&self) -> Result<()> { + let root_file = MemoryFile { + kind: Directory, + data: Arc::new(Mutex::new(Vec::new())), + }; + let root_path = PathBuf::from("/"); + self.file_map.write().unwrap().insert(root_path, root_file); + + let meta_file = MemoryFile { + kind: RegularFile, + data: Arc::new(Mutex::new(Vec::new())), + }; + let meta_file_path = Path::new(Self::FS_META_FILE_NAME).to_path_buf(); + self.file_map + .write() + .unwrap() + .insert(meta_file_path, meta_file); + Ok(()) + } + + async fn stat(&self, path: &Path) -> Result { + self.file_map + .read() + .unwrap() + .get(path) + .map(|x| self.create_file_stat(path, x)) + .ok_or(Errno::from(libc::ENOENT)) + } + + async fn read_dir(&self, path: &Path) -> Result> { + let file_map = self.file_map.read().unwrap(); + + let results: Vec = file_map + .iter() + .filter(|x| path_in_dir(path, x.0)) + .map(|(k, v)| self.create_file_stat(k, v)) + .collect(); + + Ok(results) + } + + async fn open_file(&self, path: &Path, _flags: OpenFileFlags) -> Result { + let file_stat = self.stat(path).await?; + let mut opened_file = OpenedFile::new(file_stat); + match opened_file.file_stat.kind { + Directory => Ok(opened_file), + RegularFile => { + let data = self + .file_map + .read() + .unwrap() + .get(&opened_file.file_stat.path) + .unwrap() + .data + .clone(); + opened_file.reader = Some(Box::new(MemoryFileReader { data: data.clone() })); + opened_file.writer = Some(Box::new(MemoryFileWriter { data: data })); + Ok(opened_file) + } + _ => Err(Errno::from(libc::EBADF)), + } + } + + async fn open_dir(&self, path: &Path, flags: OpenFileFlags) -> Result { + self.open_file(path, flags).await + } + + async fn create_file(&self, path: &Path, _flags: OpenFileFlags) -> Result { + let mut file_map = self.file_map.write().unwrap(); + if file_map.contains_key(path) { + return Err(Errno::from(libc::EEXIST)); + } + + let mut opened_file = OpenedFile::new(FileStat::new_file_filestat_with_path(path, 0)); + + let data = Arc::new(Mutex::new(Vec::new())); + file_map.insert( + opened_file.file_stat.path.clone(), + MemoryFile { + kind: RegularFile, + data: data.clone(), + }, + ); + + opened_file.reader = Some(Box::new(MemoryFileReader { data: data.clone() })); + opened_file.writer = Some(Box::new(MemoryFileWriter { data: data })); + + Ok(opened_file) + } + + async fn create_dir(&self, path: &Path) -> Result { + let mut file_map = self.file_map.write().unwrap(); + if file_map.contains_key(path) { + return Err(Errno::from(libc::EEXIST)); + } + + let file = FileStat::new_dir_filestat_with_path(path); + file_map.insert( + file.path.clone(), + MemoryFile { + kind: Directory, + data: Arc::new(Mutex::new(Vec::new())), + }, + ); + + Ok(file) + } + + async fn set_attr(&self, _name: &Path, _file_stat: &FileStat, _flush: bool) -> Result<()> { + Ok(()) + } + + async fn remove_file(&self, path: &Path) -> Result<()> { + let mut file_map = self.file_map.write().unwrap(); + if file_map.remove(path).is_none() { + return Err(Errno::from(libc::ENOENT)); + } + Ok(()) + } + + async fn remove_dir(&self, path: &Path) -> Result<()> { + let mut file_map = self.file_map.write().unwrap(); + let count = file_map.iter().filter(|x| path_in_dir(path, x.0)).count(); + + if count != 0 { + return Err(Errno::from(libc::ENOTEMPTY)); + } + + if file_map.remove(path).is_none() { + return Err(Errno::from(libc::ENOENT)); + } + Ok(()) + } +} + +pub(crate) struct MemoryFileReader { + pub(crate) data: Arc>>, +} + +#[async_trait] +impl FileReader for MemoryFileReader { + async fn read(&mut self, offset: u64, size: u32) -> Result { + let v = self.data.lock().unwrap(); + let start = offset as usize; + let end = usize::min(start + size as usize, v.len()); + if start >= v.len() { + return Ok(Bytes::default()); + } + Ok(v[start..end].to_vec().into()) + } +} + +pub(crate) struct MemoryFileWriter { + pub(crate) data: Arc>>, +} + +#[async_trait] +impl FileWriter for MemoryFileWriter { + async fn write(&mut self, offset: u64, data: &[u8]) -> Result { + let mut v = self.data.lock().unwrap(); + let start = offset as usize; + let end = start + data.len(); + + if v.len() < end { + v.resize(end, 0); + } + v[start..end].copy_from_slice(data); + Ok(data.len() as u32) + } +} + +fn path_in_dir(dir: &Path, path: &Path) -> bool { + if let Ok(relative_path) = path.strip_prefix(dir) { + relative_path.components().count() == 1 + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::filesystem::tests::TestPathFileSystem; + + #[test] + fn test_path_in_dir() { + let dir = Path::new("/parent"); + + let path1 = Path::new("/parent/child1"); + let path2 = Path::new("/parent/a.txt"); + let path3 = Path::new("/parent/child1/grandchild"); + let path4 = Path::new("/other"); + + assert!(!path_in_dir(dir, dir)); + assert!(path_in_dir(dir, path1)); + assert!(path_in_dir(dir, path2)); + assert!(!path_in_dir(dir, path3)); + assert!(!path_in_dir(dir, path4)); + + let dir = Path::new("/"); + + let path1 = Path::new("/child1"); + let path2 = Path::new("/a.txt"); + let path3 = Path::new("/child1/grandchild"); + + assert!(!path_in_dir(dir, dir)); + assert!(path_in_dir(dir, path1)); + assert!(path_in_dir(dir, path2)); + assert!(!path_in_dir(dir, path3)); + } + + #[tokio::test] + async fn test_memory_file_system() { + let fs = MemoryFileSystem::new().await; + let _ = fs.init().await; + let mut tester = TestPathFileSystem::new(fs); + tester.test_path_file_system().await; + } +} diff --git a/clients/filesystem-fuse/src/mount.rs b/clients/filesystem-fuse/src/mount.rs new file mode 100644 index 00000000000..102e2401643 --- /dev/null +++ b/clients/filesystem-fuse/src/mount.rs @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::default_raw_filesystem::DefaultRawFileSystem; +use crate::filesystem::FileSystemContext; +use crate::fuse_api_handle::FuseApiHandle; +use crate::fuse_server::FuseServer; +use crate::memory_filesystem::MemoryFileSystem; +use fuse3::raw::Filesystem; +use log::info; +use once_cell::sync::Lazy; +use std::sync::Arc; +use tokio::sync::Mutex; + +static SERVER: Lazy>>> = Lazy::new(|| Mutex::new(None)); + +pub async fn mount(mount_point: &str) -> fuse3::Result<()> { + info!("Starting gvfs-fuse server..."); + let svr = Arc::new(FuseServer::new(mount_point)); + { + let mut server = SERVER.lock().await; + *server = Some(svr.clone()); + } + let fs = create_fuse_fs().await; + svr.start(fs).await +} + +pub async fn unmount() { + info!("Stop gvfs-fuse server..."); + let svr = { + let mut server = SERVER.lock().await; + if server.is_none() { + info!("Server is already stopped."); + return; + } + server.take().unwrap() + }; + let _ = svr.stop().await; +} + +pub async fn create_fuse_fs() -> impl Filesystem + Sync + 'static { + let uid = unsafe { libc::getuid() }; + let gid = unsafe { libc::getgid() }; + let fs_context = FileSystemContext { + uid: uid, + gid: gid, + default_file_perm: 0o644, + default_dir_perm: 0o755, + block_size: 4 * 1024, + }; + + let gvfs = MemoryFileSystem::new().await; + let fs = DefaultRawFileSystem::new(gvfs); + FuseApiHandle::new(fs, fs_context) +} + +pub async fn create_gvfs_filesystem() { + // Gvfs-fuse filesystem structure: + // FuseApiHandle + // ├─ DefaultRawFileSystem (RawFileSystem) + // │ └─ FileSystemLog (PathFileSystem) + // │ ├─ GravitinoComposedFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ S3FileSystem (PathFileSystem) + // │ │ │ └─ OpenDALFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ HDFSFileSystem (PathFileSystem) + // │ │ │ └─ OpenDALFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ JuiceFileSystem (PathFileSystem) + // │ │ │ └─ NasFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ XXXFileSystem (PathFileSystem) + // + // `SimpleFileSystem` is a low-level filesystem designed to communicate with FUSE APIs. + // It manages file and directory relationships, as well as file mappings. + // It delegates file operations to the PathFileSystem + // + // `FileSystemLog` is a decorator that adds extra debug logging functionality to file system APIs. + // Similar implementations include permissions, caching, and metrics. + // + // `GravitinoComposeFileSystem` is a composite file system that can combine multiple `GravitinoFilesetFileSystem`. + // It use the part of catalog and schema of fileset path to a find actual GravitinoFilesetFileSystem. delegate the operation to the real storage. + // If the user only mounts a fileset, this layer is not present. There will only be one below layer. + // + // `GravitinoFilesetFileSystem` is a file system that can access a fileset.It translates the fileset path to the real storage path. + // and delegate the operation to the real storage. + // + // `OpenDALFileSystem` is a file system that use the OpenDAL to access real storage. + // it can assess the S3, HDFS, gcs, azblob and other storage. + // + // `S3FileSystem` is a file system that use `OpenDALFileSystem` to access S3 storage. + // + // `HDFSFileSystem` is a file system that use `OpenDALFileSystem` to access HDFS storage. + // + // `NasFileSystem` is a filesystem that uses a locally accessible path mounted by NAS tools, such as JuiceFS. + // + // `JuiceFileSystem` is a file that use `NasFileSystem` to access JuiceFS storage. + // + // `XXXFileSystem is a filesystem that allows you to implement file access through your own extensions. + + todo!("Implement the createGvfsFuseFileSystem function"); +} diff --git a/clients/filesystem-fuse/src/opened_file.rs b/clients/filesystem-fuse/src/opened_file.rs index ba3e41595da..5bc961c9a6b 100644 --- a/clients/filesystem-fuse/src/opened_file.rs +++ b/clients/filesystem-fuse/src/opened_file.rs @@ -126,10 +126,15 @@ pub(crate) struct OpenFileFlags(pub(crate) u32); mod tests { use super::*; use crate::filesystem::FileStat; + use std::path::Path; #[test] fn test_open_file() { - let mut open_file = OpenedFile::new(FileStat::new_file_filestat("a", "b", 10)); + let mut open_file = OpenedFile::new(FileStat::new_file_filestat( + Path::new("a"), + "b".as_ref(), + 10, + )); assert_eq!(open_file.file_stat.name, "b"); assert_eq!(open_file.file_stat.size, 10); diff --git a/clients/filesystem-fuse/src/opened_file_manager.rs b/clients/filesystem-fuse/src/opened_file_manager.rs index 17bfe00a397..ab6a5d82347 100644 --- a/clients/filesystem-fuse/src/opened_file_manager.rs +++ b/clients/filesystem-fuse/src/opened_file_manager.rs @@ -69,13 +69,14 @@ impl OpenedFileManager { mod tests { use super::*; use crate::filesystem::FileStat; + use std::path::Path; #[tokio::test] async fn test_opened_file_manager() { let manager = OpenedFileManager::new(); - let file1_stat = FileStat::new_file_filestat("", "a.txt", 13); - let file2_stat = FileStat::new_file_filestat("", "b.txt", 18); + let file1_stat = FileStat::new_file_filestat(Path::new(""), "a.txt".as_ref(), 13); + let file2_stat = FileStat::new_file_filestat(Path::new(""), "b.txt".as_ref(), 18); let file1 = OpenedFile::new(file1_stat.clone()); let file2 = OpenedFile::new(file2_stat.clone()); diff --git a/clients/filesystem-fuse/src/utils.rs b/clients/filesystem-fuse/src/utils.rs index 0c0cc80a162..21e52f86af8 100644 --- a/clients/filesystem-fuse/src/utils.rs +++ b/clients/filesystem-fuse/src/utils.rs @@ -16,52 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -use crate::filesystem::RawFileSystem; - -// join the parent and name to a path -pub fn join_file_path(parent: &str, name: &str) -> String { - //TODO handle corner cases - if parent.is_empty() { - name.to_string() - } else { - format!("{}/{}", parent, name) - } -} - -// split the path to parent and name -pub fn split_file_path(path: &str) -> (&str, &str) { - match path.rfind('/') { - Some(pos) => (&path[..pos], &path[pos + 1..]), - None => ("", path), - } -} - -// convert file id to file path string if file id is invalid return "Unknown" -pub async fn file_id_to_file_path_string(file_id: u64, fs: &impl RawFileSystem) -> String { - fs.get_file_path(file_id) - .await - .unwrap_or("Unknown".to_string()) -} #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_join_file_path() { - assert_eq!(join_file_path("", "a"), "a"); - assert_eq!(join_file_path("", "a.txt"), "a.txt"); - assert_eq!(join_file_path("a", "b"), "a/b"); - assert_eq!(join_file_path("a/b", "c"), "a/b/c"); - assert_eq!(join_file_path("a/b", "c.txt"), "a/b/c.txt"); - } - - #[test] - fn test_split_file_path() { - assert_eq!(split_file_path("a"), ("", "a")); - assert_eq!(split_file_path("a.txt"), ("", "a.txt")); - assert_eq!(split_file_path("a/b"), ("a", "b")); - assert_eq!(split_file_path("a/b/c"), ("a/b", "c")); - assert_eq!(split_file_path("a/b/c.txt"), ("a/b", "c.txt")); - } -} +mod tests {} diff --git a/clients/filesystem-fuse/tests/fuse_test.rs b/clients/filesystem-fuse/tests/fuse_test.rs new file mode 100644 index 00000000000..23aafbaf6e4 --- /dev/null +++ b/clients/filesystem-fuse/tests/fuse_test.rs @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use gvfs_fuse::{gvfs_mount, gvfs_unmount}; +use log::info; +use std::fs; +use std::fs::File; +use std::path::Path; +use std::sync::Arc; +use std::thread::sleep; +use std::time::{Duration, Instant}; +use tokio::runtime::Runtime; +use tokio::task::JoinHandle; + +struct FuseTest { + runtime: Arc, + mount_point: String, + gvfs_mount: Option>>, +} + +impl FuseTest { + pub fn setup(&mut self) { + info!("Start gvfs fuse server"); + let mount_point = self.mount_point.clone(); + self.runtime + .spawn(async move { gvfs_mount(&mount_point).await }); + let success = Self::wait_for_fuse_server_ready(&self.mount_point, Duration::from_secs(15)); + assert!(success, "Fuse server cannot start up at 15 seconds"); + } + + pub fn shutdown(&mut self) { + self.runtime.block_on(async { + gvfs_unmount().await; + }); + } + + fn wait_for_fuse_server_ready(path: &str, timeout: Duration) -> bool { + let test_file = format!("{}/.gvfs_meta", path); + let start_time = Instant::now(); + + while start_time.elapsed() < timeout { + if file_exists(&test_file) { + return true; + } + info!("Wait for fuse server ready",); + sleep(Duration::from_secs(1)); + } + false + } +} + +impl Drop for FuseTest { + fn drop(&mut self) { + info!("Shutdown fuse server"); + self.shutdown(); + } +} + +#[test] +fn test_fuse_system_with_auto() { + tracing_subscriber::fmt().init(); + + let mount_point = "build/gvfs"; + let _ = fs::create_dir_all(mount_point); + + let mut test = FuseTest { + runtime: Arc::new(Runtime::new().unwrap()), + mount_point: mount_point.to_string(), + gvfs_mount: None, + }; + + test.setup(); + test_fuse_filesystem(mount_point); +} + +fn test_fuse_system_with_manual() { + test_fuse_filesystem("build/gvfs"); +} + +fn test_fuse_filesystem(mount_point: &str) { + info!("Test startup"); + let base_path = Path::new(mount_point); + + //test create file + let test_file = base_path.join("test_create"); + let file = File::create(&test_file).expect("Failed to create file"); + assert!(file.metadata().is_ok(), "Failed to get file metadata"); + assert!(file_exists(&test_file)); + + //test write file + fs::write(&test_file, "read test").expect("Failed to write file"); + + //test read file + let content = fs::read_to_string(test_file.clone()).expect("Failed to read file"); + assert_eq!(content, "read test", "File content mismatch"); + + //test delete file + fs::remove_file(test_file.clone()).expect("Failed to delete file"); + assert!(!file_exists(test_file)); + + //test create directory + let test_dir = base_path.join("test_dir"); + fs::create_dir(&test_dir).expect("Failed to create directory"); + + //test create file in directory + let test_file = base_path.join("test_dir/test_file"); + let file = File::create(&test_file).expect("Failed to create file"); + assert!(file.metadata().is_ok(), "Failed to get file metadata"); + + //test write file in directory + let test_file = base_path.join("test_dir/test_read"); + fs::write(&test_file, "read test").expect("Failed to write file"); + + //test read file in directory + let content = fs::read_to_string(&test_file).expect("Failed to read file"); + assert_eq!(content, "read test", "File content mismatch"); + + //test delete file in directory + fs::remove_file(&test_file).expect("Failed to delete file"); + assert!(!file_exists(&test_file)); + + //test delete directory + fs::remove_dir_all(&test_dir).expect("Failed to delete directory"); + assert!(!file_exists(&test_dir)); + + info!("Success test"); +} + +fn file_exists>(path: P) -> bool { + fs::metadata(path).is_ok() +} diff --git a/clients/filesystem-fuse/tests/it.rs b/clients/filesystem-fuse/tests/it.rs deleted file mode 100644 index 989e5f9895e..00000000000 --- a/clients/filesystem-fuse/tests/it.rs +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -#[test] -fn test_math_add() { - assert_eq!(1, 1); -} From 2c4dfde6bd340887a13878f88cf6cc89738e5160 Mon Sep 17 00:00:00 2001 From: Yuhui Date: Tue, 31 Dec 2024 10:15:39 +0800 Subject: [PATCH 11/36] [#5982] feat (gvfs-fuse): Implement Gravitino fileset file system (#5984) ### What changes were proposed in this pull request? Implement an Gravitino fileset file system, Support mount fileset to local directory ### Why are the changes needed? Fix: #5982 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? UT and IT --- clients/filesystem-fuse/Cargo.toml | 7 + clients/filesystem-fuse/conf/gvfs_fuse.toml | 38 ++ clients/filesystem-fuse/src/config.rs | 330 ++++++++++++++++++ .../src/default_raw_filesystem.rs | 32 +- clients/filesystem-fuse/src/error.rs | 69 ++++ clients/filesystem-fuse/src/filesystem.rs | 56 ++- .../filesystem-fuse/src/fuse_api_handle.rs | 3 +- clients/filesystem-fuse/src/fuse_server.rs | 8 +- .../filesystem-fuse/src/gravitino_client.rs | 277 +++++++++++++++ .../src/gravitino_fileset_filesystem.rs | 130 +++++++ clients/filesystem-fuse/src/gvfs_fuse.rs | 246 +++++++++++++ clients/filesystem-fuse/src/lib.rs | 17 +- clients/filesystem-fuse/src/main.rs | 21 +- .../filesystem-fuse/src/memory_filesystem.rs | 8 +- clients/filesystem-fuse/src/mount.rs | 118 ------- clients/filesystem-fuse/src/utils.rs | 3 + .../tests/conf/gvfs_fuse_memory.toml | 40 +++ .../tests/conf/gvfs_fuse_test.toml | 40 +++ clients/filesystem-fuse/tests/fuse_test.rs | 10 +- 19 files changed, 1281 insertions(+), 172 deletions(-) create mode 100644 clients/filesystem-fuse/conf/gvfs_fuse.toml create mode 100644 clients/filesystem-fuse/src/config.rs create mode 100644 clients/filesystem-fuse/src/error.rs create mode 100644 clients/filesystem-fuse/src/gravitino_client.rs create mode 100644 clients/filesystem-fuse/src/gravitino_fileset_filesystem.rs create mode 100644 clients/filesystem-fuse/src/gvfs_fuse.rs delete mode 100644 clients/filesystem-fuse/src/mount.rs create mode 100644 clients/filesystem-fuse/tests/conf/gvfs_fuse_memory.toml create mode 100644 clients/filesystem-fuse/tests/conf/gvfs_fuse_test.toml diff --git a/clients/filesystem-fuse/Cargo.toml b/clients/filesystem-fuse/Cargo.toml index 75a4dd71301..4008ec5ca2f 100644 --- a/clients/filesystem-fuse/Cargo.toml +++ b/clients/filesystem-fuse/Cargo.toml @@ -35,11 +35,18 @@ name = "gvfs_fuse" [dependencies] async-trait = "0.1" bytes = "1.6.0" +config = "0.13" dashmap = "6.1.0" fuse3 = { version = "0.8.1", "features" = ["tokio-runtime", "unprivileged"] } futures-util = "0.3.30" libc = "0.2.168" log = "0.4.22" once_cell = "1.20.2" +reqwest = { version = "0.12.9", features = ["json"] } +serde = { version = "1.0.216", features = ["derive"] } tokio = { version = "1.38.0", features = ["full"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +urlencoding = "2.1.3" + +[dev-dependencies] +mockito = "0.31" diff --git a/clients/filesystem-fuse/conf/gvfs_fuse.toml b/clients/filesystem-fuse/conf/gvfs_fuse.toml new file mode 100644 index 00000000000..94d3d8560fd --- /dev/null +++ b/clients/filesystem-fuse/conf/gvfs_fuse.toml @@ -0,0 +1,38 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# fuse settings +[fuse] +file_mask = 0o600 +dir_mask = 0o700 +fs_type = "memory" + +[fuse.properties] + +# filesystem settings +[filesystem] +block_size = 8192 + +# Gravitino settings +[gravitino] +uri = "http://localhost:8090" +metalake = "your_metalake" + +# extent settings +[extend_config] +access_key = "your access_key" +secret_key = "your_secret_key" diff --git a/clients/filesystem-fuse/src/config.rs b/clients/filesystem-fuse/src/config.rs new file mode 100644 index 00000000000..b381caa75c5 --- /dev/null +++ b/clients/filesystem-fuse/src/config.rs @@ -0,0 +1,330 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::error::ErrorCode::{ConfigNotFound, InvalidConfig}; +use crate::utils::GvfsResult; +use config::{builder, Config}; +use log::{error, info, warn}; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs; + +pub(crate) const CONF_FUSE_FILE_MASK: ConfigEntity = ConfigEntity::new( + FuseConfig::MODULE_NAME, + "file_mask", + "The default file mask for the FUSE filesystem", + 0o600, +); + +pub(crate) const CONF_FUSE_DIR_MASK: ConfigEntity = ConfigEntity::new( + FuseConfig::MODULE_NAME, + "dir_mask", + "The default directory mask for the FUSE filesystem", + 0o700, +); + +pub(crate) const CONF_FUSE_FS_TYPE: ConfigEntity<&'static str> = ConfigEntity::new( + FuseConfig::MODULE_NAME, + "fs_type", + "The type of the FUSE filesystem", + "memory", +); + +pub(crate) const CONF_FUSE_CONFIG_PATH: ConfigEntity<&'static str> = ConfigEntity::new( + FuseConfig::MODULE_NAME, + "config_path", + "The path of the FUSE configuration file", + "/etc/gvfs/gvfs.toml", +); + +pub(crate) const CONF_FILESYSTEM_BLOCK_SIZE: ConfigEntity = ConfigEntity::new( + FilesystemConfig::MODULE_NAME, + "block_size", + "The block size of the gvfs fuse filesystem", + 4096, +); + +pub(crate) const CONF_GRAVITINO_URI: ConfigEntity<&'static str> = ConfigEntity::new( + GravitinoConfig::MODULE_NAME, + "uri", + "The URI of the Gravitino server", + "http://localhost:8090", +); + +pub(crate) const CONF_GRAVITINO_METALAKE: ConfigEntity<&'static str> = ConfigEntity::new( + GravitinoConfig::MODULE_NAME, + "metalake", + "The metalake of the Gravitino server", + "", +); + +pub(crate) struct ConfigEntity { + module: &'static str, + name: &'static str, + description: &'static str, + pub(crate) default: T, +} + +impl ConfigEntity { + const fn new( + module: &'static str, + name: &'static str, + description: &'static str, + default: T, + ) -> Self { + ConfigEntity { + module: module, + name: name, + description: description, + default: default, + } + } +} + +enum ConfigValue { + I32(ConfigEntity), + U32(ConfigEntity), + String(ConfigEntity<&'static str>), + Bool(ConfigEntity), + Float(ConfigEntity), +} + +struct DefaultConfig { + configs: HashMap, +} + +impl Default for DefaultConfig { + fn default() -> Self { + let mut configs = HashMap::new(); + + configs.insert( + Self::compose_key(CONF_FUSE_FILE_MASK), + ConfigValue::U32(CONF_FUSE_FILE_MASK), + ); + configs.insert( + Self::compose_key(CONF_FUSE_DIR_MASK), + ConfigValue::U32(CONF_FUSE_DIR_MASK), + ); + configs.insert( + Self::compose_key(CONF_FUSE_FS_TYPE), + ConfigValue::String(CONF_FUSE_FS_TYPE), + ); + configs.insert( + Self::compose_key(CONF_FUSE_CONFIG_PATH), + ConfigValue::String(CONF_FUSE_CONFIG_PATH), + ); + configs.insert( + Self::compose_key(CONF_GRAVITINO_URI), + ConfigValue::String(CONF_GRAVITINO_URI), + ); + configs.insert( + Self::compose_key(CONF_GRAVITINO_METALAKE), + ConfigValue::String(CONF_GRAVITINO_METALAKE), + ); + configs.insert( + Self::compose_key(CONF_FILESYSTEM_BLOCK_SIZE), + ConfigValue::U32(CONF_FILESYSTEM_BLOCK_SIZE), + ); + + DefaultConfig { configs } + } +} + +impl DefaultConfig { + fn compose_key(entity: ConfigEntity) -> String { + format!("{}.{}", entity.module, entity.name) + } +} + +#[derive(Debug, Deserialize)] +pub struct AppConfig { + #[serde(default)] + pub fuse: FuseConfig, + #[serde(default)] + pub filesystem: FilesystemConfig, + #[serde(default)] + pub gravitino: GravitinoConfig, + #[serde(default)] + pub extend_config: HashMap, +} + +impl Default for AppConfig { + fn default() -> Self { + let builder = Self::crete_default_config_builder(); + let conf = builder + .build() + .expect("Failed to build default configuration"); + conf.try_deserialize::() + .expect("Failed to deserialize default AppConfig") + } +} + +type ConfigBuilder = builder::ConfigBuilder; + +impl AppConfig { + fn crete_default_config_builder() -> ConfigBuilder { + let default = DefaultConfig::default(); + + default + .configs + .values() + .fold( + Config::builder(), + |builder, config_entity| match config_entity { + ConfigValue::I32(entity) => Self::add_config(builder, entity), + ConfigValue::U32(entity) => Self::add_config(builder, entity), + ConfigValue::String(entity) => Self::add_config(builder, entity), + ConfigValue::Bool(entity) => Self::add_config(builder, entity), + ConfigValue::Float(entity) => Self::add_config(builder, entity), + }, + ) + } + + fn add_config>( + builder: ConfigBuilder, + entity: &ConfigEntity, + ) -> ConfigBuilder { + let name = format!("{}.{}", entity.module, entity.name); + builder + .set_default(&name, entity.default.clone().into()) + .unwrap_or_else(|e| panic!("Failed to set default for {}: {}", entity.name, e)) + } + + pub fn from_file(config_file_path: Option<&str>) -> GvfsResult { + let builder = Self::crete_default_config_builder(); + + let config_path = { + if config_file_path.is_some() { + let path = config_file_path.unwrap(); + //check config file exists + if fs::metadata(path).is_err() { + return Err( + ConfigNotFound.to_error("The configuration file not found".to_string()) + ); + } + info!("Use configuration file: {}", path); + path + } else { + //use default config + if fs::metadata(CONF_FUSE_CONFIG_PATH.default).is_err() { + warn!( + "The default configuration file is not found, using the default configuration" + ); + return Ok(AppConfig::default()); + } else { + warn!( + "Using the default config file {}", + CONF_FUSE_CONFIG_PATH.default + ); + } + CONF_FUSE_CONFIG_PATH.default + } + }; + let config = builder + .add_source(config::File::with_name(config_path).required(true)) + .build(); + if let Err(e) = config { + let msg = format!("Failed to build configuration: {}", e); + error!("{}", msg); + return Err(InvalidConfig.to_error(msg)); + } + + let conf = config.unwrap(); + let app_config = conf.try_deserialize::(); + + if let Err(e) = app_config { + let msg = format!("Failed to deserialize configuration: {}", e); + error!("{}", msg); + return Err(InvalidConfig.to_error(msg)); + } + Ok(app_config.unwrap()) + } +} + +#[derive(Debug, Deserialize, Default)] +pub struct FuseConfig { + #[serde(default)] + pub file_mask: u32, + #[serde(default)] + pub dir_mask: u32, + #[serde(default)] + pub fs_type: String, + #[serde(default)] + pub config_path: String, + #[serde(default)] + pub properties: HashMap, +} + +impl FuseConfig { + const MODULE_NAME: &'static str = "fuse"; +} + +#[derive(Debug, Deserialize, Default)] +pub struct FilesystemConfig { + #[serde(default)] + pub block_size: u32, +} + +impl FilesystemConfig { + const MODULE_NAME: &'static str = "filesystem"; +} + +#[derive(Debug, Deserialize, Default)] +pub struct GravitinoConfig { + #[serde(default)] + pub uri: String, + #[serde(default)] + pub metalake: String, +} + +impl GravitinoConfig { + const MODULE_NAME: &'static str = "gravitino"; +} + +#[cfg(test)] +mod test { + use crate::config::AppConfig; + + #[test] + fn test_config_from_file() { + let config = AppConfig::from_file(Some("tests/conf/gvfs_fuse_test.toml")).unwrap(); + assert_eq!(config.fuse.file_mask, 0o644); + assert_eq!(config.fuse.dir_mask, 0o755); + assert_eq!(config.filesystem.block_size, 8192); + assert_eq!(config.gravitino.uri, "http://localhost:8090"); + assert_eq!(config.gravitino.metalake, "test"); + assert_eq!( + config.extend_config.get("access_key"), + Some(&"XXX_access_key".to_string()) + ); + assert_eq!( + config.extend_config.get("secret_key"), + Some(&"XXX_secret_key".to_string()) + ); + } + + #[test] + fn test_default_config() { + let config = AppConfig::default(); + assert_eq!(config.fuse.file_mask, 0o600); + assert_eq!(config.fuse.dir_mask, 0o700); + assert_eq!(config.filesystem.block_size, 4096); + assert_eq!(config.gravitino.uri, "http://localhost:8090"); + assert_eq!(config.gravitino.metalake, ""); + } +} diff --git a/clients/filesystem-fuse/src/default_raw_filesystem.rs b/clients/filesystem-fuse/src/default_raw_filesystem.rs index 0ab92e91640..0c9836e5b33 100644 --- a/clients/filesystem-fuse/src/default_raw_filesystem.rs +++ b/clients/filesystem-fuse/src/default_raw_filesystem.rs @@ -16,9 +16,10 @@ * specific language governing permissions and limitations * under the License. */ +use crate::config::AppConfig; use crate::filesystem::{ - FileStat, PathFileSystem, RawFileSystem, Result, INITIAL_FILE_ID, ROOT_DIR_FILE_ID, - ROOT_DIR_PARENT_FILE_ID, ROOT_DIR_PATH, + FileStat, FileSystemContext, PathFileSystem, RawFileSystem, Result, INITIAL_FILE_ID, + ROOT_DIR_FILE_ID, ROOT_DIR_PARENT_FILE_ID, ROOT_DIR_PATH, }; use crate::opened_file::{FileHandle, OpenFileFlags}; use crate::opened_file_manager::OpenedFileManager; @@ -47,7 +48,7 @@ pub struct DefaultRawFileSystem { } impl DefaultRawFileSystem { - pub(crate) fn new(fs: T) -> Self { + pub(crate) fn new(fs: T, _config: &AppConfig, _fs_context: &FileSystemContext) -> Self { Self { file_entry_manager: RwLock::new(FileEntryManager::new()), opened_file_manager: OpenedFileManager::new(), @@ -189,8 +190,7 @@ impl RawFileSystem for DefaultRawFileSystem { let file_entry = self.get_file_entry(file_id).await?; let mut child_filestats = self.fs.read_dir(&file_entry.path).await?; for file_stat in child_filestats.iter_mut() { - self.resolve_file_id_to_filestat(file_stat, file_stat.file_id) - .await; + self.resolve_file_id_to_filestat(file_stat, file_id).await; } Ok(child_filestats) } @@ -280,13 +280,7 @@ impl RawFileSystem for DefaultRawFileSystem { file.close().await } - async fn read( - &self, - _file_id: u64, - fh: u64, - offset: u64, - size: u32, - ) -> crate::filesystem::Result { + async fn read(&self, _file_id: u64, fh: u64, offset: u64, size: u32) -> Result { let (data, file_stat) = { let opened_file = self .opened_file_manager @@ -303,13 +297,7 @@ impl RawFileSystem for DefaultRawFileSystem { data } - async fn write( - &self, - _file_id: u64, - fh: u64, - offset: u64, - data: &[u8], - ) -> crate::filesystem::Result { + async fn write(&self, _file_id: u64, fh: u64, offset: u64, data: &[u8]) -> Result { let (len, file_stat) = { let opened_file = self .opened_file_manager @@ -405,7 +393,11 @@ mod tests { #[tokio::test] async fn test_default_raw_file_system() { let memory_fs = MemoryFileSystem::new().await; - let raw_fs = DefaultRawFileSystem::new(memory_fs); + let raw_fs = DefaultRawFileSystem::new( + memory_fs, + &AppConfig::default(), + &FileSystemContext::default(), + ); let _ = raw_fs.init().await; let mut tester = TestRawFileSystem::new(raw_fs); tester.test_raw_file_system().await; diff --git a/clients/filesystem-fuse/src/error.rs b/clients/filesystem-fuse/src/error.rs new file mode 100644 index 00000000000..ba3c037c5ca --- /dev/null +++ b/clients/filesystem-fuse/src/error.rs @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use fuse3::Errno; + +#[derive(Debug, Copy, Clone)] +pub enum ErrorCode { + UnSupportedFilesystem, + GravitinoClientError, + InvalidConfig, + ConfigNotFound, +} + +impl ErrorCode { + pub fn to_error(self, message: impl Into) -> GvfsError { + GvfsError::Error(self, message.into()) + } +} + +impl std::fmt::Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + ErrorCode::UnSupportedFilesystem => write!(f, "Unsupported filesystem"), + ErrorCode::GravitinoClientError => write!(f, "Gravitino client error"), + ErrorCode::InvalidConfig => write!(f, "Invalid config"), + ErrorCode::ConfigNotFound => write!(f, "Config not found"), + } + } +} + +#[derive(Debug)] +pub enum GvfsError { + RestError(String, reqwest::Error), + Error(ErrorCode, String), + Errno(Errno), + IOError(std::io::Error), +} +impl From for GvfsError { + fn from(err: reqwest::Error) -> Self { + GvfsError::RestError("Http request failed:".to_owned() + &err.to_string(), err) + } +} + +impl From for GvfsError { + fn from(errno: Errno) -> Self { + GvfsError::Errno(errno) + } +} + +impl From for GvfsError { + fn from(err: std::io::Error) -> Self { + GvfsError::IOError(err) + } +} diff --git a/clients/filesystem-fuse/src/filesystem.rs b/clients/filesystem-fuse/src/filesystem.rs index d9440b0e652..742cdd4c879 100644 --- a/clients/filesystem-fuse/src/filesystem.rs +++ b/clients/filesystem-fuse/src/filesystem.rs @@ -16,6 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +use crate::config::{ + AppConfig, CONF_FILESYSTEM_BLOCK_SIZE, CONF_FUSE_DIR_MASK, CONF_FUSE_FILE_MASK, +}; use crate::opened_file::{FileHandle, OpenFileFlags, OpenedFile}; use async_trait::async_trait; use bytes::Bytes; @@ -129,6 +132,8 @@ pub(crate) trait PathFileSystem: Send + Sync { /// Remove the directory by file path async fn remove_dir(&self, path: &Path) -> Result<()>; + + fn get_capacity(&self) -> Result; } // FileSystemContext is the system environment for the fuse file system. @@ -150,17 +155,30 @@ pub(crate) struct FileSystemContext { } impl FileSystemContext { - pub(crate) fn new(uid: u32, gid: u32) -> Self { + pub(crate) fn new(uid: u32, gid: u32, config: &AppConfig) -> Self { FileSystemContext { uid, gid, - default_file_perm: 0o644, - default_dir_perm: 0o755, - block_size: 4 * 1024, + default_file_perm: config.fuse.file_mask as u16, + default_dir_perm: config.fuse.dir_mask as u16, + block_size: config.filesystem.block_size, + } + } + + pub(crate) fn default() -> Self { + FileSystemContext { + uid: 0, + gid: 0, + default_file_perm: CONF_FUSE_FILE_MASK.default as u16, + default_dir_perm: CONF_FUSE_DIR_MASK.default as u16, + block_size: CONF_FILESYSTEM_BLOCK_SIZE.default, } } } +// capacity of the file system +pub struct FileSystemCapacity {} + // FileStat is the file metadata of the file #[derive(Clone, Debug)] pub struct FileStat { @@ -336,7 +354,7 @@ pub(crate) mod tests { let opened_file = self.fs.create_file(path, OpenFileFlags(0)).await; assert!(opened_file.is_ok()); let file = opened_file.unwrap(); - self.assert_file_stat(&file.file_stat, path, FileType::RegularFile, 0); + self.assert_file_stat(&file.file_stat, path, RegularFile, 0); self.test_stat_file(path, RegularFile, 0).await; } @@ -410,6 +428,9 @@ pub(crate) mod tests { // Test root dir self.test_root_dir().await; + // test read root dir + self.test_list_dir(ROOT_DIR_FILE_ID, false).await; + let parent_file_id = ROOT_DIR_FILE_ID; // Test lookup file let file_id = self @@ -445,7 +466,7 @@ pub(crate) mod tests { self.test_create_dir(parent_file_id, "dir1".as_ref()).await; // Test list dir - self.test_list_dir(parent_file_id).await; + self.test_list_dir(parent_file_id, true).await; // Test remove file self.test_remove_file(parent_file_id, "file1.txt".as_ref()) @@ -455,7 +476,7 @@ pub(crate) mod tests { self.test_remove_dir(parent_file_id, "dir1".as_ref()).await; // Test list dir again - self.test_list_dir(parent_file_id).await; + self.test_list_dir(parent_file_id, true).await; // Test file not found self.test_file_not_found(23).await; @@ -465,12 +486,7 @@ pub(crate) mod tests { let root_file_stat = self.fs.stat(ROOT_DIR_FILE_ID).await; assert!(root_file_stat.is_ok()); let root_file_stat = root_file_stat.unwrap(); - self.assert_file_stat( - &root_file_stat, - Path::new(ROOT_DIR_PATH), - FileType::Directory, - 0, - ); + self.assert_file_stat(&root_file_stat, Path::new(ROOT_DIR_PATH), Directory, 0); } async fn test_lookup_file( @@ -582,10 +598,14 @@ pub(crate) mod tests { .await; } - async fn test_list_dir(&self, root_file_id: u64) { + async fn test_list_dir(&self, root_file_id: u64, check_child: bool) { let list_dir = self.fs.read_dir(root_file_id).await; assert!(list_dir.is_ok()); let list_dir = list_dir.unwrap(); + + if !check_child { + return; + } assert_eq!(list_dir.len(), self.files.len()); for file_stat in list_dir { assert!(self.files.contains_key(&file_stat.file_id)); @@ -650,28 +670,28 @@ pub(crate) mod tests { assert_eq!(file_stat.name, "b"); assert_eq!(file_stat.path, Path::new("a/b")); assert_eq!(file_stat.size, 10); - assert_eq!(file_stat.kind, FileType::RegularFile); + assert_eq!(file_stat.kind, RegularFile); //test new dir let file_stat = FileStat::new_dir_filestat("a".as_ref(), "b".as_ref()); assert_eq!(file_stat.name, "b"); assert_eq!(file_stat.path, Path::new("a/b")); assert_eq!(file_stat.size, 0); - assert_eq!(file_stat.kind, FileType::Directory); + assert_eq!(file_stat.kind, Directory); //test new file with path let file_stat = FileStat::new_file_filestat_with_path("a/b".as_ref(), 10); assert_eq!(file_stat.name, "b"); assert_eq!(file_stat.path, Path::new("a/b")); assert_eq!(file_stat.size, 10); - assert_eq!(file_stat.kind, FileType::RegularFile); + assert_eq!(file_stat.kind, RegularFile); //test new dir with path let file_stat = FileStat::new_dir_filestat_with_path("a/b".as_ref()); assert_eq!(file_stat.name, "b"); assert_eq!(file_stat.path, Path::new("a/b")); assert_eq!(file_stat.size, 0); - assert_eq!(file_stat.kind, FileType::Directory); + assert_eq!(file_stat.kind, Directory); } #[test] diff --git a/clients/filesystem-fuse/src/fuse_api_handle.rs b/clients/filesystem-fuse/src/fuse_api_handle.rs index 1f24e94ee86..153e323891c 100644 --- a/clients/filesystem-fuse/src/fuse_api_handle.rs +++ b/clients/filesystem-fuse/src/fuse_api_handle.rs @@ -17,6 +17,7 @@ * under the License. */ +use crate::config::AppConfig; use crate::filesystem::{FileStat, FileSystemContext, RawFileSystem}; use fuse3::path::prelude::{ReplyData, ReplyOpen, ReplyStatFs, ReplyWrite}; use fuse3::path::Request; @@ -44,7 +45,7 @@ impl FuseApiHandle { const DEFAULT_ATTR_TTL: Duration = Duration::from_secs(1); const DEFAULT_MAX_WRITE_SIZE: u32 = 16 * 1024; - pub fn new(fs: T, context: FileSystemContext) -> Self { + pub fn new(fs: T, _config: &AppConfig, context: FileSystemContext) -> Self { Self { fs: fs, default_ttl: Self::DEFAULT_ATTR_TTL, diff --git a/clients/filesystem-fuse/src/fuse_server.rs b/clients/filesystem-fuse/src/fuse_server.rs index dae7c28a631..a059686e16c 100644 --- a/clients/filesystem-fuse/src/fuse_server.rs +++ b/clients/filesystem-fuse/src/fuse_server.rs @@ -16,8 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +use crate::utils::GvfsResult; use fuse3::raw::{Filesystem, Session}; -use fuse3::{MountOptions, Result}; +use fuse3::MountOptions; use log::{error, info}; use std::process::exit; use std::sync::Arc; @@ -43,7 +44,7 @@ impl FuseServer { } /// Starts the FUSE filesystem and blocks until it is stopped. - pub async fn start(&self, fuse_fs: impl Filesystem + Sync + 'static) -> Result<()> { + pub async fn start(&self, fuse_fs: impl Filesystem + Sync + 'static) -> GvfsResult<()> { //check if the mount point exists if !std::path::Path::new(&self.mount_point).exists() { error!("Mount point {} does not exist", self.mount_point); @@ -83,11 +84,12 @@ impl FuseServer { } /// Stops the FUSE filesystem. - pub async fn stop(&self) { + pub async fn stop(&self) -> GvfsResult<()> { info!("Stopping FUSE filesystem..."); self.close_notify.notify_one(); // wait for the filesystem to stop self.close_notify.notified().await; + Ok(()) } } diff --git a/clients/filesystem-fuse/src/gravitino_client.rs b/clients/filesystem-fuse/src/gravitino_client.rs new file mode 100644 index 00000000000..e5553c9f6c8 --- /dev/null +++ b/clients/filesystem-fuse/src/gravitino_client.rs @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::config::GravitinoConfig; +use crate::error::{ErrorCode, GvfsError}; +use reqwest::Client; +use serde::Deserialize; +use std::collections::HashMap; +use std::fmt::Debug; +use urlencoding::encode; + +#[derive(Debug, Deserialize)] +pub(crate) struct Fileset { + pub(crate) name: String, + #[serde(rename = "type")] + pub(crate) fileset_type: String, + comment: String, + #[serde(rename = "storageLocation")] + pub(crate) storage_location: String, + properties: HashMap, +} + +#[derive(Debug, Deserialize)] +struct FilesetResponse { + code: u32, + fileset: Fileset, +} + +#[derive(Debug, Deserialize)] +struct FileLocationResponse { + code: u32, + #[serde(rename = "fileLocation")] + location: String, +} + +pub(crate) struct GravitinoClient { + gravitino_uri: String, + metalake: String, + + client: Client, +} + +impl GravitinoClient { + pub fn new(config: &GravitinoConfig) -> Self { + Self { + gravitino_uri: config.uri.clone(), + metalake: config.metalake.clone(), + client: Client::new(), + } + } + + pub fn init(&self) {} + + pub fn do_post(&self, _path: &str, _data: &str) { + todo!() + } + + pub fn request(&self, _path: &str, _data: &str) -> Result<(), GvfsError> { + todo!() + } + + pub fn list_schema(&self) -> Result<(), GvfsError> { + todo!() + } + + pub fn list_fileset(&self) -> Result<(), GvfsError> { + todo!() + } + + fn get_fileset_url(&self, catalog_name: &str, schema_name: &str, fileset_name: &str) -> String { + format!( + "{}/api/metalakes/{}/catalogs/{}/schemas/{}/filesets/{}", + self.gravitino_uri, self.metalake, catalog_name, schema_name, fileset_name + ) + } + + async fn do_get(&self, url: &str) -> Result + where + T: for<'de> Deserialize<'de>, + { + let http_resp = + self.client.get(url).send().await.map_err(|e| { + GvfsError::RestError(format!("Failed to send request to {}", url), e) + })?; + + let res = http_resp.json::().await.map_err(|e| { + GvfsError::RestError(format!("Failed to parse response from {}", url), e) + })?; + + Ok(res) + } + + pub async fn get_fileset( + &self, + catalog_name: &str, + schema_name: &str, + fileset_name: &str, + ) -> Result { + let url = self.get_fileset_url(catalog_name, schema_name, fileset_name); + let res = self.do_get::(&url).await?; + + if res.code != 0 { + return Err(GvfsError::Error( + ErrorCode::GravitinoClientError, + "Failed to get fileset".to_string(), + )); + } + Ok(res.fileset) + } + + pub fn get_file_location_url( + &self, + catalog_name: &str, + schema_name: &str, + fileset_name: &str, + path: &str, + ) -> String { + let encoded_path = encode(path); + format!( + "{}/api/metalakes/{}/catalogs/{}/schemas/{}/filesets/{}/location?sub_path={}", + self.gravitino_uri, + self.metalake, + catalog_name, + schema_name, + fileset_name, + encoded_path + ) + } + + pub async fn get_file_location( + &self, + catalog_name: &str, + schema_name: &str, + fileset_name: &str, + path: &str, + ) -> Result { + let url = self.get_file_location_url(catalog_name, schema_name, fileset_name, path); + let res = self.do_get::(&url).await?; + + if res.code != 0 { + return Err(GvfsError::Error( + ErrorCode::GravitinoClientError, + "Failed to get file location".to_string(), + )); + } + Ok(res.location) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::mock; + + #[tokio::test] + async fn test_get_fileset_success() { + let fileset_response = r#" + { + "code": 0, + "fileset": { + "name": "example_fileset", + "type": "example_type", + "comment": "This is a test fileset", + "storageLocation": "/example/path", + "properties": { + "key1": "value1", + "key2": "value2" + } + } + }"#; + + let mock_server_url = &mockito::server_url(); + + let url = format!( + "/api/metalakes/{}/catalogs/{}/schemas/{}/filesets/{}", + "test", "catalog1", "schema1", "fileset1" + ); + let _m = mock("GET", url.as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(fileset_response) + .create(); + + let config = GravitinoConfig { + uri: mock_server_url.to_string(), + metalake: "test".to_string(), + }; + let client = GravitinoClient::new(&config); + + let result = client.get_fileset("catalog1", "schema1", "fileset1").await; + + match result { + Ok(fileset) => { + assert_eq!(fileset.name, "example_fileset"); + assert_eq!(fileset.fileset_type, "example_type"); + assert_eq!(fileset.storage_location, "/example/path"); + assert_eq!(fileset.properties.get("key1"), Some(&"value1".to_string())); + } + Err(e) => panic!("Expected Ok, but got Err: {:?}", e), + } + } + + #[tokio::test] + async fn test_get_file_location_success() { + let file_location_response = r#" + { + "code": 0, + "fileLocation": "/mybucket/a" + }"#; + + let mock_server_url = &mockito::server_url(); + + let url = format!( + "/api/metalakes/{}/catalogs/{}/schemas/{}/filesets/{}/location?sub_path={}", + "test", + "catalog1", + "schema1", + "fileset1", + encode("/example/path") + ); + let _m = mock("GET", url.as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(file_location_response) + .create(); + + let config = GravitinoConfig { + uri: mock_server_url.to_string(), + metalake: "test".to_string(), + }; + let client = GravitinoClient::new(&config); + + let result = client + .get_file_location("catalog1", "schema1", "fileset1", "/example/path") + .await; + + match result { + Ok(location) => { + assert_eq!(location, "/mybucket/a"); + } + Err(e) => panic!("Expected Ok, but got Err: {:?}", e), + } + } + + async fn get_fileset_example() { + tracing_subscriber::fmt::init(); + let config = GravitinoConfig { + uri: "http://localhost:8090".to_string(), + metalake: "test".to_string(), + }; + let client = GravitinoClient::new(&config); + client.init(); + let result = client.get_fileset("c1", "s1", "fileset1").await; + if let Err(e) = &result { + println!("{:?}", e); + } + + let fileset = result.unwrap(); + println!("{:?}", fileset); + assert_eq!(fileset.name, "fileset1"); + } +} diff --git a/clients/filesystem-fuse/src/gravitino_fileset_filesystem.rs b/clients/filesystem-fuse/src/gravitino_fileset_filesystem.rs new file mode 100644 index 00000000000..98a295dbb87 --- /dev/null +++ b/clients/filesystem-fuse/src/gravitino_fileset_filesystem.rs @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::config::AppConfig; +use crate::filesystem::{FileStat, FileSystemCapacity, FileSystemContext, PathFileSystem, Result}; +use crate::gravitino_client::GravitinoClient; +use crate::opened_file::{OpenFileFlags, OpenedFile}; +use async_trait::async_trait; +use fuse3::Errno; +use std::path::{Path, PathBuf}; + +/// GravitinoFileSystem is a filesystem that is associated with a fileset in Gravitino. +/// It mapping the fileset path to the original data storage path. and delegate the operation +/// to the inner filesystem like S3 GCS, JuiceFS. +pub(crate) struct GravitinoFilesetFileSystem { + physical_fs: Box, + client: GravitinoClient, + fileset_location: PathBuf, +} + +impl GravitinoFilesetFileSystem { + pub async fn new( + fs: Box, + location: &Path, + client: GravitinoClient, + _config: &AppConfig, + _context: &FileSystemContext, + ) -> Self { + Self { + physical_fs: fs, + client: client, + fileset_location: location.into(), + } + } + + fn gvfs_path_to_raw_path(&self, path: &Path) -> PathBuf { + self.fileset_location.join(path) + } + + fn raw_path_to_gvfs_path(&self, path: &Path) -> Result { + path.strip_prefix(&self.fileset_location) + .map_err(|_| Errno::from(libc::EBADF))?; + Ok(path.into()) + } +} + +#[async_trait] +impl PathFileSystem for GravitinoFilesetFileSystem { + async fn init(&self) -> Result<()> { + self.physical_fs.init().await + } + + async fn stat(&self, path: &Path) -> Result { + let raw_path = self.gvfs_path_to_raw_path(path); + let mut file_stat = self.physical_fs.stat(&raw_path).await?; + file_stat.path = self.raw_path_to_gvfs_path(&file_stat.path)?; + Ok(file_stat) + } + + async fn read_dir(&self, path: &Path) -> Result> { + let raw_path = self.gvfs_path_to_raw_path(path); + let mut child_filestats = self.physical_fs.read_dir(&raw_path).await?; + for file_stat in child_filestats.iter_mut() { + file_stat.path = self.raw_path_to_gvfs_path(&file_stat.path)?; + } + Ok(child_filestats) + } + + async fn open_file(&self, path: &Path, flags: OpenFileFlags) -> Result { + let raw_path = self.gvfs_path_to_raw_path(path); + let mut opened_file = self.physical_fs.open_file(&raw_path, flags).await?; + opened_file.file_stat.path = self.raw_path_to_gvfs_path(&opened_file.file_stat.path)?; + Ok(opened_file) + } + + async fn open_dir(&self, path: &Path, flags: OpenFileFlags) -> Result { + let raw_path = self.gvfs_path_to_raw_path(path); + let mut opened_file = self.physical_fs.open_dir(&raw_path, flags).await?; + opened_file.file_stat.path = self.raw_path_to_gvfs_path(&opened_file.file_stat.path)?; + Ok(opened_file) + } + + async fn create_file(&self, path: &Path, flags: OpenFileFlags) -> Result { + let raw_path = self.gvfs_path_to_raw_path(path); + let mut opened_file = self.physical_fs.create_file(&raw_path, flags).await?; + opened_file.file_stat.path = self.raw_path_to_gvfs_path(&opened_file.file_stat.path)?; + Ok(opened_file) + } + + async fn create_dir(&self, path: &Path) -> Result { + let raw_path = self.gvfs_path_to_raw_path(path); + let mut file_stat = self.physical_fs.create_dir(&raw_path).await?; + file_stat.path = self.raw_path_to_gvfs_path(&file_stat.path)?; + Ok(file_stat) + } + + async fn set_attr(&self, path: &Path, file_stat: &FileStat, flush: bool) -> Result<()> { + let raw_path = self.gvfs_path_to_raw_path(path); + self.physical_fs.set_attr(&raw_path, file_stat, flush).await + } + + async fn remove_file(&self, path: &Path) -> Result<()> { + let raw_path = self.gvfs_path_to_raw_path(path); + self.physical_fs.remove_file(&raw_path).await + } + + async fn remove_dir(&self, path: &Path) -> Result<()> { + let raw_path = self.gvfs_path_to_raw_path(path); + self.physical_fs.remove_dir(&raw_path).await + } + + fn get_capacity(&self) -> Result { + self.physical_fs.get_capacity() + } +} diff --git a/clients/filesystem-fuse/src/gvfs_fuse.rs b/clients/filesystem-fuse/src/gvfs_fuse.rs new file mode 100644 index 00000000000..d472895d2b3 --- /dev/null +++ b/clients/filesystem-fuse/src/gvfs_fuse.rs @@ -0,0 +1,246 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::config::AppConfig; +use crate::default_raw_filesystem::DefaultRawFileSystem; +use crate::error::ErrorCode::{InvalidConfig, UnSupportedFilesystem}; +use crate::filesystem::FileSystemContext; +use crate::fuse_api_handle::FuseApiHandle; +use crate::fuse_server::FuseServer; +use crate::gravitino_client::GravitinoClient; +use crate::gravitino_fileset_filesystem::GravitinoFilesetFileSystem; +use crate::memory_filesystem::MemoryFileSystem; +use crate::utils::GvfsResult; +use log::info; +use once_cell::sync::Lazy; +use std::path::Path; +use std::sync::Arc; +use tokio::sync::Mutex; + +const FILESET_PREFIX: &str = "gvfs://fileset/"; + +static SERVER: Lazy>>> = Lazy::new(|| Mutex::new(None)); + +pub(crate) enum CreateFileSystemResult { + Memory(MemoryFileSystem), + Gvfs(GravitinoFilesetFileSystem), + FuseMemoryFs(FuseApiHandle>), + FuseGvfs(FuseApiHandle>), + None, +} + +pub enum FileSystemSchema { + S3, +} + +pub async fn mount(mount_to: &str, mount_from: &str, config: &AppConfig) -> GvfsResult<()> { + info!("Starting gvfs-fuse server..."); + let svr = Arc::new(FuseServer::new(mount_to)); + { + let mut server = SERVER.lock().await; + *server = Some(svr.clone()); + } + let fs = create_fuse_fs(mount_from, config).await?; + match fs { + CreateFileSystemResult::FuseMemoryFs(vfs) => svr.start(vfs).await?, + CreateFileSystemResult::FuseGvfs(vfs) => svr.start(vfs).await?, + _ => return Err(UnSupportedFilesystem.to_error("Unsupported filesystem type".to_string())), + } + Ok(()) +} + +pub async fn unmount() -> GvfsResult<()> { + info!("Stop gvfs-fuse server..."); + let svr = { + let mut server = SERVER.lock().await; + if server.is_none() { + info!("Server is already stopped."); + return Ok(()); + } + server.take().unwrap() + }; + svr.stop().await +} + +pub(crate) async fn create_fuse_fs( + mount_from: &str, + config: &AppConfig, +) -> GvfsResult { + let uid = unsafe { libc::getuid() }; + let gid = unsafe { libc::getgid() }; + let fs_context = FileSystemContext::new(uid, gid, config); + let fs = create_path_fs(mount_from, config, &fs_context).await?; + create_raw_fs(fs, config, fs_context).await +} + +pub async fn create_raw_fs( + path_fs: CreateFileSystemResult, + config: &AppConfig, + fs_context: FileSystemContext, +) -> GvfsResult { + match path_fs { + CreateFileSystemResult::Memory(fs) => { + let fs = FuseApiHandle::new( + DefaultRawFileSystem::new(fs, config, &fs_context), + config, + fs_context, + ); + Ok(CreateFileSystemResult::FuseMemoryFs(fs)) + } + CreateFileSystemResult::Gvfs(fs) => { + let fs = FuseApiHandle::new( + DefaultRawFileSystem::new(fs, config, &fs_context), + config, + fs_context, + ); + Ok(CreateFileSystemResult::FuseGvfs(fs)) + } + _ => Err(UnSupportedFilesystem.to_error("Unsupported filesystem type".to_string())), + } +} + +pub async fn create_path_fs( + mount_from: &str, + config: &AppConfig, + fs_context: &FileSystemContext, +) -> GvfsResult { + if config.fuse.fs_type == "memory" { + Ok(CreateFileSystemResult::Memory( + MemoryFileSystem::new().await, + )) + } else { + create_gvfs_filesystem(mount_from, config, fs_context).await + } +} + +pub async fn create_gvfs_filesystem( + mount_from: &str, + config: &AppConfig, + fs_context: &FileSystemContext, +) -> GvfsResult { + // Gvfs-fuse filesystem structure: + // FuseApiHandle + // ├─ DefaultRawFileSystem (RawFileSystem) + // │ └─ FileSystemLog (PathFileSystem) + // │ ├─ GravitinoComposedFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ S3FileSystem (PathFileSystem) + // │ │ │ └─ OpenDALFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ HDFSFileSystem (PathFileSystem) + // │ │ │ └─ OpenDALFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ JuiceFileSystem (PathFileSystem) + // │ │ │ └─ NasFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ XXXFileSystem (PathFileSystem) + // + // `SimpleFileSystem` is a low-level filesystem designed to communicate with FUSE APIs. + // It manages file and directory relationships, as well as file mappings. + // It delegates file operations to the PathFileSystem + // + // `FileSystemLog` is a decorator that adds extra debug logging functionality to file system APIs. + // Similar implementations include permissions, caching, and metrics. + // + // `GravitinoComposeFileSystem` is a composite file system that can combine multiple `GravitinoFilesetFileSystem`. + // It use the part of catalog and schema of fileset path to a find actual GravitinoFilesetFileSystem. delegate the operation to the real storage. + // If the user only mounts a fileset, this layer is not present. There will only be one below layer. + // + // `GravitinoFilesetFileSystem` is a file system that can access a fileset.It translates the fileset path to the real storage path. + // and delegate the operation to the real storage. + // + // `OpenDALFileSystem` is a file system that use the OpenDAL to access real storage. + // it can assess the S3, HDFS, gcs, azblob and other storage. + // + // `S3FileSystem` is a file system that use `OpenDALFileSystem` to access S3 storage. + // + // `HDFSFileSystem` is a file system that use `OpenDALFileSystem` to access HDFS storage. + // + // `NasFileSystem` is a filesystem that uses a locally accessible path mounted by NAS tools, such as JuiceFS. + // + // `JuiceFileSystem` is a file that use `NasFileSystem` to access JuiceFS storage. + // + // `XXXFileSystem is a filesystem that allows you to implement file access through your own extensions. + + let client = GravitinoClient::new(&config.gravitino); + + let (catalog, schema, fileset) = extract_fileset(mount_from)?; + let location = client + .get_fileset(&catalog, &schema, &fileset) + .await? + .storage_location; + let (_schema, location) = extract_storage_filesystem(&location).unwrap(); + + // todo need to replace the inner filesystem with the real storage filesystem + let inner_fs = MemoryFileSystem::new().await; + + let fs = GravitinoFilesetFileSystem::new( + Box::new(inner_fs), + Path::new(&location), + client, + config, + fs_context, + ) + .await; + Ok(CreateFileSystemResult::Gvfs(fs)) +} + +pub fn extract_fileset(path: &str) -> GvfsResult<(String, String, String)> { + if !path.starts_with(FILESET_PREFIX) { + return Err(InvalidConfig.to_error("Invalid fileset path".to_string())); + } + + let path_without_prefix = &path[FILESET_PREFIX.len()..]; + + let parts: Vec<&str> = path_without_prefix.split('/').collect(); + + if parts.len() != 3 { + return Err(InvalidConfig.to_error("Invalid fileset path".to_string())); + } + // todo handle mount catalog or schema + + let catalog = parts[1].to_string(); + let schema = parts[2].to_string(); + let fileset = parts[3].to_string(); + + Ok((catalog, schema, fileset)) +} + +pub fn extract_storage_filesystem(path: &str) -> Option<(FileSystemSchema, String)> { + // todo need to improve the logic + if let Some(pos) = path.find("://") { + let protocol = &path[..pos]; + let location = &path[pos + 3..]; + let location = match location.find('/') { + Some(index) => &location[index + 1..], + None => "", + }; + let location = match location.ends_with('/') { + true => location.to_string(), + false => format!("{}/", location), + }; + + match protocol { + "s3" => Some((FileSystemSchema::S3, location.to_string())), + "s3a" => Some((FileSystemSchema::S3, location.to_string())), + _ => None, + } + } else { + None + } +} diff --git a/clients/filesystem-fuse/src/lib.rs b/clients/filesystem-fuse/src/lib.rs index 36e8c28d343..5532d619e5c 100644 --- a/clients/filesystem-fuse/src/lib.rs +++ b/clients/filesystem-fuse/src/lib.rs @@ -16,20 +16,27 @@ * specific language governing permissions and limitations * under the License. */ +use crate::config::AppConfig; +use crate::utils::GvfsResult; + +pub mod config; mod default_raw_filesystem; +mod error; mod filesystem; mod fuse_api_handle; mod fuse_server; +mod gravitino_client; +mod gravitino_fileset_filesystem; +mod gvfs_fuse; mod memory_filesystem; -mod mount; mod opened_file; mod opened_file_manager; mod utils; -pub async fn gvfs_mount(mount_point: &str) -> fuse3::Result<()> { - mount::mount(mount_point).await +pub async fn gvfs_mount(mount_to: &str, mount_from: &str, config: &AppConfig) -> GvfsResult<()> { + gvfs_fuse::mount(mount_to, mount_from, config).await } -pub async fn gvfs_unmount() { - mount::unmount().await; +pub async fn gvfs_unmount() -> GvfsResult<()> { + gvfs_fuse::unmount().await } diff --git a/clients/filesystem-fuse/src/main.rs b/clients/filesystem-fuse/src/main.rs index 28866a9bb1c..8eab5ec0d51 100644 --- a/clients/filesystem-fuse/src/main.rs +++ b/clients/filesystem-fuse/src/main.rs @@ -16,18 +16,33 @@ * specific language governing permissions and limitations * under the License. */ +use fuse3::Errno; +use gvfs_fuse::config::AppConfig; use gvfs_fuse::{gvfs_mount, gvfs_unmount}; -use log::info; +use log::{error, info}; use tokio::signal; #[tokio::main] async fn main() -> fuse3::Result<()> { tracing_subscriber::fmt().init(); - tokio::spawn(async { gvfs_mount("gvfs").await }); + + //todo(read config file from args) + let config = AppConfig::from_file(Some("conf/gvfs_fuse.toml")); + if let Err(e) = &config { + error!("Failed to load config: {:?}", e); + return Err(Errno::from(libc::EINVAL)); + } + let config = config.unwrap(); + let handle = tokio::spawn(async move { gvfs_mount("gvfs", "", &config).await }); let _ = signal::ctrl_c().await; info!("Received Ctrl+C, Unmounting gvfs..."); - gvfs_unmount().await; + if let Err(e) = handle.await { + error!("Failed to mount gvfs: {:?}", e); + return Err(Errno::from(libc::EINVAL)); + } + + let _ = gvfs_unmount().await; Ok(()) } diff --git a/clients/filesystem-fuse/src/memory_filesystem.rs b/clients/filesystem-fuse/src/memory_filesystem.rs index ca3f13fd9a6..b94d16b8d39 100644 --- a/clients/filesystem-fuse/src/memory_filesystem.rs +++ b/clients/filesystem-fuse/src/memory_filesystem.rs @@ -16,7 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -use crate::filesystem::{FileReader, FileStat, FileWriter, PathFileSystem, Result}; +use crate::filesystem::{ + FileReader, FileStat, FileSystemCapacity, FileWriter, PathFileSystem, Result, +}; use crate::opened_file::{OpenFileFlags, OpenedFile}; use async_trait::async_trait; use bytes::Bytes; @@ -193,6 +195,10 @@ impl PathFileSystem for MemoryFileSystem { } Ok(()) } + + fn get_capacity(&self) -> Result { + Ok(FileSystemCapacity {}) + } } pub(crate) struct MemoryFileReader { diff --git a/clients/filesystem-fuse/src/mount.rs b/clients/filesystem-fuse/src/mount.rs deleted file mode 100644 index 102e2401643..00000000000 --- a/clients/filesystem-fuse/src/mount.rs +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -use crate::default_raw_filesystem::DefaultRawFileSystem; -use crate::filesystem::FileSystemContext; -use crate::fuse_api_handle::FuseApiHandle; -use crate::fuse_server::FuseServer; -use crate::memory_filesystem::MemoryFileSystem; -use fuse3::raw::Filesystem; -use log::info; -use once_cell::sync::Lazy; -use std::sync::Arc; -use tokio::sync::Mutex; - -static SERVER: Lazy>>> = Lazy::new(|| Mutex::new(None)); - -pub async fn mount(mount_point: &str) -> fuse3::Result<()> { - info!("Starting gvfs-fuse server..."); - let svr = Arc::new(FuseServer::new(mount_point)); - { - let mut server = SERVER.lock().await; - *server = Some(svr.clone()); - } - let fs = create_fuse_fs().await; - svr.start(fs).await -} - -pub async fn unmount() { - info!("Stop gvfs-fuse server..."); - let svr = { - let mut server = SERVER.lock().await; - if server.is_none() { - info!("Server is already stopped."); - return; - } - server.take().unwrap() - }; - let _ = svr.stop().await; -} - -pub async fn create_fuse_fs() -> impl Filesystem + Sync + 'static { - let uid = unsafe { libc::getuid() }; - let gid = unsafe { libc::getgid() }; - let fs_context = FileSystemContext { - uid: uid, - gid: gid, - default_file_perm: 0o644, - default_dir_perm: 0o755, - block_size: 4 * 1024, - }; - - let gvfs = MemoryFileSystem::new().await; - let fs = DefaultRawFileSystem::new(gvfs); - FuseApiHandle::new(fs, fs_context) -} - -pub async fn create_gvfs_filesystem() { - // Gvfs-fuse filesystem structure: - // FuseApiHandle - // ├─ DefaultRawFileSystem (RawFileSystem) - // │ └─ FileSystemLog (PathFileSystem) - // │ ├─ GravitinoComposedFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ S3FileSystem (PathFileSystem) - // │ │ │ └─ OpenDALFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ HDFSFileSystem (PathFileSystem) - // │ │ │ └─ OpenDALFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ JuiceFileSystem (PathFileSystem) - // │ │ │ └─ NasFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ XXXFileSystem (PathFileSystem) - // - // `SimpleFileSystem` is a low-level filesystem designed to communicate with FUSE APIs. - // It manages file and directory relationships, as well as file mappings. - // It delegates file operations to the PathFileSystem - // - // `FileSystemLog` is a decorator that adds extra debug logging functionality to file system APIs. - // Similar implementations include permissions, caching, and metrics. - // - // `GravitinoComposeFileSystem` is a composite file system that can combine multiple `GravitinoFilesetFileSystem`. - // It use the part of catalog and schema of fileset path to a find actual GravitinoFilesetFileSystem. delegate the operation to the real storage. - // If the user only mounts a fileset, this layer is not present. There will only be one below layer. - // - // `GravitinoFilesetFileSystem` is a file system that can access a fileset.It translates the fileset path to the real storage path. - // and delegate the operation to the real storage. - // - // `OpenDALFileSystem` is a file system that use the OpenDAL to access real storage. - // it can assess the S3, HDFS, gcs, azblob and other storage. - // - // `S3FileSystem` is a file system that use `OpenDALFileSystem` to access S3 storage. - // - // `HDFSFileSystem` is a file system that use `OpenDALFileSystem` to access HDFS storage. - // - // `NasFileSystem` is a filesystem that uses a locally accessible path mounted by NAS tools, such as JuiceFS. - // - // `JuiceFileSystem` is a file that use `NasFileSystem` to access JuiceFS storage. - // - // `XXXFileSystem is a filesystem that allows you to implement file access through your own extensions. - - todo!("Implement the createGvfsFuseFileSystem function"); -} diff --git a/clients/filesystem-fuse/src/utils.rs b/clients/filesystem-fuse/src/utils.rs index 21e52f86af8..bbc8d7d7f8a 100644 --- a/clients/filesystem-fuse/src/utils.rs +++ b/clients/filesystem-fuse/src/utils.rs @@ -16,6 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +use crate::error::GvfsError; + +pub type GvfsResult = Result; #[cfg(test)] mod tests {} diff --git a/clients/filesystem-fuse/tests/conf/gvfs_fuse_memory.toml b/clients/filesystem-fuse/tests/conf/gvfs_fuse_memory.toml new file mode 100644 index 00000000000..013df6cfc31 --- /dev/null +++ b/clients/filesystem-fuse/tests/conf/gvfs_fuse_memory.toml @@ -0,0 +1,40 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# fuse settings +[fuse] +file_mask= 0o600 +dir_mask= 0o700 +fs_type = "memory" + +[fuse.properties] +key1 = "value1" +key2 = "value2" + +# filesystem settings +[filesystem] +block_size = 8192 + +# Gravitino settings +[gravitino] +uri = "http://localhost:8090" +metalake = "test" + +# extent settings +[extent_config] +access_key = "XXX_access_key" +secret_key = "XXX_secret_key" diff --git a/clients/filesystem-fuse/tests/conf/gvfs_fuse_test.toml b/clients/filesystem-fuse/tests/conf/gvfs_fuse_test.toml new file mode 100644 index 00000000000..ff7c6936f37 --- /dev/null +++ b/clients/filesystem-fuse/tests/conf/gvfs_fuse_test.toml @@ -0,0 +1,40 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# fuse settings +[fuse] +file_mask= 0o644 +dir_mask= 0o755 +fs_type = "memory" + +[fuse.properties] +key1 = "value1" +key2 = "value2" + +# filesystem settings +[filesystem] +block_size = 8192 + +# Gravitino settings +[gravitino] +uri = "http://localhost:8090" +metalake = "test" + +# extent settings +[extend_config] +access_key = "XXX_access_key" +secret_key = "XXX_secret_key" diff --git a/clients/filesystem-fuse/tests/fuse_test.rs b/clients/filesystem-fuse/tests/fuse_test.rs index 23aafbaf6e4..e761fabc5b6 100644 --- a/clients/filesystem-fuse/tests/fuse_test.rs +++ b/clients/filesystem-fuse/tests/fuse_test.rs @@ -17,6 +17,7 @@ * under the License. */ +use gvfs_fuse::config::AppConfig; use gvfs_fuse::{gvfs_mount, gvfs_unmount}; use log::info; use std::fs; @@ -38,15 +39,18 @@ impl FuseTest { pub fn setup(&mut self) { info!("Start gvfs fuse server"); let mount_point = self.mount_point.clone(); + + let config = AppConfig::from_file(Some("tests/conf/gvfs_fuse_memory.toml")) + .expect("Failed to load config"); self.runtime - .spawn(async move { gvfs_mount(&mount_point).await }); + .spawn(async move { gvfs_mount(&mount_point, "", &config).await }); let success = Self::wait_for_fuse_server_ready(&self.mount_point, Duration::from_secs(15)); assert!(success, "Fuse server cannot start up at 15 seconds"); } pub fn shutdown(&mut self) { self.runtime.block_on(async { - gvfs_unmount().await; + let _ = gvfs_unmount().await; }); } @@ -76,7 +80,7 @@ impl Drop for FuseTest { fn test_fuse_system_with_auto() { tracing_subscriber::fmt().init(); - let mount_point = "build/gvfs"; + let mount_point = "target/gvfs"; let _ = fs::create_dir_all(mount_point); let mut test = FuseTest { From e98498e2537380cd1d0ececec18416f893e17c7d Mon Sep 17 00:00:00 2001 From: Yuhui Date: Fri, 3 Jan 2025 17:03:39 +0800 Subject: [PATCH 12/36] [#6012] feat (gvfs-fuse): Support Gravitino S3 fileset filesystem operation in gvfs fuse (#6013) ### What changes were proposed in this pull request? Support a Gravitino S3 fileset filesystem operation in gvfs fuse, implemented by OpenDal ### Why are the changes needed? Fix: #6012 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? Manually test --------- Co-authored-by: Qiming Teng --- clients/filesystem-fuse/Cargo.toml | 1 + clients/filesystem-fuse/conf/gvfs_fuse.toml | 6 +- clients/filesystem-fuse/src/config.rs | 6 +- .../src/default_raw_filesystem.rs | 98 ++++-- clients/filesystem-fuse/src/error.rs | 2 + clients/filesystem-fuse/src/filesystem.rs | 109 ++++--- .../filesystem-fuse/src/fuse_api_handle.rs | 12 +- .../filesystem-fuse/src/gravitino_client.rs | 76 +++++ .../src/gravitino_fileset_filesystem.rs | 57 +++- clients/filesystem-fuse/src/gvfs_creator.rs | 166 ++++++++++ clients/filesystem-fuse/src/gvfs_fuse.rs | 127 +------- clients/filesystem-fuse/src/lib.rs | 3 + clients/filesystem-fuse/src/main.rs | 32 +- .../filesystem-fuse/src/memory_filesystem.rs | 32 +- .../src/open_dal_filesystem.rs | 297 ++++++++++++++++++ clients/filesystem-fuse/src/opened_file.rs | 26 ++ clients/filesystem-fuse/src/s3_filesystem.rs | 276 ++++++++++++++++ clients/filesystem-fuse/src/utils.rs | 29 +- .../{gvfs_fuse_test.toml => config_test.toml} | 6 +- .../tests/conf/gvfs_fuse_memory.toml | 8 +- .../tests/conf/gvfs_fuse_s3.toml | 43 +++ clients/filesystem-fuse/tests/fuse_test.rs | 21 +- 22 files changed, 1202 insertions(+), 231 deletions(-) create mode 100644 clients/filesystem-fuse/src/gvfs_creator.rs create mode 100644 clients/filesystem-fuse/src/open_dal_filesystem.rs create mode 100644 clients/filesystem-fuse/src/s3_filesystem.rs rename clients/filesystem-fuse/tests/conf/{gvfs_fuse_test.toml => config_test.toml} (91%) create mode 100644 clients/filesystem-fuse/tests/conf/gvfs_fuse_s3.toml diff --git a/clients/filesystem-fuse/Cargo.toml b/clients/filesystem-fuse/Cargo.toml index 4008ec5ca2f..3760bd5285f 100644 --- a/clients/filesystem-fuse/Cargo.toml +++ b/clients/filesystem-fuse/Cargo.toml @@ -42,6 +42,7 @@ futures-util = "0.3.30" libc = "0.2.168" log = "0.4.22" once_cell = "1.20.2" +opendal = { version = "0.46.0", features = ["services-s3"] } reqwest = { version = "0.12.9", features = ["json"] } serde = { version = "1.0.216", features = ["derive"] } tokio = { version = "1.38.0", features = ["full"] } diff --git a/clients/filesystem-fuse/conf/gvfs_fuse.toml b/clients/filesystem-fuse/conf/gvfs_fuse.toml index 94d3d8560fd..4bde0e9e1bd 100644 --- a/clients/filesystem-fuse/conf/gvfs_fuse.toml +++ b/clients/filesystem-fuse/conf/gvfs_fuse.toml @@ -32,7 +32,7 @@ block_size = 8192 uri = "http://localhost:8090" metalake = "your_metalake" -# extent settings +# extend settings [extend_config] -access_key = "your access_key" -secret_key = "your_secret_key" +s3-access_key_id = "your access_key" +s3-secret_access_key = "your_secret_key" diff --git a/clients/filesystem-fuse/src/config.rs b/clients/filesystem-fuse/src/config.rs index b381caa75c5..17908fd08fc 100644 --- a/clients/filesystem-fuse/src/config.rs +++ b/clients/filesystem-fuse/src/config.rs @@ -302,18 +302,18 @@ mod test { #[test] fn test_config_from_file() { - let config = AppConfig::from_file(Some("tests/conf/gvfs_fuse_test.toml")).unwrap(); + let config = AppConfig::from_file(Some("tests/conf/config_test.toml")).unwrap(); assert_eq!(config.fuse.file_mask, 0o644); assert_eq!(config.fuse.dir_mask, 0o755); assert_eq!(config.filesystem.block_size, 8192); assert_eq!(config.gravitino.uri, "http://localhost:8090"); assert_eq!(config.gravitino.metalake, "test"); assert_eq!( - config.extend_config.get("access_key"), + config.extend_config.get("s3-access_key_id"), Some(&"XXX_access_key".to_string()) ); assert_eq!( - config.extend_config.get("secret_key"), + config.extend_config.get("s3-secret_access_key"), Some(&"XXX_secret_key".to_string()) ); } diff --git a/clients/filesystem-fuse/src/default_raw_filesystem.rs b/clients/filesystem-fuse/src/default_raw_filesystem.rs index 0c9836e5b33..944181246d5 100644 --- a/clients/filesystem-fuse/src/default_raw_filesystem.rs +++ b/clients/filesystem-fuse/src/default_raw_filesystem.rs @@ -18,10 +18,11 @@ */ use crate::config::AppConfig; use crate::filesystem::{ - FileStat, FileSystemContext, PathFileSystem, RawFileSystem, Result, INITIAL_FILE_ID, - ROOT_DIR_FILE_ID, ROOT_DIR_PARENT_FILE_ID, ROOT_DIR_PATH, + FileStat, FileSystemContext, PathFileSystem, RawFileSystem, Result, FS_META_FILE_ID, + FS_META_FILE_NAME, FS_META_FILE_PATH, INITIAL_FILE_ID, ROOT_DIR_FILE_ID, + ROOT_DIR_PARENT_FILE_ID, ROOT_DIR_PATH, }; -use crate::opened_file::{FileHandle, OpenFileFlags}; +use crate::opened_file::{FileHandle, OpenFileFlags, OpenedFile}; use crate::opened_file_manager::OpenedFileManager; use async_trait::async_trait; use bytes::Bytes; @@ -78,6 +79,7 @@ impl DefaultRawFileSystem { } async fn resolve_file_id_to_filestat(&self, file_stat: &mut FileStat, parent_file_id: u64) { + debug_assert!(parent_file_id != 0); let mut file_manager = self.file_entry_manager.write().await; let file_entry = file_manager.get_file_entry_by_path(&file_stat.path); match file_entry { @@ -132,6 +134,21 @@ impl DefaultRawFileSystem { let mut file_manager = self.file_entry_manager.write().await; file_manager.insert(parent_file_id, file_id, path); } + + fn get_meta_file_stat(&self) -> FileStat { + let mut meta_file_stat = + FileStat::new_file_filestat_with_path(Path::new(FS_META_FILE_PATH), 0); + meta_file_stat.set_file_id(ROOT_DIR_FILE_ID, FS_META_FILE_ID); + meta_file_stat + } + + fn is_meta_file(&self, file_id: u64) -> bool { + file_id == FS_META_FILE_ID + } + + fn is_meta_file_name(&self, parent_file_id: u64, name: &OsStr) -> bool { + parent_file_id == ROOT_DIR_FILE_ID && name == OsStr::new(FS_META_FILE_NAME) + } } #[async_trait] @@ -144,6 +161,13 @@ impl RawFileSystem for DefaultRawFileSystem { Path::new(ROOT_DIR_PATH), ) .await; + + self.insert_file_entry_locked( + ROOT_DIR_FILE_ID, + FS_META_FILE_ID, + Path::new(FS_META_FILE_PATH), + ) + .await; self.fs.init().await } @@ -168,6 +192,10 @@ impl RawFileSystem for DefaultRawFileSystem { } async fn stat(&self, file_id: u64) -> Result { + if self.is_meta_file(file_id) { + return Ok(self.get_meta_file_stat()); + } + let file_entry = self.get_file_entry(file_id).await?; let mut file_stat = self.fs.stat(&file_entry.path).await?; file_stat.set_file_id(file_entry.parent_file_id, file_entry.file_id); @@ -175,8 +203,11 @@ impl RawFileSystem for DefaultRawFileSystem { } async fn lookup(&self, parent_file_id: u64, name: &OsStr) -> Result { - let parent_file_entry = self.get_file_entry(parent_file_id).await?; + if self.is_meta_file_name(parent_file_id, name) { + return Ok(self.get_meta_file_stat()); + } + let parent_file_entry = self.get_file_entry(parent_file_id).await?; let path = parent_file_entry.path.join(name); let mut file_stat = self.fs.stat(&path).await?; // fill the file id to file stat @@ -192,10 +223,21 @@ impl RawFileSystem for DefaultRawFileSystem { for file_stat in child_filestats.iter_mut() { self.resolve_file_id_to_filestat(file_stat, file_id).await; } + + if file_id == ROOT_DIR_FILE_ID { + child_filestats.push(self.get_meta_file_stat()); + } Ok(child_filestats) } async fn open_file(&self, file_id: u64, flags: u32) -> Result { + if self.is_meta_file(file_id) { + let meta_file = OpenedFile::new(self.get_meta_file_stat()); + let resutl = self.opened_file_manager.put(meta_file); + let file = resutl.lock().await; + return Ok(file.file_handle()); + } + self.open_file_internal(file_id, flags, FileType::RegularFile) .await } @@ -211,6 +253,10 @@ impl RawFileSystem for DefaultRawFileSystem { name: &OsStr, flags: u32, ) -> Result { + if self.is_meta_file_name(parent_file_id, name) { + return Err(Errno::from(libc::EEXIST)); + } + let parent_file_entry = self.get_file_entry(parent_file_id).await?; let mut file_without_id = self .fs @@ -247,11 +293,19 @@ impl RawFileSystem for DefaultRawFileSystem { } async fn set_attr(&self, file_id: u64, file_stat: &FileStat) -> Result<()> { + if self.is_meta_file(file_id) { + return Ok(()); + } + let file_entry = self.get_file_entry(file_id).await?; self.fs.set_attr(&file_entry.path, file_stat, true).await } async fn remove_file(&self, parent_file_id: u64, name: &OsStr) -> Result<()> { + if self.is_meta_file_name(parent_file_id, name) { + return Err(Errno::from(libc::EPERM)); + } + let parent_file_entry = self.get_file_entry(parent_file_id).await?; let path = parent_file_entry.path.join(name); self.fs.remove_file(&path).await?; @@ -271,6 +325,15 @@ impl RawFileSystem for DefaultRawFileSystem { Ok(()) } + async fn flush_file(&self, _file_id: u64, fh: u64) -> Result<()> { + let opened_file = self + .opened_file_manager + .get(fh) + .ok_or(Errno::from(libc::EBADF))?; + let mut file = opened_file.lock().await; + file.flush().await + } + async fn close_file(&self, _file_id: u64, fh: u64) -> Result<()> { let opened_file = self .opened_file_manager @@ -280,7 +343,11 @@ impl RawFileSystem for DefaultRawFileSystem { file.close().await } - async fn read(&self, _file_id: u64, fh: u64, offset: u64, size: u32) -> Result { + async fn read(&self, file_id: u64, fh: u64, offset: u64, size: u32) -> Result { + if self.is_meta_file(file_id) { + return Ok(Bytes::new()); + } + let (data, file_stat) = { let opened_file = self .opened_file_manager @@ -297,7 +364,11 @@ impl RawFileSystem for DefaultRawFileSystem { data } - async fn write(&self, _file_id: u64, fh: u64, offset: u64, data: &[u8]) -> Result { + async fn write(&self, file_id: u64, fh: u64, offset: u64, data: &[u8]) -> Result { + if self.is_meta_file(file_id) { + return Err(Errno::from(libc::EPERM)); + } + let (len, file_stat) = { let opened_file = self .opened_file_manager @@ -368,8 +439,6 @@ impl FileEntryManager { #[cfg(test)] mod tests { use super::*; - use crate::filesystem::tests::TestRawFileSystem; - use crate::memory_filesystem::MemoryFileSystem; #[test] fn test_file_entry_manager() { @@ -389,17 +458,4 @@ mod tests { assert!(manager.get_file_entry_by_id(2).is_none()); assert!(manager.get_file_entry_by_path(Path::new("a/b")).is_none()); } - - #[tokio::test] - async fn test_default_raw_file_system() { - let memory_fs = MemoryFileSystem::new().await; - let raw_fs = DefaultRawFileSystem::new( - memory_fs, - &AppConfig::default(), - &FileSystemContext::default(), - ); - let _ = raw_fs.init().await; - let mut tester = TestRawFileSystem::new(raw_fs); - tester.test_raw_file_system().await; - } } diff --git a/clients/filesystem-fuse/src/error.rs b/clients/filesystem-fuse/src/error.rs index ba3c037c5ca..7e38e46874c 100644 --- a/clients/filesystem-fuse/src/error.rs +++ b/clients/filesystem-fuse/src/error.rs @@ -24,6 +24,7 @@ pub enum ErrorCode { GravitinoClientError, InvalidConfig, ConfigNotFound, + OpenDalError, } impl ErrorCode { @@ -39,6 +40,7 @@ impl std::fmt::Display for ErrorCode { ErrorCode::GravitinoClientError => write!(f, "Gravitino client error"), ErrorCode::InvalidConfig => write!(f, "Invalid config"), ErrorCode::ConfigNotFound => write!(f, "Config not found"), + ErrorCode::OpenDalError => write!(f, "OpenDal error"), } } } diff --git a/clients/filesystem-fuse/src/filesystem.rs b/clients/filesystem-fuse/src/filesystem.rs index 742cdd4c879..dcf35f8ebca 100644 --- a/clients/filesystem-fuse/src/filesystem.rs +++ b/clients/filesystem-fuse/src/filesystem.rs @@ -36,6 +36,11 @@ pub(crate) const ROOT_DIR_NAME: &str = ""; pub(crate) const ROOT_DIR_PATH: &str = "/"; pub(crate) const INITIAL_FILE_ID: u64 = 10000; +// File system meta file is indicated the fuse filesystem is active. +pub(crate) const FS_META_FILE_PATH: &str = "/.gvfs_meta"; +pub(crate) const FS_META_FILE_NAME: &str = ".gvfs_meta"; +pub(crate) const FS_META_FILE_ID: u64 = 10; + /// RawFileSystem interface for the file system implementation. it use by FuseApiHandle, /// it ues the file id to operate the file system apis /// the `file_id` and `parent_file_id` it is the unique identifier for the file system, @@ -89,6 +94,9 @@ pub(crate) trait RawFileSystem: Send + Sync { /// Remove the directory by parent file id and file name async fn remove_dir(&self, parent_file_id: u64, name: &OsStr) -> Result<()>; + /// flush the file with file id and file handle, if successful return Ok + async fn flush_file(&self, file_id: u64, fh: u64) -> Result<()>; + /// Close the file by file id and file handle, if successful async fn close_file(&self, file_id: u64, fh: u64) -> Result<()>; @@ -289,57 +297,53 @@ pub trait FileWriter: Sync + Send { #[cfg(test)] pub(crate) mod tests { use super::*; + use libc::{O_APPEND, O_CREAT, O_RDONLY}; use std::collections::HashMap; + use std::path::Component; pub(crate) struct TestPathFileSystem { files: HashMap, fs: F, + cwd: PathBuf, } impl TestPathFileSystem { - pub(crate) fn new(fs: F) -> Self { + pub(crate) fn new(cwd: &Path, fs: F) -> Self { Self { files: HashMap::new(), fs, + cwd: cwd.into(), } } pub(crate) async fn test_path_file_system(&mut self) { - // Test root dir - self.test_root_dir().await; + // test root dir + let resutl = self.fs.stat(Path::new("/")).await; + assert!(resutl.is_ok()); + let root_file_stat = resutl.unwrap(); + self.assert_file_stat(&root_file_stat, Path::new("/"), Directory, 0); - // Test stat file - self.test_stat_file(Path::new("/.gvfs_meta"), RegularFile, 0) - .await; + // test list root dir + let result = self.fs.read_dir(Path::new("/")).await; + assert!(result.is_ok()); // Test create file - self.test_create_file(Path::new("/file1.txt")).await; + self.test_create_file(&self.cwd.join("file1.txt")).await; // Test create dir - self.test_create_dir(Path::new("/dir1")).await; + self.test_create_dir(&self.cwd.join("dir1")).await; // Test list dir - self.test_list_dir(Path::new("/")).await; + self.test_list_dir(&self.cwd).await; // Test remove file - self.test_remove_file(Path::new("/file1.txt")).await; + self.test_remove_file(&self.cwd.join("file1.txt")).await; // Test remove dir - self.test_remove_dir(Path::new("/dir1")).await; + self.test_remove_dir(&self.cwd.join("dir1")).await; // Test file not found - self.test_file_not_found(Path::new("unknown")).await; - - // Test list dir - self.test_list_dir(Path::new("/")).await; - } - - async fn test_root_dir(&mut self) { - let root_dir_path = Path::new("/"); - let root_file_stat = self.fs.stat(root_dir_path).await; - assert!(root_file_stat.is_ok()); - let root_file_stat = root_file_stat.unwrap(); - self.assert_file_stat(&root_file_stat, root_dir_path, Directory, 0); + self.test_file_not_found(&self.cwd.join("unknown")).await; } async fn test_stat_file(&mut self, path: &Path, expect_kind: FileType, expect_size: u64) { @@ -370,7 +374,6 @@ pub(crate) mod tests { let list_dir = self.fs.read_dir(path).await; assert!(list_dir.is_ok()); let list_dir = list_dir.unwrap(); - assert_eq!(list_dir.len(), self.files.len()); for file_stat in list_dir { assert!(self.files.contains_key(&file_stat.path)); let actual_file_stat = self.files.get(&file_stat.path).unwrap(); @@ -414,13 +417,15 @@ pub(crate) mod tests { pub(crate) struct TestRawFileSystem { fs: F, files: HashMap, + cwd: PathBuf, } impl TestRawFileSystem { - pub(crate) fn new(fs: F) -> Self { + pub(crate) fn new(cwd: &Path, fs: F) -> Self { Self { fs, files: HashMap::new(), + cwd: cwd.into(), } } @@ -431,31 +436,45 @@ pub(crate) mod tests { // test read root dir self.test_list_dir(ROOT_DIR_FILE_ID, false).await; - let parent_file_id = ROOT_DIR_FILE_ID; - // Test lookup file + // Test lookup meta file let file_id = self - .test_lookup_file(parent_file_id, ".gvfs_meta".as_ref(), RegularFile, 0) + .test_lookup_file(ROOT_DIR_FILE_ID, ".gvfs_meta".as_ref(), RegularFile, 0) .await; - // Test get file stat + // Test get meta file stat self.test_stat_file(file_id, Path::new("/.gvfs_meta"), RegularFile, 0) .await; // Test get file path self.test_get_file_path(file_id, "/.gvfs_meta").await; - // Test create file - self.test_create_file(parent_file_id, "file1.txt".as_ref()) - .await; + // get cwd file id + let mut parent_file_id = ROOT_DIR_FILE_ID; + for child in self.cwd.components() { + if child == Component::RootDir { + continue; + } + let file_id = self.fs.create_dir(parent_file_id, child.as_os_str()).await; + assert!(file_id.is_ok()); + parent_file_id = file_id.unwrap(); + } - // Test open file + // Test create file let file_handle = self - .test_open_file(parent_file_id, "file1.txt".as_ref()) + .test_create_file(parent_file_id, "file1.txt".as_ref()) .await; // Test write file self.test_write_file(&file_handle, "test").await; + // Test close file + self.test_close_file(&file_handle).await; + + // Test open file with read + let file_handle = self + .test_open_file(parent_file_id, "file1.txt".as_ref(), O_RDONLY as u32) + .await; + // Test read file self.test_read_file(&file_handle, "test").await; @@ -526,8 +545,11 @@ pub(crate) mod tests { self.files.insert(file_stat.file_id, file_stat); } - async fn test_create_file(&mut self, root_file_id: u64, name: &OsStr) { - let file = self.fs.create_file(root_file_id, name, 0).await; + async fn test_create_file(&mut self, root_file_id: u64, name: &OsStr) -> FileHandle { + let file = self + .fs + .create_file(root_file_id, name, (O_CREAT | O_APPEND) as u32) + .await; assert!(file.is_ok()); let file = file.unwrap(); assert!(file.handle_id > 0); @@ -537,11 +559,12 @@ pub(crate) mod tests { self.test_stat_file(file.file_id, &file_stat.unwrap().path, RegularFile, 0) .await; + file } - async fn test_open_file(&self, root_file_id: u64, name: &OsStr) -> FileHandle { + async fn test_open_file(&self, root_file_id: u64, name: &OsStr, flags: u32) -> FileHandle { let file = self.fs.lookup(root_file_id, name).await.unwrap(); - let file_handle = self.fs.open_file(file.file_id, 0).await; + let file_handle = self.fs.open_file(file.file_id, flags).await; assert!(file_handle.is_ok()); let file_handle = file_handle.unwrap(); assert_eq!(file_handle.file_id, file.file_id); @@ -558,9 +581,16 @@ pub(crate) mod tests { content.as_bytes(), ) .await; + assert!(write_size.is_ok()); assert_eq!(write_size.unwrap(), content.len() as u32); + let result = self + .fs + .flush_file(file_handle.file_id, file_handle.handle_id) + .await; + assert!(result.is_ok()); + self.files.get_mut(&file_handle.file_id).unwrap().size = content.len() as u64; } @@ -606,7 +636,6 @@ pub(crate) mod tests { if !check_child { return; } - assert_eq!(list_dir.len(), self.files.len()); for file_stat in list_dir { assert!(self.files.contains_key(&file_stat.file_id)); let actual_file_stat = self.files.get(&file_stat.file_id).unwrap(); @@ -652,7 +681,7 @@ pub(crate) mod tests { assert_eq!(file_stat.path, path); assert_eq!(file_stat.kind, kind); assert_eq!(file_stat.size, size); - if file_stat.file_id == 1 { + if file_stat.file_id == ROOT_DIR_FILE_ID || file_stat.file_id == FS_META_FILE_ID { assert_eq!(file_stat.parent_file_id, 1); } else { assert!(file_stat.file_id >= INITIAL_FILE_ID); diff --git a/clients/filesystem-fuse/src/fuse_api_handle.rs b/clients/filesystem-fuse/src/fuse_api_handle.rs index 153e323891c..15679a222bd 100644 --- a/clients/filesystem-fuse/src/fuse_api_handle.rs +++ b/clients/filesystem-fuse/src/fuse_api_handle.rs @@ -227,7 +227,7 @@ impl Filesystem for FuseApiHandle { async fn release( &self, - _eq: Request, + _req: Request, inode: Inode, fh: u64, _flags: u32, @@ -237,6 +237,16 @@ impl Filesystem for FuseApiHandle { self.fs.close_file(inode, fh).await } + async fn flush( + &self, + _req: Request, + inode: Inode, + fh: u64, + _lock_owner: u64, + ) -> fuse3::Result<()> { + self.fs.flush_file(inode, fh).await + } + async fn opendir(&self, _req: Request, inode: Inode, flags: u32) -> fuse3::Result { let file_handle = self.fs.open_dir(inode, flags).await?; Ok(ReplyOpen { diff --git a/clients/filesystem-fuse/src/gravitino_client.rs b/clients/filesystem-fuse/src/gravitino_client.rs index e5553c9f6c8..9bdfbb2c288 100644 --- a/clients/filesystem-fuse/src/gravitino_client.rs +++ b/clients/filesystem-fuse/src/gravitino_client.rs @@ -48,6 +48,22 @@ struct FileLocationResponse { location: String, } +#[derive(Debug, Deserialize)] +pub(crate) struct Catalog { + pub(crate) name: String, + #[serde(rename = "type")] + pub(crate) catalog_type: String, + provider: String, + comment: String, + pub(crate) properties: HashMap, +} + +#[derive(Debug, Deserialize)] +struct CatalogResponse { + code: u32, + catalog: Catalog, +} + pub(crate) struct GravitinoClient { gravitino_uri: String, metalake: String, @@ -105,6 +121,26 @@ impl GravitinoClient { Ok(res) } + pub async fn get_catalog_url(&self, catalog_name: &str) -> String { + format!( + "{}/api/metalakes/{}/catalogs/{}", + self.gravitino_uri, self.metalake, catalog_name + ) + } + + pub async fn get_catalog(&self, catalog_name: &str) -> Result { + let url = self.get_catalog_url(catalog_name).await; + let res = self.do_get::(&url).await?; + + if res.code != 0 { + return Err(GvfsError::Error( + ErrorCode::GravitinoClientError, + "Failed to get catalog".to_string(), + )); + } + Ok(res.catalog) + } + pub async fn get_fileset( &self, catalog_name: &str, @@ -257,6 +293,46 @@ mod tests { } } + #[tokio::test] + async fn test_get_catalog_success() { + let catalog_response = r#" + { + "code": 0, + "catalog": { + "name": "example_catalog", + "type": "example_type", + "provider": "example_provider", + "comment": "This is a test catalog", + "properties": { + "key1": "value1", + "key2": "value2" + } + } + }"#; + + let mock_server_url = &mockito::server_url(); + + let url = format!("/api/metalakes/{}/catalogs/{}", "test", "catalog1"); + let _m = mock("GET", url.as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(catalog_response) + .create(); + + let config = GravitinoConfig { + uri: mock_server_url.to_string(), + metalake: "test".to_string(), + }; + let client = GravitinoClient::new(&config); + + let result = client.get_catalog("catalog1").await; + + match result { + Ok(_) => {} + Err(e) => panic!("Expected Ok, but got Err: {:?}", e), + } + } + async fn get_fileset_example() { tracing_subscriber::fmt::init(); let config = GravitinoConfig { diff --git a/clients/filesystem-fuse/src/gravitino_fileset_filesystem.rs b/clients/filesystem-fuse/src/gravitino_fileset_filesystem.rs index 98a295dbb87..7da2f572dcc 100644 --- a/clients/filesystem-fuse/src/gravitino_fileset_filesystem.rs +++ b/clients/filesystem-fuse/src/gravitino_fileset_filesystem.rs @@ -30,13 +30,15 @@ use std::path::{Path, PathBuf}; pub(crate) struct GravitinoFilesetFileSystem { physical_fs: Box, client: GravitinoClient, - fileset_location: PathBuf, + // location is a absolute path in the physical filesystem that is associated with the fileset. + // e.g. fileset location : s3://bucket/path/to/file the location is /path/to/file + location: PathBuf, } impl GravitinoFilesetFileSystem { pub async fn new( fs: Box, - location: &Path, + target_path: &Path, client: GravitinoClient, _config: &AppConfig, _context: &FileSystemContext, @@ -44,18 +46,25 @@ impl GravitinoFilesetFileSystem { Self { physical_fs: fs, client: client, - fileset_location: location.into(), + location: target_path.into(), } } fn gvfs_path_to_raw_path(&self, path: &Path) -> PathBuf { - self.fileset_location.join(path) + let relation_path = path.strip_prefix("/").expect("path should start with /"); + if relation_path == Path::new("") { + return self.location.clone(); + } + self.location.join(relation_path) } fn raw_path_to_gvfs_path(&self, path: &Path) -> Result { - path.strip_prefix(&self.fileset_location) + let stripped_path = path + .strip_prefix(&self.location) .map_err(|_| Errno::from(libc::EBADF))?; - Ok(path.into()) + let mut result_path = PathBuf::from("/"); + result_path.push(stripped_path); + Ok(result_path) } } @@ -128,3 +137,39 @@ impl PathFileSystem for GravitinoFilesetFileSystem { self.physical_fs.get_capacity() } } + +#[cfg(test)] +mod tests { + use crate::config::GravitinoConfig; + use crate::gravitino_fileset_filesystem::GravitinoFilesetFileSystem; + use crate::memory_filesystem::MemoryFileSystem; + use std::path::Path; + + #[tokio::test] + async fn test_map_fileset_path_to_raw_path() { + let fs = GravitinoFilesetFileSystem { + physical_fs: Box::new(MemoryFileSystem::new().await), + client: super::GravitinoClient::new(&GravitinoConfig::default()), + location: "/c1/fileset1".into(), + }; + let path = fs.gvfs_path_to_raw_path(Path::new("/a")); + assert_eq!(path, Path::new("/c1/fileset1/a")); + let path = fs.gvfs_path_to_raw_path(Path::new("/")); + assert_eq!(path, Path::new("/c1/fileset1")); + } + + #[tokio::test] + async fn test_map_raw_path_to_fileset_path() { + let fs = GravitinoFilesetFileSystem { + physical_fs: Box::new(MemoryFileSystem::new().await), + client: super::GravitinoClient::new(&GravitinoConfig::default()), + location: "/c1/fileset1".into(), + }; + let path = fs + .raw_path_to_gvfs_path(Path::new("/c1/fileset1/a")) + .unwrap(); + assert_eq!(path, Path::new("/a")); + let path = fs.raw_path_to_gvfs_path(Path::new("/c1/fileset1")).unwrap(); + assert_eq!(path, Path::new("/")); + } +} diff --git a/clients/filesystem-fuse/src/gvfs_creator.rs b/clients/filesystem-fuse/src/gvfs_creator.rs new file mode 100644 index 00000000000..aac88ad9d08 --- /dev/null +++ b/clients/filesystem-fuse/src/gvfs_creator.rs @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::config::AppConfig; +use crate::error::ErrorCode::{InvalidConfig, UnSupportedFilesystem}; +use crate::filesystem::{FileSystemContext, PathFileSystem}; +use crate::gravitino_client::{Catalog, Fileset, GravitinoClient}; +use crate::gravitino_fileset_filesystem::GravitinoFilesetFileSystem; +use crate::gvfs_fuse::{CreateFileSystemResult, FileSystemSchema}; +use crate::s3_filesystem::S3FileSystem; +use crate::utils::{extract_root_path, parse_location, GvfsResult}; + +const GRAVITINO_FILESET_SCHEMA: &str = "gvfs"; + +pub async fn create_gvfs_filesystem( + mount_from: &str, + config: &AppConfig, + fs_context: &FileSystemContext, +) -> GvfsResult { + // Gvfs-fuse filesystem structure: + // FuseApiHandle + // ├─ DefaultRawFileSystem (RawFileSystem) + // │ └─ FileSystemLog (PathFileSystem) + // │ ├─ GravitinoComposedFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ S3FileSystem (PathFileSystem) + // │ │ │ └─ OpenDALFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ HDFSFileSystem (PathFileSystem) + // │ │ │ └─ OpenDALFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ JuiceFileSystem (PathFileSystem) + // │ │ │ └─ NasFileSystem (PathFileSystem) + // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) + // │ │ │ └─ XXXFileSystem (PathFileSystem) + // + // `SimpleFileSystem` is a low-level filesystem designed to communicate with FUSE APIs. + // It manages file and directory relationships, as well as file mappings. + // It delegates file operations to the PathFileSystem + // + // `FileSystemLog` is a decorator that adds extra debug logging functionality to file system APIs. + // Similar implementations include permissions, caching, and metrics. + // + // `GravitinoComposeFileSystem` is a composite file system that can combine multiple `GravitinoFilesetFileSystem`. + // It use the part of catalog and schema of fileset path to a find actual GravitinoFilesetFileSystem. delegate the operation to the real storage. + // If the user only mounts a fileset, this layer is not present. There will only be one below layer. + // + // `GravitinoFilesetFileSystem` is a file system that can access a fileset.It translates the fileset path to the real storage path. + // and delegate the operation to the real storage. + // + // `OpenDALFileSystem` is a file system that use the OpenDAL to access real storage. + // it can assess the S3, HDFS, gcs, azblob and other storage. + // + // `S3FileSystem` is a file system that use `OpenDALFileSystem` to access S3 storage. + // + // `HDFSFileSystem` is a file system that use `OpenDALFileSystem` to access HDFS storage. + // + // `NasFileSystem` is a filesystem that uses a locally accessible path mounted by NAS tools, such as JuiceFS. + // + // `JuiceFileSystem` is a file that use `NasFileSystem` to access JuiceFS storage. + // + // `XXXFileSystem is a filesystem that allows you to implement file access through your own extensions. + + let client = GravitinoClient::new(&config.gravitino); + + let (catalog_name, schema_name, fileset_name) = extract_fileset(mount_from)?; + let catalog = client.get_catalog(&catalog_name).await?; + if catalog.catalog_type != "fileset" { + return Err(InvalidConfig.to_error(format!("Catalog {} is not a fileset", catalog_name))); + } + let fileset = client + .get_fileset(&catalog_name, &schema_name, &fileset_name) + .await?; + + let inner_fs = create_fs_with_fileset(&catalog, &fileset, config, fs_context)?; + + let target_path = extract_root_path(fileset.storage_location.as_str())?; + let fs = + GravitinoFilesetFileSystem::new(inner_fs, &target_path, client, config, fs_context).await; + Ok(CreateFileSystemResult::Gvfs(fs)) +} + +fn create_fs_with_fileset( + catalog: &Catalog, + fileset: &Fileset, + config: &AppConfig, + fs_context: &FileSystemContext, +) -> GvfsResult> { + let schema = extract_filesystem_scheme(&fileset.storage_location)?; + + match schema { + FileSystemSchema::S3 => Ok(Box::new(S3FileSystem::new( + catalog, fileset, config, fs_context, + )?)), + } +} + +pub fn extract_fileset(path: &str) -> GvfsResult<(String, String, String)> { + let path = parse_location(path)?; + + if path.scheme() != GRAVITINO_FILESET_SCHEMA { + return Err(InvalidConfig.to_error(format!("Invalid fileset schema: {}", path))); + } + + let split = path.path_segments(); + if split.is_none() { + return Err(InvalidConfig.to_error(format!("Invalid fileset path: {}", path))); + } + let split = split.unwrap().collect::>(); + if split.len() != 4 { + return Err(InvalidConfig.to_error(format!("Invalid fileset path: {}", path))); + } + + let catalog = split[1].to_string(); + let schema = split[2].to_string(); + let fileset = split[3].to_string(); + Ok((catalog, schema, fileset)) +} + +pub fn extract_filesystem_scheme(path: &str) -> GvfsResult { + let url = parse_location(path)?; + let scheme = url.scheme(); + + match scheme { + "s3" => Ok(FileSystemSchema::S3), + "s3a" => Ok(FileSystemSchema::S3), + _ => Err(UnSupportedFilesystem.to_error(format!("Invalid storage schema: {}", path))), + } +} + +#[cfg(test)] +mod tests { + use crate::gvfs_creator::extract_fileset; + use crate::gvfs_fuse::FileSystemSchema; + + #[test] + fn test_extract_fileset() { + let location = "gvfs://fileset/test/c1/s1/fileset1"; + let (catalog, schema, fileset) = extract_fileset(location).unwrap(); + assert_eq!(catalog, "c1"); + assert_eq!(schema, "s1"); + assert_eq!(fileset, "fileset1"); + } + + #[test] + fn test_extract_schema() { + let location = "s3://bucket/path/to/file"; + let schema = super::extract_filesystem_scheme(location).unwrap(); + assert_eq!(schema, FileSystemSchema::S3); + } +} diff --git a/clients/filesystem-fuse/src/gvfs_fuse.rs b/clients/filesystem-fuse/src/gvfs_fuse.rs index d472895d2b3..88079e99b91 100644 --- a/clients/filesystem-fuse/src/gvfs_fuse.rs +++ b/clients/filesystem-fuse/src/gvfs_fuse.rs @@ -18,22 +18,19 @@ */ use crate::config::AppConfig; use crate::default_raw_filesystem::DefaultRawFileSystem; -use crate::error::ErrorCode::{InvalidConfig, UnSupportedFilesystem}; +use crate::error::ErrorCode::UnSupportedFilesystem; use crate::filesystem::FileSystemContext; use crate::fuse_api_handle::FuseApiHandle; use crate::fuse_server::FuseServer; -use crate::gravitino_client::GravitinoClient; use crate::gravitino_fileset_filesystem::GravitinoFilesetFileSystem; +use crate::gvfs_creator::create_gvfs_filesystem; use crate::memory_filesystem::MemoryFileSystem; use crate::utils::GvfsResult; use log::info; use once_cell::sync::Lazy; -use std::path::Path; use std::sync::Arc; use tokio::sync::Mutex; -const FILESET_PREFIX: &str = "gvfs://fileset/"; - static SERVER: Lazy>>> = Lazy::new(|| Mutex::new(None)); pub(crate) enum CreateFileSystemResult { @@ -44,6 +41,7 @@ pub(crate) enum CreateFileSystemResult { None, } +#[derive(Debug, PartialEq)] pub enum FileSystemSchema { S3, } @@ -65,7 +63,7 @@ pub async fn mount(mount_to: &str, mount_from: &str, config: &AppConfig) -> Gvfs } pub async fn unmount() -> GvfsResult<()> { - info!("Stop gvfs-fuse server..."); + info!("Stopping gvfs-fuse server..."); let svr = { let mut server = SERVER.lock().await; if server.is_none() { @@ -127,120 +125,3 @@ pub async fn create_path_fs( create_gvfs_filesystem(mount_from, config, fs_context).await } } - -pub async fn create_gvfs_filesystem( - mount_from: &str, - config: &AppConfig, - fs_context: &FileSystemContext, -) -> GvfsResult { - // Gvfs-fuse filesystem structure: - // FuseApiHandle - // ├─ DefaultRawFileSystem (RawFileSystem) - // │ └─ FileSystemLog (PathFileSystem) - // │ ├─ GravitinoComposedFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ S3FileSystem (PathFileSystem) - // │ │ │ └─ OpenDALFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ HDFSFileSystem (PathFileSystem) - // │ │ │ └─ OpenDALFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ JuiceFileSystem (PathFileSystem) - // │ │ │ └─ NasFileSystem (PathFileSystem) - // │ │ ├─ GravitinoFilesetFileSystem (PathFileSystem) - // │ │ │ └─ XXXFileSystem (PathFileSystem) - // - // `SimpleFileSystem` is a low-level filesystem designed to communicate with FUSE APIs. - // It manages file and directory relationships, as well as file mappings. - // It delegates file operations to the PathFileSystem - // - // `FileSystemLog` is a decorator that adds extra debug logging functionality to file system APIs. - // Similar implementations include permissions, caching, and metrics. - // - // `GravitinoComposeFileSystem` is a composite file system that can combine multiple `GravitinoFilesetFileSystem`. - // It use the part of catalog and schema of fileset path to a find actual GravitinoFilesetFileSystem. delegate the operation to the real storage. - // If the user only mounts a fileset, this layer is not present. There will only be one below layer. - // - // `GravitinoFilesetFileSystem` is a file system that can access a fileset.It translates the fileset path to the real storage path. - // and delegate the operation to the real storage. - // - // `OpenDALFileSystem` is a file system that use the OpenDAL to access real storage. - // it can assess the S3, HDFS, gcs, azblob and other storage. - // - // `S3FileSystem` is a file system that use `OpenDALFileSystem` to access S3 storage. - // - // `HDFSFileSystem` is a file system that use `OpenDALFileSystem` to access HDFS storage. - // - // `NasFileSystem` is a filesystem that uses a locally accessible path mounted by NAS tools, such as JuiceFS. - // - // `JuiceFileSystem` is a file that use `NasFileSystem` to access JuiceFS storage. - // - // `XXXFileSystem is a filesystem that allows you to implement file access through your own extensions. - - let client = GravitinoClient::new(&config.gravitino); - - let (catalog, schema, fileset) = extract_fileset(mount_from)?; - let location = client - .get_fileset(&catalog, &schema, &fileset) - .await? - .storage_location; - let (_schema, location) = extract_storage_filesystem(&location).unwrap(); - - // todo need to replace the inner filesystem with the real storage filesystem - let inner_fs = MemoryFileSystem::new().await; - - let fs = GravitinoFilesetFileSystem::new( - Box::new(inner_fs), - Path::new(&location), - client, - config, - fs_context, - ) - .await; - Ok(CreateFileSystemResult::Gvfs(fs)) -} - -pub fn extract_fileset(path: &str) -> GvfsResult<(String, String, String)> { - if !path.starts_with(FILESET_PREFIX) { - return Err(InvalidConfig.to_error("Invalid fileset path".to_string())); - } - - let path_without_prefix = &path[FILESET_PREFIX.len()..]; - - let parts: Vec<&str> = path_without_prefix.split('/').collect(); - - if parts.len() != 3 { - return Err(InvalidConfig.to_error("Invalid fileset path".to_string())); - } - // todo handle mount catalog or schema - - let catalog = parts[1].to_string(); - let schema = parts[2].to_string(); - let fileset = parts[3].to_string(); - - Ok((catalog, schema, fileset)) -} - -pub fn extract_storage_filesystem(path: &str) -> Option<(FileSystemSchema, String)> { - // todo need to improve the logic - if let Some(pos) = path.find("://") { - let protocol = &path[..pos]; - let location = &path[pos + 3..]; - let location = match location.find('/') { - Some(index) => &location[index + 1..], - None => "", - }; - let location = match location.ends_with('/') { - true => location.to_string(), - false => format!("{}/", location), - }; - - match protocol { - "s3" => Some((FileSystemSchema::S3, location.to_string())), - "s3a" => Some((FileSystemSchema::S3, location.to_string())), - _ => None, - } - } else { - None - } -} diff --git a/clients/filesystem-fuse/src/lib.rs b/clients/filesystem-fuse/src/lib.rs index 5532d619e5c..31e7c7fd8e1 100644 --- a/clients/filesystem-fuse/src/lib.rs +++ b/clients/filesystem-fuse/src/lib.rs @@ -27,10 +27,13 @@ mod fuse_api_handle; mod fuse_server; mod gravitino_client; mod gravitino_fileset_filesystem; +mod gvfs_creator; mod gvfs_fuse; mod memory_filesystem; +mod open_dal_filesystem; mod opened_file; mod opened_file_manager; +mod s3_filesystem; mod utils; pub async fn gvfs_mount(mount_to: &str, mount_from: &str, config: &AppConfig) -> GvfsResult<()> { diff --git a/clients/filesystem-fuse/src/main.rs b/clients/filesystem-fuse/src/main.rs index 8eab5ec0d51..3534e033465 100644 --- a/clients/filesystem-fuse/src/main.rs +++ b/clients/filesystem-fuse/src/main.rs @@ -26,21 +26,37 @@ use tokio::signal; async fn main() -> fuse3::Result<()> { tracing_subscriber::fmt().init(); + // todo need inmprove the args parsing + let args: Vec = std::env::args().collect(); + let (mount_point, mount_from, config_path) = match args.len() { + 4 => (args[1].clone(), args[2].clone(), args[3].clone()), + _ => { + error!("Usage: {} ", args[0]); + return Err(Errno::from(libc::EINVAL)); + } + }; + //todo(read config file from args) - let config = AppConfig::from_file(Some("conf/gvfs_fuse.toml")); + let config = AppConfig::from_file(Some(&config_path)); if let Err(e) = &config { error!("Failed to load config: {:?}", e); return Err(Errno::from(libc::EINVAL)); } let config = config.unwrap(); - let handle = tokio::spawn(async move { gvfs_mount("gvfs", "", &config).await }); - - let _ = signal::ctrl_c().await; - info!("Received Ctrl+C, Unmounting gvfs..."); + let handle = tokio::spawn(async move { + let result = gvfs_mount(&mount_point, &mount_from, &config).await; + if let Err(e) = result { + error!("Failed to mount gvfs: {:?}", e); + return Err(Errno::from(libc::EINVAL)); + } + Ok(()) + }); - if let Err(e) = handle.await { - error!("Failed to mount gvfs: {:?}", e); - return Err(Errno::from(libc::EINVAL)); + tokio::select! { + _ = handle => {} + _ = signal::ctrl_c() => { + info!("Received Ctrl+C, unmounting gvfs..."); + } } let _ = gvfs_unmount().await; diff --git a/clients/filesystem-fuse/src/memory_filesystem.rs b/clients/filesystem-fuse/src/memory_filesystem.rs index b94d16b8d39..f56e65ea33a 100644 --- a/clients/filesystem-fuse/src/memory_filesystem.rs +++ b/clients/filesystem-fuse/src/memory_filesystem.rs @@ -42,8 +42,6 @@ pub struct MemoryFileSystem { } impl MemoryFileSystem { - const FS_META_FILE_NAME: &'static str = "/.gvfs_meta"; - pub(crate) async fn new() -> Self { Self { file_map: RwLock::new(Default::default()), @@ -69,16 +67,6 @@ impl PathFileSystem for MemoryFileSystem { }; let root_path = PathBuf::from("/"); self.file_map.write().unwrap().insert(root_path, root_file); - - let meta_file = MemoryFile { - kind: RegularFile, - data: Arc::new(Mutex::new(Vec::new())), - }; - let meta_file_path = Path::new(Self::FS_META_FILE_NAME).to_path_buf(); - self.file_map - .write() - .unwrap() - .insert(meta_file_path, meta_file); Ok(()) } @@ -248,7 +236,10 @@ fn path_in_dir(dir: &Path, path: &Path) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::filesystem::tests::TestPathFileSystem; + use crate::config::AppConfig; + use crate::default_raw_filesystem::DefaultRawFileSystem; + use crate::filesystem::tests::{TestPathFileSystem, TestRawFileSystem}; + use crate::filesystem::{FileSystemContext, RawFileSystem}; #[test] fn test_path_in_dir() { @@ -281,7 +272,20 @@ mod tests { async fn test_memory_file_system() { let fs = MemoryFileSystem::new().await; let _ = fs.init().await; - let mut tester = TestPathFileSystem::new(fs); + let mut tester = TestPathFileSystem::new(Path::new("/ab"), fs); tester.test_path_file_system().await; } + + #[tokio::test] + async fn test_memory_file_system_with_raw_file_system() { + let memory_fs = MemoryFileSystem::new().await; + let raw_fs = DefaultRawFileSystem::new( + memory_fs, + &AppConfig::default(), + &FileSystemContext::default(), + ); + let _ = raw_fs.init().await; + let mut tester = TestRawFileSystem::new(Path::new("/ab"), raw_fs); + tester.test_raw_file_system().await; + } } diff --git a/clients/filesystem-fuse/src/open_dal_filesystem.rs b/clients/filesystem-fuse/src/open_dal_filesystem.rs new file mode 100644 index 00000000000..e53fbaf6032 --- /dev/null +++ b/clients/filesystem-fuse/src/open_dal_filesystem.rs @@ -0,0 +1,297 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::config::AppConfig; +use crate::filesystem::{ + FileReader, FileStat, FileSystemCapacity, FileSystemContext, FileWriter, PathFileSystem, Result, +}; +use crate::opened_file::{OpenFileFlags, OpenedFile}; +use async_trait::async_trait; +use bytes::Bytes; +use fuse3::FileType::{Directory, RegularFile}; +use fuse3::{Errno, FileType, Timestamp}; +use log::error; +use opendal::{EntryMode, ErrorKind, Metadata, Operator}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +pub(crate) struct OpenDalFileSystem { + op: Operator, +} + +impl OpenDalFileSystem {} + +impl OpenDalFileSystem { + pub(crate) fn new(op: Operator, _config: &AppConfig, _fs_context: &FileSystemContext) -> Self { + Self { op: op } + } + + fn opendal_meta_to_file_stat(&self, meta: &Metadata, file_stat: &mut FileStat) { + let now = SystemTime::now(); + let mtime = meta.last_modified().map(|x| x.into()).unwrap_or(now); + + file_stat.size = meta.content_length(); + file_stat.kind = opendal_filemode_to_filetype(meta.mode()); + file_stat.ctime = Timestamp::from(mtime); + file_stat.atime = Timestamp::from(now); + file_stat.mtime = Timestamp::from(mtime); + } +} + +#[async_trait] +impl PathFileSystem for OpenDalFileSystem { + async fn init(&self) -> Result<()> { + Ok(()) + } + + async fn stat(&self, path: &Path) -> Result { + let file_name = path.to_string_lossy().to_string(); + let meta_result = self.op.stat(&file_name).await; + + // path may be a directory, so try to stat it as a directory + let meta = match meta_result { + Ok(meta) => meta, + Err(err) => { + if err.kind() == ErrorKind::NotFound { + let dir_name = build_dir_path(path); + self.op + .stat(&dir_name) + .await + .map_err(opendal_error_to_errno)? + } else { + return Err(opendal_error_to_errno(err)); + } + } + }; + + let mut file_stat = FileStat::new_file_filestat_with_path(path, 0); + self.opendal_meta_to_file_stat(&meta, &mut file_stat); + + Ok(file_stat) + } + + async fn read_dir(&self, path: &Path) -> Result> { + // dir name should end with '/' in opendal. + let dir_name = build_dir_path(path); + let entries = self + .op + .list(&dir_name) + .await + .map_err(opendal_error_to_errno)?; + entries + .iter() + .map(|entry| { + let mut path = PathBuf::from(path); + path.push(entry.name()); + + let mut file_stat = FileStat::new_file_filestat_with_path(&path, 0); + self.opendal_meta_to_file_stat(entry.metadata(), &mut file_stat); + Ok(file_stat) + }) + .collect() + } + + async fn open_file(&self, path: &Path, flags: OpenFileFlags) -> Result { + let file_stat = self.stat(path).await?; + debug_assert!(file_stat.kind == RegularFile); + + let mut file = OpenedFile::new(file_stat); + let file_name = path.to_string_lossy().to_string(); + if flags.is_read() { + let reader = self + .op + .reader_with(&file_name) + .await + .map_err(opendal_error_to_errno)?; + file.reader = Some(Box::new(FileReaderImpl { reader })); + } + if flags.is_write() || flags.is_create() || flags.is_append() || flags.is_truncate() { + let writer = self + .op + .writer_with(&file_name) + .await + .map_err(opendal_error_to_errno)?; + file.writer = Some(Box::new(FileWriterImpl { writer })); + } + Ok(file) + } + + async fn open_dir(&self, path: &Path, _flags: OpenFileFlags) -> Result { + let file_stat = self.stat(path).await?; + debug_assert!(file_stat.kind == Directory); + + let opened_file = OpenedFile::new(file_stat); + Ok(opened_file) + } + + async fn create_file(&self, path: &Path, flags: OpenFileFlags) -> Result { + let file_name = path.to_string_lossy().to_string(); + + let mut writer = self + .op + .writer_with(&file_name) + .await + .map_err(opendal_error_to_errno)?; + + writer.close().await.map_err(opendal_error_to_errno)?; + + let file = self.open_file(path, flags).await?; + Ok(file) + } + + async fn create_dir(&self, path: &Path) -> Result { + let dir_name = build_dir_path(path); + self.op + .create_dir(&dir_name) + .await + .map_err(opendal_error_to_errno)?; + let file_stat = self.stat(path).await?; + Ok(file_stat) + } + + async fn set_attr(&self, _path: &Path, _file_stat: &FileStat, _flush: bool) -> Result<()> { + // no need to implement + Ok(()) + } + + async fn remove_file(&self, path: &Path) -> Result<()> { + let file_name = path.to_string_lossy().to_string(); + self.op + .remove(vec![file_name]) + .await + .map_err(opendal_error_to_errno) + } + + async fn remove_dir(&self, path: &Path) -> Result<()> { + //todo:: need to consider keeping the behavior of posix remove dir when the dir is not empty + let dir_name = build_dir_path(path); + self.op + .remove(vec![dir_name]) + .await + .map_err(opendal_error_to_errno) + } + + fn get_capacity(&self) -> Result { + Ok(FileSystemCapacity {}) + } +} + +struct FileReaderImpl { + reader: opendal::Reader, +} + +#[async_trait] +impl FileReader for FileReaderImpl { + async fn read(&mut self, offset: u64, size: u32) -> Result { + let end = offset + size as u64; + let v = self + .reader + .read(offset..end) + .await + .map_err(opendal_error_to_errno)?; + Ok(v.to_bytes()) + } +} + +struct FileWriterImpl { + writer: opendal::Writer, +} + +#[async_trait] +impl FileWriter for FileWriterImpl { + async fn write(&mut self, _offset: u64, data: &[u8]) -> Result { + self.writer + .write(data.to_vec()) + .await + .map_err(opendal_error_to_errno)?; + Ok(data.len() as u32) + } + + async fn close(&mut self) -> Result<()> { + self.writer.close().await.map_err(opendal_error_to_errno)?; + Ok(()) + } +} + +fn build_dir_path(path: &Path) -> String { + let mut dir_path = path.to_string_lossy().to_string(); + if !dir_path.ends_with('/') { + dir_path.push('/'); + } + dir_path +} + +fn opendal_error_to_errno(err: opendal::Error) -> Errno { + error!("opendal operator error {:?}", err); + match err.kind() { + ErrorKind::Unsupported => Errno::from(libc::EOPNOTSUPP), + ErrorKind::IsADirectory => Errno::from(libc::EISDIR), + ErrorKind::NotFound => Errno::from(libc::ENOENT), + ErrorKind::PermissionDenied => Errno::from(libc::EACCES), + ErrorKind::AlreadyExists => Errno::from(libc::EEXIST), + ErrorKind::NotADirectory => Errno::from(libc::ENOTDIR), + ErrorKind::RateLimited => Errno::from(libc::EBUSY), + _ => Errno::from(libc::ENOENT), + } +} + +fn opendal_filemode_to_filetype(mode: EntryMode) -> FileType { + match mode { + EntryMode::DIR => Directory, + _ => RegularFile, + } +} + +#[cfg(test)] +mod test { + use crate::config::AppConfig; + use crate::s3_filesystem::extract_s3_config; + use opendal::layers::LoggingLayer; + use opendal::{services, Builder, Operator}; + + #[tokio::test] + async fn test_s3_stat() { + let config = AppConfig::from_file(Some("tests/conf/gvfs_fuse_s3.toml")).unwrap(); + let opendal_config = extract_s3_config(&config); + + let builder = services::S3::from_map(opendal_config); + + // Init an operator + let op = Operator::new(builder) + .expect("opendal create failed") + .layer(LoggingLayer::default()) + .finish(); + + let path = "/"; + let list = op.list(path).await; + if let Ok(l) = list { + for i in l { + println!("list result: {:?}", i); + } + } else { + println!("list error: {:?}", list.err()); + } + + let meta = op.stat_with(path).await; + if let Ok(m) = meta { + println!("stat result: {:?}", m); + } else { + println!("stat error: {:?}", meta.err()); + } + } +} diff --git a/clients/filesystem-fuse/src/opened_file.rs b/clients/filesystem-fuse/src/opened_file.rs index 5bc961c9a6b..0c630e07217 100644 --- a/clients/filesystem-fuse/src/opened_file.rs +++ b/clients/filesystem-fuse/src/opened_file.rs @@ -122,6 +122,32 @@ pub(crate) struct FileHandle { // OpenFileFlags is the open file flags for the file system. pub(crate) struct OpenFileFlags(pub(crate) u32); +impl OpenFileFlags { + pub fn is_read(&self) -> bool { + (self.0 & libc::O_WRONLY as u32) == 0 + } + + pub fn is_write(&self) -> bool { + (self.0 & libc::O_WRONLY as u32) != 0 || (self.0 & libc::O_RDWR as u32) != 0 + } + + pub fn is_append(&self) -> bool { + (self.0 & libc::O_APPEND as u32) != 0 + } + + pub fn is_create(&self) -> bool { + (self.0 & libc::O_CREAT as u32) != 0 + } + + pub fn is_truncate(&self) -> bool { + (self.0 & libc::O_TRUNC as u32) != 0 + } + + pub fn is_exclusive(&self) -> bool { + (self.0 & libc::O_EXCL as u32) != 0 + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/clients/filesystem-fuse/src/s3_filesystem.rs b/clients/filesystem-fuse/src/s3_filesystem.rs new file mode 100644 index 00000000000..e0ca69b4ccf --- /dev/null +++ b/clients/filesystem-fuse/src/s3_filesystem.rs @@ -0,0 +1,276 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +use crate::config::AppConfig; +use crate::error::ErrorCode::{InvalidConfig, OpenDalError}; +use crate::filesystem::{FileStat, FileSystemCapacity, FileSystemContext, PathFileSystem, Result}; +use crate::gravitino_client::{Catalog, Fileset}; +use crate::open_dal_filesystem::OpenDalFileSystem; +use crate::opened_file::{OpenFileFlags, OpenedFile}; +use crate::utils::{parse_location, GvfsResult}; +use async_trait::async_trait; +use log::error; +use opendal::layers::LoggingLayer; +use opendal::services::S3; +use opendal::{Builder, Operator}; +use std::collections::HashMap; +use std::path::Path; + +pub(crate) struct S3FileSystem { + open_dal_fs: OpenDalFileSystem, +} + +impl S3FileSystem {} + +impl S3FileSystem { + const S3_CONFIG_PREFIX: &'static str = "s3-"; + + pub(crate) fn new( + catalog: &Catalog, + fileset: &Fileset, + config: &AppConfig, + _fs_context: &FileSystemContext, + ) -> GvfsResult { + let mut opendal_config = extract_s3_config(config); + let bucket = extract_bucket(&fileset.storage_location)?; + opendal_config.insert("bucket".to_string(), bucket); + + let region = Self::get_s3_region(catalog)?; + opendal_config.insert("region".to_string(), region); + + let builder = S3::from_map(opendal_config); + + let op = Operator::new(builder); + if let Err(e) = op { + error!("opendal create failed: {:?}", e); + return Err(OpenDalError.to_error(format!("opendal create failed: {:?}", e))); + } + let op = op.unwrap().layer(LoggingLayer::default()).finish(); + let open_dal_fs = OpenDalFileSystem::new(op, config, _fs_context); + Ok(Self { + open_dal_fs: open_dal_fs, + }) + } + + fn get_s3_region(catalog: &Catalog) -> GvfsResult { + if let Some(region) = catalog.properties.get("s3-region") { + Ok(region.clone()) + } else if let Some(endpoint) = catalog.properties.get("s3-endpoint") { + extract_region(endpoint) + } else { + Err(InvalidConfig.to_error(format!( + "Cant not retrieve region in the Catalog {}", + catalog.name + ))) + } + } +} + +#[async_trait] +impl PathFileSystem for S3FileSystem { + async fn init(&self) -> Result<()> { + Ok(()) + } + + async fn stat(&self, path: &Path) -> Result { + self.open_dal_fs.stat(path).await + } + + async fn read_dir(&self, path: &Path) -> Result> { + self.open_dal_fs.read_dir(path).await + } + + async fn open_file(&self, path: &Path, flags: OpenFileFlags) -> Result { + self.open_dal_fs.open_file(path, flags).await + } + + async fn open_dir(&self, path: &Path, flags: OpenFileFlags) -> Result { + self.open_dal_fs.open_dir(path, flags).await + } + + async fn create_file(&self, path: &Path, flags: OpenFileFlags) -> Result { + self.open_dal_fs.create_file(path, flags).await + } + + async fn create_dir(&self, path: &Path) -> Result { + self.open_dal_fs.create_dir(path).await + } + + async fn set_attr(&self, path: &Path, file_stat: &FileStat, flush: bool) -> Result<()> { + self.open_dal_fs.set_attr(path, file_stat, flush).await + } + + async fn remove_file(&self, path: &Path) -> Result<()> { + self.open_dal_fs.remove_file(path).await + } + + async fn remove_dir(&self, path: &Path) -> Result<()> { + self.open_dal_fs.remove_dir(path).await + } + + fn get_capacity(&self) -> Result { + self.open_dal_fs.get_capacity() + } +} + +pub(crate) fn extract_bucket(location: &str) -> GvfsResult { + let url = parse_location(location)?; + match url.host_str() { + Some(host) => Ok(host.to_string()), + None => Err(InvalidConfig.to_error(format!( + "Invalid fileset location without bucket: {}", + location + ))), + } +} + +pub(crate) fn extract_region(location: &str) -> GvfsResult { + let url = parse_location(location)?; + match url.host_str() { + Some(host) => { + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() > 1 { + Ok(parts[1].to_string()) + } else { + Err(InvalidConfig.to_error(format!( + "Invalid location: expected region in host, got {}", + location + ))) + } + } + None => Err(InvalidConfig.to_error(format!( + "Invalid fileset location without bucket: {}", + location + ))), + } +} + +pub fn extract_s3_config(config: &AppConfig) -> HashMap { + config + .extend_config + .clone() + .into_iter() + .filter_map(|(k, v)| { + if k.starts_with(S3FileSystem::S3_CONFIG_PREFIX) { + Some(( + k.strip_prefix(S3FileSystem::S3_CONFIG_PREFIX) + .unwrap() + .to_string(), + v, + )) + } else { + None + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::default_raw_filesystem::DefaultRawFileSystem; + use crate::filesystem::tests::{TestPathFileSystem, TestRawFileSystem}; + use crate::filesystem::RawFileSystem; + use opendal::layers::TimeoutLayer; + use std::time::Duration; + + #[test] + fn test_extract_bucket() { + let location = "s3://bucket/path/to/file"; + let result = extract_bucket(location); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "bucket"); + } + + #[test] + fn test_extract_region() { + let location = "http://s3.ap-southeast-2.amazonaws.com"; + let result = extract_region(location); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "ap-southeast-2"); + } + + async fn delete_dir(op: &Operator, dir_name: &str) { + let childs = op.list(dir_name).await.expect("list dir failed"); + for child in childs { + let child_name = dir_name.to_string() + child.name(); + if child.metadata().is_dir() { + Box::pin(delete_dir(op, &child_name)).await; + } else { + op.delete(&child_name).await.expect("delete file failed"); + } + } + op.delete(dir_name).await.expect("delete dir failed"); + } + + async fn create_s3_fs(cwd: &Path) -> S3FileSystem { + let config = AppConfig::from_file(Some("tests/conf/gvfs_fuse_s3.toml")).unwrap(); + let opendal_config = extract_s3_config(&config); + + let fs_context = FileSystemContext::default(); + + let builder = S3::from_map(opendal_config); + let op = Operator::new(builder) + .expect("opendal create failed") + .layer(LoggingLayer::default()) + .layer( + TimeoutLayer::new() + .with_timeout(Duration::from_secs(300)) + .with_io_timeout(Duration::from_secs(300)), + ) + .finish(); + + // clean up the test directory + let file_name = cwd.to_string_lossy().to_string() + "/"; + delete_dir(&op, &file_name).await; + op.create_dir(&file_name) + .await + .expect("create test dir failed"); + + let open_dal_fs = OpenDalFileSystem::new(op, &config, &fs_context); + S3FileSystem { open_dal_fs } + } + + #[tokio::test] + async fn test_s3_file_system() { + if std::env::var("RUN_S3_TESTS").is_err() { + return; + } + let cwd = Path::new("/gvfs_test1"); + let fs = create_s3_fs(cwd).await; + + let _ = fs.init().await; + let mut tester = TestPathFileSystem::new(cwd, fs); + tester.test_path_file_system().await; + } + + #[tokio::test] + async fn test_s3_file_system_with_raw_file_system() { + if std::env::var("RUN_S3_TESTS").is_err() { + return; + } + + let cwd = Path::new("/gvfs_test2"); + let s3_fs = create_s3_fs(cwd).await; + let raw_fs = + DefaultRawFileSystem::new(s3_fs, &AppConfig::default(), &FileSystemContext::default()); + let _ = raw_fs.init().await; + let mut tester = TestRawFileSystem::new(cwd, raw_fs); + tester.test_raw_file_system().await; + } +} diff --git a/clients/filesystem-fuse/src/utils.rs b/clients/filesystem-fuse/src/utils.rs index bbc8d7d7f8a..53eb9179d71 100644 --- a/clients/filesystem-fuse/src/utils.rs +++ b/clients/filesystem-fuse/src/utils.rs @@ -16,9 +16,36 @@ * specific language governing permissions and limitations * under the License. */ +use crate::error::ErrorCode::InvalidConfig; use crate::error::GvfsError; +use reqwest::Url; +use std::path::PathBuf; pub type GvfsResult = Result; +pub(crate) fn parse_location(location: &str) -> GvfsResult { + let parsed_url = Url::parse(location); + if let Err(e) = parsed_url { + return Err(InvalidConfig.to_error(format!("Invalid fileset location: {}", e))); + } + Ok(parsed_url.unwrap()) +} + +pub(crate) fn extract_root_path(location: &str) -> GvfsResult { + let url = parse_location(location)?; + Ok(PathBuf::from(url.path())) +} + #[cfg(test)] -mod tests {} +mod tests { + use crate::utils::extract_root_path; + use std::path::PathBuf; + + #[test] + fn test_extract_root_path() { + let location = "s3://bucket/path/to/file"; + let result = extract_root_path(location); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from("/path/to/file")); + } +} diff --git a/clients/filesystem-fuse/tests/conf/gvfs_fuse_test.toml b/clients/filesystem-fuse/tests/conf/config_test.toml similarity index 91% rename from clients/filesystem-fuse/tests/conf/gvfs_fuse_test.toml rename to clients/filesystem-fuse/tests/conf/config_test.toml index ff7c6936f37..524e0aa94fb 100644 --- a/clients/filesystem-fuse/tests/conf/gvfs_fuse_test.toml +++ b/clients/filesystem-fuse/tests/conf/config_test.toml @@ -34,7 +34,7 @@ block_size = 8192 uri = "http://localhost:8090" metalake = "test" -# extent settings +# extend settings [extend_config] -access_key = "XXX_access_key" -secret_key = "XXX_secret_key" +s3-access_key_id = "XXX_access_key" +s3-secret_access_key = "XXX_secret_key" diff --git a/clients/filesystem-fuse/tests/conf/gvfs_fuse_memory.toml b/clients/filesystem-fuse/tests/conf/gvfs_fuse_memory.toml index 013df6cfc31..0ec447cd087 100644 --- a/clients/filesystem-fuse/tests/conf/gvfs_fuse_memory.toml +++ b/clients/filesystem-fuse/tests/conf/gvfs_fuse_memory.toml @@ -34,7 +34,7 @@ block_size = 8192 uri = "http://localhost:8090" metalake = "test" -# extent settings -[extent_config] -access_key = "XXX_access_key" -secret_key = "XXX_secret_key" +# extend settings +[extend_config] +s3-access_key_id = "XXX_access_key" +s3-secret_access_key = "XXX_secret_key" diff --git a/clients/filesystem-fuse/tests/conf/gvfs_fuse_s3.toml b/clients/filesystem-fuse/tests/conf/gvfs_fuse_s3.toml new file mode 100644 index 00000000000..7d182cd40df --- /dev/null +++ b/clients/filesystem-fuse/tests/conf/gvfs_fuse_s3.toml @@ -0,0 +1,43 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# fuse settings +[fuse] +file_mask= 0o600 +dir_mask= 0o700 +fs_type = "memory" + +[fuse.properties] +key1 = "value1" +key2 = "value2" + +# filesystem settings +[filesystem] +block_size = 8192 + +# Gravitino settings +[gravitino] +uri = "http://localhost:8090" +metalake = "test" + +# extend settings +[extend_config] +s3-access_key_id = "XXX_access_key" +s3-secret_access_key = "XXX_secret_key" +s3-region = "XXX_region" +s3-bucket = "XXX_bucket" + diff --git a/clients/filesystem-fuse/tests/fuse_test.rs b/clients/filesystem-fuse/tests/fuse_test.rs index e761fabc5b6..d06199d782e 100644 --- a/clients/filesystem-fuse/tests/fuse_test.rs +++ b/clients/filesystem-fuse/tests/fuse_test.rs @@ -17,15 +17,16 @@ * under the License. */ +use fuse3::Errno; use gvfs_fuse::config::AppConfig; use gvfs_fuse::{gvfs_mount, gvfs_unmount}; -use log::info; -use std::fs; +use log::{error, info}; use std::fs::File; use std::path::Path; use std::sync::Arc; use std::thread::sleep; use std::time::{Duration, Instant}; +use std::{fs, panic, process}; use tokio::runtime::Runtime; use tokio::task::JoinHandle; @@ -42,8 +43,14 @@ impl FuseTest { let config = AppConfig::from_file(Some("tests/conf/gvfs_fuse_memory.toml")) .expect("Failed to load config"); - self.runtime - .spawn(async move { gvfs_mount(&mount_point, "", &config).await }); + self.runtime.spawn(async move { + let result = gvfs_mount(&mount_point, "", &config).await; + if let Err(e) = result { + error!("Failed to mount gvfs: {:?}", e); + return Err(Errno::from(libc::EINVAL)); + } + Ok(()) + }); let success = Self::wait_for_fuse_server_ready(&self.mount_point, Duration::from_secs(15)); assert!(success, "Fuse server cannot start up at 15 seconds"); } @@ -60,6 +67,7 @@ impl FuseTest { while start_time.elapsed() < timeout { if file_exists(&test_file) { + info!("Fuse server is ready",); return true; } info!("Wait for fuse server ready",); @@ -80,6 +88,11 @@ impl Drop for FuseTest { fn test_fuse_system_with_auto() { tracing_subscriber::fmt().init(); + panic::set_hook(Box::new(|info| { + error!("A panic occurred: {:?}", info); + process::exit(1); + })); + let mount_point = "target/gvfs"; let _ = fs::create_dir_all(mount_point); From bfb85680668b91d68f8190a8247156477f326039 Mon Sep 17 00:00:00 2001 From: Lord of Abyss <103809695+Abyss-lord@users.noreply.github.com> Date: Sat, 4 Jan 2025 06:25:18 +0800 Subject: [PATCH 13/36] [#5960] fix(CLI): Add register and link commands to CLI for model (#6066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What changes were proposed in this pull request? Add register and link commands to CLI for model - register a model:`model create` - link a model:`model update <—uri uri> [--alias aliaA aliaB]` meantime, add two options - `—uri` :The URI of the model version artifact. - `—alias` :The aliases of the model version. The documentation will be updated after #6047 merge and I will create a new issue. ### Why are the changes needed? Fix: #5960 ### Does this PR introduce _any_ user-facing change? NO ### How was this patch tested? #### register test ```bash # register a model gcli model create -m demo_metalake --name Hive_catalog.default.model # register a model with comment gcli model create -m demo_metalake --name Hive_catalog.default.model --comment comment # register a model with properties gcli model create -m demo_metalake --name Hive_catalog.default.model --properties key1=val1 key2=val2 # register a model with properties and comment gcli model create -m demo_metalake --name Hive_catalog.default.model --properties key1=val1 klinkey2=val2 --comment comment ``` #### link test ```bash # link a model gcli model update -m demo_metalake --name Hive_catalog.default.model --uri file:///tmp/file # link a model with alias gcli model update -m demo_metalake --name Hive_catalog.default.model --uri file:///tmp/file --alias aliasA aliasB # link a model with all component gcli model update -m demo_metalake --name Hive_catalog.default.model --uri file:///tmp/file --alias aliasA aliasB --comment comment --properties key1=val1 key2=val2 # link a model without uri gcli model update -m demo_metalake --name Hive_catalog.default.model ``` --- .../apache/gravitino/cli/ErrorMessages.java | 3 +- .../gravitino/cli/GravitinoCommandLine.java | 34 +++ .../gravitino/cli/GravitinoOptions.java | 6 + .../gravitino/cli/TestableCommandLine.java | 29 ++ .../gravitino/cli/commands/LinkModel.java | 106 +++++++ .../gravitino/cli/commands/RegisterModel.java | 103 +++++++ .../gravitino/cli/TestModelCommands.java | 284 ++++++++++++++++++ 7 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 clients/cli/src/main/java/org/apache/gravitino/cli/commands/LinkModel.java create mode 100644 clients/cli/src/main/java/org/apache/gravitino/cli/commands/RegisterModel.java diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java index 084b5c34c85..e90c5259638 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java @@ -36,6 +36,7 @@ public class ErrorMessages { public static final String MISSING_USER = "Missing --user option."; public static final String MISSING_ROLE = "Missing --role option."; public static final String MISSING_TAG = "Missing --tag option."; + public static final String MISSING_URI = "Missing --uri option."; public static final String METALAKE_EXISTS = "Metalake already exists."; public static final String CATALOG_EXISTS = "Catalog already exists."; public static final String SCHEMA_EXISTS = "Schema already exists."; @@ -51,13 +52,13 @@ public class ErrorMessages { public static final String COLUMN_EXISTS = "Column already exists."; public static final String UNKNOWN_TOPIC = "Unknown topic."; public static final String TOPIC_EXISTS = "Topic already exists."; + public static final String MODEL_EXISTS = "Model already exists."; public static final String UNKNOWN_FILESET = "Unknown fileset."; public static final String FILESET_EXISTS = "Fileset already exists."; public static final String TAG_EMPTY = "Error: Must configure --tag option."; public static final String UNKNOWN_ROLE = "Unknown role."; public static final String ROLE_EXISTS = "Role already exists."; public static final String TABLE_EXISTS = "Table already exists."; - public static final String MODEL_EXISTS = "Model already exists."; public static final String INVALID_SET_COMMAND = "Unsupported combination of options either use --name, --user, --group or --property and --value."; public static final String INVALID_REMOVE_COMMAND = diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index c23fb8b7cd0..3a9322d010e 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -1192,6 +1192,40 @@ private void handleModelCommand() { } break; + case CommandActions.CREATE: + String createComment = line.getOptionValue(GravitinoOptions.COMMENT); + String[] createProperties = line.getOptionValues(GravitinoOptions.PROPERTIES); + Map createPropertyMap = new Properties().parse(createProperties); + newCreateModel( + url, ignore, metalake, catalog, schema, model, createComment, createPropertyMap) + .handle(); + break; + + case CommandActions.UPDATE: + String[] alias = line.getOptionValues(GravitinoOptions.ALIAS); + String uri = line.getOptionValue(GravitinoOptions.URI); + if (uri == null) { + System.err.println(ErrorMessages.MISSING_URI); + Main.exit(-1); + } + + String linkComment = line.getOptionValue(GravitinoOptions.COMMENT); + String[] linkProperties = line.getOptionValues(CommandActions.PROPERTIES); + Map linkPropertityMap = new Properties().parse(linkProperties); + newLinkModel( + url, + ignore, + metalake, + catalog, + schema, + model, + uri, + alias, + linkComment, + linkPropertityMap) + .handle(); + break; + default: System.err.println(ErrorMessages.UNSUPPORTED_ACTION); break; diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoOptions.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoOptions.java index 657566036dc..aaeb8f0184f 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoOptions.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoOptions.java @@ -62,6 +62,8 @@ public class GravitinoOptions { public static final String ALL = "all"; public static final String ENABLE = "enable"; public static final String DISABLE = "disable"; + public static final String ALIAS = "alias"; + public static final String URI = "uri"; /** * Builds and returns the CLI options for Gravitino. @@ -109,6 +111,10 @@ public Options options() { options.addOption(createArgOption(COLUMNFILE, "CSV file describing columns")); options.addOption(createSimpleOption(null, ALL, "all operation for --enable")); + // model options + options.addOption(createArgOption(null, URI, "model version artifact")); + options.addOption(createArgsOption(null, ALIAS, "model aliases")); + // Options that support multiple values options.addOption(createArgsOption("p", PROPERTIES, "property name/value pairs")); options.addOption(createArgsOption("t", TAG, "tag name")); diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/TestableCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/TestableCommandLine.java index 6a468749178..8df9498d97b 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/TestableCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/TestableCommandLine.java @@ -55,6 +55,7 @@ import org.apache.gravitino.cli.commands.GrantPrivilegesToRole; import org.apache.gravitino.cli.commands.GroupAudit; import org.apache.gravitino.cli.commands.GroupDetails; +import org.apache.gravitino.cli.commands.LinkModel; import org.apache.gravitino.cli.commands.ListAllTags; import org.apache.gravitino.cli.commands.ListCatalogProperties; import org.apache.gravitino.cli.commands.ListCatalogs; @@ -83,6 +84,7 @@ import org.apache.gravitino.cli.commands.ModelAudit; import org.apache.gravitino.cli.commands.ModelDetails; import org.apache.gravitino.cli.commands.OwnerDetails; +import org.apache.gravitino.cli.commands.RegisterModel; import org.apache.gravitino.cli.commands.RemoveAllTags; import org.apache.gravitino.cli.commands.RemoveCatalogProperty; import org.apache.gravitino.cli.commands.RemoveFilesetProperty; @@ -925,4 +927,31 @@ protected ModelDetails newModelDetails( String url, boolean ignore, String metalake, String catalog, String schema, String model) { return new ModelDetails(url, ignore, metalake, catalog, schema, model); } + + protected RegisterModel newCreateModel( + String url, + boolean ignore, + String metalake, + String catalog, + String schema, + String model, + String comment, + Map properties) { + return new RegisterModel(url, ignore, metalake, catalog, schema, model, comment, properties); + } + + protected LinkModel newLinkModel( + String url, + boolean ignore, + String metalake, + String catalog, + String schema, + String model, + String uri, + String[] alias, + String comment, + Map properties) { + return new LinkModel( + url, ignore, metalake, catalog, schema, model, uri, alias, comment, properties); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/LinkModel.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/LinkModel.java new file mode 100644 index 00000000000..6e8a4ffb76d --- /dev/null +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/LinkModel.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.cli.commands; + +/** Link a new model version to the registered model. */ +import java.util.Arrays; +import java.util.Map; +import org.apache.gravitino.NameIdentifier; +import org.apache.gravitino.cli.ErrorMessages; +import org.apache.gravitino.client.GravitinoClient; +import org.apache.gravitino.exceptions.ModelVersionAliasesAlreadyExistException; +import org.apache.gravitino.exceptions.NoSuchCatalogException; +import org.apache.gravitino.exceptions.NoSuchMetalakeException; +import org.apache.gravitino.exceptions.NoSuchModelException; +import org.apache.gravitino.exceptions.NoSuchSchemaException; +import org.apache.gravitino.model.ModelCatalog; + +public class LinkModel extends Command { + protected final String metalake; + protected final String catalog; + protected final String schema; + protected final String model; + protected final String uri; + protected final String[] alias; + protected final String comment; + protected final Map properties; + + /** + * Link a new model version to the registered model. + * + * @param url The URL of the Gravitino server. + * @param ignoreVersions If true don't check the client/server versions match. + * @param metalake The name of the metalake. + * @param catalog The name of the catalog. + * @param schema The name of schema. + * @param model The name of model. + * @param uri The URI of the model version artifact. + * @param alias The aliases of the model version. + * @param comment The comment of the model version. + * @param properties The properties of the model version. + */ + public LinkModel( + String url, + boolean ignoreVersions, + String metalake, + String catalog, + String schema, + String model, + String uri, + String[] alias, + String comment, + Map properties) { + super(url, ignoreVersions); + this.metalake = metalake; + this.catalog = catalog; + this.schema = schema; + this.model = model; + this.uri = uri; + this.alias = alias; + this.comment = comment; + this.properties = properties; + } + + /** Link a new model version to the registered model. */ + @Override + public void handle() { + NameIdentifier name = NameIdentifier.of(schema, model); + + try { + GravitinoClient client = buildClient(metalake); + ModelCatalog modelCatalog = client.loadCatalog(catalog).asModelCatalog(); + modelCatalog.linkModelVersion(name, uri, alias, comment, properties); + } catch (NoSuchMetalakeException err) { + exitWithError(ErrorMessages.UNKNOWN_METALAKE); + } catch (NoSuchCatalogException err) { + exitWithError(ErrorMessages.UNKNOWN_CATALOG); + } catch (NoSuchSchemaException err) { + exitWithError(ErrorMessages.UNKNOWN_SCHEMA); + } catch (NoSuchModelException err) { + exitWithError(ErrorMessages.UNKNOWN_MODEL); + } catch (ModelVersionAliasesAlreadyExistException err) { + exitWithError(Arrays.toString(alias) + " already exist."); + } catch (Exception err) { + exitWithError(err.getMessage()); + } + + System.out.println( + "Linked model " + model + " to " + uri + " with aliases " + Arrays.toString(alias)); + } +} diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RegisterModel.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RegisterModel.java new file mode 100644 index 00000000000..d50dbed50e2 --- /dev/null +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RegisterModel.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.cli.commands; + +import java.util.Map; +import org.apache.gravitino.NameIdentifier; +import org.apache.gravitino.cli.ErrorMessages; +import org.apache.gravitino.cli.Main; +import org.apache.gravitino.client.GravitinoClient; +import org.apache.gravitino.exceptions.ModelAlreadyExistsException; +import org.apache.gravitino.exceptions.NoSuchCatalogException; +import org.apache.gravitino.exceptions.NoSuchMetalakeException; +import org.apache.gravitino.exceptions.NoSuchSchemaException; +import org.apache.gravitino.model.Model; +import org.apache.gravitino.model.ModelCatalog; + +/** Register a model in the catalog */ +public class RegisterModel extends Command { + + protected final String metalake; + protected final String catalog; + protected final String schema; + protected final String model; + protected final String comment; + protected final Map properties; + + /** + * Register a model in the catalog + * + * @param url The URL of the Gravitino server. + * @param ignoreVersions If true don't check the client/server versions match. + * @param metalake The name of the metalake. + * @param catalog The name of the catalog. + * @param schema The name of schema. + * @param model The name of model. + * @param comment The comment of the model version. + * @param properties The properties of the model version. + */ + public RegisterModel( + String url, + boolean ignoreVersions, + String metalake, + String catalog, + String schema, + String model, + String comment, + Map properties) { + super(url, ignoreVersions); + this.metalake = metalake; + this.catalog = catalog; + this.schema = schema; + this.model = model; + this.comment = comment; + this.properties = properties; + } + + /** Register a model in the catalog */ + @Override + public void handle() { + NameIdentifier name = NameIdentifier.of(schema, model); + Model registeredModel = null; + + try { + GravitinoClient client = buildClient(metalake); + ModelCatalog modelCatalog = client.loadCatalog(catalog).asModelCatalog(); + registeredModel = modelCatalog.registerModel(name, comment, properties); + } catch (NoSuchMetalakeException err) { + exitWithError(ErrorMessages.UNKNOWN_METALAKE); + } catch (NoSuchCatalogException err) { + exitWithError(ErrorMessages.UNKNOWN_CATALOG); + } catch (NoSuchSchemaException err) { + exitWithError(ErrorMessages.UNKNOWN_SCHEMA); + } catch (ModelAlreadyExistsException err) { + exitWithError(ErrorMessages.MODEL_EXISTS); + } catch (Exception err) { + exitWithError(err.getMessage()); + } + + if (registeredModel != null) { + System.out.println("Successful register " + registeredModel.name() + "."); + } else { + System.err.println("Failed to register model: " + model + "."); + Main.exit(-1); + } + } +} diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java index e486c41a9d1..391201f292f 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java @@ -21,6 +21,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doReturn; @@ -35,11 +36,14 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; +import java.util.Map; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Options; +import org.apache.gravitino.cli.commands.LinkModel; import org.apache.gravitino.cli.commands.ListModel; import org.apache.gravitino.cli.commands.ModelAudit; import org.apache.gravitino.cli.commands.ModelDetails; +import org.apache.gravitino.cli.commands.RegisterModel; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -289,4 +293,284 @@ void testModelAuditCommand() { commandLine.handleCommandLine(); verify(mockAudit).handle(); } + + @Test + void testRegisterModelCommand() { + RegisterModel mockCreate = mock(RegisterModel.class); + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); + when(mockCommandLine.hasOption(GravitinoOptions.PROPERTIES)).thenReturn(false); + when(mockCommandLine.hasOption(GravitinoOptions.COMMENT)).thenReturn(false); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.CREATE)); + + doReturn(mockCreate) + .when(commandLine) + .newCreateModel( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + eq("catalog"), + eq("schema"), + eq("model"), + isNull(), + argThat(Map::isEmpty)); + commandLine.handleCommandLine(); + verify(mockCreate).handle(); + } + + @Test + void testRegisterModelCommandWithComment() { + RegisterModel mockCreate = mock(RegisterModel.class); + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); + when(mockCommandLine.hasOption(GravitinoOptions.PROPERTIES)).thenReturn(false); + when(mockCommandLine.hasOption(GravitinoOptions.COMMENT)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.COMMENT)).thenReturn("comment"); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.CREATE)); + + doReturn(mockCreate) + .when(commandLine) + .newCreateModel( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + eq("catalog"), + eq("schema"), + eq("model"), + eq("comment"), + argThat(Map::isEmpty)); + commandLine.handleCommandLine(); + verify(mockCreate).handle(); + } + + @Test + void testRegisterModelCommandWithProperties() { + RegisterModel mockCreate = mock(RegisterModel.class); + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); + when(mockCommandLine.hasOption(GravitinoOptions.PROPERTIES)).thenReturn(true); + when(mockCommandLine.getOptionValues(GravitinoOptions.PROPERTIES)) + .thenReturn(new String[] {"key1=val1", "key2" + "=val2"}); + when(mockCommandLine.hasOption(GravitinoOptions.COMMENT)).thenReturn(false); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.CREATE)); + + doReturn(mockCreate) + .when(commandLine) + .newCreateModel( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + eq("catalog"), + eq("schema"), + eq("model"), + isNull(), + argThat( + argument -> + argument.size() == 2 + && argument.containsKey("key1") + && argument.get("key1").equals("val1"))); + commandLine.handleCommandLine(); + verify(mockCreate).handle(); + } + + @Test + void testRegisterModelCommandWithCommentAndProperties() { + RegisterModel mockCreate = mock(RegisterModel.class); + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); + when(mockCommandLine.hasOption(GravitinoOptions.PROPERTIES)).thenReturn(true); + when(mockCommandLine.getOptionValues(GravitinoOptions.PROPERTIES)) + .thenReturn(new String[] {"key1=val1", "key2" + "=val2"}); + when(mockCommandLine.hasOption(GravitinoOptions.COMMENT)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.COMMENT)).thenReturn("comment"); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.CREATE)); + + doReturn(mockCreate) + .when(commandLine) + .newCreateModel( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + eq("catalog"), + eq("schema"), + eq("model"), + eq("comment"), + argThat( + argument -> + argument.size() == 2 + && argument.containsKey("key1") + && argument.get("key1").equals("val1"))); + commandLine.handleCommandLine(); + verify(mockCreate).handle(); + } + + @Test + void testLinkModelCommandWithoutAlias() { + LinkModel linkModelMock = mock(LinkModel.class); + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); + when(mockCommandLine.hasOption(GravitinoOptions.URI)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.URI)).thenReturn("file:///tmp/file"); + when(mockCommandLine.hasOption(GravitinoOptions.ALIAS)).thenReturn(false); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.UPDATE)); + + doReturn(linkModelMock) + .when(commandLine) + .newLinkModel( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + eq("catalog"), + eq("schema"), + eq("model"), + eq("file:///tmp/file"), + isNull(), + isNull(), + argThat(Map::isEmpty)); + commandLine.handleCommandLine(); + verify(linkModelMock).handle(); + } + + @Test + void testLinkModelCommandWithAlias() { + LinkModel linkModelMock = mock(LinkModel.class); + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); + when(mockCommandLine.hasOption(GravitinoOptions.URI)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.URI)).thenReturn("file:///tmp/file"); + when(mockCommandLine.hasOption(GravitinoOptions.ALIAS)).thenReturn(true); + when(mockCommandLine.getOptionValues(GravitinoOptions.ALIAS)) + .thenReturn(new String[] {"aliasA", "aliasB"}); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.UPDATE)); + + doReturn(linkModelMock) + .when(commandLine) + .newLinkModel( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + eq("catalog"), + eq("schema"), + eq("model"), + eq("file:///tmp/file"), + argThat( + argument -> + argument.length == 2 + && "aliasA".equals(argument[0]) + && "aliasB".equals(argument[1])), + isNull(), + argThat(Map::isEmpty)); + commandLine.handleCommandLine(); + verify(linkModelMock).handle(); + } + + @Test + void testLinkModelCommandWithoutURI() { + Main.useExit = false; + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); + when(mockCommandLine.hasOption(GravitinoOptions.URI)).thenReturn(false); + when(mockCommandLine.hasOption(GravitinoOptions.ALIAS)).thenReturn(false); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.UPDATE)); + + assertThrows(RuntimeException.class, commandLine::handleCommandLine); + verify(commandLine, never()) + .newLinkModel( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + eq("catalog"), + eq("schema"), + eq("model"), + isNull(), + isNull(), + isNull(), + argThat(Map::isEmpty)); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_URI, output); + } + + @Test + void testLinkModelCommandWithAllComponent() { + LinkModel linkModelMock = mock(LinkModel.class); + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); + when(mockCommandLine.hasOption(GravitinoOptions.URI)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.URI)).thenReturn("file:///tmp/file"); + when(mockCommandLine.hasOption(GravitinoOptions.ALIAS)).thenReturn(true); + when(mockCommandLine.getOptionValues(GravitinoOptions.ALIAS)) + .thenReturn(new String[] {"aliasA", "aliasB"}); + when(mockCommandLine.hasOption(GravitinoOptions.COMMENT)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.COMMENT)).thenReturn("comment"); + when(mockCommandLine.hasOption(GravitinoOptions.PROPERTIES)).thenReturn(true); + when(mockCommandLine.getOptionValues(GravitinoOptions.PROPERTIES)) + .thenReturn(new String[] {"key1=val1", "key2" + "=val2"}); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.UPDATE)); + + doReturn(linkModelMock) + .when(commandLine) + .newLinkModel( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + eq("catalog"), + eq("schema"), + eq("model"), + eq("file:///tmp/file"), + argThat( + argument -> + argument.length == 2 + && "aliasA".equals(argument[0]) + && "aliasB".equals(argument[1])), + eq("comment"), + argThat( + argument -> + argument.size() == 2 + && argument.containsKey("key1") + && argument.containsKey("key2") + && "val1".equals(argument.get("key1")) + && "val2".equals(argument.get("key2")))); + commandLine.handleCommandLine(); + verify(linkModelMock).handle(); + } } From 4c886eb290bf1aec5a72734ad4a583a60717f7f8 Mon Sep 17 00:00:00 2001 From: Vincent Chee Jia Hong <33974196+jhchee@users.noreply.github.com> Date: Sun, 5 Jan 2025 22:06:11 +0000 Subject: [PATCH 14/36] [#5755] Add List and Map types to table/columns in Gravitino CLI (#6098) ### What changes were proposed in this pull request? - Supporting list and map types in Gravitino CLI operations. ### Why are the changes needed? Fix: # (issue) https://github.com/apache/gravitino/issues/5755 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? Unit test to verify ParseType class can handle list and map input. --- .../org/apache/gravitino/cli/ParseType.java | 39 ++++++++- .../apache/gravitino/cli/TestParseType.java | 84 +++++++++++++------ 2 files changed, 96 insertions(+), 27 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/ParseType.java b/clients/cli/src/main/java/org/apache/gravitino/cli/ParseType.java index e797d0552ad..9442175ef80 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/ParseType.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/ParseType.java @@ -22,6 +22,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.gravitino.rel.types.Type; +import org.apache.gravitino.rel.types.Types; public class ParseType { @@ -36,7 +37,7 @@ public class ParseType { * @return a {@link org.apache.gravitino.cli.ParsedType} object representing the parsed type name. * @throws IllegalArgumentException if the data type format is unsupported or malformed */ - public static ParsedType parse(String datatype) { + public static ParsedType parseBasicType(String datatype) { Pattern pattern = Pattern.compile("^(\\w+)\\((\\d+)(?:,(\\d+))?\\)$"); Matcher matcher = pattern.matcher(datatype); @@ -57,8 +58,8 @@ public static ParsedType parse(String datatype) { return null; } - public static Type toType(String datatype) { - ParsedType parsed = parse(datatype); + private static Type toBasicType(String datatype) { + ParsedType parsed = parseBasicType(datatype); if (parsed != null) { if (parsed.getPrecision() != null && parsed.getScale() != null) { @@ -70,4 +71,36 @@ public static Type toType(String datatype) { return TypeConverter.convert(datatype); } + + private static Type toListType(String datatype) { + Pattern pattern = Pattern.compile("^list\\((.+)\\)$"); + Matcher matcher = pattern.matcher(datatype); + if (matcher.matches()) { + Type elementType = toBasicType(matcher.group(1)); + return Types.ListType.of(elementType, false); + } + throw new IllegalArgumentException("Malformed list type: " + datatype); + } + + private static Type toMapType(String datatype) { + Pattern pattern = Pattern.compile("^map\\((.+),(.+)\\)$"); + Matcher matcher = pattern.matcher(datatype); + if (matcher.matches()) { + Type keyType = toBasicType(matcher.group(1)); + Type valueType = toBasicType(matcher.group(2)); + return Types.MapType.of(keyType, valueType, false); + } + throw new IllegalArgumentException("Malformed map type: " + datatype); + } + + public static Type toType(String datatype) { + if (datatype.startsWith("list")) { + return toListType(datatype); + } else if (datatype.startsWith("map")) { + return toMapType(datatype); + } + + // fallback: if not complex type, parse as primitive type + return toBasicType(datatype); + } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestParseType.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestParseType.java index c53d3c2bdcd..6c9132dbf4b 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestParseType.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestParseType.java @@ -19,49 +19,85 @@ package org.apache.gravitino.cli; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.apache.gravitino.rel.types.Type; +import org.apache.gravitino.rel.types.Types; import org.junit.jupiter.api.Test; public class TestParseType { @Test - public void testParseVarcharWithLength() { - ParsedType parsed = ParseType.parse("varchar(10)"); - assertNotNull(parsed); - assertEquals("varchar", parsed.getTypeName()); - assertEquals(10, parsed.getLength()); - assertNull(parsed.getScale()); - assertNull(parsed.getPrecision()); + public void testParseTypeVarcharWithLength() { + Type type = ParseType.toType("varchar(10)"); + assertThat(type, instanceOf(Types.VarCharType.class)); + assertEquals(10, ((Types.VarCharType) type).length()); } @Test - public void testParseDecimalWithPrecisionAndScale() { - ParsedType parsed = ParseType.parse("decimal(10,5)"); - assertNotNull(parsed); - assertEquals("decimal", parsed.getTypeName()); - assertEquals(10, parsed.getPrecision()); - assertEquals(5, parsed.getScale()); - assertNull(parsed.getLength()); + public void testParseTypeDecimalWithPrecisionAndScale() { + Type type = ParseType.toType("decimal(10,5)"); + assertThat(type, instanceOf(Types.DecimalType.class)); + assertEquals(10, ((Types.DecimalType) type).precision()); + assertEquals(5, ((Types.DecimalType) type).scale()); } @Test - public void testParseIntegerWithoutParameters() { - ParsedType parsed = ParseType.parse("int()"); - assertNull(parsed); // Expect null because the format is unsupported + public void testParseTypeListValidInput() { + Type type = ParseType.toType("list(integer)"); + assertThat(type, instanceOf(Types.ListType.class)); + Type elementType = ((Types.ListType) type).elementType(); + assertThat(elementType, instanceOf(Types.IntegerType.class)); } @Test - public void testParseOrdinaryInput() { - assertNull(ParseType.parse("string")); - assertNull(ParseType.parse("int")); + public void testParseTypeListMalformedInput() { + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("list()")); + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("list(10)")); + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("list(unknown)")); + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("list(integer,integer)")); + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("list(integer")); } @Test - public void testParseMalformedInput() { - assertNull(ParseType.parse("varchar(-10)")); - assertNull(ParseType.parse("decimal(10,abc)")); + public void testParseTypeMapValidInput() { + Type type = ParseType.toType("map(string,integer)"); + assertThat(type, instanceOf(Types.MapType.class)); + Type keyType = ((Types.MapType) type).keyType(); + Type valueType = ((Types.MapType) type).valueType(); + assertThat(keyType, instanceOf(Types.StringType.class)); + assertThat(valueType, instanceOf(Types.IntegerType.class)); + } + + @Test + public void testParseTypeMapMalformedInput() { + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("map()")); + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("map(10,10)")); + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("map(unknown,unknown)")); + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("map(string)")); + assertThrows( + IllegalArgumentException.class, () -> ParseType.toType("map(string,integer,integer)")); + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("map(string,integer")); + } + + @Test + public void testParseTypeIntegerWithoutParameters() { + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("int()")); + } + + @Test + public void testParseTypeOrdinaryInput() { + assertNull(ParseType.parseBasicType("string")); + assertNull(ParseType.parseBasicType("int")); + } + + @Test + public void testParseTypeMalformedInput() { + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("varchar(-10)")); + assertThrows(IllegalArgumentException.class, () -> ParseType.toType("decimal(10,abc)")); } } From c16f5955d64208da5f9b09c5ebc33471f56bfaef Mon Sep 17 00:00:00 2001 From: Lord of Abyss <103809695+Abyss-lord@users.noreply.github.com> Date: Mon, 6 Jan 2025 06:09:53 +0800 Subject: [PATCH 15/36] [#6086] fix(CLI): Refactor the validation logic of Metalake (#6091) ### What changes were proposed in this pull request? Add `validate` method to Command, and refactor the validation code. ### Why are the changes needed? Fix: #6086 ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? #### UT local ut #### bash ```bash gcli metalake set # Missing --metalake option. gcli metalake details # Missing --metalake option. gcli metalake set -m demo_metalake # Missing --property and --value options. gcli metalake set -m demo_metalake --property propertyA # Missing --value option. gcli metalake set -m demo_metalake --value valA # Missing --property option. gcli metalake details --audit # Missing --metalake option. gcli metalake remove -m demo_metalake # Missing --property option. gcli metalake update -m demo_metalake # The command does nothing. ``` --------- Co-authored-by: roryqi Co-authored-by: Yuhui Co-authored-by: Qiming Teng --- .../apache/gravitino/cli/ErrorMessages.java | 2 + .../org/apache/gravitino/cli/FullName.java | 1 + .../gravitino/cli/GravitinoCommandLine.java | 40 +++++------ .../gravitino/cli/commands/Command.java | 17 ++++- .../cli/commands/RemoveMetalakeProperty.java | 6 ++ .../cli/commands/SetMetalakeProperty.java | 8 +++ .../apache/gravitino/cli/TestFulllName.java | 7 +- .../gravitino/cli/TestMetalakeCommands.java | 72 ++++++++++++++++++- 8 files changed, 126 insertions(+), 27 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java index e90c5259638..7fd4e272217 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java @@ -37,6 +37,8 @@ public class ErrorMessages { public static final String MISSING_ROLE = "Missing --role option."; public static final String MISSING_TAG = "Missing --tag option."; public static final String MISSING_URI = "Missing --uri option."; + public static final String MISSING_PROPERTY = "Missing --property option."; + public static final String MISSING_VALUE = "Missing --value option."; public static final String METALAKE_EXISTS = "Metalake already exists."; public static final String CATALOG_EXISTS = "Catalog already exists."; public static final String SCHEMA_EXISTS = "Schema already exists."; diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/FullName.java b/clients/cli/src/main/java/org/apache/gravitino/cli/FullName.java index a3b206dfdd1..7a9481cb95b 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/FullName.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/FullName.java @@ -74,6 +74,7 @@ public String getMetalakeName() { } System.err.println(ErrorMessages.MISSING_METALAKE); + Main.exit(-1); return null; } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index 3a9322d010e..f8347dfe1f5 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -30,7 +30,6 @@ import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Objects; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.HelpFormatter; import org.apache.commons.cli.Options; @@ -167,51 +166,49 @@ private void handleMetalakeCommand() { String auth = getAuth(); String userName = line.getOptionValue(GravitinoOptions.LOGIN); FullName name = new FullName(line); - String metalake = name.getMetalakeName(); String outputFormat = line.getOptionValue(GravitinoOptions.OUTPUT); Command.setAuthenticationMode(auth, userName); + if (CommandActions.LIST.equals(command)) { + newListMetalakes(url, ignore, outputFormat).validate().handle(); + return; + } + + String metalake = name.getMetalakeName(); + switch (command) { case CommandActions.DETAILS: if (line.hasOption(GravitinoOptions.AUDIT)) { - newMetalakeAudit(url, ignore, metalake).handle(); + newMetalakeAudit(url, ignore, metalake).validate().handle(); } else { - newMetalakeDetails(url, ignore, outputFormat, metalake).handle(); + newMetalakeDetails(url, ignore, outputFormat, metalake).validate().handle(); } break; - case CommandActions.LIST: - newListMetalakes(url, ignore, outputFormat).handle(); - break; - case CommandActions.CREATE: - if (Objects.isNull(metalake)) { - System.err.println(CommandEntities.METALAKE + " is not defined"); - Main.exit(-1); - } String comment = line.getOptionValue(GravitinoOptions.COMMENT); - newCreateMetalake(url, ignore, metalake, comment).handle(); + newCreateMetalake(url, ignore, metalake, comment).validate().handle(); break; case CommandActions.DELETE: boolean force = line.hasOption(GravitinoOptions.FORCE); - newDeleteMetalake(url, ignore, force, metalake).handle(); + newDeleteMetalake(url, ignore, force, metalake).validate().handle(); break; case CommandActions.SET: String property = line.getOptionValue(GravitinoOptions.PROPERTY); String value = line.getOptionValue(GravitinoOptions.VALUE); - newSetMetalakeProperty(url, ignore, metalake, property, value).handle(); + newSetMetalakeProperty(url, ignore, metalake, property, value).validate().handle(); break; case CommandActions.REMOVE: property = line.getOptionValue(GravitinoOptions.PROPERTY); - newRemoveMetalakeProperty(url, ignore, metalake, property).handle(); + newRemoveMetalakeProperty(url, ignore, metalake, property).validate().handle(); break; case CommandActions.PROPERTIES: - newListMetalakeProperties(url, ignore, metalake).handle(); + newListMetalakeProperties(url, ignore, metalake).validate().handle(); break; case CommandActions.UPDATE: @@ -221,21 +218,22 @@ private void handleMetalakeCommand() { } if (line.hasOption(GravitinoOptions.ENABLE)) { boolean enableAllCatalogs = line.hasOption(GravitinoOptions.ALL); - newMetalakeEnable(url, ignore, metalake, enableAllCatalogs).handle(); + newMetalakeEnable(url, ignore, metalake, enableAllCatalogs).validate().handle(); } if (line.hasOption(GravitinoOptions.DISABLE)) { - newMetalakeDisable(url, ignore, metalake).handle(); + newMetalakeDisable(url, ignore, metalake).validate().handle(); } if (line.hasOption(GravitinoOptions.COMMENT)) { comment = line.getOptionValue(GravitinoOptions.COMMENT); - newUpdateMetalakeComment(url, ignore, metalake, comment).handle(); + newUpdateMetalakeComment(url, ignore, metalake, comment).validate().handle(); } if (line.hasOption(GravitinoOptions.RENAME)) { String newName = line.getOptionValue(GravitinoOptions.RENAME); force = line.hasOption(GravitinoOptions.FORCE); - newUpdateMetalakeName(url, ignore, force, metalake, newName).handle(); + newUpdateMetalakeName(url, ignore, force, metalake, newName).validate().handle(); } + break; default: diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java index f91dae40425..cb11d7dfcef 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java @@ -21,6 +21,7 @@ import static org.apache.gravitino.client.GravitinoClientBase.Builder; +import com.google.common.base.Joiner; import java.io.File; import org.apache.gravitino.cli.GravitinoConfig; import org.apache.gravitino.cli.KerberosData; @@ -39,6 +40,7 @@ public abstract class Command { public static final String OUTPUT_FORMAT_TABLE = "table"; public static final String OUTPUT_FORMAT_PLAIN = "plain"; + public static final Joiner COMMA_JOINER = Joiner.on(", ").skipNulls(); protected static String authentication = null; protected static String userName = null; @@ -46,7 +48,6 @@ public abstract class Command { private static final String SIMPLE_AUTH = "simple"; private static final String OAUTH_AUTH = "oauth"; private static final String KERBEROS_AUTH = "kerberos"; - private final String url; private final boolean ignoreVersions; private final String outputFormat; @@ -99,6 +100,16 @@ public static void setAuthenticationMode(String authentication, String userName) /** All commands have a handle method to handle and run the required command. */ public abstract void handle(); + + /** + * verify the arguments. All commands have a verify method to verify the arguments. + * + * @return Returns itself via argument validation, otherwise exits. + */ + public Command validate() { + return this; + } + /** * Builds a {@link GravitinoClient} instance with the provided server URL and metalake. * @@ -192,4 +203,8 @@ protected void output(T entity) { throw new IllegalArgumentException("Unsupported output format"); } } + + protected String getMissingEntitiesInfo(String... entities) { + return "Missing required argument(s): " + COMMA_JOINER.join(entities); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveMetalakeProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveMetalakeProperty.java index 9642456f375..0664ddaad15 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveMetalakeProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveMetalakeProperty.java @@ -60,4 +60,10 @@ public void handle() { System.out.println(property + " property removed."); } + + @Override + public Command validate() { + if (property == null) exitWithError(ErrorMessages.MISSING_PROPERTY); + return this; + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java index 817beaec91e..71e5b558985 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java @@ -63,4 +63,12 @@ public void handle() { System.out.println(metalake + " property set."); } + + @Override + public Command validate() { + if (property == null && value == null) exitWithError("Missing --property and --value options."); + if (property == null) exitWithError(ErrorMessages.MISSING_PROPERTY); + if (value == null) exitWithError(ErrorMessages.MISSING_VALUE); + return this; + } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestFulllName.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestFulllName.java index 48ee79cfcc5..f13d6e09201 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestFulllName.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestFulllName.java @@ -47,6 +47,7 @@ public class TestFulllName { @BeforeEach public void setUp() { + Main.useExit = false; options = new GravitinoOptions().options(); System.setOut(new PrintStream(outContent)); System.setErr(new PrintStream(errContent)); @@ -82,8 +83,7 @@ public void entityNotFound() throws Exception { CommandLine commandLine = new DefaultParser().parse(options, args); FullName fullName = new FullName(commandLine); - String metalakeName = fullName.getMetalakeName(); - assertNull(metalakeName); + assertThrows(RuntimeException.class, fullName::getMetalakeName); } @Test @@ -231,8 +231,7 @@ public void testGetMetalakeWithoutMetalakeOption() throws ParseException { String[] args = {"table", "list", "-i", "--name", "Hive_catalog.default"}; CommandLine commandLine = new DefaultParser().parse(options, args); FullName fullName = new FullName(commandLine); - String metalakeName = fullName.getMetalakeName(); - assertNull(metalakeName); + assertThrows(RuntimeException.class, fullName::getMetalakeName); String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); assertEquals(errOutput, ErrorMessages.MISSING_METALAKE); } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestMetalakeCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestMetalakeCommands.java index 01eebb6dab5..7df08b8ada5 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestMetalakeCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestMetalakeCommands.java @@ -19,6 +19,8 @@ package org.apache.gravitino.cli; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -29,6 +31,7 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Options; import org.apache.gravitino.cli.commands.CreateMetalake; @@ -87,6 +90,7 @@ void testListMetalakesCommand() { doReturn(mockList) .when(commandLine) .newListMetalakes(GravitinoCommandLine.DEFAULT_URL, false, null); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -104,6 +108,7 @@ void testMetalakeDetailsCommand() { doReturn(mockDetails) .when(commandLine) .newMetalakeDetails(GravitinoCommandLine.DEFAULT_URL, false, null, "metalake_demo"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @@ -121,6 +126,7 @@ void testMetalakeAuditCommand() { doReturn(mockAudit) .when(commandLine) .newMetalakeAudit(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo"); + doReturn(mockAudit).when(mockAudit).validate(); commandLine.handleCommandLine(); verify(mockAudit).handle(); } @@ -139,6 +145,7 @@ void testCreateMetalakeCommand() { doReturn(mockCreate) .when(commandLine) .newCreateMetalake(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "comment"); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -155,6 +162,7 @@ void testCreateMetalakeCommandNoComment() { doReturn(mockCreate) .when(commandLine) .newCreateMetalake(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", null); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -171,6 +179,7 @@ void testDeleteMetalakeCommand() { doReturn(mockDelete) .when(commandLine) .newDeleteMetalake(GravitinoCommandLine.DEFAULT_URL, false, false, "metalake_demo"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -188,6 +197,7 @@ void testDeleteMetalakeForceCommand() { doReturn(mockDelete) .when(commandLine) .newDeleteMetalake(GravitinoCommandLine.DEFAULT_URL, false, true, "metalake_demo"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -209,10 +219,50 @@ void testSetMetalakePropertyCommand() { .when(commandLine) .newSetMetalakeProperty( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "property", "value"); + doReturn(mockSetProperty).when(mockSetProperty).validate(); commandLine.handleCommandLine(); verify(mockSetProperty).handle(); } + @Test + void testSetMetalakePropertyCommandWithoutPropertyAndValue() { + Main.useExit = false; + SetMetalakeProperty metalakeProperty = + spy( + new SetMetalakeProperty( + GravitinoCommandLine.DEFAULT_URL, false, "demo_metalake", null, null)); + + assertThrows(RuntimeException.class, metalakeProperty::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals("Missing --property and --value options.", errOutput); + } + + @Test + void testSetMetalakePropertyCommandWithoutProperty() { + Main.useExit = false; + SetMetalakeProperty metalakeProperty = + spy( + new SetMetalakeProperty( + GravitinoCommandLine.DEFAULT_URL, false, "demo_metalake", null, "val1")); + + assertThrows(RuntimeException.class, metalakeProperty::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, errOutput); + } + + @Test + void testSetMetalakePropertyCommandWithoutValue() { + Main.useExit = false; + SetMetalakeProperty metalakeProperty = + spy( + new SetMetalakeProperty( + GravitinoCommandLine.DEFAULT_URL, false, "demo_metalake", "property1", null)); + + assertThrows(RuntimeException.class, metalakeProperty::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_VALUE, errOutput); + } + @Test void testRemoveMetalakePropertyCommand() { RemoveMetalakeProperty mockRemoveProperty = mock(RemoveMetalakeProperty.class); @@ -228,10 +278,24 @@ void testRemoveMetalakePropertyCommand() { .when(commandLine) .newRemoveMetalakeProperty( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "property"); + doReturn(mockRemoveProperty).when(mockRemoveProperty).validate(); commandLine.handleCommandLine(); verify(mockRemoveProperty).handle(); } + @Test + void testRemoveMetalakePropertyCommandWithoutProperty() { + Main.useExit = false; + RemoveMetalakeProperty mockRemoveProperty = + spy( + new RemoveMetalakeProperty( + GravitinoCommandLine.DEFAULT_URL, false, "demo_metalake", null)); + + assertThrows(RuntimeException.class, mockRemoveProperty::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, errOutput); + } + @Test void testListMetalakePropertiesCommand() { ListMetalakeProperties mockListProperties = mock(ListMetalakeProperties.class); @@ -244,6 +308,7 @@ void testListMetalakePropertiesCommand() { doReturn(mockListProperties) .when(commandLine) .newListMetalakeProperties(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo"); + doReturn(mockListProperties).when(mockListProperties).validate(); commandLine.handleCommandLine(); verify(mockListProperties).handle(); } @@ -263,6 +328,7 @@ void testUpdateMetalakeCommentCommand() { .when(commandLine) .newUpdateMetalakeComment( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "new comment"); + doReturn(mockUpdateComment).when(mockUpdateComment).validate(); commandLine.handleCommandLine(); verify(mockUpdateComment).handle(); } @@ -282,6 +348,7 @@ void testUpdateMetalakeNameCommand() { .when(commandLine) .newUpdateMetalakeName( GravitinoCommandLine.DEFAULT_URL, false, false, "metalake_demo", "new_name"); + doReturn(mockUpdateName).when(mockUpdateName).validate(); commandLine.handleCommandLine(); verify(mockUpdateName).handle(); } @@ -302,6 +369,7 @@ void testUpdateMetalakeNameForceCommand() { .when(commandLine) .newUpdateMetalakeName( GravitinoCommandLine.DEFAULT_URL, false, true, "metalake_demo", "new_name"); + doReturn(mockUpdateName).when(mockUpdateName).validate(); commandLine.handleCommandLine(); verify(mockUpdateName).handle(); } @@ -319,6 +387,7 @@ void testEnableMetalakeCommand() { doReturn(mockEnable) .when(commandLine) .newMetalakeEnable(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", false); + doReturn(mockEnable).when(mockEnable).validate(); commandLine.handleCommandLine(); verify(mockEnable).handle(); } @@ -337,6 +406,7 @@ void testEnableMetalakeCommandWithRecursive() { doReturn(mockEnable) .when(commandLine) .newMetalakeEnable(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", true); + doReturn(mockEnable).when(mockEnable).validate(); commandLine.handleCommandLine(); verify(mockEnable).handle(); } @@ -355,7 +425,7 @@ void testDisableMetalakeCommand() { doReturn(mockDisable) .when(commandLine) .newMetalakeDisable(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo"); - + doReturn(mockDisable).when(mockDisable).validate(); commandLine.handleCommandLine(); verify(mockDisable).handle(); } From 86ef6e89112aba8c77d96ef2f23f2db1ef63c3f2 Mon Sep 17 00:00:00 2001 From: Vignesh Suresh Kumar <55813127+VigneshSK17@users.noreply.github.com> Date: Sun, 5 Jan 2025 17:10:44 -0500 Subject: [PATCH 16/36] [#5963] feat(client): added delete cli command model (#6099) ### What changes were proposed in this pull request? The delete command is one of the commands suggested by @justinmclean as part of adding Model entity support for the CLI. ### Why are the changes needed? To add delete functionality for a Model using the CLI Improvement: https://github.com/apache/gravitino/issues/5963 (NOTE: Create command is redundant with addition of Register command) ### Does this PR introduce any user-facing change? Yes. The delete command for a model was added. ### How was this patch tested? Unit tests were added for Model CLI support and ran successfully for the delete command, along with CI tests in forked repository --- .../gravitino/cli/GravitinoCommandLine.java | 5 + .../gravitino/cli/TestableCommandLine.java | 12 +++ .../gravitino/cli/commands/DeleteModel.java | 96 +++++++++++++++++++ clients/cli/src/main/resources/model_help.txt | 37 ++++++- .../gravitino/cli/TestModelCommands.java | 30 +++++- 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteModel.java diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index f8347dfe1f5..507416d9bb0 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -1190,6 +1190,11 @@ private void handleModelCommand() { } break; + case CommandActions.DELETE: + boolean force = line.hasOption(GravitinoOptions.FORCE); + newDeleteModel(url, ignore, force, metalake, catalog, schema, model).handle(); + break; + case CommandActions.CREATE: String createComment = line.getOptionValue(GravitinoOptions.COMMENT); String[] createProperties = line.getOptionValues(GravitinoOptions.PROPERTIES); diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/TestableCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/TestableCommandLine.java index 8df9498d97b..c08a0950523 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/TestableCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/TestableCommandLine.java @@ -45,6 +45,7 @@ import org.apache.gravitino.cli.commands.DeleteFileset; import org.apache.gravitino.cli.commands.DeleteGroup; import org.apache.gravitino.cli.commands.DeleteMetalake; +import org.apache.gravitino.cli.commands.DeleteModel; import org.apache.gravitino.cli.commands.DeleteRole; import org.apache.gravitino.cli.commands.DeleteSchema; import org.apache.gravitino.cli.commands.DeleteTable; @@ -940,6 +941,17 @@ protected RegisterModel newCreateModel( return new RegisterModel(url, ignore, metalake, catalog, schema, model, comment, properties); } + protected DeleteModel newDeleteModel( + String url, + boolean ignore, + boolean force, + String metalake, + String catalog, + String schema, + String model) { + return new DeleteModel(url, ignore, force, metalake, catalog, schema, model); + } + protected LinkModel newLinkModel( String url, boolean ignore, diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteModel.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteModel.java new file mode 100644 index 00000000000..f44814ce68c --- /dev/null +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteModel.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.cli.commands; + +import org.apache.gravitino.NameIdentifier; +import org.apache.gravitino.cli.AreYouSure; +import org.apache.gravitino.cli.ErrorMessages; +import org.apache.gravitino.client.GravitinoClient; +import org.apache.gravitino.exceptions.NoSuchCatalogException; +import org.apache.gravitino.exceptions.NoSuchMetalakeException; +import org.apache.gravitino.exceptions.NoSuchModelException; +import org.apache.gravitino.exceptions.NoSuchSchemaException; + +/** Deletes an existing model. */ +public class DeleteModel extends Command { + + protected final String metalake; + protected final String catalog; + protected final String schema; + protected final String model; + protected final boolean force; + + /** + * Deletes an existing model. + * + * @param url The URL of the Gravitino server. + * @param ignoreVersions If true don't check the client/server versions match. + * @param force Force operation. + * @param metalake The name of the metalake. + * @param catalog The name of the catalog. + * @param schema The name of the schema. + * @param model The name of the model. + */ + public DeleteModel( + String url, + boolean ignoreVersions, + boolean force, + String metalake, + String catalog, + String schema, + String model) { + super(url, ignoreVersions); + this.force = force; + this.metalake = metalake; + this.catalog = catalog; + this.schema = schema; + this.model = model; + } + + /** Deletes an existing model. */ + public void handle() { + boolean deleted = false; + + if (!AreYouSure.really(force)) { + return; + } + + try (GravitinoClient client = buildClient(metalake)) { + NameIdentifier name = NameIdentifier.of(schema, model); + deleted = client.loadCatalog(catalog).asModelCatalog().deleteModel(name); + } catch (NoSuchMetalakeException noSuchMetalakeException) { + exitWithError(ErrorMessages.UNKNOWN_METALAKE); + } catch (NoSuchCatalogException noSuchCatalogException) { + exitWithError(ErrorMessages.UNKNOWN_CATALOG); + } catch (NoSuchSchemaException noSuchSchemaException) { + exitWithError(ErrorMessages.UNKNOWN_SCHEMA); + } catch (NoSuchModelException noSuchModelException) { + exitWithError(ErrorMessages.UNKNOWN_MODEL); + } catch (Exception err) { + exitWithError(err.getMessage()); + } + + if (deleted) { + System.out.println(model + " deleted."); + } else { + System.out.println(model + " not deleted."); + } + } +} diff --git a/clients/cli/src/main/resources/model_help.txt b/clients/cli/src/main/resources/model_help.txt index 04e9b8262ef..7becf2fd55d 100644 --- a/clients/cli/src/main/resources/model_help.txt +++ b/clients/cli/src/main/resources/model_help.txt @@ -1,8 +1,41 @@ -gcli model [details] +gcli model [list|details|create|update|delete] Please set the metalake in the Gravitino configuration file or the environment variable before running any of these commands. Example commands +Register a model +gcli model create --name hadoop.schema.model + +Register a model with comment +gcli model create --name hadoop.schema.model --comment comment + +Register a model with properties +gcli model create --name hadoop.schema.model --properties key1=val1 key2=val2 + +Register a model with properties" and comment +gcli model create --name hadoop.schema.model --properties key1=val1 key2=val2 --comment comment + +List models +gcli model list --name hadoop.schema + +Show a model's details +gcli model details --name hadoop.schema.model + Show model audit information -gcli model details --name catalog_postgres.hr --audit \ No newline at end of file +gcli model details --name hadoop.schema.model --audit + +Link a model +gcli model update --name hadoop.schema.model --uri file:///tmp/file + +Link a model with alias +gcli model update --name hadoop.schema.model --uri file:///tmp/file --alias aliasA aliasB + +Link a model with all component +gcli model update --name hadoop.schema.model --uri file:///tmp/file --alias aliasA aliasB --comment comment --properties key1=val1 key2=val2 + +Link a model without uri +gcli model update --name hadoop.schema.model + +Delete a model +gcli model delete --name hadoop.schema.model \ No newline at end of file diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java index 391201f292f..79000226013 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java @@ -39,6 +39,7 @@ import java.util.Map; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Options; +import org.apache.gravitino.cli.commands.DeleteModel; import org.apache.gravitino.cli.commands.LinkModel; import org.apache.gravitino.cli.commands.ListModel; import org.apache.gravitino.cli.commands.ModelAudit; @@ -303,11 +304,11 @@ void testRegisterModelCommand() { when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); when(mockCommandLine.hasOption(GravitinoOptions.PROPERTIES)).thenReturn(false); when(mockCommandLine.hasOption(GravitinoOptions.COMMENT)).thenReturn(false); + GravitinoCommandLine commandLine = spy( new GravitinoCommandLine( mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.CREATE)); - doReturn(mockCreate) .when(commandLine) .newCreateModel( @@ -337,7 +338,6 @@ void testRegisterModelCommandWithComment() { spy( new GravitinoCommandLine( mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.CREATE)); - doReturn(mockCreate) .when(commandLine) .newCreateModel( @@ -424,6 +424,32 @@ void testRegisterModelCommandWithCommentAndProperties() { verify(mockCreate).handle(); } + @Test + void testDeleteModelCommand() { + DeleteModel mockDelete = mock(DeleteModel.class); + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); + + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.DELETE)); + doReturn(mockDelete) + .when(commandLine) + .newDeleteModel( + GravitinoCommandLine.DEFAULT_URL, + false, + false, + "metalake_demo", + "catalog", + "schema", + "model"); + commandLine.handleCommandLine(); + verify(mockDelete).handle(); + } + @Test void testLinkModelCommandWithoutAlias() { LinkModel linkModelMock = mock(LinkModel.class); From e551330e1b3067415c19a19f9d9bba2479a86cd7 Mon Sep 17 00:00:00 2001 From: Justin Mclean Date: Mon, 6 Jan 2025 13:07:08 +1100 Subject: [PATCH 17/36] [Minor] fix duplicate/merge issue in Gravitino CLI model command (#6101) ### What changes were proposed in this pull request? remove duplicate if statement. ### Why are the changes needed? Looks like a merge issue. Fix: # N/A ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? tested locally --- .../java/org/apache/gravitino/cli/GravitinoCommandLine.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index 507416d9bb0..c545fbe2430 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -139,8 +139,6 @@ private void executeCommand() { handleCatalogCommand(); } else if (entity.equals(CommandEntities.METALAKE)) { handleMetalakeCommand(); - } else if (entity.equals(CommandEntities.MODEL)) { - handleModelCommand(); } else if (entity.equals(CommandEntities.TOPIC)) { handleTopicCommand(); } else if (entity.equals(CommandEntities.FILESET)) { From 900fef329c3b20d9eb68a8ca2d47c87ab8843a61 Mon Sep 17 00:00:00 2001 From: Justin Mclean Date: Mon, 6 Jan 2025 13:30:13 +1100 Subject: [PATCH 18/36] [#6103] Clean up error messages in Gravitino CLI (#6108) What changes were proposed in this pull request? Clean up the error messages. ### Why are the changes needed? Put them all in one place and made more consistent. Fix: #6103 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? Tested locally. --- .../apache/gravitino/cli/ErrorMessages.java | 92 +++++++++++-------- .../gravitino/cli/GravitinoCommandLine.java | 10 +- .../java/org/apache/gravitino/cli/Main.java | 2 +- .../org/apache/gravitino/cli/Privileges.java | 2 +- .../gravitino/cli/commands/Command.java | 3 +- .../gravitino/cli/commands/CreateTag.java | 2 +- .../gravitino/cli/commands/DeleteCatalog.java | 2 +- .../cli/commands/DeleteMetalake.java | 2 +- .../gravitino/cli/commands/DeleteTag.java | 2 +- .../cli/commands/GrantPrivilegesToRole.java | 2 +- .../gravitino/cli/commands/RegisterModel.java | 2 +- .../commands/RevokePrivilegesFromRole.java | 2 +- .../gravitino/cli/TestCatalogCommands.java | 6 +- .../gravitino/cli/TestColumnCommands.java | 14 +-- .../gravitino/cli/TestFilesetCommands.java | 10 +- .../org/apache/gravitino/cli/TestMain.java | 4 +- .../gravitino/cli/TestMetalakeCommands.java | 2 +- .../gravitino/cli/TestModelCommands.java | 10 +- .../gravitino/cli/TestSchemaCommands.java | 6 +- .../gravitino/cli/TestTableCommands.java | 10 +- .../apache/gravitino/cli/TestTagCommands.java | 6 +- .../gravitino/cli/TestTopicCommands.java | 10 +- 22 files changed, 109 insertions(+), 92 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java index 7fd4e272217..10b1e9579a0 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java @@ -21,51 +21,67 @@ /* User friendly error messages. */ public class ErrorMessages { - public static final String UNSUPPORTED_COMMAND = "Unsupported or unknown command."; - public static final String UNKNOWN_ENTITY = "Unknown entity."; - public static final String TOO_MANY_ARGUMENTS = "Too many arguments."; - public static final String UNKNOWN_METALAKE = "Unknown metalake name."; - public static final String UNKNOWN_CATALOG = "Unknown catalog name."; - public static final String UNKNOWN_SCHEMA = "Unknown schema name."; - public static final String UNKNOWN_TABLE = "Unknown table name."; - public static final String UNKNOWN_MODEL = "Unknown model name."; + public static final String CATALOG_EXISTS = "Catalog already exists."; + public static final String COLUMN_EXISTS = "Column already exists."; + public static final String FILESET_EXISTS = "Fileset already exists."; + public static final String GROUP_EXISTS = "Group already exists."; + public static final String METALAKE_EXISTS = "Metalake already exists."; + public static final String MODEL_EXISTS = "Model already exists."; + public static final String ROLE_EXISTS = "Role already exists."; + public static final String SCHEMA_EXISTS = "Schema already exists."; + public static final String TABLE_EXISTS = "Table already exists."; + public static final String TAG_EXISTS = "Tag already exists."; + public static final String TOPIC_EXISTS = "Topic already exists."; + public static final String USER_EXISTS = "User already exists."; + + public static final String ENTITY_IN_USE = " in use, please disable it first."; + + public static final String INVALID_ENABLE_DISABLE = + "Unable to us --enable and --disable at the same time"; + public static final String INVALID_OWNER_COMMAND = + "Unsupported combination of options either use --user or --group."; + public static final String INVALID_REMOVE_COMMAND = + "Unsupported combination of options either use --name or --property."; + public static final String INVALID_SET_COMMAND = + "Unsupported combination of options either use --name, --user, --group or --property and --value."; + + public static final String HELP_FAILED = "Failed to load help message: "; + public static final String MALFORMED_NAME = "Malformed entity name."; - public static final String MISSING_NAME = "Missing --name option."; - public static final String MISSING_METALAKE = "Missing --metalake option."; + public static final String MISSING_ENTITIES = "Missing required entity names: "; + public static final String MISSING_GROUP = "Missing --group option."; - public static final String MISSING_USER = "Missing --user option."; + public static final String MISSING_METALAKE = "Missing --metalake option."; + public static final String MISSING_NAME = "Missing --name option."; + public static final String MISSING_PROPERTY = "Missing --property option."; public static final String MISSING_ROLE = "Missing --role option."; public static final String MISSING_TAG = "Missing --tag option."; public static final String MISSING_URI = "Missing --uri option."; - public static final String MISSING_PROPERTY = "Missing --property option."; + public static final String MISSING_USER = "Missing --user option."; public static final String MISSING_VALUE = "Missing --value option."; - public static final String METALAKE_EXISTS = "Metalake already exists."; - public static final String CATALOG_EXISTS = "Catalog already exists."; - public static final String SCHEMA_EXISTS = "Schema already exists."; - public static final String UNKNOWN_USER = "Unknown user."; - public static final String USER_EXISTS = "User already exists."; - public static final String UNKNOWN_GROUP = "Unknown group."; - public static final String GROUP_EXISTS = "Group already exists."; - public static final String UNKNOWN_TAG = "Unknown tag."; + public static final String MULTIPLE_TAG_COMMAND_ERROR = - "Error: The current command only supports one --tag option."; - public static final String TAG_EXISTS = "Tag already exists."; - public static final String UNKNOWN_COLUMN = "Unknown column."; - public static final String COLUMN_EXISTS = "Column already exists."; - public static final String UNKNOWN_TOPIC = "Unknown topic."; - public static final String TOPIC_EXISTS = "Topic already exists."; - public static final String MODEL_EXISTS = "Model already exists."; - public static final String UNKNOWN_FILESET = "Unknown fileset."; - public static final String FILESET_EXISTS = "Fileset already exists."; - public static final String TAG_EMPTY = "Error: Must configure --tag option."; + "This command only supports one --tag option."; + + public static final String REGISTER_FAILED = "Failed to register model: "; + + public static final String UNKNOWN_CATALOG = "Unknown catalog name."; + public static final String UNKNOWN_COLUMN = "Unknown column name."; + public static final String UNKNOWN_ENTITY = "Unknown entity."; + public static final String UNKNOWN_FILESET = "Unknown fileset name."; + public static final String UNKNOWN_GROUP = "Unknown group."; + public static final String UNKNOWN_METALAKE = "Unknown metalake name."; + public static final String UNKNOWN_MODEL = "Unknown model name."; + public static final String UNKNOWN_PRIVILEGE = "Unknown privilege"; public static final String UNKNOWN_ROLE = "Unknown role."; - public static final String ROLE_EXISTS = "Role already exists."; - public static final String TABLE_EXISTS = "Table already exists."; - public static final String INVALID_SET_COMMAND = - "Unsupported combination of options either use --name, --user, --group or --property and --value."; - public static final String INVALID_REMOVE_COMMAND = - "Unsupported combination of options either use --name or --property."; - public static final String INVALID_OWNER_COMMAND = - "Unsupported combination of options either use --user or --group."; + public static final String UNKNOWN_SCHEMA = "Unknown schema name."; + public static final String UNKNOWN_TABLE = "Unknown table name."; + public static final String UNKNOWN_TAG = "Unknown tag."; + public static final String UNKNOWN_TOPIC = "Unknown topic name."; + public static final String UNKNOWN_USER = "Unknown user."; + + public static final String PARSE_ERROR = "Error parsing command line: "; + public static final String TOO_MANY_ARGUMENTS = "Too many arguments."; public static final String UNSUPPORTED_ACTION = "Entity doesn't support this action."; + public static final String UNSUPPORTED_COMMAND = "Unsupported or unknown command."; } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index c545fbe2430..e19cc733f21 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -211,7 +211,7 @@ private void handleMetalakeCommand() { case CommandActions.UPDATE: if (line.hasOption(GravitinoOptions.ENABLE) && line.hasOption(GravitinoOptions.DISABLE)) { - System.err.println("Unable to enable and disable at the same time"); + System.err.println(ErrorMessages.INVALID_ENABLE_DISABLE); Main.exit(-1); } if (line.hasOption(GravitinoOptions.ENABLE)) { @@ -304,7 +304,7 @@ private void handleCatalogCommand() { case CommandActions.UPDATE: if (line.hasOption(GravitinoOptions.ENABLE) && line.hasOption(GravitinoOptions.DISABLE)) { - System.err.println("Unable to enable and disable at the same time"); + System.err.println(ErrorMessages.INVALID_ENABLE_DISABLE); Main.exit(-1); } if (line.hasOption(GravitinoOptions.ENABLE)) { @@ -673,7 +673,7 @@ protected void handleTagCommand() { } newTagEntity(url, ignore, metalake, name, tags).handle(); } else { - System.err.println("The set command only supports tag properties or attaching tags."); + System.err.println(ErrorMessages.INVALID_SET_COMMAND); Main.exit(-1); } break; @@ -932,7 +932,7 @@ private void handleHelpCommand() { } System.out.print(helpMessage.toString()); } catch (IOException e) { - System.err.println("Failed to load help message: " + e.getMessage()); + System.err.println(ErrorMessages.HELP_FAILED + e.getMessage()); Main.exit(-1); } } @@ -1309,7 +1309,7 @@ public String getAuth() { private void checkEntities(List entities) { if (!entities.isEmpty()) { - System.err.println("Missing required argument(s): " + COMMA_JOINER.join(entities)); + System.err.println(ErrorMessages.MISSING_ENTITIES + COMMA_JOINER.join(entities)); Main.exit(-1); } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/Main.java b/clients/cli/src/main/java/org/apache/gravitino/cli/Main.java index 1f4a3926ef5..8c28d7e8a29 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/Main.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/Main.java @@ -56,7 +56,7 @@ public static void main(String[] args) { commandLine.handleSimpleLine(); } } catch (ParseException exp) { - System.err.println("Error parsing command line: " + exp.getMessage()); + System.err.println(ErrorMessages.PARSE_ERROR + exp.getMessage()); GravitinoCommandLine.displayHelp(options); exit(-1); } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/Privileges.java b/clients/cli/src/main/java/org/apache/gravitino/cli/Privileges.java index 9d47d8fc9c8..fa904663318 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/Privileges.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/Privileges.java @@ -112,7 +112,7 @@ public static Privilege.Name toName(String privilege) { case MANAGE_GRANTS: return Privilege.Name.MANAGE_GRANTS; default: - System.err.println("Unknown privilege"); + System.err.println(ErrorMessages.UNKNOWN_PRIVILEGE + " " + privilege); return null; } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java index cb11d7dfcef..d881a1dbcd6 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java @@ -23,6 +23,7 @@ import com.google.common.base.Joiner; import java.io.File; +import org.apache.gravitino.cli.ErrorMessages; import org.apache.gravitino.cli.GravitinoConfig; import org.apache.gravitino.cli.KerberosData; import org.apache.gravitino.cli.Main; @@ -205,6 +206,6 @@ protected void output(T entity) { } protected String getMissingEntitiesInfo(String... entities) { - return "Missing required argument(s): " + COMMA_JOINER.join(entities); + return ErrorMessages.MISSING_ENTITIES + COMMA_JOINER.join(entities); } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTag.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTag.java index 0dd4289bb75..87ab0da779d 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTag.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTag.java @@ -53,7 +53,7 @@ public CreateTag( @Override public void handle() { if (tags == null || tags.length == 0) { - System.err.println(ErrorMessages.TAG_EMPTY); + System.err.println(ErrorMessages.MISSING_TAG); } else { boolean hasOnlyOneTag = tags.length == 1; if (hasOnlyOneTag) { diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteCatalog.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteCatalog.java index 6aa8e5ad904..7cb9bf7d9c8 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteCatalog.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteCatalog.java @@ -66,7 +66,7 @@ public void handle() { } catch (NoSuchCatalogException err) { exitWithError(ErrorMessages.UNKNOWN_CATALOG); } catch (CatalogInUseException catalogInUseException) { - System.err.println(catalog + " in use, please disable it first."); + System.err.println(catalog + ErrorMessages.ENTITY_IN_USE); } catch (Exception exp) { exitWithError(exp.getMessage()); } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteMetalake.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteMetalake.java index e88ae41486f..3bad108a9ec 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteMetalake.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteMetalake.java @@ -58,7 +58,7 @@ public void handle() { } catch (NoSuchMetalakeException err) { exitWithError(ErrorMessages.UNKNOWN_METALAKE); } catch (MetalakeInUseException inUseException) { - System.err.println(metalake + " in use, please disable it first."); + System.err.println(metalake + ErrorMessages.ENTITY_IN_USE); } catch (Exception exp) { exitWithError(exp.getMessage()); } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteTag.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteTag.java index d3db384c094..1e05292c82a 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteTag.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteTag.java @@ -59,7 +59,7 @@ public void handle() { } if (tags == null || tags.length == 0) { - System.err.println(ErrorMessages.TAG_EMPTY); + System.err.println(ErrorMessages.MISSING_TAG); } else { boolean hasOnlyOneTag = tags.length == 1; if (hasOnlyOneTag) { diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/GrantPrivilegesToRole.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/GrantPrivilegesToRole.java index e3c9fa4944e..584e073beac 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/GrantPrivilegesToRole.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/GrantPrivilegesToRole.java @@ -73,7 +73,7 @@ public void handle() { for (String privilege : privileges) { if (!Privileges.isValid(privilege)) { - System.err.println("Unknown privilege " + privilege); + System.err.println(ErrorMessages.UNKNOWN_PRIVILEGE + " " + privilege); return; } PrivilegeDTO privilegeDTO = diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RegisterModel.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RegisterModel.java index d50dbed50e2..7c8cd120bf4 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RegisterModel.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RegisterModel.java @@ -96,7 +96,7 @@ public void handle() { if (registeredModel != null) { System.out.println("Successful register " + registeredModel.name() + "."); } else { - System.err.println("Failed to register model: " + model + "."); + System.err.println(ErrorMessages.REGISTER_FAILED + model + "."); Main.exit(-1); } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RevokePrivilegesFromRole.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RevokePrivilegesFromRole.java index 8077532319e..a62e977a2fb 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RevokePrivilegesFromRole.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RevokePrivilegesFromRole.java @@ -73,7 +73,7 @@ public void handle() { for (String privilege : privileges) { if (!Privileges.isValid(privilege)) { - System.err.println("Unknown privilege " + privilege); + System.err.println(ErrorMessages.UNKNOWN_PRIVILEGE + " " + privilege); return; } PrivilegeDTO privilegeDTO = diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java index 44e5537955f..bd8f30b5adb 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java @@ -345,9 +345,9 @@ void testCatalogDetailsCommandWithoutCatalog() { String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); assertEquals( output, - "Missing --name option." + ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + CommandEntities.CATALOG); } @@ -436,6 +436,6 @@ void testCatalogWithDisableAndEnableOptions() { GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", false); verify(commandLine, never()) .newCatalogDisable(GravitinoCommandLine.DEFAULT_URL, false, "melake_demo", "catalog"); - assertTrue(errContent.toString().contains("Unable to enable and disable at the same time")); + assertTrue(errContent.toString().contains(ErrorMessages.INVALID_ENABLE_DISABLE)); } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestColumnCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestColumnCommands.java index b6159343ef0..2d1e12debcf 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestColumnCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestColumnCommands.java @@ -464,7 +464,7 @@ void testDeleteColumnCommandWithoutCatalog() { output, ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ") .join( Arrays.asList( @@ -496,7 +496,7 @@ void testDeleteColumnCommandWithoutSchema() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ") .join( Arrays.asList( @@ -531,7 +531,7 @@ void testDeleteColumnCommandWithoutTable() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.TABLE, CommandEntities.COLUMN))); } @@ -563,7 +563,7 @@ void testDeleteColumnCommandWithoutColumn() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.COLUMN))); } @@ -588,7 +588,7 @@ void testListColumnCommandWithoutCatalog() { output, ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ") .join( Arrays.asList( @@ -617,7 +617,7 @@ void testListColumnCommandWithoutSchema() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.SCHEMA, CommandEntities.TABLE))); } @@ -643,7 +643,7 @@ void testListColumnCommandWithoutTable() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + CommandEntities.TABLE); } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestFilesetCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestFilesetCommands.java index b46b73cc3dd..1e8c54124c1 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestFilesetCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestFilesetCommands.java @@ -369,7 +369,7 @@ void testListFilesetCommandWithoutCatalog() { output, ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.CATALOG, CommandEntities.SCHEMA))); } @@ -394,7 +394,7 @@ void testListFilesetCommandWithoutSchema() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.SCHEMA))); } @@ -419,7 +419,7 @@ void testFilesetDetailCommandWithoutCatalog() { output, ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ") .join( Arrays.asList( @@ -448,7 +448,7 @@ void testFilesetDetailCommandWithoutSchema() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.SCHEMA, CommandEntities.FILESET))); } @@ -474,7 +474,7 @@ void testFilesetDetailCommandWithoutFileset() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.FILESET))); } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestMain.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestMain.java index 1d1ffded0ff..c9cd437cf3d 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestMain.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestMain.java @@ -189,7 +189,7 @@ public void CreateTagWithNoTag() { Main.main(args); - assertTrue(errContent.toString().contains(ErrorMessages.TAG_EMPTY)); // Expect error + assertTrue(errContent.toString().contains(ErrorMessages.MISSING_TAG)); // Expect error } @SuppressWarnings("DefaultCharset") @@ -198,6 +198,6 @@ public void DeleteTagWithNoTag() { Main.main(args); - assertTrue(errContent.toString().contains(ErrorMessages.TAG_EMPTY)); // Expect error + assertTrue(errContent.toString().contains(ErrorMessages.MISSING_TAG)); // Expect error } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestMetalakeCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestMetalakeCommands.java index 7df08b8ada5..dae2fe63400 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestMetalakeCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestMetalakeCommands.java @@ -449,6 +449,6 @@ void testMetalakeWithDisableAndEnableOptions() { .newMetalakeEnable(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", false); verify(commandLine, never()) .newMetalakeEnable(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", false); - assertTrue(errContent.toString().contains("Unable to enable and disable at the same time")); + assertTrue(errContent.toString().contains(ErrorMessages.INVALID_ENABLE_DISABLE)); } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java index 79000226013..8d475d3625a 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java @@ -121,7 +121,7 @@ void testListModelCommandWithoutCatalog() { assertEquals( ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + joiner.join(Arrays.asList(CommandEntities.CATALOG, CommandEntities.SCHEMA)), output); } @@ -150,7 +150,7 @@ void testListModelCommandWithoutSchema() { assertEquals( ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + joiner.join(Collections.singletonList(CommandEntities.SCHEMA)), output); } @@ -205,7 +205,7 @@ void testModelDetailsCommandWithoutCatalog() { assertEquals( ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + joiner.join( Arrays.asList( CommandEntities.CATALOG, CommandEntities.SCHEMA, CommandEntities.MODEL)), @@ -238,7 +238,7 @@ void testModelDetailsCommandWithoutSchema() { assertEquals( ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + joiner.join(Arrays.asList(CommandEntities.SCHEMA, CommandEntities.MODEL)), output); } @@ -269,7 +269,7 @@ void testModelDetailsCommandWithoutModel() { assertEquals( ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + joiner.join(Collections.singletonList(CommandEntities.MODEL)), output); } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java index 190e866355b..b3f67174fbd 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java @@ -287,7 +287,7 @@ void testListSchemaWithoutCatalog() { verify(commandLine, never()) .newListSchema(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", null); assertTrue( - errContent.toString().contains("Missing required argument(s): " + CommandEntities.CATALOG)); + errContent.toString().contains(ErrorMessages.MISSING_ENTITIES + CommandEntities.CATALOG)); } @Test @@ -308,7 +308,7 @@ void testDetailsSchemaWithoutCatalog() { errContent .toString() .contains( - "Missing required argument(s): " + ErrorMessages.MISSING_ENTITIES + CommandEntities.CATALOG + ", " + CommandEntities.SCHEMA)); @@ -330,6 +330,6 @@ void testDetailsSchemaWithoutSchema() { assertThrows(RuntimeException.class, commandLine::handleCommandLine); assertTrue( - errContent.toString().contains("Missing required argument(s): " + CommandEntities.SCHEMA)); + errContent.toString().contains(ErrorMessages.MISSING_ENTITIES + CommandEntities.SCHEMA)); } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java index c4a8223dd48..946c330178d 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java @@ -457,7 +457,7 @@ void testListTableWithoutCatalog() { output, ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + CommandEntities.CATALOG + ", " + CommandEntities.SCHEMA); @@ -485,7 +485,7 @@ void testListTableWithoutSchema() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + CommandEntities.SCHEMA); } @@ -510,7 +510,7 @@ void testDetailTableWithoutCatalog() { output, ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + CommandEntities.CATALOG + ", " + CommandEntities.SCHEMA @@ -539,7 +539,7 @@ void testDetailTableWithoutSchema() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + CommandEntities.SCHEMA + ", " + CommandEntities.TABLE); @@ -568,7 +568,7 @@ void testDetailTableWithoutTable() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + CommandEntities.TABLE); } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java index 74932ca87b3..3279c23d141 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java @@ -322,7 +322,7 @@ void testSetTagPropertyCommandWithoutPropertyOption() { isNull(), eq("value")); String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); - assertEquals(output, "The set command only supports tag properties or attaching tags."); + assertEquals(output, ErrorMessages.INVALID_SET_COMMAND); } @Test @@ -350,7 +350,7 @@ void testSetTagPropertyCommandWithoutValueOption() { eq("property"), isNull()); String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); - assertEquals(output, "The set command only supports tag properties or attaching tags."); + assertEquals(output, ErrorMessages.INVALID_SET_COMMAND); } @Test @@ -371,7 +371,7 @@ void testSetMultipleTagPropertyCommandError() { Assertions.assertThrows( IllegalArgumentException.class, () -> commandLine.handleCommandLine(), - "Error: The current command only supports one --tag option."); + ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR); } @Test diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTopicCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTopicCommands.java index 7fa2e453f32..c886b4f8ede 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTopicCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTopicCommands.java @@ -317,7 +317,7 @@ void testListTopicCommandWithoutCatalog() { output, ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.CATALOG, CommandEntities.SCHEMA))); } @@ -342,7 +342,7 @@ void testListTopicCommandWithoutSchema() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.SCHEMA))); } @@ -367,7 +367,7 @@ void testTopicDetailsCommandWithoutCatalog() { output, ErrorMessages.MISSING_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ") .join( Arrays.asList( @@ -396,7 +396,7 @@ void testTopicDetailsCommandWithoutSchema() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.SCHEMA, CommandEntities.TOPIC))); } @@ -422,7 +422,7 @@ void testTopicDetailsCommandWithoutTopic() { output, ErrorMessages.MALFORMED_NAME + "\n" - + "Missing required argument(s): " + + ErrorMessages.MISSING_ENTITIES + Joiner.on(", ").join(Arrays.asList(CommandEntities.TOPIC))); } } From 5ee5fb5d06a78d5951cd3049e38ec28bc1af67b6 Mon Sep 17 00:00:00 2001 From: FANNG Date: Mon, 6 Jan 2025 11:16:43 +0800 Subject: [PATCH 19/36] [#6028] feat(core): add GCS get bucket permission for GCS fileset operation (#6041) ### What changes were proposed in this pull request? add get bucket permission for GCS fileset operation ### Why are the changes needed? Fix: #6028 ### Does this PR introduce _any_ user-facing change? no ### How was this patch tested? run pass fileset IT --- .../apache/gravitino/gcs/credential/GCSTokenProvider.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bundles/gcp/src/main/java/org/apache/gravitino/gcs/credential/GCSTokenProvider.java b/bundles/gcp/src/main/java/org/apache/gravitino/gcs/credential/GCSTokenProvider.java index 3f7d5bcfaa3..f499b8c3e85 100644 --- a/bundles/gcp/src/main/java/org/apache/gravitino/gcs/credential/GCSTokenProvider.java +++ b/bundles/gcp/src/main/java/org/apache/gravitino/gcs/credential/GCSTokenProvider.java @@ -146,6 +146,13 @@ private CredentialAccessBoundary getAccessBoundary( CredentialAccessBoundary.newBuilder(); readBuckets.forEach( bucket -> { + // Hadoop GCS connector needs to get bucket info + AccessBoundaryRule bucketInfoRule = + AccessBoundaryRule.newBuilder() + .setAvailableResource(toGCSBucketResource(bucket)) + .setAvailablePermissions(Arrays.asList("inRole:roles/storage.legacyBucketReader")) + .build(); + credentialAccessBoundaryBuilder.addRule(bucketInfoRule); List readConditions = readExpressions.get(bucket); AccessBoundaryRule rule = getAccessBoundaryRule( From 9d251096b1fa4d5f3023c17f75403c481f844985 Mon Sep 17 00:00:00 2001 From: FANNG Date: Mon, 6 Jan 2025 11:19:06 +0800 Subject: [PATCH 20/36] [MINOR] add auto cherry pick to branch-0.8 (#6089) ### What changes were proposed in this pull request? add auto cherry pick to branch-0.8 ### Why are the changes needed? prepare release 0.8 ### Does this PR introduce _any_ user-facing change? no ### How was this patch tested? no --- .github/workflows/auto-cherry-pick.yml | 40 +++++++------------------- 1 file changed, 11 insertions(+), 29 deletions(-) diff --git a/.github/workflows/auto-cherry-pick.yml b/.github/workflows/auto-cherry-pick.yml index 0a264e83526..efea2823706 100644 --- a/.github/workflows/auto-cherry-pick.yml +++ b/.github/workflows/auto-cherry-pick.yml @@ -7,60 +7,42 @@ on: types: ["closed"] jobs: - cherry_pick_branch_0_5: - runs-on: ubuntu-latest - name: Cherry pick into branch_0.5 - if: ${{ contains(github.event.pull_request.labels.*.name, 'branch-0.5') && github.event.pull_request.merged == true }} - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Cherry pick into branch-0.5 - uses: carloscastrojumo/github-cherry-pick-action@v1.0.9 - with: - branch: branch-0.5 - labels: | - cherry-pick - reviewers: | - jerryshao - - cherry_pick_branch_0_6: + cherry_pick_branch_0_7: runs-on: ubuntu-latest - name: Cherry pick into branch_0.6 - if: ${{ contains(github.event.pull_request.labels.*.name, 'branch-0.6') && github.event.pull_request.merged == true }} + name: Cherry pick into branch_0.7 + if: ${{ contains(github.event.pull_request.labels.*.name, 'branch-0.7') && github.event.pull_request.merged == true }} steps: - name: Checkout uses: actions/checkout@v2 with: fetch-depth: 0 - - name: Cherry pick into branch-0.6 + - name: Cherry pick into branch-0.7 uses: carloscastrojumo/github-cherry-pick-action@v1.0.9 with: - branch: branch-0.6 + branch: branch-0.7 labels: | cherry-pick reviewers: | jerryshao - - cherry_pick_branch_0_7: + cherry_pick_branch_0_8: runs-on: ubuntu-latest - name: Cherry pick into branch_0.7 - if: ${{ contains(github.event.pull_request.labels.*.name, 'branch-0.7') && github.event.pull_request.merged == true }} + name: Cherry pick into branch_0.8 + if: ${{ contains(github.event.pull_request.labels.*.name, 'branch-0.8') && github.event.pull_request.merged == true }} steps: - name: Checkout uses: actions/checkout@v2 with: fetch-depth: 0 - - name: Cherry pick into branch-0.7 + - name: Cherry pick into branch-0.8 uses: carloscastrojumo/github-cherry-pick-action@v1.0.9 with: - branch: branch-0.7 + branch: branch-0.8 labels: | cherry-pick reviewers: | jerryshao + FANNG1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 09ad370429b51333b0d1ae70b6dd939ee5f29424 Mon Sep 17 00:00:00 2001 From: Lord of Abyss <103809695+Abyss-lord@users.noreply.github.com> Date: Mon, 6 Jan 2025 13:30:14 +0800 Subject: [PATCH 21/36] [#6087] fix(CLI): Refactor the validation logic of catalog (#6104) ### What changes were proposed in this pull request? Add `validate` method to Command, and refactor the validation code of catalog. ### Why are the changes needed? Fix: #6087 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? ut + local test ```bash gcli catalog create -m demo_metalake --name test_catalog # Missing --provider option. gcli catalog set -m demo_metalake --name Hive_catalog # Missing --property and --value options. gcli catalog set -m demo_metalake --name Hive_catalog --property propertyA # Missing --value option. gcli catalog set -m demo_metalake --name Hive_catalog --value valA # Missing --property option. gcli catalog remove -m demo_metalake --name Hive_catalog # Missing --property option. ``` --------- Co-authored-by: Justin Mclean --- .../apache/gravitino/cli/ErrorMessages.java | 2 + .../gravitino/cli/GravitinoCommandLine.java | 26 ++--- .../gravitino/cli/commands/Command.java | 12 +++ .../gravitino/cli/commands/CreateCatalog.java | 6 ++ .../cli/commands/RemoveCatalogProperty.java | 6 ++ .../cli/commands/SetCatalogProperty.java | 6 ++ .../cli/commands/SetMetalakeProperty.java | 4 +- .../gravitino/cli/TestCatalogCommands.java | 94 +++++++++++++++++++ 8 files changed, 142 insertions(+), 14 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java index 10b1e9579a0..c6c2a8d9814 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java @@ -62,6 +62,8 @@ public class ErrorMessages { public static final String MULTIPLE_TAG_COMMAND_ERROR = "This command only supports one --tag option."; + public static final String MISSING_PROPERTY_AND_VALUE = "Missing --property and --value options."; + public static final String MISSING_PROVIDER = "Missing --provider option."; public static final String REGISTER_FAILED = "Failed to register model: "; diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index e19cc733f21..b3917c4f063 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -268,9 +268,9 @@ private void handleCatalogCommand() { switch (command) { case CommandActions.DETAILS: if (line.hasOption(GravitinoOptions.AUDIT)) { - newCatalogAudit(url, ignore, metalake, catalog).handle(); + newCatalogAudit(url, ignore, metalake, catalog).validate().handle(); } else { - newCatalogDetails(url, ignore, outputFormat, metalake, catalog).handle(); + newCatalogDetails(url, ignore, outputFormat, metalake, catalog).validate().handle(); } break; @@ -279,27 +279,29 @@ private void handleCatalogCommand() { String provider = line.getOptionValue(GravitinoOptions.PROVIDER); String[] properties = line.getOptionValues(CommandActions.PROPERTIES); Map propertyMap = new Properties().parse(properties); - newCreateCatalog(url, ignore, metalake, catalog, provider, comment, propertyMap).handle(); + newCreateCatalog(url, ignore, metalake, catalog, provider, comment, propertyMap) + .validate() + .handle(); break; case CommandActions.DELETE: boolean force = line.hasOption(GravitinoOptions.FORCE); - newDeleteCatalog(url, ignore, force, metalake, catalog).handle(); + newDeleteCatalog(url, ignore, force, metalake, catalog).validate().handle(); break; case CommandActions.SET: String property = line.getOptionValue(GravitinoOptions.PROPERTY); String value = line.getOptionValue(GravitinoOptions.VALUE); - newSetCatalogProperty(url, ignore, metalake, catalog, property, value).handle(); + newSetCatalogProperty(url, ignore, metalake, catalog, property, value).validate().handle(); break; case CommandActions.REMOVE: property = line.getOptionValue(GravitinoOptions.PROPERTY); - newRemoveCatalogProperty(url, ignore, metalake, catalog, property).handle(); + newRemoveCatalogProperty(url, ignore, metalake, catalog, property).validate().handle(); break; case CommandActions.PROPERTIES: - newListCatalogProperties(url, ignore, metalake, catalog).handle(); + newListCatalogProperties(url, ignore, metalake, catalog).validate().handle(); break; case CommandActions.UPDATE: @@ -309,19 +311,21 @@ private void handleCatalogCommand() { } if (line.hasOption(GravitinoOptions.ENABLE)) { boolean enableMetalake = line.hasOption(GravitinoOptions.ALL); - newCatalogEnable(url, ignore, metalake, catalog, enableMetalake).handle(); + newCatalogEnable(url, ignore, metalake, catalog, enableMetalake).validate().handle(); } if (line.hasOption(GravitinoOptions.DISABLE)) { - newCatalogDisable(url, ignore, metalake, catalog).handle(); + newCatalogDisable(url, ignore, metalake, catalog).validate().handle(); } if (line.hasOption(GravitinoOptions.COMMENT)) { String updateComment = line.getOptionValue(GravitinoOptions.COMMENT); - newUpdateCatalogComment(url, ignore, metalake, catalog, updateComment).handle(); + newUpdateCatalogComment(url, ignore, metalake, catalog, updateComment) + .validate() + .handle(); } if (line.hasOption(GravitinoOptions.RENAME)) { String newName = line.getOptionValue(GravitinoOptions.RENAME); - newUpdateCatalogName(url, ignore, metalake, catalog, newName).handle(); + newUpdateCatalogName(url, ignore, metalake, catalog, newName).validate().handle(); } break; diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java index d881a1dbcd6..98c4096cb04 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java @@ -111,6 +111,18 @@ public Command validate() { return this; } + /** + * Validates that both property and value parameters are not null. + * + * @param property The property name to check + * @param value The value associated with the property + */ + protected void checkProperty(String property, String value) { + if (property == null && value == null) exitWithError(ErrorMessages.MISSING_PROPERTY_AND_VALUE); + if (property == null) exitWithError(ErrorMessages.MISSING_PROPERTY); + if (value == null) exitWithError(ErrorMessages.MISSING_VALUE); + } + /** * Builds a {@link GravitinoClient} instance with the provided server URL and metalake. * diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateCatalog.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateCatalog.java index e0c11c1e040..2870dd7103e 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateCatalog.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateCatalog.java @@ -81,4 +81,10 @@ public void handle() { System.out.println(catalog + " catalog created"); } + + @Override + public Command validate() { + if (provider == null) exitWithError(ErrorMessages.MISSING_PROVIDER); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveCatalogProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveCatalogProperty.java index a460d91b2fe..c777ba16282 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveCatalogProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveCatalogProperty.java @@ -66,4 +66,10 @@ public void handle() { System.out.println(property + " property removed."); } + + @Override + public Command validate() { + if (property == null) exitWithError(ErrorMessages.MISSING_PROPERTY); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetCatalogProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetCatalogProperty.java index 21b1a6f1c9f..8b511d7458b 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetCatalogProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetCatalogProperty.java @@ -74,4 +74,10 @@ public void handle() { System.out.println(catalog + " property set."); } + + @Override + public Command validate() { + checkProperty(property, value); + return this; + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java index 71e5b558985..ff945cf7425 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java @@ -66,9 +66,7 @@ public void handle() { @Override public Command validate() { - if (property == null && value == null) exitWithError("Missing --property and --value options."); - if (property == null) exitWithError(ErrorMessages.MISSING_PROPERTY); - if (value == null) exitWithError(ErrorMessages.MISSING_VALUE); + checkProperty(property, value); return this; } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java index bd8f30b5adb..04c0dacc13b 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java @@ -112,6 +112,7 @@ void testCatalogDetailsCommand() { .when(commandLine) .newCatalogDetails( GravitinoCommandLine.DEFAULT_URL, false, null, "metalake_demo", "catalog"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @@ -131,6 +132,7 @@ void testCatalogAuditCommand() { doReturn(mockAudit) .when(commandLine) .newCatalogAudit(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog"); + doReturn(mockAudit).when(mockAudit).validate(); commandLine.handleCommandLine(); verify(mockAudit).handle(); } @@ -167,10 +169,30 @@ void testCreateCatalogCommand() { "postgres", "comment", map); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } + @Test + void testCreateCatalogCommandWithoutProvider() { + Main.useExit = false; + CreateCatalog mockCreateCatalog = + spy( + new CreateCatalog( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + null, + "comment", + null)); + + assertThrows(RuntimeException.class, mockCreateCatalog::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROVIDER, errOutput); + } + @Test void testDeleteCatalogCommand() { DeleteCatalog mockDelete = mock(DeleteCatalog.class); @@ -186,6 +208,7 @@ void testDeleteCatalogCommand() { .when(commandLine) .newDeleteCatalog( GravitinoCommandLine.DEFAULT_URL, false, false, "metalake_demo", "catalog"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -206,6 +229,7 @@ void testDeleteCatalogForceCommand() { .when(commandLine) .newDeleteCatalog( GravitinoCommandLine.DEFAULT_URL, false, true, "metalake_demo", "catalog"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -234,10 +258,60 @@ void testSetCatalogPropertyCommand() { "catalog", "property", "value"); + doReturn(mockSetProperty).when(mockSetProperty).validate(); commandLine.handleCommandLine(); verify(mockSetProperty).handle(); } + @Test + void testSetCatalogPropertyCommandWithoutPropertyAndValue() { + Main.useExit = false; + SetCatalogProperty mockSetProperty = + spy( + new SetCatalogProperty( + GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", null, null)); + + assertThrows(RuntimeException.class, mockSetProperty::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals("Missing --property and --value options.", errOutput); + } + + @Test + void testSetCatalogPropertyCommandWithoutProperty() { + Main.useExit = false; + SetCatalogProperty mockSetProperty = + spy( + new SetCatalogProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + null, + "value")); + + assertThrows(RuntimeException.class, mockSetProperty::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, errOutput); + } + + @Test + void testSetCatalogPropertyCommandWithoutValue() { + Main.useExit = false; + SetCatalogProperty mockSetProperty = + spy( + new SetCatalogProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "property", + null)); + + assertThrows(RuntimeException.class, mockSetProperty::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_VALUE, errOutput); + } + @Test void testRemoveCatalogPropertyCommand() { RemoveCatalogProperty mockRemoveProperty = mock(RemoveCatalogProperty.class); @@ -255,10 +329,24 @@ void testRemoveCatalogPropertyCommand() { .when(commandLine) .newRemoveCatalogProperty( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "property"); + doReturn(mockRemoveProperty).when(mockRemoveProperty).validate(); commandLine.handleCommandLine(); verify(mockRemoveProperty).handle(); } + @Test + void testRemoveCatalogPropertyCommandWithoutProperty() { + Main.useExit = false; + RemoveCatalogProperty mockRemoveProperty = + spy( + new RemoveCatalogProperty( + GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", null)); + + assertThrows(RuntimeException.class, mockRemoveProperty::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, errOutput); + } + @Test void testListCatalogPropertiesCommand() { ListCatalogProperties mockListProperties = mock(ListCatalogProperties.class); @@ -274,6 +362,7 @@ void testListCatalogPropertiesCommand() { .when(commandLine) .newListCatalogProperties( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog"); + doReturn(mockListProperties).when(mockListProperties).validate(); commandLine.handleCommandLine(); verify(mockListProperties).handle(); } @@ -295,6 +384,7 @@ void testUpdateCatalogCommentCommand() { .when(commandLine) .newUpdateCatalogComment( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "new comment"); + doReturn(mockUpdateComment).when(mockUpdateComment).validate(); commandLine.handleCommandLine(); verify(mockUpdateComment).handle(); } @@ -317,6 +407,7 @@ void testUpdateCatalogNameCommand() { .when(commandLine) .newUpdateCatalogName( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "new_name"); + doReturn(mockUpdateName).when(mockUpdateName).validate(); commandLine.handleCommandLine(); verify(mockUpdateName).handle(); } @@ -368,6 +459,7 @@ void testEnableCatalogCommand() { .when(commandLine) .newCatalogEnable( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", false); + doReturn(mockEnable).when(mockEnable).validate(); commandLine.handleCommandLine(); verify(mockEnable).handle(); } @@ -390,6 +482,7 @@ void testEnableCatalogCommandWithRecursive() { .when(commandLine) .newCatalogEnable( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", true); + doReturn(mockEnable).when(mockEnable).validate(); commandLine.handleCommandLine(); verify(mockEnable).handle(); } @@ -410,6 +503,7 @@ void testDisableCatalogCommand() { doReturn(mockDisable) .when(commandLine) .newCatalogDisable(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog"); + doReturn(mockDisable).when(mockDisable).validate(); commandLine.handleCommandLine(); verify(mockDisable).handle(); } From 4da94437525f89f26798043130e3cb8630c227b9 Mon Sep 17 00:00:00 2001 From: Lord of Abyss <103809695+Abyss-lord@users.noreply.github.com> Date: Mon, 6 Jan 2025 16:42:05 +0800 Subject: [PATCH 22/36] [#6102] fix(CLI): Fix Exception thrown when trying to set multiple tags properties in Gravitino CLI (#6111) ### What changes were proposed in this pull request? Fix Exception thrown when trying to set multiple tags properties in Gravitino CLI. It should give some user-friendly information. ### Why are the changes needed? Fix: #6102 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? ut + local test ```bash gcli tag set --metalake demo_metalake --tag tagA tagB tagC --property test --value value # This command only supports one --tag option. gcli tag details --metalake demo_metalake --tag tagA tagB tagC # This command only supports one --tag option. gcli tag remove --metalake demo_metalake --tag tagA tagB tagC --property test # This command only supports one --tag option. gcli tag update --metalake demo_metalake --tag tagA tagB tagC --comment "new comment" # This command only supports one --tag option. gcli tag update --metalake demo_metalake --tag tagA tagB tagC --rename "new name" # This command only supports one --tag option. ``` --- .../gravitino/cli/GravitinoCommandLine.java | 5 +- .../apache/gravitino/cli/TestTagCommands.java | 116 +++++++++++++++++- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index b3917c4f063..f6b3520b86d 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -724,7 +724,10 @@ protected void handleTagCommand() { } private String getOneTag(String[] tags) { - Preconditions.checkArgument(tags.length <= 1, ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR); + if (tags.length > 1) { + System.err.println(ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR); + Main.exit(-1); + } return tags[0]; } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java index 3279c23d141..d3b0c8bfe18 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java @@ -117,6 +117,25 @@ void testTagDetailsCommand() { verify(mockDetails).handle(); } + @Test + void testTagDetailsCommandWithMultipleTag() { + Main.useExit = false; + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.getOptionValues(GravitinoOptions.TAG)) + .thenReturn(new String[] {"tagA", "tagB"}); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.DETAILS)); + + assertThrows(RuntimeException.class, commandLine::handleCommandLine); + verify(commandLine, never()) + .newTagDetails(eq(GravitinoCommandLine.DEFAULT_URL), eq(false), eq("metalake_demo"), any()); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR, output); + } + @Test void testCreateTagCommand() { CreateTag mockCreate = mock(CreateTag.class); @@ -355,6 +374,7 @@ void testSetTagPropertyCommandWithoutValueOption() { @Test void testSetMultipleTagPropertyCommandError() { + Main.useExit = false; when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(true); @@ -368,10 +388,17 @@ void testSetMultipleTagPropertyCommandError() { spy( new GravitinoCommandLine( mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.SET)); - Assertions.assertThrows( - IllegalArgumentException.class, - () -> commandLine.handleCommandLine(), - ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR); + Assertions.assertThrows(RuntimeException.class, commandLine::handleCommandLine); + verify(commandLine, never()) + .newSetTagProperty( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + any(), + eq("property"), + eq("value")); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR, output); } @Test @@ -395,6 +422,33 @@ void testRemoveTagPropertyCommand() { verify(mockRemoveProperty).handle(); } + @Test + void testRemoveTagPropertyCommandWithMultipleTags() { + Main.useExit = false; + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(true); + when(mockCommandLine.getOptionValues(GravitinoOptions.TAG)) + .thenReturn(new String[] {"tagA", "tagB"}); + when(mockCommandLine.hasOption(GravitinoOptions.PROPERTY)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.PROPERTY)).thenReturn("property"); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.REMOVE)); + + assertThrows(RuntimeException.class, commandLine::handleCommandLine); + verify(commandLine, never()) + .newRemoveTagProperty( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + any(), + eq("property")); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR, output); + } + @Test void testListTagPropertiesCommand() { ListTagProperties mockListProperties = mock(ListTagProperties.class); @@ -459,6 +513,33 @@ void testUpdateTagCommentCommand() { verify(mockUpdateComment).handle(); } + @Test + void testUpdateTagCommentCommandWithMultipleTags() { + Main.useExit = false; + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.COMMENT)).thenReturn(true); + when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(true); + when(mockCommandLine.getOptionValues(GravitinoOptions.TAG)) + .thenReturn(new String[] {"tagA", "tagB"}); + when(mockCommandLine.getOptionValue(GravitinoOptions.COMMENT)).thenReturn("new comment"); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.UPDATE)); + + assertThrows(RuntimeException.class, commandLine::handleCommandLine); + verify(commandLine, never()) + .newUpdateTagComment( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + any(), + eq("new comment")); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR, output); + } + @Test void testUpdateTagNameCommand() { UpdateTagName mockUpdateName = mock(UpdateTagName.class); @@ -479,6 +560,33 @@ void testUpdateTagNameCommand() { verify(mockUpdateName).handle(); } + @Test + void testUpdateTagNameCommandWithMultipleTags() { + Main.useExit = false; + when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); + when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(true); + when(mockCommandLine.getOptionValues(GravitinoOptions.TAG)) + .thenReturn(new String[] {"tagA", "tagB"}); + when(mockCommandLine.hasOption(GravitinoOptions.RENAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.RENAME)).thenReturn("tagC"); + GravitinoCommandLine commandLine = + spy( + new GravitinoCommandLine( + mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.UPDATE)); + + assertThrows(RuntimeException.class, commandLine::handleCommandLine); + verify(commandLine, never()) + .newUpdateTagName( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + any(), + eq("tagC")); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR, output); + } + @Test void testListEntityTagsCommand() { ListEntityTags mockListTags = mock(ListEntityTags.class); From 156898a13625c1d0bff9c0214cb8eb5cf200ae7b Mon Sep 17 00:00:00 2001 From: Lord of Abyss <103809695+Abyss-lord@users.noreply.github.com> Date: Mon, 6 Jan 2025 16:50:57 +0800 Subject: [PATCH 23/36] [#6106] fix(CLI): Refactor the validation logic of user and group (#6113) ### What changes were proposed in this pull request? Refactor the validation logic of user and group. ### Why are the changes needed? Fix: #6106 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? local test. --- .../gravitino/cli/GravitinoCommandLine.java | 28 +++++++++---------- .../gravitino/cli/TestGroupCommands.java | 10 +++++++ .../gravitino/cli/TestUserCommands.java | 10 +++++++ 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index f6b3520b86d..cd1ce5f6c0f 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -517,29 +517,29 @@ protected void handleUserCommand() { switch (command) { case CommandActions.DETAILS: if (line.hasOption(GravitinoOptions.AUDIT)) { - newUserAudit(url, ignore, metalake, user).handle(); + newUserAudit(url, ignore, metalake, user).validate().handle(); } else { - newUserDetails(url, ignore, metalake, user).handle(); + newUserDetails(url, ignore, metalake, user).validate().handle(); } break; case CommandActions.LIST: - newListUsers(url, ignore, metalake).handle(); + newListUsers(url, ignore, metalake).validate().handle(); break; case CommandActions.CREATE: - newCreateUser(url, ignore, metalake, user).handle(); + newCreateUser(url, ignore, metalake, user).validate().handle(); break; case CommandActions.DELETE: boolean force = line.hasOption(GravitinoOptions.FORCE); - newDeleteUser(url, ignore, force, metalake, user).handle(); + newDeleteUser(url, ignore, force, metalake, user).validate().handle(); break; case CommandActions.REVOKE: String[] revokeRoles = line.getOptionValues(GravitinoOptions.ROLE); for (String role : revokeRoles) { - newRemoveRoleFromUser(url, ignore, metalake, user, role).handle(); + newRemoveRoleFromUser(url, ignore, metalake, user, role).validate().handle(); } System.out.printf("Remove roles %s from user %s%n", COMMA_JOINER.join(revokeRoles), user); break; @@ -547,7 +547,7 @@ protected void handleUserCommand() { case CommandActions.GRANT: String[] grantRoles = line.getOptionValues(GravitinoOptions.ROLE); for (String role : grantRoles) { - newAddRoleToUser(url, ignore, metalake, user, role).handle(); + newAddRoleToUser(url, ignore, metalake, user, role).validate().handle(); } System.out.printf("Grant roles %s to user %s%n", COMMA_JOINER.join(grantRoles), user); break; @@ -578,29 +578,29 @@ protected void handleGroupCommand() { switch (command) { case CommandActions.DETAILS: if (line.hasOption(GravitinoOptions.AUDIT)) { - newGroupAudit(url, ignore, metalake, group).handle(); + newGroupAudit(url, ignore, metalake, group).validate().handle(); } else { - newGroupDetails(url, ignore, metalake, group).handle(); + newGroupDetails(url, ignore, metalake, group).validate().handle(); } break; case CommandActions.LIST: - newListGroups(url, ignore, metalake).handle(); + newListGroups(url, ignore, metalake).validate().handle(); break; case CommandActions.CREATE: - newCreateGroup(url, ignore, metalake, group).handle(); + newCreateGroup(url, ignore, metalake, group).validate().handle(); break; case CommandActions.DELETE: boolean force = line.hasOption(GravitinoOptions.FORCE); - newDeleteGroup(url, ignore, force, metalake, group).handle(); + newDeleteGroup(url, ignore, force, metalake, group).validate().handle(); break; case CommandActions.REVOKE: String[] revokeRoles = line.getOptionValues(GravitinoOptions.ROLE); for (String role : revokeRoles) { - newRemoveRoleFromGroup(url, ignore, metalake, group, role).handle(); + newRemoveRoleFromGroup(url, ignore, metalake, group, role).validate().handle(); } System.out.printf("Remove roles %s from group %s%n", COMMA_JOINER.join(revokeRoles), group); break; @@ -608,7 +608,7 @@ protected void handleGroupCommand() { case CommandActions.GRANT: String[] grantRoles = line.getOptionValues(GravitinoOptions.ROLE); for (String role : grantRoles) { - newAddRoleToGroup(url, ignore, metalake, group, role).handle(); + newAddRoleToGroup(url, ignore, metalake, group, role).validate().handle(); } System.out.printf("Grant roles %s to group %s%n", COMMA_JOINER.join(grantRoles), group); break; diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestGroupCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestGroupCommands.java index 98e3ea910fb..ce7a8956821 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestGroupCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestGroupCommands.java @@ -83,6 +83,7 @@ void testListGroupsCommand() { doReturn(mockList) .when(commandLine) .newListGroups(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -101,6 +102,7 @@ void testGroupDetailsCommand() { doReturn(mockDetails) .when(commandLine) .newGroupDetails(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "groupA"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @@ -120,6 +122,7 @@ void testGroupAuditCommand() { doReturn(mockAudit) .when(commandLine) .newGroupAudit(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "group"); + doReturn(mockAudit).when(mockAudit).validate(); commandLine.handleCommandLine(); verify(mockAudit).handle(); } @@ -138,6 +141,7 @@ void testCreateGroupCommand() { doReturn(mockCreate) .when(commandLine) .newCreateGroup(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "groupA"); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -156,6 +160,7 @@ void testDeleteGroupCommand() { doReturn(mockDelete) .when(commandLine) .newDeleteGroup(GravitinoCommandLine.DEFAULT_URL, false, false, "metalake_demo", "groupA"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -175,6 +180,7 @@ void testDeleteGroupForceCommand() { doReturn(mockDelete) .when(commandLine) .newDeleteGroup(GravitinoCommandLine.DEFAULT_URL, false, true, "metalake_demo", "groupA"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -246,6 +252,8 @@ void testRemoveRolesFromGroupCommand() { .newRemoveRoleFromGroup( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "groupA", "role1"); + doReturn(mockRemoveFirstRole).when(mockRemoveFirstRole).validate(); + doReturn(mockRemoveSecondRole).when(mockRemoveSecondRole).validate(); commandLine.handleCommandLine(); verify(mockRemoveFirstRole).handle(); @@ -279,6 +287,8 @@ void testAddRolesToGroupCommand() { .newAddRoleToGroup( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "groupA", "role1"); + doReturn(mockAddFirstRole).when(mockAddFirstRole).validate(); + doReturn(mockAddSecondRole).when(mockAddSecondRole).validate(); commandLine.handleCommandLine(); verify(mockAddSecondRole).handle(); diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestUserCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestUserCommands.java index e8630ce9755..c7612f6c870 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestUserCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestUserCommands.java @@ -83,6 +83,7 @@ void testListUsersCommand() { doReturn(mockList) .when(commandLine) .newListUsers(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -101,6 +102,7 @@ void testUserDetailsCommand() { doReturn(mockDetails) .when(commandLine) .newUserDetails(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "user"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @@ -120,6 +122,7 @@ void testUserAuditCommand() { doReturn(mockAudit) .when(commandLine) .newUserAudit(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "admin"); + doReturn(mockAudit).when(mockAudit).validate(); commandLine.handleCommandLine(); verify(mockAudit).handle(); } @@ -138,6 +141,7 @@ void testCreateUserCommand() { doReturn(mockCreate) .when(commandLine) .newCreateUser(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "user"); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -156,6 +160,7 @@ void testDeleteUserCommand() { doReturn(mockDelete) .when(commandLine) .newDeleteUser(GravitinoCommandLine.DEFAULT_URL, false, false, "metalake_demo", "user"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -175,6 +180,7 @@ void testDeleteUserForceCommand() { doReturn(mockDelete) .when(commandLine) .newDeleteUser(GravitinoCommandLine.DEFAULT_URL, false, true, "metalake_demo", "user"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -247,6 +253,8 @@ void testRemoveRolesFromUserCommand() { .newRemoveRoleFromUser( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "user", "role1"); + doReturn(mockRemoveFirstRole).when(mockRemoveFirstRole).validate(); + doReturn(mockRemoveSecondRole).when(mockRemoveSecondRole).validate(); commandLine.handleCommandLine(); verify(mockRemoveSecondRole).handle(); @@ -281,6 +289,8 @@ void testAddRolesToUserCommand() { .newAddRoleToUser( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "user", "role1"); + doReturn(mockAddFirstRole).when(mockAddFirstRole).validate(); + doReturn(mockAddSecondRole).when(mockAddSecondRole).validate(); commandLine.handleCommandLine(); verify(mockAddFirstRole).handle(); From c9467512d557bd1ab2337523acc1fdd42cadb409 Mon Sep 17 00:00:00 2001 From: Lord of Abyss <103809695+Abyss-lord@users.noreply.github.com> Date: Mon, 6 Jan 2025 18:23:45 +0800 Subject: [PATCH 24/36] [#6105] fix(CLI): Refactor the validation logic of schema and table (#6109) ### What changes were proposed in this pull request? (Please outline the changes and how this PR fixes the issue.) ### Why are the changes needed? 1. Refactor the validation logic of schema and table. 2. Add test case. Fix: #6105 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? UT + local test Schema test ```bash gcli schema set -m demo_metalake --name Hive_catalog.default # Missing --property and --value options. gcli schema set -m demo_metalake --name Hive_catalog.default --property propertyA # Missing --value option. gcli schema set -m demo_metalake --name Hive_catalog.default --value valA # Missing --property option. gcli schema remove -m demo_metalake --name Hive_catalog.default # Missing --property option. ``` Table test ```bash gcli table set -m demo_metalake --name Hive_catalog.default.test_dates # Missing --property and --value options. gcli table set -m demo_metalake --name Hive_catalog.default.test_dates --property propertyA # Missing --value option. gcli table set -m demo_metalake --name Hive_catalog.default.test_dates --value valA # Missing --property option. gcli table remove -m demo_metalake --name Hive_catalog.default.test_dates # Missing --property option. gcli table create -m demo_metalake --name Hive_catalog.default.test_dates # Missing --columnfile option. ``` --- .../apache/gravitino/cli/ErrorMessages.java | 3 +- .../gravitino/cli/GravitinoCommandLine.java | 48 ++++--- .../gravitino/cli/commands/Command.java | 17 ++- .../gravitino/cli/commands/CreateTable.java | 6 + .../cli/commands/RemoveCatalogProperty.java | 2 +- .../cli/commands/RemoveMetalakeProperty.java | 4 +- .../cli/commands/RemoveSchemaProperty.java | 6 + .../cli/commands/RemoveTableProperty.java | 6 + .../cli/commands/SetCatalogProperty.java | 2 +- .../cli/commands/SetMetalakeProperty.java | 2 +- .../cli/commands/SetSchemaProperty.java | 6 + .../cli/commands/SetTableProperty.java | 6 + .../gravitino/cli/TestSchemaCommands.java | 88 +++++++++++++ .../gravitino/cli/TestTableCommands.java | 121 +++++++++++++++++- 14 files changed, 285 insertions(+), 32 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java index c6c2a8d9814..c839ad162b5 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java @@ -48,12 +48,14 @@ public class ErrorMessages { public static final String HELP_FAILED = "Failed to load help message: "; public static final String MALFORMED_NAME = "Malformed entity name."; + public static final String MISSING_COLUMN_FILE = "Missing --columnfile option."; public static final String MISSING_ENTITIES = "Missing required entity names: "; public static final String MISSING_GROUP = "Missing --group option."; public static final String MISSING_METALAKE = "Missing --metalake option."; public static final String MISSING_NAME = "Missing --name option."; public static final String MISSING_PROPERTY = "Missing --property option."; + public static final String MISSING_PROPERTY_AND_VALUE = "Missing --property and --value options."; public static final String MISSING_ROLE = "Missing --role option."; public static final String MISSING_TAG = "Missing --tag option."; public static final String MISSING_URI = "Missing --uri option."; @@ -62,7 +64,6 @@ public class ErrorMessages { public static final String MULTIPLE_TAG_COMMAND_ERROR = "This command only supports one --tag option."; - public static final String MISSING_PROPERTY_AND_VALUE = "Missing --property and --value options."; public static final String MISSING_PROVIDER = "Missing --provider option."; public static final String REGISTER_FAILED = "Failed to register model: "; diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index cd1ce5f6c0f..589aa437df5 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -367,35 +367,39 @@ private void handleSchemaCommand() { switch (command) { case CommandActions.DETAILS: if (line.hasOption(GravitinoOptions.AUDIT)) { - newSchemaAudit(url, ignore, metalake, catalog, schema).handle(); + newSchemaAudit(url, ignore, metalake, catalog, schema).validate().handle(); } else { - newSchemaDetails(url, ignore, metalake, catalog, schema).handle(); + newSchemaDetails(url, ignore, metalake, catalog, schema).validate().handle(); } break; case CommandActions.CREATE: String comment = line.getOptionValue(GravitinoOptions.COMMENT); - newCreateSchema(url, ignore, metalake, catalog, schema, comment).handle(); + newCreateSchema(url, ignore, metalake, catalog, schema, comment).validate().handle(); break; case CommandActions.DELETE: boolean force = line.hasOption(GravitinoOptions.FORCE); - newDeleteSchema(url, ignore, force, metalake, catalog, schema).handle(); + newDeleteSchema(url, ignore, force, metalake, catalog, schema).validate().handle(); break; case CommandActions.SET: String property = line.getOptionValue(GravitinoOptions.PROPERTY); String value = line.getOptionValue(GravitinoOptions.VALUE); - newSetSchemaProperty(url, ignore, metalake, catalog, schema, property, value).handle(); + newSetSchemaProperty(url, ignore, metalake, catalog, schema, property, value) + .validate() + .handle(); break; case CommandActions.REMOVE: property = line.getOptionValue(GravitinoOptions.PROPERTY); - newRemoveSchemaProperty(url, ignore, metalake, catalog, schema, property).handle(); + newRemoveSchemaProperty(url, ignore, metalake, catalog, schema, property) + .validate() + .handle(); break; case CommandActions.PROPERTIES: - newListSchemaProperties(url, ignore, metalake, catalog, schema).handle(); + newListSchemaProperties(url, ignore, metalake, catalog, schema).validate().handle(); break; default: @@ -436,17 +440,17 @@ private void handleTableCommand() { switch (command) { case CommandActions.DETAILS: if (line.hasOption(GravitinoOptions.AUDIT)) { - newTableAudit(url, ignore, metalake, catalog, schema, table).handle(); + newTableAudit(url, ignore, metalake, catalog, schema, table).validate().handle(); } else if (line.hasOption(GravitinoOptions.INDEX)) { - newListIndexes(url, ignore, metalake, catalog, schema, table).handle(); + newListIndexes(url, ignore, metalake, catalog, schema, table).validate().handle(); } else if (line.hasOption(GravitinoOptions.DISTRIBUTION)) { - newTableDistribution(url, ignore, metalake, catalog, schema, table).handle(); + newTableDistribution(url, ignore, metalake, catalog, schema, table).validate().handle(); } else if (line.hasOption(GravitinoOptions.PARTITION)) { - newTablePartition(url, ignore, metalake, catalog, schema, table).handle(); + newTablePartition(url, ignore, metalake, catalog, schema, table).validate().handle(); } else if (line.hasOption(GravitinoOptions.SORTORDER)) { - newTableSortOrder(url, ignore, metalake, catalog, schema, table).handle(); + newTableSortOrder(url, ignore, metalake, catalog, schema, table).validate().handle(); } else { - newTableDetails(url, ignore, metalake, catalog, schema, table).handle(); + newTableDetails(url, ignore, metalake, catalog, schema, table).validate().handle(); } break; @@ -455,39 +459,47 @@ private void handleTableCommand() { String columnFile = line.getOptionValue(GravitinoOptions.COLUMNFILE); String comment = line.getOptionValue(GravitinoOptions.COMMENT); newCreateTable(url, ignore, metalake, catalog, schema, table, columnFile, comment) + .validate() .handle(); break; } case CommandActions.DELETE: boolean force = line.hasOption(GravitinoOptions.FORCE); - newDeleteTable(url, ignore, force, metalake, catalog, schema, table).handle(); + newDeleteTable(url, ignore, force, metalake, catalog, schema, table).validate().handle(); break; case CommandActions.SET: String property = line.getOptionValue(GravitinoOptions.PROPERTY); String value = line.getOptionValue(GravitinoOptions.VALUE); newSetTableProperty(url, ignore, metalake, catalog, schema, table, property, value) + .validate() .handle(); break; case CommandActions.REMOVE: property = line.getOptionValue(GravitinoOptions.PROPERTY); - newRemoveTableProperty(url, ignore, metalake, catalog, schema, table, property).handle(); + newRemoveTableProperty(url, ignore, metalake, catalog, schema, table, property) + .validate() + .handle(); break; case CommandActions.PROPERTIES: - newListTableProperties(url, ignore, metalake, catalog, schema, table).handle(); + newListTableProperties(url, ignore, metalake, catalog, schema, table).validate().handle(); break; case CommandActions.UPDATE: { if (line.hasOption(GravitinoOptions.COMMENT)) { String comment = line.getOptionValue(GravitinoOptions.COMMENT); - newUpdateTableComment(url, ignore, metalake, catalog, schema, table, comment).handle(); + newUpdateTableComment(url, ignore, metalake, catalog, schema, table, comment) + .validate() + .handle(); } if (line.hasOption(GravitinoOptions.RENAME)) { String newName = line.getOptionValue(GravitinoOptions.RENAME); - newUpdateTableName(url, ignore, metalake, catalog, schema, table, newName).handle(); + newUpdateTableName(url, ignore, metalake, catalog, schema, table, newName) + .validate() + .handle(); } break; } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java index 98c4096cb04..ea6abdd6393 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/Command.java @@ -112,17 +112,26 @@ public Command validate() { } /** - * Validates that both property and value parameters are not null. + * Validates that both property and value arguments are not null. * * @param property The property name to check * @param value The value associated with the property */ - protected void checkProperty(String property, String value) { + protected void validatePropertyAndValue(String property, String value) { if (property == null && value == null) exitWithError(ErrorMessages.MISSING_PROPERTY_AND_VALUE); if (property == null) exitWithError(ErrorMessages.MISSING_PROPERTY); if (value == null) exitWithError(ErrorMessages.MISSING_VALUE); } + /** + * Validates that the property argument is not null. + * + * @param property The property name to validate + */ + protected void validateProperty(String property) { + if (property == null) exitWithError(ErrorMessages.MISSING_PROPERTY); + } + /** * Builds a {@link GravitinoClient} instance with the provided server URL and metalake. * @@ -216,8 +225,4 @@ protected void output(T entity) { throw new IllegalArgumentException("Unsupported output format"); } } - - protected String getMissingEntitiesInfo(String... entities) { - return ErrorMessages.MISSING_ENTITIES + COMMA_JOINER.join(entities); - } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTable.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTable.java index fefa6267221..aa409941e59 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTable.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTable.java @@ -108,4 +108,10 @@ public void handle() { System.out.println(table + " created"); } + + @Override + public Command validate() { + if (columnFile == null) exitWithError(ErrorMessages.MISSING_COLUMN_FILE); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveCatalogProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveCatalogProperty.java index c777ba16282..dc1a76765b1 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveCatalogProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveCatalogProperty.java @@ -69,7 +69,7 @@ public void handle() { @Override public Command validate() { - if (property == null) exitWithError(ErrorMessages.MISSING_PROPERTY); + validateProperty(property); return super.validate(); } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveMetalakeProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveMetalakeProperty.java index 0664ddaad15..ce3a50fee16 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveMetalakeProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveMetalakeProperty.java @@ -63,7 +63,7 @@ public void handle() { @Override public Command validate() { - if (property == null) exitWithError(ErrorMessages.MISSING_PROPERTY); - return this; + validateProperty(property); + return super.validate(); } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveSchemaProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveSchemaProperty.java index 6fc41c01252..8fedcb62168 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveSchemaProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveSchemaProperty.java @@ -77,4 +77,10 @@ public void handle() { System.out.println(property + " property removed."); } + + @Override + public Command validate() { + validateProperty(property); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveTableProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveTableProperty.java index 8b3cd2383fb..af370ce64b7 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveTableProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveTableProperty.java @@ -86,4 +86,10 @@ public void handle() { System.out.println(property + " property removed."); } + + @Override + public Command validate() { + validateProperty(property); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetCatalogProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetCatalogProperty.java index 8b511d7458b..034b1b8e2a3 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetCatalogProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetCatalogProperty.java @@ -77,7 +77,7 @@ public void handle() { @Override public Command validate() { - checkProperty(property, value); + validatePropertyAndValue(property, value); return this; } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java index ff945cf7425..ef67d008bc8 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetMetalakeProperty.java @@ -66,7 +66,7 @@ public void handle() { @Override public Command validate() { - checkProperty(property, value); + validatePropertyAndValue(property, value); return this; } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetSchemaProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetSchemaProperty.java index cc6151eaa2c..bd9851ba8cb 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetSchemaProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetSchemaProperty.java @@ -81,4 +81,10 @@ public void handle() { System.out.println(schema + " property set."); } + + @Override + public Command validate() { + validatePropertyAndValue(property, value); + return this; + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTableProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTableProperty.java index 0209d218250..54ab88f3435 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTableProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTableProperty.java @@ -90,4 +90,10 @@ public void handle() { System.out.println(table + " property set."); } + + @Override + public Command validate() { + validatePropertyAndValue(property, value); + return super.validate(); + } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java index b3f67174fbd..9059afeedb2 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java @@ -19,6 +19,7 @@ package org.apache.gravitino.cli; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.doReturn; @@ -30,6 +31,7 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Options; import org.apache.gravitino.cli.commands.CreateSchema; @@ -106,6 +108,7 @@ void testSchemaDetailsCommand() { .when(commandLine) .newSchemaDetails( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @@ -126,6 +129,7 @@ void testSchemaAuditCommand() { .when(commandLine) .newSchemaAudit( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema"); + doReturn(mockAudit).when(mockAudit).validate(); commandLine.handleCommandLine(); verify(mockAudit).handle(); } @@ -153,6 +157,7 @@ void testCreateSchemaCommand() { "catalog", "schema", "comment"); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -172,6 +177,7 @@ void testDeleteSchemaCommand() { .when(commandLine) .newDeleteSchema( GravitinoCommandLine.DEFAULT_URL, false, false, "metalake_demo", "catalog", "schema"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -192,6 +198,7 @@ void testDeleteSchemaForceCommand() { .when(commandLine) .newDeleteSchema( GravitinoCommandLine.DEFAULT_URL, false, true, "metalake_demo", "catalog", "schema"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -221,10 +228,71 @@ void testSetSchemaPropertyCommand() { "schema", "property", "value"); + doReturn(mockSetProperty).when(mockSetProperty).validate(); commandLine.handleCommandLine(); verify(mockSetProperty).handle(); } + @Test + void testSetSchemaPropertyCommandWithoutPropertyAndValue() { + Main.useExit = false; + SetSchemaProperty spySetProperty = + spy( + new SetSchemaProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + null, + null)); + + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY_AND_VALUE, output); + } + + @Test + void testSetSchemaPropertyCommandWithoutProperty() { + Main.useExit = false; + SetSchemaProperty spySetProperty = + spy( + new SetSchemaProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + null, + "value")); + + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, output); + } + + @Test + void testSetSchemaPropertyCommandWithoutValue() { + Main.useExit = false; + SetSchemaProperty spySetProperty = + spy( + new SetSchemaProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "property", + null)); + + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_VALUE, output); + } + @Test void testRemoveSchemaPropertyCommand() { RemoveSchemaProperty mockRemoveProperty = mock(RemoveSchemaProperty.class); @@ -247,10 +315,29 @@ void testRemoveSchemaPropertyCommand() { "catalog", "schema", "property"); + doReturn(mockRemoveProperty).when(mockRemoveProperty).validate(); commandLine.handleCommandLine(); verify(mockRemoveProperty).handle(); } + @Test + void testRemoveSchemaPropertyCommandWithoutProperty() { + Main.useExit = false; + RemoveSchemaProperty mockRemoveProperty = + spy( + new RemoveSchemaProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "demo_metalake", + "catalog", + "schema", + null)); + + assertThrows(RuntimeException.class, mockRemoveProperty::validate); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, errOutput); + } + @Test void testListSchemaPropertiesCommand() { ListSchemaProperties mockListProperties = mock(ListSchemaProperties.class); @@ -266,6 +353,7 @@ void testListSchemaPropertiesCommand() { .when(commandLine) .newListSchemaProperties( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema"); + doReturn(mockListProperties).when(mockListProperties).validate(); commandLine.handleCommandLine(); verify(mockListProperties).handle(); } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java index 946c330178d..0193c834a5a 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java @@ -115,6 +115,7 @@ void testTableDetailsCommand() { .when(commandLine) .newTableDetails( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "users"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @@ -135,6 +136,7 @@ void testTableIndexCommand() { .when(commandLine) .newListIndexes( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "users"); + doReturn(mockIndex).when(mockIndex).validate(); commandLine.handleCommandLine(); verify(mockIndex).handle(); } @@ -155,6 +157,7 @@ void testTablePartitionCommand() { .when(commandLine) .newTablePartition( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "users"); + doReturn(mockPartition).when(mockPartition).validate(); commandLine.handleCommandLine(); verify(mockPartition).handle(); } @@ -175,6 +178,7 @@ void testTableDistributionCommand() { .when(commandLine) .newTableDistribution( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "users"); + doReturn(mockDistribution).when(mockDistribution).validate(); commandLine.handleCommandLine(); verify(mockDistribution).handle(); } @@ -197,7 +201,7 @@ void testTableSortOrderCommand() { .when(commandLine) .newTableSortOrder( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "users"); - + doReturn(mockSortOrder).when(mockSortOrder).validate(); commandLine.handleCommandLine(); verify(mockSortOrder).handle(); } @@ -218,6 +222,7 @@ void testTableAuditCommand() { .when(commandLine) .newTableAudit( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "users"); + doReturn(mockAudit).when(mockAudit).validate(); commandLine.handleCommandLine(); verify(mockAudit).handle(); } @@ -243,6 +248,7 @@ void testDeleteTableCommand() { "catalog", "schema", "users"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -269,6 +275,7 @@ void testDeleteTableForceCommand() { "catalog", "schema", "users"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -289,12 +296,13 @@ void testListTablePropertiesCommand() { .when(commandLine) .newListTableProperties( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "users"); + doReturn(mockListProperties).when(mockListProperties).validate(); commandLine.handleCommandLine(); verify(mockListProperties).handle(); } @Test - void testSetFilesetPropertyCommand() { + void testSetTablePropertyCommand() { SetTableProperty mockSetProperties = mock(SetTableProperty.class); when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); @@ -320,10 +328,74 @@ void testSetFilesetPropertyCommand() { "user", "property", "value"); + doReturn(mockSetProperties).when(mockSetProperties).validate(); commandLine.handleCommandLine(); verify(mockSetProperties).handle(); } + @Test + void testSetTablePropertyCommandWithoutPropertyAndValue() { + Main.useExit = false; + SetTableProperty spySetProperty = + spy( + new SetTableProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "table", + null, + null)); + + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY_AND_VALUE, output); + } + + @Test + void testSetTablePropertyCommandWithoutProperty() { + Main.useExit = false; + SetTableProperty spySetProperty = + spy( + new SetTableProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "table", + null, + "value")); + + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, output); + } + + @Test + void testSetTablePropertyCommandWithoutValue() { + Main.useExit = false; + SetTableProperty spySetProperty = + spy( + new SetTableProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "table", + "property", + null)); + + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_VALUE, output); + } + @Test void testRemoveTablePropertyCommand() { RemoveTableProperty mockSetProperties = mock(RemoveTableProperty.class); @@ -348,10 +420,31 @@ void testRemoveTablePropertyCommand() { "schema", "users", "property"); + doReturn(mockSetProperties).when(mockSetProperties).validate(); commandLine.handleCommandLine(); verify(mockSetProperties).handle(); } + @Test + void testRemoveTablePropertyCommandWithoutProperty() { + Main.useExit = false; + RemoveTableProperty spyRemoveProperty = + spy( + new RemoveTableProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "table", + null)); + + assertThrows(RuntimeException.class, spyRemoveProperty::validate); + verify(spyRemoveProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, output); + } + @Test void testUpdateTableCommentsCommand() { UpdateTableComment mockUpdate = mock(UpdateTableComment.class); @@ -375,6 +468,7 @@ void testUpdateTableCommentsCommand() { "schema", "users", "New comment"); + doReturn(mockUpdate).when(mockUpdate).validate(); commandLine.handleCommandLine(); verify(mockUpdate).handle(); } @@ -402,6 +496,7 @@ void testupdateTableNmeCommand() { "schema", "users", "people"); + doReturn(mockUpdate).when(mockUpdate).validate(); commandLine.handleCommandLine(); verify(mockUpdate).handle(); } @@ -432,10 +527,32 @@ void testCreateTable() { "users", "users.csv", "comment"); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } + @Test + void testCreateTableWithoutFile() { + Main.useExit = false; + CreateTable spyCreate = + spy( + new CreateTable( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "table", + null, + "comment")); + + assertThrows(RuntimeException.class, spyCreate::validate); + verify(spyCreate, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_COLUMN_FILE, output); + } + @Test @SuppressWarnings("DefaultCharset") void testListTableWithoutCatalog() { From a25f7588a5091bb8975a55c8af2b31615fe37ac0 Mon Sep 17 00:00:00 2001 From: Cheng-Yi Shih <48374270+cool9850311@users.noreply.github.com> Date: Mon, 6 Jan 2025 19:09:40 +0800 Subject: [PATCH 25/36] [#5937]feat(build): enhance spotless integration in Gradle build script (#6077) ### What changes were proposed in this pull request? Added configuration to ensure that subprojects using the 'com.diffplug.spotless' plugin are included in the 'spotlessCheck' task dependencies. ### Why are the changes needed? Fix: https://github.com/apache/gravitino/issues/5937 ### Does this PR introduce any user-facing change? no ### How was this patch tested? Break style by adding spaces to any java file in iceberg-common module, and test it by running ./gradlew compileIcebergRESTServer -x test check dependence trees by running ./gradlew compileIcebergRESTServer taskTree --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 154b4e7f776..4ebd09a9a2e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -501,6 +501,9 @@ subprojects { exclude("test/**") } } + tasks.named("compileJava").configure { + dependsOn("spotlessCheck") + } } tasks.rat { From 1884df67906d2efd674fe40a5d3c263b81ca1de5 Mon Sep 17 00:00:00 2001 From: Lord of Abyss <103809695+Abyss-lord@users.noreply.github.com> Date: Tue, 7 Jan 2025 09:23:54 +0800 Subject: [PATCH 26/36] [#6121] fix(CLI): Refactor the validation logic of topic and fileset (#6122) ### What changes were proposed in this pull request? Refactor the validation logic of fileset and topic, meanwhile fix the test case. ### Why are the changes needed? Fix: #6121 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? ut + local test Topic test ```bash gcli topic set -m demo_metalake --name catalog.schema.topic # Missing --property and --value options. gcli topic set -m demo_metalake --name catalog.schema.topic --property property # Missing --value option. gcli topic set -m demo_metalake --name catalog.schema.topic --value value # Missing --property option. gcli topic remove -m demo_metalake --name catalog.schema.topic # Missing --property option. ``` Fileset test ```bash gcli fileset set -m demo_metalake --name catalog.schema.fileset # Missing --property and --value options. gcli fileset set -m demo_metalake --name catalog.schema.fileset --property property # Missing --value option. gcli fileset set -m demo_metalake --name catalog.schema.fileset --value value # Missing --property option. gcli fileset remove -m demo_metalake --name catalog.schema.fileset # Missing --property option. ``` --- .../gravitino/cli/GravitinoCommandLine.java | 41 +++++--- .../cli/commands/RemoveFilesetProperty.java | 6 ++ .../cli/commands/RemoveTopicProperty.java | 6 ++ .../cli/commands/SetFilesetProperty.java | 6 ++ .../cli/commands/SetTopicProperty.java | 6 ++ .../gravitino/cli/TestFilesetCommands.java | 93 +++++++++++++++++++ .../gravitino/cli/TestTopicCommands.java | 89 ++++++++++++++++++ 7 files changed, 235 insertions(+), 12 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index 589aa437df5..f93da3003cc 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -1015,7 +1015,7 @@ private void handleTopicCommand() { if (CommandActions.LIST.equals(command)) { checkEntities(missingEntities); - newListTopics(url, ignore, metalake, catalog, schema).handle(); + newListTopics(url, ignore, metalake, catalog, schema).validate().handle(); return; } @@ -1025,20 +1025,22 @@ private void handleTopicCommand() { switch (command) { case CommandActions.DETAILS: - newTopicDetails(url, ignore, metalake, catalog, schema, topic).handle(); + newTopicDetails(url, ignore, metalake, catalog, schema, topic).validate().handle(); break; case CommandActions.CREATE: { String comment = line.getOptionValue(GravitinoOptions.COMMENT); - newCreateTopic(url, ignore, metalake, catalog, schema, topic, comment).handle(); + newCreateTopic(url, ignore, metalake, catalog, schema, topic, comment) + .validate() + .handle(); break; } case CommandActions.DELETE: { boolean force = line.hasOption(GravitinoOptions.FORCE); - newDeleteTopic(url, ignore, force, metalake, catalog, schema, topic).handle(); + newDeleteTopic(url, ignore, force, metalake, catalog, schema, topic).validate().handle(); break; } @@ -1046,7 +1048,9 @@ private void handleTopicCommand() { { if (line.hasOption(GravitinoOptions.COMMENT)) { String comment = line.getOptionValue(GravitinoOptions.COMMENT); - newUpdateTopicComment(url, ignore, metalake, catalog, schema, topic, comment).handle(); + newUpdateTopicComment(url, ignore, metalake, catalog, schema, topic, comment) + .validate() + .handle(); } break; } @@ -1056,6 +1060,7 @@ private void handleTopicCommand() { String property = line.getOptionValue(GravitinoOptions.PROPERTY); String value = line.getOptionValue(GravitinoOptions.VALUE); newSetTopicProperty(url, ignore, metalake, catalog, schema, topic, property, value) + .validate() .handle(); break; } @@ -1063,12 +1068,14 @@ private void handleTopicCommand() { case CommandActions.REMOVE: { String property = line.getOptionValue(GravitinoOptions.PROPERTY); - newRemoveTopicProperty(url, ignore, metalake, catalog, schema, topic, property).handle(); + newRemoveTopicProperty(url, ignore, metalake, catalog, schema, topic, property) + .validate() + .handle(); break; } case CommandActions.PROPERTIES: - newListTopicProperties(url, ignore, metalake, catalog, schema, topic).handle(); + newListTopicProperties(url, ignore, metalake, catalog, schema, topic).validate().handle(); break; default: @@ -1098,7 +1105,7 @@ private void handleFilesetCommand() { // Handle CommandActions.LIST action separately as it doesn't require the `fileset` if (CommandActions.LIST.equals(command)) { checkEntities(missingEntities); - newListFilesets(url, ignore, metalake, catalog, schema).handle(); + newListFilesets(url, ignore, metalake, catalog, schema).validate().handle(); return; } @@ -1108,7 +1115,7 @@ private void handleFilesetCommand() { switch (command) { case CommandActions.DETAILS: - newFilesetDetails(url, ignore, metalake, catalog, schema, fileset).handle(); + newFilesetDetails(url, ignore, metalake, catalog, schema, fileset).validate().handle(); break; case CommandActions.CREATE: @@ -1117,6 +1124,7 @@ private void handleFilesetCommand() { String[] properties = line.getOptionValues(CommandActions.PROPERTIES); Map propertyMap = new Properties().parse(properties); newCreateFileset(url, ignore, metalake, catalog, schema, fileset, comment, propertyMap) + .validate() .handle(); break; } @@ -1124,7 +1132,9 @@ private void handleFilesetCommand() { case CommandActions.DELETE: { boolean force = line.hasOption(GravitinoOptions.FORCE); - newDeleteFileset(url, ignore, force, metalake, catalog, schema, fileset).handle(); + newDeleteFileset(url, ignore, force, metalake, catalog, schema, fileset) + .validate() + .handle(); break; } @@ -1133,6 +1143,7 @@ private void handleFilesetCommand() { String property = line.getOptionValue(GravitinoOptions.PROPERTY); String value = line.getOptionValue(GravitinoOptions.VALUE); newSetFilesetProperty(url, ignore, metalake, catalog, schema, fileset, property, value) + .validate() .handle(); break; } @@ -1141,12 +1152,15 @@ private void handleFilesetCommand() { { String property = line.getOptionValue(GravitinoOptions.PROPERTY); newRemoveFilesetProperty(url, ignore, metalake, catalog, schema, fileset, property) + .validate() .handle(); break; } case CommandActions.PROPERTIES: - newListFilesetProperties(url, ignore, metalake, catalog, schema, fileset).handle(); + newListFilesetProperties(url, ignore, metalake, catalog, schema, fileset) + .validate() + .handle(); break; case CommandActions.UPDATE: @@ -1154,11 +1168,14 @@ private void handleFilesetCommand() { if (line.hasOption(GravitinoOptions.COMMENT)) { String comment = line.getOptionValue(GravitinoOptions.COMMENT); newUpdateFilesetComment(url, ignore, metalake, catalog, schema, fileset, comment) + .validate() .handle(); } if (line.hasOption(GravitinoOptions.RENAME)) { String newName = line.getOptionValue(GravitinoOptions.RENAME); - newUpdateFilesetName(url, ignore, metalake, catalog, schema, fileset, newName).handle(); + newUpdateFilesetName(url, ignore, metalake, catalog, schema, fileset, newName) + .validate() + .handle(); } break; } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveFilesetProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveFilesetProperty.java index 00deebe265a..c443bf0fdfe 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveFilesetProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveFilesetProperty.java @@ -86,4 +86,10 @@ public void handle() { System.out.println(property + " property removed."); } + + @Override + public Command validate() { + validateProperty(property); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveTopicProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveTopicProperty.java index a43820933e8..51be0a139d9 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveTopicProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveTopicProperty.java @@ -87,4 +87,10 @@ public void handle() { System.out.println(property + " property removed."); } + + @Override + public Command validate() { + validateProperty(property); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetFilesetProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetFilesetProperty.java index 2c179db104c..afafa3c9dbd 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetFilesetProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetFilesetProperty.java @@ -90,4 +90,10 @@ public void handle() { System.out.println(schema + " property set."); } + + @Override + public Command validate() { + validatePropertyAndValue(property, value); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTopicProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTopicProperty.java index 941c0b0321e..2641259cdde 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTopicProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTopicProperty.java @@ -92,4 +92,10 @@ public void handle() { System.out.println(property + " property set."); } + + @Override + public Command validate() { + validatePropertyAndValue(property, value); + return super.validate(); + } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestFilesetCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestFilesetCommands.java index 1e8c54124c1..3529e60bf77 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestFilesetCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestFilesetCommands.java @@ -92,6 +92,7 @@ void testListFilesetsCommand() { .when(commandLine) .newListFilesets( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -117,6 +118,7 @@ void testFilesetDetailsCommand() { "catalog", "schema", "fileset"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @@ -147,6 +149,7 @@ void testCreateFilesetCommand() { eq("fileset"), eq("comment"), any()); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -173,6 +176,7 @@ void testDeleteFilesetCommand() { "catalog", "schema", "fileset"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -200,6 +204,7 @@ void testDeleteFilesetForceCommand() { "catalog", "schema", "fileset"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -229,6 +234,7 @@ void testUpdateFilesetCommentCommand() { "schema", "fileset", "new_comment"); + doReturn(mockUpdateComment).when(mockUpdateComment).validate(); commandLine.handleCommandLine(); verify(mockUpdateComment).handle(); } @@ -258,6 +264,7 @@ void testUpdateFilesetNameCommand() { "schema", "fileset", "new_name"); + doReturn(mockUpdateName).when(mockUpdateName).validate(); commandLine.handleCommandLine(); verify(mockUpdateName).handle(); } @@ -284,6 +291,7 @@ void testListFilesetPropertiesCommand() { "catalog", "schema", "fileset"); + doReturn(mockListProperties).when(mockListProperties).validate(); commandLine.handleCommandLine(); verify(mockListProperties).handle(); } @@ -316,10 +324,74 @@ void testSetFilesetPropertyCommand() { "fileset", "property", "value"); + doReturn(mockSetProperties).when(mockSetProperties).validate(); commandLine.handleCommandLine(); verify(mockSetProperties).handle(); } + @Test + void testSetFilesetPropertyCommandWithoutPropertyAndValue() { + Main.useExit = false; + SetFilesetProperty spySetProperty = + spy( + new SetFilesetProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "fileset", + null, + null)); + + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY_AND_VALUE, errOutput); + } + + @Test + void testSetFilesetPropertyCommandWithoutProperty() { + Main.useExit = false; + SetFilesetProperty spySetProperty = + spy( + new SetFilesetProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "fileset", + null, + "value")); + + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, errOutput); + } + + @Test + void testSetFilesetPropertyCommandWithoutValue() { + Main.useExit = false; + SetFilesetProperty spySetProperty = + spy( + new SetFilesetProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "fileset", + "property", + null)); + + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_VALUE, errOutput); + } + @Test void testRemoveFilesetPropertyCommand() { RemoveFilesetProperty mockSetProperties = mock(RemoveFilesetProperty.class); @@ -345,10 +417,31 @@ void testRemoveFilesetPropertyCommand() { "schema", "fileset", "property"); + doReturn(mockSetProperties).when(mockSetProperties).validate(); commandLine.handleCommandLine(); verify(mockSetProperties).handle(); } + @Test + void testRemoveFilesetPropertyCommandWithoutProperty() { + Main.useExit = false; + RemoveFilesetProperty spyRemoveProperty = + spy( + new RemoveFilesetProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "fileset", + null)); + + assertThrows(RuntimeException.class, spyRemoveProperty::validate); + verify(spyRemoveProperty, never()).handle(); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, errOutput); + } + @Test @SuppressWarnings("DefaultCharset") void testListFilesetCommandWithoutCatalog() { diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTopicCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTopicCommands.java index c886b4f8ede..31904b88563 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTopicCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTopicCommands.java @@ -89,6 +89,7 @@ void testListTopicsCommand() { .when(commandLine) .newListTopics( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -108,6 +109,7 @@ void testTopicDetailsCommand() { .when(commandLine) .newTopicDetails( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "topic"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @@ -136,6 +138,7 @@ void testCreateTopicCommand() { "schema", "topic", "comment"); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -161,6 +164,7 @@ void testDeleteTopicCommand() { "catalog", "schema", "topic"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -187,6 +191,7 @@ void testDeleteTopicForceCommand() { "catalog", "schema", "topic"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -215,6 +220,7 @@ void testUpdateCommentTopicCommand() { "schema", "topic", "new comment"); + doReturn(mockUpdate).when(mockUpdate).validate(); commandLine.handleCommandLine(); verify(mockUpdate).handle(); } @@ -235,6 +241,7 @@ void testListTopicPropertiesCommand() { .when(commandLine) .newListTopicProperties( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "topic"); + doReturn(mockListProperties).when(mockListProperties).validate(); commandLine.handleCommandLine(); verify(mockListProperties).handle(); } @@ -266,10 +273,71 @@ void testSetTopicPropertyCommand() { "topic", "property", "value"); + doReturn(mockSetProperties).when(mockSetProperties).validate(); commandLine.handleCommandLine(); verify(mockSetProperties).handle(); } + @Test + void testSetTopicPropertyCommandWithoutPropertyAndValue() { + Main.useExit = false; + SetTopicProperty spySetProperty = + spy( + new SetTopicProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "topic", + null, + null)); + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY_AND_VALUE, output); + } + + @Test + void testSetTopicPropertyCommandWithoutProperty() { + Main.useExit = false; + SetTopicProperty spySetProperty = + spy( + new SetTopicProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "topic", + null, + "value")); + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, output); + } + + @Test + void testSetTopicPropertyCommandWithoutValue() { + Main.useExit = false; + SetTopicProperty spySetProperty = + spy( + new SetTopicProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "topic", + "property", + null)); + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_VALUE, output); + } + @Test void testRemoveTopicPropertyCommand() { RemoveTopicProperty mockSetProperties = mock(RemoveTopicProperty.class); @@ -294,10 +362,31 @@ void testRemoveTopicPropertyCommand() { "schema", "topic", "property"); + doReturn(mockSetProperties).when(mockSetProperties).validate(); commandLine.handleCommandLine(); verify(mockSetProperties).handle(); } + @Test + void testRemoveTopicPropertyCommandWithoutProperty() { + Main.useExit = false; + RemoveTopicProperty spyRemoveProperty = + spy( + new RemoveTopicProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "topic", + null)); + + assertThrows(RuntimeException.class, spyRemoveProperty::validate); + verify(spyRemoveProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PROPERTY, output); + } + @Test @SuppressWarnings("DefaultCharset") void testListTopicCommandWithoutCatalog() { From d5a29a41615539cf77b82438338eeeeeff222d7d Mon Sep 17 00:00:00 2001 From: Lord of Abyss <103809695+Abyss-lord@users.noreply.github.com> Date: Tue, 7 Jan 2025 09:25:04 +0800 Subject: [PATCH 27/36] [#6112] fix(CLI): Refactor the validation logic of column and model (#6120) ### What changes were proposed in this pull request? Refactor the validation logic of column and model. ### Why are the changes needed? Fix: #6112 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? ut + local test ```bash gcli model update -m demo_metalake --name catalog.schema.model # Missing --uri option. gcli column update -m demo_metalake --name Hive_catalog.default.test_dates.id --default # Missing --datatype option. ``` --- .../apache/gravitino/cli/ErrorMessages.java | 1 + .../gravitino/cli/GravitinoCommandLine.java | 35 +++++++------ .../gravitino/cli/commands/LinkModel.java | 6 +++ .../cli/commands/UpdateColumnDefault.java | 6 +++ .../gravitino/cli/TestCatalogCommands.java | 1 + .../gravitino/cli/TestColumnCommands.java | 33 ++++++++++++ .../gravitino/cli/TestModelCommands.java | 50 ++++++++++--------- .../gravitino/cli/TestSchemaCommands.java | 1 + .../gravitino/cli/TestTableCommands.java | 1 + 9 files changed, 96 insertions(+), 38 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java index c839ad162b5..abc6421d955 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java @@ -49,6 +49,7 @@ public class ErrorMessages { public static final String MALFORMED_NAME = "Malformed entity name."; public static final String MISSING_COLUMN_FILE = "Missing --columnfile option."; + public static final String MISSING_DATATYPE = "Missing --datatype option."; public static final String MISSING_ENTITIES = "Missing required entity names: "; public static final String MISSING_GROUP = "Missing --group option."; diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index f93da3003cc..07a1ecd5b7f 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -257,7 +257,7 @@ private void handleCatalogCommand() { // Handle the CommandActions.LIST action separately as it doesn't use `catalog` if (CommandActions.LIST.equals(command)) { - newListCatalogs(url, ignore, outputFormat, metalake).handle(); + newListCatalogs(url, ignore, outputFormat, metalake).validate().handle(); return; } @@ -356,7 +356,7 @@ private void handleSchemaCommand() { // Handle the CommandActions.LIST action separately as it doesn't use `schema` if (CommandActions.LIST.equals(command)) { checkEntities(missingEntities); - newListSchema(url, ignore, metalake, catalog).handle(); + newListSchema(url, ignore, metalake, catalog).validate().handle(); return; } @@ -429,7 +429,7 @@ private void handleTableCommand() { // Handle CommandActions.LIST action separately as it doesn't require the `table` if (CommandActions.LIST.equals(command)) { checkEntities(missingEntities); - newListTables(url, ignore, metalake, catalog, schema).handle(); + newListTables(url, ignore, metalake, catalog, schema).validate().handle(); return; } @@ -833,7 +833,7 @@ private void handleColumnCommand() { if (CommandActions.LIST.equals(command)) { checkEntities(missingEntities); - newListColumns(url, ignore, metalake, catalog, schema, table).handle(); + newListColumns(url, ignore, metalake, catalog, schema, table).validate().handle(); return; } @@ -844,7 +844,7 @@ private void handleColumnCommand() { switch (command) { case CommandActions.DETAILS: if (line.hasOption(GravitinoOptions.AUDIT)) { - newColumnAudit(url, ignore, metalake, catalog, schema, table, column).handle(); + newColumnAudit(url, ignore, metalake, catalog, schema, table, column).validate().handle(); } else { System.err.println(ErrorMessages.UNSUPPORTED_ACTION); Main.exit(-1); @@ -878,12 +878,13 @@ private void handleColumnCommand() { nullable, autoIncrement, defaultValue) + .validate() .handle(); break; } case CommandActions.DELETE: - newDeleteColumn(url, ignore, metalake, catalog, schema, table, column).handle(); + newDeleteColumn(url, ignore, metalake, catalog, schema, table, column).validate().handle(); break; case CommandActions.UPDATE: @@ -891,34 +892,40 @@ private void handleColumnCommand() { if (line.hasOption(GravitinoOptions.COMMENT)) { String comment = line.getOptionValue(GravitinoOptions.COMMENT); newUpdateColumnComment(url, ignore, metalake, catalog, schema, table, column, comment) + .validate() .handle(); } if (line.hasOption(GravitinoOptions.RENAME)) { String newName = line.getOptionValue(GravitinoOptions.RENAME); newUpdateColumnName(url, ignore, metalake, catalog, schema, table, column, newName) + .validate() .handle(); } if (line.hasOption(GravitinoOptions.DATATYPE) && !line.hasOption(GravitinoOptions.DEFAULT)) { String datatype = line.getOptionValue(GravitinoOptions.DATATYPE); newUpdateColumnDatatype(url, ignore, metalake, catalog, schema, table, column, datatype) + .validate() .handle(); } if (line.hasOption(GravitinoOptions.POSITION)) { String position = line.getOptionValue(GravitinoOptions.POSITION); newUpdateColumnPosition(url, ignore, metalake, catalog, schema, table, column, position) + .validate() .handle(); } if (line.hasOption(GravitinoOptions.NULL)) { boolean nullable = line.getOptionValue(GravitinoOptions.NULL).equals("true"); newUpdateColumnNullability( url, ignore, metalake, catalog, schema, table, column, nullable) + .validate() .handle(); } if (line.hasOption(GravitinoOptions.AUTO)) { boolean autoIncrement = line.getOptionValue(GravitinoOptions.AUTO).equals("true"); newUpdateColumnAutoIncrement( url, ignore, metalake, catalog, schema, table, column, autoIncrement) + .validate() .handle(); } if (line.hasOption(GravitinoOptions.DEFAULT)) { @@ -926,6 +933,7 @@ private void handleColumnCommand() { String dataType = line.getOptionValue(GravitinoOptions.DATATYPE); newUpdateColumnDefault( url, ignore, metalake, catalog, schema, table, column, defaultValue, dataType) + .validate() .handle(); } break; @@ -1207,7 +1215,7 @@ private void handleModelCommand() { // Handle CommandActions.LIST action separately as it doesn't require the `model` if (CommandActions.LIST.equals(command)) { checkEntities(missingEntities); - newListModel(url, ignore, metalake, catalog, schema).handle(); + newListModel(url, ignore, metalake, catalog, schema).validate().handle(); return; } @@ -1218,15 +1226,15 @@ private void handleModelCommand() { switch (command) { case CommandActions.DETAILS: if (line.hasOption(GravitinoOptions.AUDIT)) { - newModelAudit(url, ignore, metalake, catalog, schema, model).handle(); + newModelAudit(url, ignore, metalake, catalog, schema, model).validate().handle(); } else { - newModelDetails(url, ignore, metalake, catalog, schema, model).handle(); + newModelDetails(url, ignore, metalake, catalog, schema, model).validate().handle(); } break; case CommandActions.DELETE: boolean force = line.hasOption(GravitinoOptions.FORCE); - newDeleteModel(url, ignore, force, metalake, catalog, schema, model).handle(); + newDeleteModel(url, ignore, force, metalake, catalog, schema, model).validate().handle(); break; case CommandActions.CREATE: @@ -1235,17 +1243,13 @@ private void handleModelCommand() { Map createPropertyMap = new Properties().parse(createProperties); newCreateModel( url, ignore, metalake, catalog, schema, model, createComment, createPropertyMap) + .validate() .handle(); break; case CommandActions.UPDATE: String[] alias = line.getOptionValues(GravitinoOptions.ALIAS); String uri = line.getOptionValue(GravitinoOptions.URI); - if (uri == null) { - System.err.println(ErrorMessages.MISSING_URI); - Main.exit(-1); - } - String linkComment = line.getOptionValue(GravitinoOptions.COMMENT); String[] linkProperties = line.getOptionValues(CommandActions.PROPERTIES); Map linkPropertityMap = new Properties().parse(linkProperties); @@ -1260,6 +1264,7 @@ private void handleModelCommand() { alias, linkComment, linkPropertityMap) + .validate() .handle(); break; diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/LinkModel.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/LinkModel.java index 6e8a4ffb76d..cf34eae882a 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/LinkModel.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/LinkModel.java @@ -103,4 +103,10 @@ public void handle() { System.out.println( "Linked model " + model + " to " + uri + " with aliases " + Arrays.toString(alias)); } + + @Override + public Command validate() { + if (uri == null) exitWithError(ErrorMessages.MISSING_URI); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/UpdateColumnDefault.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/UpdateColumnDefault.java index 7c7c2d3b402..976cf623054 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/UpdateColumnDefault.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/UpdateColumnDefault.java @@ -103,4 +103,10 @@ public void handle() { System.out.println(column + " default changed."); } + + @Override + public Command validate() { + if (dataType == null) exitWithError(ErrorMessages.MISSING_DATATYPE); + return super.validate(); + } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java index 04c0dacc13b..afa19b94c5a 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestCatalogCommands.java @@ -92,6 +92,7 @@ void testListCatalogsCommand() { doReturn(mockList) .when(commandLine) .newListCatalogs(GravitinoCommandLine.DEFAULT_URL, false, null, "metalake_demo"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestColumnCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestColumnCommands.java index 2d1e12debcf..31a3139482c 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestColumnCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestColumnCommands.java @@ -93,6 +93,7 @@ void testListColumnsCommand() { .when(commandLine) .newListColumns( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "users"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -120,6 +121,7 @@ void testColumnAuditCommand() { "schema", "users", "name"); + doReturn(mockAudit).when(mockAudit).validate(); commandLine.handleCommandLine(); verify(mockAudit).handle(); } @@ -187,6 +189,7 @@ void testAddColumn() { true, false, null); + doReturn(mockAddColumn).when(mockAddColumn).validate(); commandLine.handleCommandLine(); verify(mockAddColumn).handle(); } @@ -214,6 +217,7 @@ void testDeleteColumn() { "schema", "users", "name"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -246,6 +250,7 @@ void testUpdateColumnComment() { "users", "name", "new comment"); + doReturn(mockUpdateColumn).when(mockUpdateColumn).validate(); commandLine.handleCommandLine(); verify(mockUpdateColumn).handle(); } @@ -278,6 +283,7 @@ void testUpdateColumnName() { "users", "name", "renamed"); + doReturn(mockUpdateName).when(mockUpdateName).validate(); commandLine.handleCommandLine(); verify(mockUpdateName).handle(); } @@ -310,6 +316,7 @@ void testUpdateColumnDatatype() { "users", "name", "varchar(250)"); + doReturn(mockUpdateDatatype).when(mockUpdateDatatype).validate(); commandLine.handleCommandLine(); verify(mockUpdateDatatype).handle(); } @@ -342,6 +349,7 @@ void testUpdateColumnPosition() { "users", "name", "first"); + doReturn(mockUpdatePosition).when(mockUpdatePosition).validate(); commandLine.handleCommandLine(); verify(mockUpdatePosition).handle(); } @@ -373,6 +381,7 @@ void testUpdateColumnNullability() { "users", "name", true); + doReturn(mockUpdateNull).when(mockUpdateNull).validate(); commandLine.handleCommandLine(); verify(mockUpdateNull).handle(); } @@ -404,6 +413,7 @@ void testUpdateColumnAutoIncrement() { "users", "name", true); + doReturn(mockUpdateAuto).when(mockUpdateAuto).validate(); commandLine.handleCommandLine(); verify(mockUpdateAuto).handle(); } @@ -439,10 +449,33 @@ void testUpdateColumnDefault() { "name", "Fred Smith", "varchar(100)"); + doReturn(mockUpdateDefault).when(mockUpdateDefault).validate(); commandLine.handleCommandLine(); verify(mockUpdateDefault).handle(); } + @Test + void testUpdateColumnDefaultWithoutDataType() { + Main.useExit = false; + UpdateColumnDefault spyUpdate = + spy( + new UpdateColumnDefault( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "user", + "name", + "", + null)); + + assertThrows(RuntimeException.class, spyUpdate::validate); + verify(spyUpdate, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_DATATYPE, output); + } + @Test @SuppressWarnings("DefaultCharset") void testDeleteColumnCommandWithoutCatalog() { diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java index 8d475d3625a..b83cc3c3136 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestModelCommands.java @@ -94,6 +94,7 @@ void testListModelCommand() { eq("metalake_demo"), eq("catalog"), eq("schema")); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -176,6 +177,7 @@ void testModelDetailsCommand() { eq("catalog"), eq("schema"), eq("model")); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -291,6 +293,7 @@ void testModelAuditCommand() { .when(commandLine) .newModelAudit( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema", "model"); + doReturn(mockAudit).when(mockAudit).validate(); commandLine.handleCommandLine(); verify(mockAudit).handle(); } @@ -320,6 +323,7 @@ void testRegisterModelCommand() { eq("model"), isNull(), argThat(Map::isEmpty)); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -349,6 +353,7 @@ void testRegisterModelCommandWithComment() { eq("model"), eq("comment"), argThat(Map::isEmpty)); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -384,6 +389,7 @@ void testRegisterModelCommandWithProperties() { argument.size() == 2 && argument.containsKey("key1") && argument.get("key1").equals("val1"))); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -420,6 +426,7 @@ void testRegisterModelCommandWithCommentAndProperties() { argument.size() == 2 && argument.containsKey("key1") && argument.get("key1").equals("val1"))); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -446,6 +453,7 @@ void testDeleteModelCommand() { "catalog", "schema", "model"); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -478,6 +486,7 @@ void testLinkModelCommandWithoutAlias() { isNull(), isNull(), argThat(Map::isEmpty)); + doReturn(linkModelMock).when(linkModelMock).validate(); commandLine.handleCommandLine(); verify(linkModelMock).handle(); } @@ -516,6 +525,7 @@ void testLinkModelCommandWithAlias() { && "aliasB".equals(argument[1])), isNull(), argThat(Map::isEmpty)); + doReturn(linkModelMock).when(linkModelMock).validate(); commandLine.handleCommandLine(); verify(linkModelMock).handle(); } @@ -523,30 +533,23 @@ void testLinkModelCommandWithAlias() { @Test void testLinkModelCommandWithoutURI() { Main.useExit = false; - when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); - when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); - when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); - when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.model"); - when(mockCommandLine.hasOption(GravitinoOptions.URI)).thenReturn(false); - when(mockCommandLine.hasOption(GravitinoOptions.ALIAS)).thenReturn(false); - GravitinoCommandLine commandLine = - spy( - new GravitinoCommandLine( - mockCommandLine, mockOptions, CommandEntities.MODEL, CommandActions.UPDATE)); - assertThrows(RuntimeException.class, commandLine::handleCommandLine); - verify(commandLine, never()) - .newLinkModel( - eq(GravitinoCommandLine.DEFAULT_URL), - eq(false), - eq("metalake_demo"), - eq("catalog"), - eq("schema"), - eq("model"), - isNull(), - isNull(), - isNull(), - argThat(Map::isEmpty)); + LinkModel spyLinkModel = + spy( + new LinkModel( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "catalog", + "schema", + "model", + null, + new String[] {"aliasA", "aliasB"}, + "comment", + Collections.EMPTY_MAP)); + + assertThrows(RuntimeException.class, spyLinkModel::validate); + verify(spyLinkModel, never()).handle(); String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); assertEquals(ErrorMessages.MISSING_URI, output); } @@ -596,6 +599,7 @@ void testLinkModelCommandWithAllComponent() { && argument.containsKey("key2") && "val1".equals(argument.get("key1")) && "val2".equals(argument.get("key2")))); + doReturn(linkModelMock).when(linkModelMock).validate(); commandLine.handleCommandLine(); verify(linkModelMock).handle(); } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java index 9059afeedb2..6b8770d8edf 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestSchemaCommands.java @@ -88,6 +88,7 @@ void testListSchemasCommand() { doReturn(mockList) .when(commandLine) .newListSchema(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java index 0193c834a5a..f0683320457 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTableCommands.java @@ -95,6 +95,7 @@ void testListTablesCommand() { .when(commandLine) .newListTables( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "catalog", "schema"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } From 2019fcb9bb09a7946ae95fc4223761ea49e5a6b5 Mon Sep 17 00:00:00 2001 From: Cheng-Yi Shih <48374270+cool9850311@users.noreply.github.com> Date: Tue, 7 Jan 2025 09:27:35 +0800 Subject: [PATCH 28/36] [#6004] fix: use fullName instead of names.get(0) when get role (#6057) What changes were proposed in this pull request? use fullName instead of names.get(0) when get role Why are the changes needed? Fix: #6004 Does this PR introduce any user-facing change? NO How was this patch tested? existing ut --- .../org/apache/gravitino/MetadataObjects.java | 4 + .../apache/gravitino/TestMetadataObjects.java | 15 +++ .../service/MetadataObjectService.java | 4 +- .../storage/relational/TestJDBCBackend.java | 93 +++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/api/src/main/java/org/apache/gravitino/MetadataObjects.java b/api/src/main/java/org/apache/gravitino/MetadataObjects.java index 74da23c10ea..557ccdefc49 100644 --- a/api/src/main/java/org/apache/gravitino/MetadataObjects.java +++ b/api/src/main/java/org/apache/gravitino/MetadataObjects.java @@ -21,6 +21,7 @@ import com.google.common.base.Joiner; import com.google.common.base.Preconditions; import com.google.common.base.Splitter; +import java.util.Collections; import java.util.List; import javax.annotation.Nullable; import org.apache.commons.lang3.StringUtils; @@ -151,6 +152,9 @@ public static MetadataObject parse(String fullName, MetadataObject.Type type) { StringUtils.isNotBlank(fullName), "Metadata object full name cannot be blank"); List parts = DOT_SPLITTER.splitToList(fullName); + if (type == MetadataObject.Type.ROLE) { + return MetadataObjects.of(Collections.singletonList(fullName), MetadataObject.Type.ROLE); + } return MetadataObjects.of(parts, type); } diff --git a/api/src/test/java/org/apache/gravitino/TestMetadataObjects.java b/api/src/test/java/org/apache/gravitino/TestMetadataObjects.java index bab5c5833fe..f792220e185 100644 --- a/api/src/test/java/org/apache/gravitino/TestMetadataObjects.java +++ b/api/src/test/java/org/apache/gravitino/TestMetadataObjects.java @@ -84,4 +84,19 @@ public void testColumnObject() { MetadataObjects.of( Lists.newArrayList("catalog", "schema", "table"), MetadataObject.Type.COLUMN)); } + + @Test + public void testRoleObject() { + MetadataObject roleObject = MetadataObjects.of(null, "role.test", MetadataObject.Type.ROLE); + Assertions.assertEquals("role.test", roleObject.fullName()); + + MetadataObject roleObject1 = MetadataObjects.of(null, "role", MetadataObject.Type.ROLE); + Assertions.assertEquals("role", roleObject1.fullName()); + + MetadataObject roleObject2 = MetadataObjects.parse("role.test", MetadataObject.Type.ROLE); + Assertions.assertEquals("role.test", roleObject2.fullName()); + + MetadataObject roleObject3 = MetadataObjects.parse("role", MetadataObject.Type.ROLE); + Assertions.assertEquals("role", roleObject3.fullName()); + } } diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetadataObjectService.java b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetadataObjectService.java index 9834bafa0e0..e6790a602c1 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetadataObjectService.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetadataObjectService.java @@ -50,10 +50,10 @@ public static long getMetadataObjectId( return MetalakeMetaService.getInstance().getMetalakeIdByName(fullName); } - List names = DOT_SPLITTER.splitToList(fullName); if (type == MetadataObject.Type.ROLE) { - return RoleMetaService.getInstance().getRoleIdByMetalakeIdAndName(metalakeId, names.get(0)); + return RoleMetaService.getInstance().getRoleIdByMetalakeIdAndName(metalakeId, fullName); } + List names = DOT_SPLITTER.splitToList(fullName); long catalogId = CatalogMetaService.getInstance().getCatalogIdByMetalakeIdAndName(metalakeId, names.get(0)); diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java index 3c9339ff62f..8cd2c802e86 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java @@ -81,6 +81,7 @@ import org.apache.gravitino.storage.RandomIdGenerator; import org.apache.gravitino.storage.relational.mapper.GroupMetaMapper; import org.apache.gravitino.storage.relational.mapper.UserMetaMapper; +import org.apache.gravitino.storage.relational.service.MetalakeMetaService; import org.apache.gravitino.storage.relational.service.RoleMetaService; import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper; import org.apache.gravitino.storage.relational.utils.SessionUtils; @@ -952,6 +953,98 @@ public void testMetaLifeCycleFromCreationToDeletion() throws IOException { assertEquals(1, listFilesetVersions(anotherFileset.id()).size()); } + @Test + public void testGetRoleIdByMetalakeIdAndName() throws IOException { + AuditInfo auditInfo = + AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build(); + String metalakeName = "testMetalake"; + String catalogName = "catalog"; + String roleNameWithDot = "role.with.dot"; + String roleNameWithoutDot = "roleWithoutDot"; + + BaseMetalake metalake = + createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), metalakeName, auditInfo); + backend.insert(metalake, false); + + CatalogEntity catalog = + createCatalog( + RandomIdGenerator.INSTANCE.nextId(), + NamespaceUtil.ofCatalog(metalakeName), + catalogName, + auditInfo); + backend.insert(catalog, false); + + RoleEntity roleWithDot = + createRoleEntity( + RandomIdGenerator.INSTANCE.nextId(), + AuthorizationUtils.ofRoleNamespace(metalakeName), + roleNameWithDot, + auditInfo, + catalogName); + backend.insert(roleWithDot, false); + + RoleEntity roleWithoutDot = + createRoleEntity( + RandomIdGenerator.INSTANCE.nextId(), + AuthorizationUtils.ofRoleNamespace(metalakeName), + roleNameWithoutDot, + auditInfo, + catalogName); + backend.insert(roleWithoutDot, false); + + Long metalakeId = MetalakeMetaService.getInstance().getMetalakeIdByName(metalakeName); + + Long roleIdWithDot = + RoleMetaService.getInstance().getRoleIdByMetalakeIdAndName(metalakeId, roleNameWithDot); + assertEquals(roleWithDot.id(), roleIdWithDot); + + Long roleIdWithoutDot = + RoleMetaService.getInstance().getRoleIdByMetalakeIdAndName(metalakeId, roleNameWithoutDot); + assertEquals(roleWithoutDot.id(), roleIdWithoutDot); + } + + @Test + public void testInsertRelationWithDotInRoleName() throws IOException { + AuditInfo auditInfo = + AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build(); + String metalakeName = "testMetalake"; + String catalogName = "catalog"; + String roleNameWithDot = "role.with.dot"; + + BaseMetalake metalake = + createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), metalakeName, auditInfo); + backend.insert(metalake, false); + + CatalogEntity catalog = + createCatalog( + RandomIdGenerator.INSTANCE.nextId(), + NamespaceUtil.ofCatalog(metalakeName), + catalogName, + auditInfo); + backend.insert(catalog, false); + + RoleEntity role = + createRoleEntity( + RandomIdGenerator.INSTANCE.nextId(), + AuthorizationUtils.ofRoleNamespace(metalakeName), + roleNameWithDot, + auditInfo, + catalogName); + backend.insert(role, false); + + UserEntity user = + createUserEntity( + RandomIdGenerator.INSTANCE.nextId(), + AuthorizationUtils.ofUserNamespace(metalakeName), + "user", + auditInfo); + backend.insert(user, false); + + backend.insertRelation( + OWNER_REL, role.nameIdentifier(), role.type(), user.nameIdentifier(), user.type(), true); + assertEquals(1, countActiveOwnerRel(user.id())); + } + private boolean legacyRecordExistsInDB(Long id, Entity.EntityType entityType) { String tableName; String idColumnName; From 8e48de32287ab0c0b8b3bcfbd022a1ee00b5485e Mon Sep 17 00:00:00 2001 From: Jerry Shao Date: Tue, 7 Jan 2025 09:32:26 +0800 Subject: [PATCH 29/36] [#5933] doc(catalog-model): Add docs for model management (#6052) ### What changes were proposed in this pull request? Add the docs for model management. ### Why are the changes needed? This is part of work to support model management in Gravitino. Fix: #5933 ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? N/A --- .../gravitino/client/GenericModelCatalog.java | 2 +- .../client/TestGenericModelCatalog.java | 3 +- .../gravitino/client/generic_model_catalog.py | 2 +- docs/assets/gravitino-model-arch.png | Bin 270264 -> 281743 bytes docs/assets/metadata-model.png | Bin 102235 -> 0 bytes docs/index.md | 7 + docs/kafka-catalog.md | 2 +- docs/manage-metalake-using-gravitino.md | 2 +- docs/manage-model-metadata-using-gravitino.md | 637 ++++++++++++++++++ docs/model-catalog.md | 87 +++ docs/open-api/models.yaml | 54 +- docs/overview.md | 28 +- .../server/web/rest/ModelOperations.java | 2 +- .../server/web/rest/TestModelOperations.java | 4 + web/web/src/lib/api/models/index.js | 2 +- 15 files changed, 786 insertions(+), 46 deletions(-) delete mode 100644 docs/assets/metadata-model.png create mode 100644 docs/manage-model-metadata-using-gravitino.md create mode 100644 docs/model-catalog.md diff --git a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericModelCatalog.java b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericModelCatalog.java index 9c1c4654d38..50e9eb246ac 100644 --- a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericModelCatalog.java +++ b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericModelCatalog.java @@ -204,7 +204,7 @@ public void linkModelVersion( NameIdentifier modelFullIdent = modelFullNameIdentifier(ident); BaseResponse resp = restClient.post( - formatModelVersionRequestPath(modelFullIdent), + formatModelVersionRequestPath(modelFullIdent) + "/versions", req, BaseResponse.class, Collections.emptyMap(), diff --git a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGenericModelCatalog.java b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGenericModelCatalog.java index 10e3ed678d3..a3575988fc0 100644 --- a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGenericModelCatalog.java +++ b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGenericModelCatalog.java @@ -380,7 +380,8 @@ public void testLinkModelVersion() throws JsonProcessingException { String modelVersionPath = withSlash( GenericModelCatalog.formatModelVersionRequestPath( - NameIdentifier.of(METALAKE_NAME, CATALOG_NAME, "schema1", "model1"))); + NameIdentifier.of(METALAKE_NAME, CATALOG_NAME, "schema1", "model1")) + + "/versions"); ModelVersionLinkRequest request = new ModelVersionLinkRequest( diff --git a/clients/client-python/gravitino/client/generic_model_catalog.py b/clients/client-python/gravitino/client/generic_model_catalog.py index ca6b5cd31fb..89bf29be13a 100644 --- a/clients/client-python/gravitino/client/generic_model_catalog.py +++ b/clients/client-python/gravitino/client/generic_model_catalog.py @@ -303,7 +303,7 @@ def link_model_version( request.validate() resp = self.rest_client.post( - f"{self._format_model_version_request_path(model_full_ident)}", + f"{self._format_model_version_request_path(model_full_ident)}/versions", request, error_handler=MODEL_ERROR_HANDLER, ) diff --git a/docs/assets/gravitino-model-arch.png b/docs/assets/gravitino-model-arch.png index de10689c0768ea07d96b3afc37a69ebe793af07f..5f43f1c29afc91a0e8a1193da31f97b2ce34607d 100644 GIT binary patch literal 281743 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

Y_%=292^n>TOm^z8F4sla>4*7^{piKj$z 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 literal 270264 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 diff --git a/docs/assets/metadata-model.png b/docs/assets/metadata-model.png deleted file mode 100644 index 143cb292bbadb873c22f7987705d3fab622a7311..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102235 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ÊapqRye6Qq-~Y+Q1GS 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 ob_8y&a$wKFi;|`AQJA*ltQ92g*y|SsUJK#k= + + +```shell +curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" -d '{ + "name": "model_catalog", + "type": "MODEL", + "comment": "This is a model catalog", + "properties": { + "k1": "v1" + } +}' http://localhost:8090/api/metalakes/example/catalogs +``` + + + + +```java +GravitinoClient gravitinoClient = GravitinoClient + .builder("http://localhost:8090") + .withMetalake("example") + .build(); + +Map properties = ImmutableMap.builder() + .put("k1", "v1") + .build(); + +Catalog catalog = gravitinoClient.createCatalog( + "model_catalog", + Type.MODEL, + "This is a model catalog", + properties); +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") +catalog = gravitino_client.create_catalog(name="model_catalog", + type=Catalog.Type.MODEL, + provider=None, + comment="This is a model catalog", + properties={"k1": "v1"}) +``` + + + + +### Load a catalog + +Refer to [Load a catalog](./manage-relational-metadata-using-gravitino.md#load-a-catalog) +in relational catalog for more details. For a model catalog, the load operation is the same. + +### Alter a catalog + +Refer to [Alter a catalog](./manage-relational-metadata-using-gravitino.md#alter-a-catalog) +in relational catalog for more details. For a model catalog, the alter operation is the same. + +### Drop a catalog + +Refer to [Drop a catalog](./manage-relational-metadata-using-gravitino.md#drop-a-catalog) +in relational catalog for more details. For a model catalog, the drop operation is the same. + +### List all catalogs in a metalake + +Please refer to [List all catalogs in a metalake](./manage-relational-metadata-using-gravitino.md#list-all-catalogs-in-a-metalake) +in relational catalog for more details. For a model catalog, the list operation is the same. + +### List all catalogs' information in a metalake + +Please refer to [List all catalogs' information in a metalake](./manage-relational-metadata-using-gravitino.md#list-all-catalogs-information-in-a-metalake) +in relational catalog for more details. For a model catalog, the list operation is the same. + +## Schema operations + +`Schema` is a virtual namespace in a model catalog, which is used to organize the models. It +is similar to the concept of `schema` in the relational catalog. + +:::tip +Users should create a metalake and a catalog before creating a schema. +::: + +### Create a schema + +You can create a schema by sending a `POST` request to the `/api/metalakes/{metalake_name}/catalogs/{catalog_name}/schemas` +endpoint or just use the Gravitino Java/Python client. The following is an example of creating a +schema: + + + + +```shell +curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" -d '{ + "name": "model_schema", + "comment": "This is a model schema", + "properties": { + "k1": "v1" + } +}' http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas +``` + + + + +```java +GravitinoClient gravitinoClient = GravitinoClient + .builder("http://localhost:8090") + .withMetalake("example") + .build(); + +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); + +SupportsSchemas supportsSchemas = catalog.asSchemas(); + +Map schemaProperties = ImmutableMap.builder() + .put("k1", "v1") + .build(); +Schema schema = supportsSchemas.createSchema( + "model_schema", + "This is a schema", + schemaProperties); +// ... +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +catalog.as_schemas().create_schema(name="model_schema", + comment="This is a schema", + properties={"k1": "v1"}) +``` + + + + +### Load a schema + +Please refer to [Load a schema](./manage-relational-metadata-using-gravitino.md#load-a-schema) +in relational catalog for more details. For a model catalog, the schema load operation is the +same. + +### Alter a schema + +Please refer to [Alter a schema](./manage-relational-metadata-using-gravitino.md#alter-a-schema) +in relational catalog for more details. For a model catalog, the schema alter operation is the +same. + +### Drop a schema + +Please refer to [Drop a schema](./manage-relational-metadata-using-gravitino.md#drop-a-schema) +in relational catalog for more details. For a model catalog, the schema drop operation is the +same. + +Note that the drop operation will delete all the model metadata under this schema if `cascade` +set to `true`. + +### List all schemas under a catalog + +Please refer to [List all schemas under a catalog](./manage-relational-metadata-using-gravitino.md#list-all-schemas-under-a-catalog) +in relational catalog for more details. For a model catalog, the schema list operation is the +same. + +## Model operations + +:::tip + - Users should create a metalake, a catalog, and a schema before creating a model. +::: + +### Register a model + +You can register a model by sending a `POST` request to the `/api/metalakes/{metalake_name} +/catalogs/{catalog_name}/schemas/{schema_name}/models` endpoint or just use the Gravitino +Java/Python client. The following is an example of creating a model: + + + + +```shell +curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" -d '{ + "name": "example_model", + "comment": "This is an example model", + "properties": { + "k1": "v1" + } +}' http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models +``` + + + + +```java +GravitinoClient gravitinoClient = GravitinoClient + .builder("http://localhost:8090") + .withMetalake("example") + .build(); + +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +Map propertiesMap = ImmutableMap.builder() + .put("k1", "v1") + .build(); + +Model model = catalog.asModelCatalog().registerModel( + NameIdentifier.of("model_schema", "example_model"), + "This is an example model", + propertiesMap); +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +model: Model = catalog.as_model_catalog().register_model(ident=NameIdentifier.of("model_schema", "example_model"), + comment="This is an example model", + properties={"k1": "v1"}) +``` + + + + +### Get a model + +You can get a model by sending a `GET` request to the `/api/metalakes/{metalake_name} +/catalogs/{catalog_name}/schemas/{schema_name}/models/{model_name}` endpoint or by using the +Gravitino Java/Python client. The following is an example of getting a model: + + + + +```shell +curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" \ +http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models/example_model +``` + + + + +```java +// ... +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +Model model = catalog.asModelCatalog().getModel(NameIdentifier.of("model_schema", "example_model")); +// ... +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +model: Model = catalog.as_model_catalog().get_model(ident=NameIdentifier.of("model_schema", "example_model")) +``` + + + + +### Delete a model + +You can delete a model by sending a `DELETE` request to the `/api/metalakes/{metalake_name} +/catalogs/{catalog_name}/schemas/{schema_name}/models/{model_name}` endpoint or by using the +Gravitino Java/Python client. The following is an example of deleting a model: + + + + +```shell +curl -X DELETE -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" \ +http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models/example_model +``` + + + + +```java +// ... +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +catalog.asModelCatalog().deleteModel(NameIdentifier.of("model_schema", "example_model")); +// ... +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +catalog.as_model_catalog().delete_model(NameIdentifier.of("model_schema", "example_model")) +``` + + + + +Note that the delete operation will delete all the model versions under this model. + +### List models + +You can list all the models in a schema by sending a `GET` request to the `/api/metalakes/ +{metalake_name}/catalogs/{catalog_name}/schemas/{schema_name}/models` endpoint or by using the +Gravitino Java/Python client. The following is an example of listing all the models in a schema: + + + + +```shell +curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" \ +http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models +``` + + + + +```java +// ... +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +NameIdentifier[] identifiers = catalog.asModelCatalog().listModels(Namespace.of("model_schema")); +// ... +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +model_list = catalog.as_model_catalog().list_models(namespace=Namespace.of("model_schema"))) +``` + + + + +## ModelVersion operations + +:::tip + - Users should create a metalake, a catalog, a schema, and a model before link a model version + to the model. +::: + +### Link a ModelVersion + +You can link a ModelVersion by sending a `POST` request to the `/api/metalakes/{metalake_name} +/catalogs/{catalog_name}/schemas/{schema_name}/models/{model_name}/versions` endpoint or by using +the Gravitino Java/Python client. The following is an example of linking a ModelVersion: + + + + +```shell +curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" -d '{ + "uri": "path/to/model", + "aliases": ["alias1", "alias2"], + "comment": "This is version 0", + "properties": { + "k1": "v1" + } +}' http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models/example_model/versions +``` + + + + +```java +// ... +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +catalog.asModelCatalog().linkModelVersion( + NameIdentifier.of("model_schema", "example_model"), + "path/to/model", + new String[] {"alias1", "alias2"}, + "This is version 0", + ImmutableMap.of("k1", "v1")); +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +catalog.as_model_catalog().link_model_version(model_ident=NameIdentifier.of("model_schema", "example_model"), + uri="path/to/model", + aliases=["alias1", "alias2"], + comment="This is version 0", + properties={"k1": "v1"}) +``` + + + + +The comment and properties of ModelVersion can be different from the model. + +### Get a ModelVersion + +You can get a ModelVersion by sending a `GET` request to the `/api/metalakes/{metalake_name} +/catalogs/{catalog_name}/schemas/{schema_name}/models/{model_name}/versions/{version_number}` +endpoint or by using the Gravitino Java/Python client. The following is an example of getting +a ModelVersion: + + + + +```shell +curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" \ +http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models/example_model/versions/0 +``` + + + + +```java +// ... +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +catalog.asModelCatalog().getModelVersion(NameIdentifier.of("model_schema", "example_model"), 0); +// ... +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +catalog.as_model_catalog().get_model_version(model_ident=NameIdentifier.of("model_schema", "example_model"), version=0) +``` + + + + +### Get a ModelVersion by alias + +You can also get a ModelVersion by sending a `GET` request to the `/api/metalakes/{metalake_name} +/catalogs/{catalog_name}/schemas/{schema_name}/models/{model_name}/aliases/{alias}` endpoint or +by using the Gravitino Java/Python client. The following is an example of getting a ModelVersion +by alias: + + + + +```shell +curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" \ +http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models/example_model/aliases/alias1 +``` + + + + +```java +// ... +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +ModelVersion modelVersion = catalog.asModelCatalog().getModelVersion(NameIdentifier.of("model_schema", "example_model"), "alias1"); +// ... +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +model_version: ModelVersion = catalog.as_model_catalog().get_model_version_by_alias(model_ident=NameIdentifier.of("model_schema", "example_model"), alias="alias1") +``` + + + + +### Delete a ModelVersion + +You can delete a ModelVersion by sending a `DELETE` request to the `/api/metalakes/{metalake_name} +/catalogs/{catalog_name}/schemas/{schema_name}/models/{model_name}/versions/{version_number}` +endpoint or by using the Gravitino Java/Python client. The following is an example of deleting +a ModelVersion: + + + + +```shell +curl -X DELETE -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" \ +http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models/example_model/versions/0 +``` + + + + +```java +// ... +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +catalog.asModelCatalog().deleteModelVersion(NameIdentifier.of("model_schema", "example_model"), 0); +// ... +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +catalog.as_model_catalog().delete_model_version(model_ident=NameIdentifier.of("model_schema", "example_model"), version=0) +``` + + + + +### Delete a ModelVersion by alias + +You can also delete a ModelVersion by sending a `DELETE` request to the `/api/metalakes/ +{metalake_name}/catalogs/{catalog_name}/schemas/{schema_name}/models/{model_name}/aliases/{alias}` endpoint or +by using the Gravitino Java/Python client. The following is an example of deleting a ModelVersion +by alias: + + + + +```shell +curl -X DELETE -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" \ +http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models/example_model/aliases/alias1 +``` + + + + +```java +// ... +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +catalog.asModelCatalog().deleteModelVersion(NameIdentifier.of("model_schema", "example_model"), "alias1"); +// ... +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +catalog.as_model_catalog().delete_model_version_by_alias(model_ident=NameIdentifier.of("model_schema", "example_model"), alias="alias1") +``` + + + + +### List ModelVersions + +You can list all the ModelVersions in a model by sending a `GET` request to the `/api/metalakes/ +{metalake_name}/catalogs/{catalog_name}/schemas/{schema_name}/models/{model_name}/versions` endpoint +or by using the Gravitino Java/Python client. The following is an example of listing all the +ModelVersions in a model: + + + + +```shell +curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \ +-H "Content-Type: application/json" \ +http://localhost:8090/api/metalakes/example/catalogs/model_catalog/schemas/model_schema/models/example_model/versions +``` + + + + +```java +// ... +Catalog catalog = gravitinoClient.loadCatalog("model_catalog"); +int[] modelVersions = catalog.asModelCatalog().listModelVersions(NameIdentifier.of("model_schema", "example_model")); +// ... +``` + + + + +```python +gravitino_client: GravitinoClient = GravitinoClient(uri="http://localhost:8090", metalake_name="example") + +catalog: Catalog = gravitino_client.load_catalog(name="model_catalog") +model_versions: List[int] = catalog.as_model_catalog().list_model_versions(model_ident=NameIdentifier.of("model_schema", "example_model")) +``` + + + diff --git a/docs/model-catalog.md b/docs/model-catalog.md new file mode 100644 index 00000000000..a9da0c8b3f6 --- /dev/null +++ b/docs/model-catalog.md @@ -0,0 +1,87 @@ +--- +title: "Model catalog" +slug: /model-catalog +date: 2024-12-26 +keyword: model catalog +license: "This software is licensed under the Apache License version 2." +--- + +## Introduction + +A Model catalog is a metadata catalog that provides the unified interface to manage the metadata of +machine learning models in a centralized way. It follows the typical Gravitino 3-level namespace +(catalog, schema, and model) to manage the ML models metadata. In addition, it supports +managing the versions for each model. + +The advantages of using model catalog are: + +* Centralized management of ML models with user defined namespaces. Users can better discover + and govern the models from sematic level, rather than managing the model files directly. +* Version management for each model. Users can easily track the model versions and manage the + model lifecycle. + +The key concept of model management is to manage the path (URI) of the model. Instead of +managing the model storage path physically and separately, model metadata defines the mapping +relation between the model name and the storage path. In the meantime, with the support of +extensible properties of model metadata, users can define the model metadata with more detailed information +rather than just the storage path. + +* **Model**: A model is a metadata object defined in the model catalog, to manage a ML model. Each + model can have many **Model Versions**, and each version can have its own properties. Models + can be retrieved by the name. +* **ModelVersion**: The model version is a metadata defined in the model catalog, to manage each + version of the ML model. Each version has a unique version number, and can have its own + properties and storage path. ModelVersion can be retrieved by the model name and version + number. Also, each version can have a list of aliases, which can also be used to retrieve. + +## Catalog + +### Catalog properties + +A Model catalog doesn't have specific properties. It uses the [common catalog properties](./gravitino-server-config.md#apache-gravitino-catalog-properties-configuration). + +### Catalog operations + +Refer to [Catalog operations](./manage-model-metadata-using-gravitino.md#catalog-operations) for more details. + +## Schema + +### Schema capabilities + +Schema is the second level of the model catalog namespace, the model catalog supports creating, updating, deleting, and listing schemas. + +### Schema properties + +Schema in the model catalog doesn't have predefined properties. Users can define the properties for each schema. + +### Schema operations + +Refer to [Schema operation](./manage-model-metadata-using-gravitino.md#schema-operations) for more details. + +## Model + +### Model capabilities + +The Model catalog supports registering, listing and deleting models and model versions. + +### Model properties + +Model doesn't have predefined properties. Users can define the properties for each model and model version. + +### Model operations + +Refer to [Model operation](./manage-model-metadata-using-gravitino.md#model-operations) for more details. + +## ModelVersion + +### ModelVersion capabilities + +The Model catalog supports linking, listing and deleting model versions. + +### ModelVersion properties + +ModelVersion doesn't have predefined properties. Users can define the properties for each version. + +### ModelVersion operations + +Refer to [ModelVersion operation](./manage-model-metadata-using-gravitino.md#model-version-operations) for more details. diff --git a/docs/open-api/models.yaml b/docs/open-api/models.yaml index 713a7037cd6..652923286b3 100644 --- a/docs/open-api/models.yaml +++ b/docs/open-api/models.yaml @@ -122,6 +122,33 @@ paths: "5xx": $ref: "./openapi.yaml#/components/responses/ServerErrorResponse" + /metalakes/{metalake}/catalogs/{catalog}/schemas/{schema}/models/{model}/versions: + parameters: + - $ref: "./openapi.yaml#/components/parameters/metalake" + - $ref: "./openapi.yaml#/components/parameters/catalog" + - $ref: "./openapi.yaml#/components/parameters/schema" + - $ref: "./openapi.yaml#/components/parameters/model" + + get: + tags: + - model + summary: List model versions + operationId: listModelVersions + responses: + "200": + $ref: "#/components/responses/ModelVersionListResponse" + "404": + description: Not Found - The target model does not exist + content: + application/vnd.gravitino.v1+json: + schema: + $ref: "./openapi.yaml#/components/schemas/ErrorModel" + examples: + NoSuchModelException: + $ref: "#/components/examples/NoSuchModelException" + "5xx": + $ref: "./openapi.yaml#/components/responses/ServerErrorResponse" + post: tags: - model @@ -159,33 +186,6 @@ paths: "5xx": $ref: "./openapi.yaml#/components/responses/ServerErrorResponse" - /metalakes/{metalake}/catalogs/{catalog}/schemas/{schema}/models/{model}/versions: - parameters: - - $ref: "./openapi.yaml#/components/parameters/metalake" - - $ref: "./openapi.yaml#/components/parameters/catalog" - - $ref: "./openapi.yaml#/components/parameters/schema" - - $ref: "./openapi.yaml#/components/parameters/model" - - get: - tags: - - model - summary: List model versions - operationId: listModelVersions - responses: - "200": - $ref: "#/components/responses/ModelVersionListResponse" - "404": - description: Not Found - The target model does not exist - content: - application/vnd.gravitino.v1+json: - schema: - $ref: "./openapi.yaml#/components/schemas/ErrorModel" - examples: - NoSuchModelException: - $ref: "#/components/examples/NoSuchModelException" - "5xx": - $ref: "./openapi.yaml#/components/responses/ServerErrorResponse" - /metalakes/{metalake}/catalogs/{catalog}/schemas/{schema}/models/{model}/versions/{version}: parameters: - $ref: "./openapi.yaml#/components/parameters/metalake" diff --git a/docs/overview.md b/docs/overview.md index 2b215412ede..17d0ee48e30 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -37,7 +37,7 @@ For example, relational metadata models for tabular data, like Hive, MySQL, Post File metadata model for all the unstructured data, like HDFS, S3, and others. Besides the unified metadata models, Gravitino also provides a unified metadata governance layer -(WIP) to manage the metadata in a unified way, including access control, auditing, discovery and +to manage the metadata in a unified way, including access control, auditing, discovery and others. ### Direct metadata management @@ -63,24 +63,28 @@ change the existing SQL dialects. In the meantime, other query engine support is on the roadmap, including [Apache Spark](https://spark.apache.org/), [Apache Flink](https://flink.apache.org/) and others. -### AI asset management (WIP) +### AI asset management -The goal of Gravitino is to unify the data management in both data and AI assets. The support of AI -assets like models, features, and others are under development. +The goal of Gravitino is to unify the data management in both data and AI assets, including raw files, models, etc. ## Terminology -### The model of Apache Gravitino +### The metadata object of Apache Gravitino -![Gravitino Model](assets/metadata-model.png) - -* **Metalake**: The top-level container for metadata. Typically, one group has one metalake - to manage all the metadata in it. Each metalake exposes a three-level namespace(catalog.schema. +* **Metalake**: The container/tenant for metadata. Typically, one group has one metalake + to manage all the metadata in it. Each metalake exposes a three-level namespace (catalog.schema. table) to organize the data. * **Catalog**: A catalog is a collection of metadata from a specific metadata source. Each catalog has a related connector to connect to the specific metadata source. -* **Schema**: A schema is equivalent to a database, Schemas only exist in the specific catalogs - that support relational metadata sources, such as Apache Hive, MySQL, PostgreSQL, and others. +* **Schema**: Schema is the second level namespace to group a collection of metadata, schema can + refer to the database/schema in the relational metadata sources, such as Apache Hive, MySQL, + PostgreSQL, and others. Schema can also refer to the logic namespace for the fileset and model + catalog. * **Table**: The lowest level in the object hierarchy for catalogs that support relational metadata sources. You can create Tables in specific schemas in the catalogs. -* **Model**: The model represents the metadata in the specific catalogs that support model management. +* **Fileset**: The fileset metadata object refers to a collection of files and directories in + the file system. The fileset metadata object is used to manage the logic metadata for the files. +* **Model**: The model metadata object represents the metadata in the specific catalogs that + support model management. +* **Topic**: The topic metadata object represents the metadata in the specific catalogs that + support managing the topic for a message queue system, such as Kafka. diff --git a/server/src/main/java/org/apache/gravitino/server/web/rest/ModelOperations.java b/server/src/main/java/org/apache/gravitino/server/web/rest/ModelOperations.java index fd507821086..e4b80d0526e 100644 --- a/server/src/main/java/org/apache/gravitino/server/web/rest/ModelOperations.java +++ b/server/src/main/java/org/apache/gravitino/server/web/rest/ModelOperations.java @@ -286,7 +286,7 @@ public Response getModelVersionByAlias( } @POST - @Path("{model}") + @Path("{model}/versions") @Produces("application/vnd.gravitino.v1+json") @Timed(name = "link-model-version." + MetricNames.HTTP_PROCESS_DURATION, absolute = true) @ResponseMetered(name = "link-model-version", absolute = true) diff --git a/server/src/test/java/org/apache/gravitino/server/web/rest/TestModelOperations.java b/server/src/test/java/org/apache/gravitino/server/web/rest/TestModelOperations.java index 42e48d0302f..c383a07a463 100644 --- a/server/src/test/java/org/apache/gravitino/server/web/rest/TestModelOperations.java +++ b/server/src/test/java/org/apache/gravitino/server/web/rest/TestModelOperations.java @@ -601,6 +601,7 @@ public void testLinkModelVersion() { Response resp = target(modelPath()) .path("model1") + .path("versions") .request(MediaType.APPLICATION_JSON_TYPE) .accept("application/vnd.gravitino.v1+json") .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); @@ -619,6 +620,7 @@ public void testLinkModelVersion() { Response resp1 = target(modelPath()) .path("model1") + .path("versions") .request(MediaType.APPLICATION_JSON_TYPE) .accept("application/vnd.gravitino.v1+json") .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); @@ -637,6 +639,7 @@ public void testLinkModelVersion() { Response resp2 = target(modelPath()) .path("model1") + .path("versions") .request(MediaType.APPLICATION_JSON_TYPE) .accept("application/vnd.gravitino.v1+json") .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); @@ -656,6 +659,7 @@ public void testLinkModelVersion() { Response resp3 = target(modelPath()) .path("model1") + .path("versions") .request(MediaType.APPLICATION_JSON_TYPE) .accept("application/vnd.gravitino.v1+json") .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); diff --git a/web/web/src/lib/api/models/index.js b/web/web/src/lib/api/models/index.js index fa968326d1a..74d2e0d368d 100644 --- a/web/web/src/lib/api/models/index.js +++ b/web/web/src/lib/api/models/index.js @@ -45,7 +45,7 @@ const Apis = { LINK_VERSION: ({ metalake, catalog, schema, model }) => `/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent( catalog - )}/schemas/${encodeURIComponent(schema)}/models/${encodeURIComponent(model)}`, + )}/schemas/${encodeURIComponent(schema)}/models/${encodeURIComponent(model)}/versions`, DELETE_VERSION: ({ metalake, catalog, schema, model, version }) => { return `/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent( catalog From 79536fed03767b4fbcc5b34a93385aaf0f31cf82 Mon Sep 17 00:00:00 2001 From: TungYuChiang <75083792+TungYuChiang@users.noreply.github.com> Date: Tue, 7 Jan 2025 09:55:53 +0800 Subject: [PATCH 30/36] [#5779] feat(iceberg): add OSS support for IcebergRESTService docker image (#6096) ### What changes were proposed in this pull request? add OSS support for IcebergRESTService docker image ### Why are the changes needed? Fix: #5779 no ### How was this patch tested? run SQL with access Aliyun OSS data --- .../iceberg-rest-server/iceberg-rest-server-dependency.sh | 7 +++++++ dev/docker/iceberg-rest-server/rewrite_config.py | 7 +++++++ docs/docker-image-details.md | 2 ++ docs/iceberg-rest-service.md | 8 +++++++- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/dev/docker/iceberg-rest-server/iceberg-rest-server-dependency.sh b/dev/docker/iceberg-rest-server/iceberg-rest-server-dependency.sh index 2235313dc09..852b55b0206 100755 --- a/dev/docker/iceberg-rest-server/iceberg-rest-server-dependency.sh +++ b/dev/docker/iceberg-rest-server/iceberg-rest-server-dependency.sh @@ -38,6 +38,7 @@ cd ${gravitino_home} ./gradlew :bundles:gcp-bundle:jar ./gradlew :bundles:aws-bundle:jar ./gradlew :bundles:azure-bundle:jar +./gradlew :bundles:aliyun-bundle:jar # prepare bundle jar cd ${iceberg_rest_server_dir} @@ -45,6 +46,7 @@ mkdir -p bundles cp ${gravitino_home}/bundles/gcp-bundle/build/libs/gravitino-gcp-bundle-*.jar bundles/ cp ${gravitino_home}/bundles/aws-bundle/build/libs/gravitino-aws-bundle-*.jar bundles/ cp ${gravitino_home}/bundles/azure-bundle/build/libs/gravitino-azure-bundle-*.jar bundles/ +cp ${gravitino_home}/bundles/aliyun-bundle/build/libs/gravitino-aliyun-bundle-*.jar bundles/ iceberg_gcp_bundle="iceberg-gcp-bundle-1.5.2.jar" if [ ! -f "bundles/${iceberg_gcp_bundle}" ]; then @@ -61,6 +63,11 @@ if [ ! -f "bundles/${iceberg_azure_bundle}" ]; then curl -L -s -o bundles/${iceberg_azure_bundle} https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-azure-bundle/1.5.2/${iceberg_azure_bundle} fi +iceberg_aliyun_bundle="iceberg-aliyun-bundle-1.5.2.jar" +if [ ! -f "bundles/${iceberg_aliyun_bundle}" ]; then + curl -L -s -o bundles/${iceberg_aliyun_bundle} https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-aliyun-bundle/1.5.2/${iceberg_aliyun_bundle} +fi + # download jdbc driver curl -L -s -o bundles/sqlite-jdbc-3.42.0.0.jar https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.42.0.0/sqlite-jdbc-3.42.0.0.jar diff --git a/dev/docker/iceberg-rest-server/rewrite_config.py b/dev/docker/iceberg-rest-server/rewrite_config.py index b10cdb4bfb7..8b9b42a531c 100755 --- a/dev/docker/iceberg-rest-server/rewrite_config.py +++ b/dev/docker/iceberg-rest-server/rewrite_config.py @@ -36,6 +36,13 @@ "GRAVITINO_AZURE_TENANT_ID" : "azure-tenant-id", "GRAVITINO_AZURE_CLIENT_ID" : "azure-client-id", "GRAVITINO_AZURE_CLIENT_SECRET" : "azure-client-secret", + "GRAVITINO_OSS_ACCESS_KEY": "oss-access-key-id", + "GRAVITINO_OSS_SECRET_KEY": "oss-secret-access-key", + "GRAVITINO_OSS_ENDPOINT": "oss-endpoint", + "GRAVITINO_OSS_REGION": "oss-region", + "GRAVITINO_OSS_ROLE_ARN": "oss-role-arn", + "GRAVITINO_OSS_EXTERNAL_ID": "oss-external-id", + } init_config = { diff --git a/docs/docker-image-details.md b/docs/docker-image-details.md index c723c009d93..48b3bd191a1 100644 --- a/docs/docker-image-details.md +++ b/docs/docker-image-details.md @@ -59,6 +59,8 @@ docker run --rm -d -p 9001:9001 apache/gravitino-iceberg-rest:0.7.0-incubating ``` Changelog +- apache/gravitino-iceberg-rest:0.8.0-incubating + - Supports OSS and ADLS storage. - apache/gravitino-iceberg-rest:0.7.0-incubating - Using JDBC catalog backend. diff --git a/docs/iceberg-rest-service.md b/docs/iceberg-rest-service.md index f21ca35a43a..5adc75ad835 100644 --- a/docs/iceberg-rest-service.md +++ b/docs/iceberg-rest-service.md @@ -441,7 +441,7 @@ SELECT * FROM dml.test; You could run Gravitino Iceberg REST server though docker container: ```shell -docker run -d -p 9001:9001 apache/gravitino-iceberg-rest:0.7.0-incubating +docker run -d -p 9001:9001 apache/gravitino-iceberg-rest:0.8.0-incubating ``` Gravitino Iceberg REST server in docker image could access local storage by default, you could set the following environment variables if the storage is cloud/remote storage like S3, please refer to [storage section](#storage) for more details. @@ -464,6 +464,12 @@ Gravitino Iceberg REST server in docker image could access local storage by defa | `GRAVITINO_AZURE_TENANT_ID` | `gravitino.iceberg-rest.azure-tenant-id` | 0.8.0-incubating | | `GRAVITINO_AZURE_CLIENT_ID` | `gravitino.iceberg-rest.azure-client-id` | 0.8.0-incubating | | `GRAVITINO_AZURE_CLIENT_SECRET` | `gravitino.iceberg-rest.azure-client-secret` | 0.8.0-incubating | +| `GRAVITINO_OSS_ACCESS_KEY` | `gravitino.iceberg-rest.oss-access-key-id` | 0.8.0-incubating | +| `GRAVITINO_OSS_SECRET_KEY` | `gravitino.iceberg-rest.oss-secret-access-key` | 0.8.0-incubating | +| `GRAVITINO_OSS_ENDPOINT` | `gravitino.iceberg-rest.oss-endpoint` | 0.8.0-incubating | +| `GRAVITINO_OSS_REGION` | `gravitino.iceberg-rest.oss-region` | 0.8.0-incubating | +| `GRAVITINO_OSS_ROLE_ARN` | `gravitino.iceberg-rest.oss-role-arn` | 0.8.0-incubating | +| `GRAVITINO_OSS_EXTERNAL_ID` | `gravitino.iceberg-rest.oss-external-id` | 0.8.0-incubating | The below environment is deprecated, please use the corresponding configuration items instead. From f613dce9727ec50e8a7728cc6567fae7e30c8d21 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 10:36:28 +0800 Subject: [PATCH 31/36] build(deps): bump cross-spawn from 7.0.3 to 7.0.6 in /web/web (#6126) Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.

Changelog

Sourced from cross-spawn's changelog.

7.0.6 (2024-11-18)

Bug Fixes

  • update cross-spawn version to 7.0.5 in package-lock.json (f700743)

7.0.5 (2024-11-07)

Bug Fixes

  • fix escaping bug introduced by backtracking (640d391)

7.0.4 (2024-11-07)

Bug Fixes

Commits
  • 77cd97f chore(release): 7.0.6
  • 6717de4 chore: upgrade standard-version
  • f700743 fix: update cross-spawn version to 7.0.5 in package-lock.json
  • 9a7e3b2 chore: fix build status badge
  • 0852683 chore(release): 7.0.5
  • 640d391 fix: fix escaping bug introduced by backtracking
  • bff0c87 chore: remove codecov
  • a7c6abc chore: replace travis with github workflows
  • 9b9246e chore(release): 7.0.4
  • 5ff3a07 fix: disable regexp backtracking (#160)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cross-spawn&package-manager=npm_and_yarn&previous-version=7.0.3&new-version=7.0.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/apache/gravitino/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/web/pnpm-lock.yaml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/web/web/pnpm-lock.yaml b/web/web/pnpm-lock.yaml index e5c77f19178..2c30d580cb6 100644 --- a/web/web/pnpm-lock.yaml +++ b/web/web/pnpm-lock.yaml @@ -1171,8 +1171,8 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} css-in-js-utils@3.1.0: @@ -4232,7 +4232,7 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 - cross-spawn@7.0.3: + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 @@ -4407,7 +4407,7 @@ snapshots: env-cmd@10.1.0: dependencies: commander: 4.1.1 - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 error-ex@1.3.2: dependencies: @@ -4588,7 +4588,7 @@ snapshots: debug: 4.3.5 enhanced-resolve: 5.17.0 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 @@ -4600,7 +4600,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -4621,7 +4621,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.14.0 is-glob: 4.0.3 @@ -4703,7 +4703,7 @@ snapshots: '@ungap/structured-clone': 1.2.0 ajv: 6.12.6 chalk: 4.1.2 - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 debug: 4.3.5 doctrine: 3.0.0 escape-string-regexp: 4.0.0 @@ -4754,7 +4754,7 @@ snapshots: execa@5.1.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 6.0.1 human-signals: 2.1.0 is-stream: 2.0.1 @@ -4833,7 +4833,7 @@ snapshots: foreground-child@3.2.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 signal-exit: 4.1.0 form-data@4.0.0: From caf42cf8deccac31df2635f0b76e19130e2e72c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 10:37:14 +0800 Subject: [PATCH 32/36] build(deps): bump next from 14.2.10 to 14.2.21 in /web/web (#6124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [next](https://github.com/vercel/next.js) from 14.2.10 to 14.2.21.
Release notes

Sourced from next's releases.

v14.2.21

[!NOTE]
This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

Misc Changes

Credits

Huge thanks to @​unstubbable, @​ztanner, and @​styfle for helping!

v14.2.20

[!NOTE]
This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

Credits

Huge thanks to @​wyattjoh for helping!

v14.2.19

[!NOTE]
This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • ensure worker exits bubble to parent process (#73433)
  • Increase max cache tags to 128 (#73125)

Misc Changes

  • Update max tag items limit in docs (#73445)

Credits

Huge thanks to @​ztanner and @​ijjk for helping!

v14.2.18

[!NOTE]
This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • Fix: (third-parties) sendGTMEvent not queueing events before GTM init (#68683) (#72111)
  • Ignore error pages for cache revalidate (#72412) (#72484)

Credits

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=next&package-manager=npm_and_yarn&previous-version=14.2.10&new-version=14.2.21)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/apache/gravitino/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/web/package.json | 2 +- web/web/pnpm-lock.yaml | 90 +++++++++++++++++++++--------------------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/web/web/package.json b/web/web/package.json index 471844e2170..29557e07dbd 100644 --- a/web/web/package.json +++ b/web/web/package.json @@ -33,7 +33,7 @@ "clsx": "^2.1.1", "dayjs": "^1.11.11", "lodash-es": "^4.17.21", - "next": "14.2.10", + "next": "14.2.21", "nprogress": "^0.2.0", "qs": "^6.12.2", "react": "^18.3.1", diff --git a/web/web/pnpm-lock.yaml b/web/web/pnpm-lock.yaml index 2c30d580cb6..0f2bbbf4bc5 100644 --- a/web/web/pnpm-lock.yaml +++ b/web/web/pnpm-lock.yaml @@ -57,8 +57,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 next: - specifier: 14.2.10 - version: 14.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 14.2.21 + version: 14.2.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -672,62 +672,62 @@ packages: '@next/bundle-analyzer@14.2.4': resolution: {integrity: sha512-ydSDikSgGhYmBlnvzS4tgdGyn40SCFI9uWDldbkRSwXS60tg4WBJR4qJoTSERTmdAFb1PeUYCyFdfC80i2WL1w==} - '@next/env@14.2.10': - resolution: {integrity: sha512-dZIu93Bf5LUtluBXIv4woQw2cZVZ2DJTjax5/5DOs3lzEOeKLy7GxRSr4caK9/SCPdaW6bCgpye6+n4Dh9oJPw==} + '@next/env@14.2.21': + resolution: {integrity: sha512-lXcwcJd5oR01tggjWJ6SrNNYFGuOOMB9c251wUNkjCpkoXOPkDeF/15c3mnVlBqrW4JJXb2kVxDFhC4GduJt2A==} '@next/eslint-plugin-next@14.0.3': resolution: {integrity: sha512-j4K0n+DcmQYCVnSAM+UByTVfIHnYQy2ODozfQP+4RdwtRDfobrIvKq1K4Exb2koJ79HSSa7s6B2SA8T/1YR3RA==} - '@next/swc-darwin-arm64@14.2.10': - resolution: {integrity: sha512-V3z10NV+cvMAfxQUMhKgfQnPbjw+Ew3cnr64b0lr8MDiBJs3eLnM6RpGC46nhfMZsiXgQngCJKWGTC/yDcgrDQ==} + '@next/swc-darwin-arm64@14.2.21': + resolution: {integrity: sha512-HwEjcKsXtvszXz5q5Z7wCtrHeTTDSTgAbocz45PHMUjU3fBYInfvhR+ZhavDRUYLonm53aHZbB09QtJVJj8T7g==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@14.2.10': - resolution: {integrity: sha512-Y0TC+FXbFUQ2MQgimJ/7Ina2mXIKhE7F+GUe1SgnzRmwFY3hX2z8nyVCxE82I2RicspdkZnSWMn4oTjIKz4uzA==} + '@next/swc-darwin-x64@14.2.21': + resolution: {integrity: sha512-TSAA2ROgNzm4FhKbTbyJOBrsREOMVdDIltZ6aZiKvCi/v0UwFmwigBGeqXDA97TFMpR3LNNpw52CbVelkoQBxA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@14.2.10': - resolution: {integrity: sha512-ZfQ7yOy5zyskSj9rFpa0Yd7gkrBnJTkYVSya95hX3zeBG9E55Z6OTNPn1j2BTFWvOVVj65C3T+qsjOyVI9DQpA==} + '@next/swc-linux-arm64-gnu@14.2.21': + resolution: {integrity: sha512-0Dqjn0pEUz3JG+AImpnMMW/m8hRtl1GQCNbO66V1yp6RswSTiKmnHf3pTX6xMdJYSemf3O4Q9ykiL0jymu0TuA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@14.2.10': - resolution: {integrity: sha512-n2i5o3y2jpBfXFRxDREr342BGIQCJbdAUi/K4q6Env3aSx8erM9VuKXHw5KNROK9ejFSPf0LhoSkU/ZiNdacpQ==} + '@next/swc-linux-arm64-musl@14.2.21': + resolution: {integrity: sha512-Ggfw5qnMXldscVntwnjfaQs5GbBbjioV4B4loP+bjqNEb42fzZlAaK+ldL0jm2CTJga9LynBMhekNfV8W4+HBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@14.2.10': - resolution: {integrity: sha512-GXvajAWh2woTT0GKEDlkVhFNxhJS/XdDmrVHrPOA83pLzlGPQnixqxD8u3bBB9oATBKB//5e4vpACnx5Vaxdqg==} + '@next/swc-linux-x64-gnu@14.2.21': + resolution: {integrity: sha512-uokj0lubN1WoSa5KKdThVPRffGyiWlm/vCc/cMkWOQHw69Qt0X1o3b2PyLLx8ANqlefILZh1EdfLRz9gVpG6tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@14.2.10': - resolution: {integrity: sha512-opFFN5B0SnO+HTz4Wq4HaylXGFV+iHrVxd3YvREUX9K+xfc4ePbRrxqOuPOFjtSuiVouwe6uLeDtabjEIbkmDA==} + '@next/swc-linux-x64-musl@14.2.21': + resolution: {integrity: sha512-iAEBPzWNbciah4+0yI4s7Pce6BIoxTQ0AGCkxn/UBuzJFkYyJt71MadYQkjPqCQCJAFQ26sYh7MOKdU+VQFgPg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@14.2.10': - resolution: {integrity: sha512-9NUzZuR8WiXTvv+EiU/MXdcQ1XUvFixbLIMNQiVHuzs7ZIFrJDLJDaOF1KaqttoTujpcxljM/RNAOmw1GhPPQQ==} + '@next/swc-win32-arm64-msvc@14.2.21': + resolution: {integrity: sha512-plykgB3vL2hB4Z32W3ktsfqyuyGAPxqwiyrAi2Mr8LlEUhNn9VgkiAl5hODSBpzIfWweX3er1f5uNpGDygfQVQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-ia32-msvc@14.2.10': - resolution: {integrity: sha512-fr3aEbSd1GeW3YUMBkWAu4hcdjZ6g4NBl1uku4gAn661tcxd1bHs1THWYzdsbTRLcCKLjrDZlNp6j2HTfrw+Bg==} + '@next/swc-win32-ia32-msvc@14.2.21': + resolution: {integrity: sha512-w5bacz4Vxqrh06BjWgua3Yf7EMDb8iMcVhNrNx8KnJXt8t+Uu0Zg4JHLDL/T7DkTCEEfKXO/Er1fcfWxn2xfPA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@next/swc-win32-x64-msvc@14.2.10': - resolution: {integrity: sha512-UjeVoRGKNL2zfbcQ6fscmgjBAS/inHBh63mjIlfPg/NG8Yn2ztqylXt5qilYb6hoHIwaU2ogHknHWWmahJjgZQ==} + '@next/swc-win32-x64-msvc@14.2.21': + resolution: {integrity: sha512-sT6+llIkzpsexGYZq8cjjthRyRGe5cJVhqh12FmlbxHqna6zsDDK8UNaV7g41T6atFHCJUPeLb3uyAwrBwy0NA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2079,8 +2079,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@14.2.10: - resolution: {integrity: sha512-sDDExXnh33cY3RkS9JuFEKaS4HmlWmDKP1VJioucCG6z5KuA008DPsDZOzi8UfqEk3Ii+2NCQSJrfbEWtZZfww==} + next@14.2.21: + resolution: {integrity: sha512-rZmLwucLHr3/zfDMYbJXbw0ZeoBpirxkXuvsJbk7UPorvPYZhP7vq7aHbKnU7dQNCYIimRrbB2pp3xmf+wsYUg==} engines: {node: '>=18.17.0'} hasBin: true peerDependencies: @@ -3645,37 +3645,37 @@ snapshots: - bufferutil - utf-8-validate - '@next/env@14.2.10': {} + '@next/env@14.2.21': {} '@next/eslint-plugin-next@14.0.3': dependencies: glob: 7.1.7 - '@next/swc-darwin-arm64@14.2.10': + '@next/swc-darwin-arm64@14.2.21': optional: true - '@next/swc-darwin-x64@14.2.10': + '@next/swc-darwin-x64@14.2.21': optional: true - '@next/swc-linux-arm64-gnu@14.2.10': + '@next/swc-linux-arm64-gnu@14.2.21': optional: true - '@next/swc-linux-arm64-musl@14.2.10': + '@next/swc-linux-arm64-musl@14.2.21': optional: true - '@next/swc-linux-x64-gnu@14.2.10': + '@next/swc-linux-x64-gnu@14.2.21': optional: true - '@next/swc-linux-x64-musl@14.2.10': + '@next/swc-linux-x64-musl@14.2.21': optional: true - '@next/swc-win32-arm64-msvc@14.2.10': + '@next/swc-win32-arm64-msvc@14.2.21': optional: true - '@next/swc-win32-ia32-msvc@14.2.10': + '@next/swc-win32-ia32-msvc@14.2.21': optional: true - '@next/swc-win32-x64-msvc@14.2.10': + '@next/swc-win32-x64-msvc@14.2.21': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5321,9 +5321,9 @@ snapshots: natural-compare@1.4.0: {} - next@14.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 14.2.10 + '@next/env': 14.2.21 '@swc/helpers': 0.5.5 busboy: 1.6.0 caniuse-lite: 1.0.30001639 @@ -5333,15 +5333,15 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.1(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 14.2.10 - '@next/swc-darwin-x64': 14.2.10 - '@next/swc-linux-arm64-gnu': 14.2.10 - '@next/swc-linux-arm64-musl': 14.2.10 - '@next/swc-linux-x64-gnu': 14.2.10 - '@next/swc-linux-x64-musl': 14.2.10 - '@next/swc-win32-arm64-msvc': 14.2.10 - '@next/swc-win32-ia32-msvc': 14.2.10 - '@next/swc-win32-x64-msvc': 14.2.10 + '@next/swc-darwin-arm64': 14.2.21 + '@next/swc-darwin-x64': 14.2.21 + '@next/swc-linux-arm64-gnu': 14.2.21 + '@next/swc-linux-arm64-musl': 14.2.21 + '@next/swc-linux-x64-gnu': 14.2.21 + '@next/swc-linux-x64-musl': 14.2.21 + '@next/swc-win32-arm64-msvc': 14.2.21 + '@next/swc-win32-ia32-msvc': 14.2.21 + '@next/swc-win32-x64-msvc': 14.2.21 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros From bec35a6f81dd52d321f4d8e8d071580c9198f407 Mon Sep 17 00:00:00 2001 From: luoshipeng <806855059@qq.com> Date: Tue, 7 Jan 2025 11:34:35 +0800 Subject: [PATCH 33/36] [#4305] improvement(core): Improved the way of fill parentEntityId in POBuilder (#6114) ### What changes were proposed in this pull request? remove the for each statement, and get parentEntityId directly. ### Why are the changes needed? issue: https://github.com/apache/gravitino/issues/4305 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? ut --------- Co-authored-by: luoshipeng --- .../relational/service/CommonMetaService.java | 25 ++++++++++++++++++ .../service/FilesetMetaService.java | 26 ++++--------------- .../relational/service/ModelMetaService.java | 18 +++---------- .../relational/service/SchemaMetaService.java | 20 +++----------- .../relational/service/TableMetaService.java | 26 ++++--------------- .../relational/service/TopicMetaService.java | 26 ++++--------------- 6 files changed, 48 insertions(+), 93 deletions(-) diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/service/CommonMetaService.java b/core/src/main/java/org/apache/gravitino/storage/relational/service/CommonMetaService.java index f990e94fdcc..bdab2ad9fe5 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/service/CommonMetaService.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/service/CommonMetaService.java @@ -57,4 +57,29 @@ public Long getParentEntityIdByNamespace(Namespace namespace) { "Parent entity id should not be null and should be greater than 0."); return parentEntityId; } + + public Long[] getParentEntityIdsByNamespace(Namespace namespace) { + Preconditions.checkArgument( + !namespace.isEmpty() && namespace.levels().length <= 3, + "Namespace should not be empty and length should be less than or equal to 3."); + Long[] parentEntityIds = new Long[namespace.levels().length]; + if (namespace.levels().length >= 1) { + parentEntityIds[0] = + MetalakeMetaService.getInstance().getMetalakeIdByName(namespace.level(0)); + } + + if (namespace.levels().length >= 2) { + parentEntityIds[1] = + CatalogMetaService.getInstance() + .getCatalogIdByMetalakeIdAndName(parentEntityIds[0], namespace.level(1)); + } + + if (namespace.levels().length >= 3) { + parentEntityIds[2] = + SchemaMetaService.getInstance() + .getSchemaIdByCatalogIdAndName(parentEntityIds[1], namespace.level(2)); + } + + return parentEntityIds; + } } diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java b/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java index e049f436406..9233005c34a 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java @@ -314,26 +314,10 @@ public int deleteFilesetVersionsByRetentionCount(Long versionRetentionCount, int private void fillFilesetPOBuilderParentEntityId(FilesetPO.Builder builder, Namespace namespace) { NamespaceUtil.checkFileset(namespace); - Long parentEntityId = null; - for (int level = 0; level < namespace.levels().length; level++) { - String name = namespace.level(level); - switch (level) { - case 0: - parentEntityId = MetalakeMetaService.getInstance().getMetalakeIdByName(name); - builder.withMetalakeId(parentEntityId); - continue; - case 1: - parentEntityId = - CatalogMetaService.getInstance() - .getCatalogIdByMetalakeIdAndName(parentEntityId, name); - builder.withCatalogId(parentEntityId); - continue; - case 2: - parentEntityId = - SchemaMetaService.getInstance().getSchemaIdByCatalogIdAndName(parentEntityId, name); - builder.withSchemaId(parentEntityId); - break; - } - } + Long[] parentEntityIds = + CommonMetaService.getInstance().getParentEntityIdsByNamespace(namespace); + builder.withMetalakeId(parentEntityIds[0]); + builder.withCatalogId(parentEntityIds[1]); + builder.withSchemaId(parentEntityIds[2]); } } diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/service/ModelMetaService.java b/core/src/main/java/org/apache/gravitino/storage/relational/service/ModelMetaService.java index 2da43755c51..0197dfdd2dd 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/service/ModelMetaService.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/service/ModelMetaService.java @@ -172,20 +172,10 @@ ModelPO getModelPOById(Long modelId) { private void fillModelPOBuilderParentEntityId(ModelPO.Builder builder, Namespace ns) { NamespaceUtil.checkModel(ns); - String metalake = ns.level(0); - String catalog = ns.level(1); - String schema = ns.level(2); - - Long metalakeId = MetalakeMetaService.getInstance().getMetalakeIdByName(metalake); - builder.withMetalakeId(metalakeId); - - Long catalogId = - CatalogMetaService.getInstance().getCatalogIdByMetalakeIdAndName(metalakeId, catalog); - builder.withCatalogId(catalogId); - - Long schemaId = - SchemaMetaService.getInstance().getSchemaIdByCatalogIdAndName(catalogId, schema); - builder.withSchemaId(schemaId); + Long[] parentEntityIds = CommonMetaService.getInstance().getParentEntityIdsByNamespace(ns); + builder.withMetalakeId(parentEntityIds[0]); + builder.withCatalogId(parentEntityIds[1]); + builder.withSchemaId(parentEntityIds[2]); } ModelPO getModelPOByIdentifier(NameIdentifier ident) { diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java index 4c9c828cb9c..f300e70cae3 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java @@ -316,21 +316,9 @@ public int deleteSchemaMetasByLegacyTimeline(Long legacyTimeline, int limit) { private void fillSchemaPOBuilderParentEntityId(SchemaPO.Builder builder, Namespace namespace) { NamespaceUtil.checkSchema(namespace); - Long parentEntityId = null; - for (int level = 0; level < namespace.levels().length; level++) { - String name = namespace.level(level); - switch (level) { - case 0: - parentEntityId = MetalakeMetaService.getInstance().getMetalakeIdByName(name); - builder.withMetalakeId(parentEntityId); - continue; - case 1: - parentEntityId = - CatalogMetaService.getInstance() - .getCatalogIdByMetalakeIdAndName(parentEntityId, name); - builder.withCatalogId(parentEntityId); - break; - } - } + Long[] parentEntityIds = + CommonMetaService.getInstance().getParentEntityIdsByNamespace(namespace); + builder.withMetalakeId(parentEntityIds[0]); + builder.withCatalogId(parentEntityIds[1]); } } diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java index 248dedd8a73..bc44ac43a92 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java @@ -253,27 +253,11 @@ public int deleteTableMetasByLegacyTimeline(Long legacyTimeline, int limit) { private void fillTablePOBuilderParentEntityId(TablePO.Builder builder, Namespace namespace) { NamespaceUtil.checkTable(namespace); - Long parentEntityId = null; - for (int level = 0; level < namespace.levels().length; level++) { - String name = namespace.level(level); - switch (level) { - case 0: - parentEntityId = MetalakeMetaService.getInstance().getMetalakeIdByName(name); - builder.withMetalakeId(parentEntityId); - continue; - case 1: - parentEntityId = - CatalogMetaService.getInstance() - .getCatalogIdByMetalakeIdAndName(parentEntityId, name); - builder.withCatalogId(parentEntityId); - continue; - case 2: - parentEntityId = - SchemaMetaService.getInstance().getSchemaIdByCatalogIdAndName(parentEntityId, name); - builder.withSchemaId(parentEntityId); - break; - } - } + Long[] parentEntityIds = + CommonMetaService.getInstance().getParentEntityIdsByNamespace(namespace); + builder.withMetalakeId(parentEntityIds[0]); + builder.withCatalogId(parentEntityIds[1]); + builder.withSchemaId(parentEntityIds[2]); } private TablePO getTablePOBySchemaIdAndName(Long schemaId, String tableName) { diff --git a/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java b/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java index 7bc933824aa..66a12aa9de1 100644 --- a/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java +++ b/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java @@ -154,27 +154,11 @@ public TopicPO getTopicPOById(Long topicId) { private void fillTopicPOBuilderParentEntityId(TopicPO.Builder builder, Namespace namespace) { NamespaceUtil.checkTopic(namespace); - Long parentEntityId = null; - for (int level = 0; level < namespace.levels().length; level++) { - String name = namespace.level(level); - switch (level) { - case 0: - parentEntityId = MetalakeMetaService.getInstance().getMetalakeIdByName(name); - builder.withMetalakeId(parentEntityId); - continue; - case 1: - parentEntityId = - CatalogMetaService.getInstance() - .getCatalogIdByMetalakeIdAndName(parentEntityId, name); - builder.withCatalogId(parentEntityId); - continue; - case 2: - parentEntityId = - SchemaMetaService.getInstance().getSchemaIdByCatalogIdAndName(parentEntityId, name); - builder.withSchemaId(parentEntityId); - break; - } - } + Long[] parentEntityIds = + CommonMetaService.getInstance().getParentEntityIdsByNamespace(namespace); + builder.withMetalakeId(parentEntityIds[0]); + builder.withCatalogId(parentEntityIds[1]); + builder.withSchemaId(parentEntityIds[2]); } public TopicEntity getTopicByIdentifier(NameIdentifier identifier) { From b3a848bf333caca4af38a021459f3016616bd29c Mon Sep 17 00:00:00 2001 From: FANNG Date: Tue, 7 Jan 2025 13:57:28 +0800 Subject: [PATCH 34/36] [#6092] docs(core): add credential openapi document (#6088) ### What changes were proposed in this pull request? add credential openapi document ### Why are the changes needed? Fix: #6092 ### Does this PR introduce _any_ user-facing change? no ### How was this patch tested? just document --- .../credential/SupportsCredentials.java | 2 +- .../gravitino/client/ErrorHandlers.java | 5 - .../client/TestSupportCredentials.java | 12 -- docs/open-api/catalogs.yaml | 1 + docs/open-api/credentials.yaml | 119 ++++++++++++++++++ docs/open-api/openapi.yaml | 3 + docs/open-api/tags.yaml | 22 +++- ...estMetadataObjectCredentialOperations.java | 23 ---- 8 files changed, 145 insertions(+), 42 deletions(-) create mode 100644 docs/open-api/credentials.yaml diff --git a/api/src/main/java/org/apache/gravitino/credential/SupportsCredentials.java b/api/src/main/java/org/apache/gravitino/credential/SupportsCredentials.java index 678172c422a..b2569fe393d 100644 --- a/api/src/main/java/org/apache/gravitino/credential/SupportsCredentials.java +++ b/api/src/main/java/org/apache/gravitino/credential/SupportsCredentials.java @@ -41,7 +41,7 @@ public interface SupportsCredentials { * org.apache.gravitino.file.Fileset}, {@link org.apache.gravitino.rel.Table}. There will be * at most one credential for one credential type. */ - Credential[] getCredentials() throws NoSuchCredentialException; + Credential[] getCredentials(); /** * Retrieves an {@link Credential} object based on the specified credential type. diff --git a/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java b/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java index 2fca9cde35c..d9b4ddb49f6 100644 --- a/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java +++ b/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java @@ -44,7 +44,6 @@ import org.apache.gravitino.exceptions.ModelAlreadyExistsException; import org.apache.gravitino.exceptions.ModelVersionAliasesAlreadyExistException; import org.apache.gravitino.exceptions.NoSuchCatalogException; -import org.apache.gravitino.exceptions.NoSuchCredentialException; import org.apache.gravitino.exceptions.NoSuchFilesetException; import org.apache.gravitino.exceptions.NoSuchGroupException; import org.apache.gravitino.exceptions.NoSuchMetadataObjectException; @@ -898,10 +897,6 @@ public void accept(ErrorResponse errorResponse) { case ErrorConstants.NOT_FOUND_CODE: if (errorResponse.getType().equals(NoSuchMetalakeException.class.getSimpleName())) { throw new NoSuchMetalakeException(errorMessage); - } else if (errorResponse - .getType() - .equals(NoSuchCredentialException.class.getSimpleName())) { - throw new NoSuchCredentialException(errorMessage); } else { throw new NotFoundException(errorMessage); } diff --git a/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportCredentials.java b/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportCredentials.java index 7b0817c8bb5..842af4a6403 100644 --- a/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportCredentials.java +++ b/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportCredentials.java @@ -19,7 +19,6 @@ package org.apache.gravitino.client; import static org.apache.hc.core5.http.HttpStatus.SC_INTERNAL_SERVER_ERROR; -import static org.apache.hc.core5.http.HttpStatus.SC_NOT_FOUND; import static org.apache.hc.core5.http.HttpStatus.SC_OK; import com.fasterxml.jackson.core.JsonProcessingException; @@ -39,7 +38,6 @@ import org.apache.gravitino.dto.responses.CredentialResponse; import org.apache.gravitino.dto.responses.ErrorResponse; import org.apache.gravitino.dto.util.DTOConverters; -import org.apache.gravitino.exceptions.NoSuchCredentialException; import org.apache.gravitino.file.Fileset; import org.apache.hc.core5.http.Method; import org.junit.jupiter.api.Assertions; @@ -154,16 +152,6 @@ private void testGetCredentials( credentials = supportsCredentials.getCredentials(); Assertions.assertEquals(0, credentials.length); - // Test throw NoSuchCredentialException - ErrorResponse errorResp = - ErrorResponse.notFound(NoSuchCredentialException.class.getSimpleName(), "mock error"); - buildMockResource(Method.GET, path, null, errorResp, SC_NOT_FOUND); - - Throwable ex = - Assertions.assertThrows( - NoSuchCredentialException.class, () -> supportsCredentials.getCredentials()); - Assertions.assertTrue(ex.getMessage().contains("mock error")); - // Test throw internal error ErrorResponse errorResp1 = ErrorResponse.internalError("mock error"); buildMockResource(Method.GET, path, null, errorResp1, SC_INTERNAL_SERVER_ERROR); diff --git a/docs/open-api/catalogs.yaml b/docs/open-api/catalogs.yaml index 0096944f27f..9e4efdaf588 100644 --- a/docs/open-api/catalogs.yaml +++ b/docs/open-api/catalogs.yaml @@ -291,6 +291,7 @@ components: - hive - lakehouse-iceberg - lakehouse-paimon + - lakehouse-hudi - jdbc-mysql - jdbc-postgresql - jdbc-doris diff --git a/docs/open-api/credentials.yaml b/docs/open-api/credentials.yaml new file mode 100644 index 00000000000..4f5106c3964 --- /dev/null +++ b/docs/open-api/credentials.yaml @@ -0,0 +1,119 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +--- + +paths: + + /metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/credentials: + parameters: + - $ref: "./openapi.yaml#/components/parameters/metalake" + - $ref: "./openapi.yaml#/components/parameters/metadataObjectType" + - $ref: "./openapi.yaml#/components/parameters/metadataObjectFullName" + get: + tags: + - credentials + summary: Get credentials + operationId: getCredentials + responses: + "200": + description: Returns the list of credential objects associated with specified metadata object. + content: + application/vnd.gravitino.v1+json: + schema: + $ref: "#/components/responses/CredentialResponse" + examples: + CredentialResponse: + $ref: "#/components/examples/CredentialResponse" + "400": + $ref: "./openapi.yaml#/components/responses/BadRequestErrorResponse" + "404": + description: Not Found - The specified metalake does not exist + content: + application/vnd.gravitino.v1+json: + schema: + $ref: "./openapi.yaml#/components/schemas/ErrorModel" + examples: + NoSuchMetalakeException: + $ref: "./metalakes.yaml#/components/examples/NoSuchMetalakeException" + "5xx": + $ref: "./openapi.yaml#/components/responses/ServerErrorResponse" + + +components: + schemas: + Credential: + type: object + description: A credential + required: + - credentialType + - expireTimeInMs + - credentialInfo + properties: + credentialType: + type: string + description: The type of the credential, for example, s3-token, s3-secret-key, oss-token, oss-secret-key, gcs-token, adls-token, azure-account-key, etc. + expireTimeInMs: + type: integer + description: The expiration time of the credential in milliseconds since the epoch, 0 means not expire. + credentialInfo: + type: object + description: The specific information of the credential. + default: { } + additionalProperties: + type: string + + responses: + CredentialResponse: + type: object + properties: + code: + type: integer + format: int32 + description: Status code of the response + enum: + - 0 + credentials: + type: array + description: A list of credential objects + items: + $ref: "#/components/schemas/Credential" + + examples: + CredentialResponse: + value: { + "code": 0, + "credentials": [ + { + "credentialType": "s3-token", + "expireTimeInMs": 1735891948411, + "credentialInfo": { + "s3-access-key-id": "value1", + "s3-secret-access-key": "value2", + "s3-session-token": "value3" + } + }, + { + "credentialType": "s3-secret-key", + "expireTimeInMs": 0, + "credentialInfo": { + "s3-access-key-id": "value1", + "s3-secret-access-key": "value2" + } + }, + ] + } \ No newline at end of file diff --git a/docs/open-api/openapi.yaml b/docs/open-api/openapi.yaml index d0c941ab471..f39a90f55f5 100644 --- a/docs/open-api/openapi.yaml +++ b/docs/open-api/openapi.yaml @@ -68,6 +68,9 @@ paths: /metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/roles: $ref: "./roles.yaml#/paths/~1metalakes~1%7Bmetalake%7D~1objects~1%7BmetadataObjectType%7D~1%7BmetadataObjectFullName%7D~1roles" + /metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/credentials: + $ref: "./credentials.yaml#/paths/~1metalakes~1%7Bmetalake%7D~1objects~1%7BmetadataObjectType%7D~1%7BmetadataObjectFullName%7D~1credentials" + /metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/tags/{tag}: $ref: "./tags.yaml#/paths/~1metalakes~1%7Bmetalake%7D~1objects~1%7BmetadataObjectType%7D~1%7BmetadataObjectFullName%7D~1tags~1%7Btag%7D" diff --git a/docs/open-api/tags.yaml b/docs/open-api/tags.yaml index 7b8deef2520..a3be5230b94 100644 --- a/docs/open-api/tags.yaml +++ b/docs/open-api/tags.yaml @@ -206,6 +206,15 @@ paths: $ref: "#/components/examples/TagListResponse" "400": $ref: "./openapi.yaml#/components/responses/BadRequestErrorResponse" + "404": + description: Not Found - The specified metalake does not exist + content: + application/vnd.gravitino.v1+json: + schema: + $ref: "./openapi.yaml#/components/schemas/ErrorModel" + examples: + NoSuchMetalakeException: + $ref: "./metalakes.yaml#/components/examples/NoSuchMetalakeException" "5xx": $ref: "./openapi.yaml#/components/responses/ServerErrorResponse" @@ -233,6 +242,15 @@ paths: examples: NameListResponse: $ref: "#/components/examples/NameListResponse" + "404": + description: Not Found - The specified metalake does not exist + content: + application/vnd.gravitino.v1+json: + schema: + $ref: "./openapi.yaml#/components/schemas/ErrorModel" + examples: + NoSuchMetalakeException: + $ref: "./metalakes.yaml#/components/examples/NoSuchMetalakeException" "409": description: Conflict - The target tag already associated with the specified metadata object content: @@ -272,7 +290,7 @@ paths: "400": $ref: "./openapi.yaml#/components/responses/BadRequestErrorResponse" "404": - description: Not Found - The specified metadata object does not exist or the specified tag is not associated with the specified metadata object + description: Not Found - The specified metalake does not exist or the specified tag is not associated with the specified metadata object content: application/vnd.gravitino.v1+json: schema: @@ -280,6 +298,8 @@ paths: examples: NoSuchTagException: $ref: "#/components/examples/NoSuchTagException" + NoSuchMetalakeException: + $ref: "./metalakes.yaml#/components/examples/NoSuchMetalakeException" "5xx": $ref: "./openapi.yaml#/components/responses/ServerErrorResponse" diff --git a/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectCredentialOperations.java b/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectCredentialOperations.java index 464ccd86984..ce759fac65f 100644 --- a/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectCredentialOperations.java +++ b/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectCredentialOperations.java @@ -19,7 +19,6 @@ package org.apache.gravitino.server.web.rest; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -35,10 +34,7 @@ import org.apache.gravitino.credential.CredentialOperationDispatcher; import org.apache.gravitino.credential.S3SecretKeyCredential; import org.apache.gravitino.dto.responses.CredentialResponse; -import org.apache.gravitino.dto.responses.ErrorConstants; -import org.apache.gravitino.dto.responses.ErrorResponse; import org.apache.gravitino.dto.util.DTOConverters; -import org.apache.gravitino.exceptions.NoSuchCredentialException; import org.apache.gravitino.rest.RESTUtils; import org.glassfish.jersey.internal.inject.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; @@ -138,25 +134,6 @@ private void testGetCredentialsForObject(MetadataObject metadataObject) { credentialResponse = response.readEntity(CredentialResponse.class); Assertions.assertEquals(0, credentialResponse.getCode()); Assertions.assertEquals(0, credentialResponse.getCredentials().length); - - // Test throws NoSuchCredentialException - doThrow(new NoSuchCredentialException("mock error")) - .when(credentialOperationDispatcher) - .getCredentials(any()); - response = - target(basePath(metalake)) - .path(metadataObject.type().toString()) - .path(metadataObject.fullName()) - .path("/credentials") - .request(MediaType.APPLICATION_JSON_TYPE) - .accept("application/vnd.gravitino.v1+json") - .get(); - - Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatus()); - ErrorResponse errorResponse = response.readEntity(ErrorResponse.class); - Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, errorResponse.getCode()); - Assertions.assertEquals( - NoSuchCredentialException.class.getSimpleName(), errorResponse.getType()); } private String basePath(String metalake) { From 32df91fa3925d0269cf7b29cc7d6fa6dc29dcd62 Mon Sep 17 00:00:00 2001 From: FANNG Date: Tue, 7 Jan 2025 14:17:30 +0800 Subject: [PATCH 35/36] [#6070][#5649] docs(core): add credential vending document (#6071) ### What changes were proposed in this pull request? move credential vending related document from iceberg-rest-server part to a separate file, then fileset could refer to it. ### Why are the changes needed? Fix: #6070 Fix: #5649 ### Does this PR introduce _any_ user-facing change? no ### How was this patch tested? just document --- docs/hadoop-catalog.md | 27 ++++- docs/iceberg-rest-service.md | 55 +++------ docs/security/credential-vending.md | 178 ++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 45 deletions(-) create mode 100644 docs/security/credential-vending.md diff --git a/docs/hadoop-catalog.md b/docs/hadoop-catalog.md index 9048556ffa5..99e1dd7854e 100644 --- a/docs/hadoop-catalog.md +++ b/docs/hadoop-catalog.md @@ -23,9 +23,12 @@ Hadoop 3. If there's any compatibility issue, please create an [issue](https://g Besides the [common catalog properties](./gravitino-server-config.md#apache-gravitino-catalog-properties-configuration), the Hadoop catalog has the following properties: -| Property Name | Description | Default Value | Required | Since Version | -|---------------|-------------------------------------------------|---------------|----------|---------------| -| `location` | The storage location managed by Hadoop catalog. | (none) | No | 0.5.0 | +| Property Name | Description | Default Value | Required | Since Version | +|------------------------|----------------------------------------------------|---------------|----------|------------------| +| `location` | The storage location managed by Hadoop catalog. | (none) | No | 0.5.0 | +| `credential-providers` | The credential provider types, separated by comma. | (none) | No | 0.8.0-incubating | + +Please refer to [Credential vending](./security/credential-vending.md) for more details about credential vending. Apart from the above properties, to access fileset like HDFS, S3, GCS, OSS or custom fileset, you need to configure the following extra properties. @@ -50,6 +53,8 @@ Apart from the above properties, to access fileset like HDFS, S3, GCS, OSS or cu | `s3-access-key-id` | The access key of the AWS S3. | (none) | Yes if it's a S3 fileset. | 0.7.0-incubating | | `s3-secret-access-key` | The secret key of the AWS S3. | (none) | Yes if it's a S3 fileset. | 0.7.0-incubating | +Please refer to [S3 credentials](./security/credential-vending.md#s3-credentials) for credential related configurations. + At the same time, you need to place the corresponding bundle jar [`gravitino-aws-bundle-${version}.jar`](https://repo1.maven.org/maven2/org/apache/gravitino/gravitino-aws-bundle/) in the directory `${GRAVITINO_HOME}/catalogs/hadoop/libs`. #### GCS fileset @@ -60,6 +65,8 @@ At the same time, you need to place the corresponding bundle jar [`gravitino-aws | `default-filesystem-provider` | The name default filesystem providers of this Hadoop catalog if users do not specify the scheme in the URI. Default value is `builtin-local`, for GCS, if we set this value, we can omit the prefix 'gs://' in the location. | `builtin-local` | No | 0.7.0-incubating | | `gcs-service-account-file` | The path of GCS service account JSON file. | (none) | Yes if it's a GCS fileset. | 0.7.0-incubating | +Please refer to [GCS credentials](./security/credential-vending.md#gcs-credentials) for credential related configurations. + In the meantime, you need to place the corresponding bundle jar [`gravitino-gcp-bundle-${version}.jar`](https://repo1.maven.org/maven2/org/apache/gravitino/gravitino-gcp-bundle/) in the directory `${GRAVITINO_HOME}/catalogs/hadoop/libs`. #### OSS fileset @@ -72,6 +79,8 @@ In the meantime, you need to place the corresponding bundle jar [`gravitino-gcp- | `oss-access-key-id` | The access key of the Aliyun OSS. | (none) | Yes if it's a OSS fileset. | 0.7.0-incubating | | `oss-secret-access-key` | The secret key of the Aliyun OSS. | (none) | Yes if it's a OSS fileset. | 0.7.0-incubating | +Please refer to [OSS credentials](./security/credential-vending.md#oss-credentials) for credential related configurations. + In the meantime, you need to place the corresponding bundle jar [`gravitino-aliyun-bundle-${version}.jar`](https://repo1.maven.org/maven2/org/apache/gravitino/gravitino-aliyun-bundle/) in the directory `${GRAVITINO_HOME}/catalogs/hadoop/libs`. @@ -84,6 +93,8 @@ In the meantime, you need to place the corresponding bundle jar [`gravitino-aliy | `azure-storage-account-name ` | The account name of Azure Blob Storage. | (none) | Yes if it's a Azure Blob Storage fileset. | 0.8.0-incubating | | `azure-storage-account-key` | The account key of Azure Blob Storage. | (none) | Yes if it's a Azure Blob Storage fileset. | 0.8.0-incubating | +Please refer to [ADLS credentials](./security/credential-vending.md#adls-credentials) for credential related configurations. + Similar to the above, you need to place the corresponding bundle jar [`gravitino-azure-bundle-${version}.jar`](https://repo1.maven.org/maven2/org/apache/gravitino/gravitino-azure-bundle/) in the directory `${GRAVITINO_HOME}/catalogs/hadoop/libs`. :::note @@ -146,7 +157,8 @@ The Hadoop catalog supports creating, updating, deleting, and listing schema. | `authentication.impersonation-enable` | Whether to enable impersonation for this schema of the Hadoop catalog. | The parent(catalog) value | No | 0.6.0-incubating | | `authentication.type` | The type of authentication for this schema of Hadoop catalog , currently we only support `kerberos`, `simple`. | The parent(catalog) value | No | 0.6.0-incubating | | `authentication.kerberos.principal` | The principal of the Kerberos authentication for this schema. | The parent(catalog) value | No | 0.6.0-incubating | -| `authentication.kerberos.keytab-uri` | The URI of The keytab for the Kerberos authentication for this scheam. | The parent(catalog) value | No | 0.6.0-incubating | +| `authentication.kerberos.keytab-uri` | The URI of The keytab for the Kerberos authentication for this schema. | The parent(catalog) value | No | 0.6.0-incubating | +| `credential-providers` | The credential provider types, separated by comma. | (none) | No | 0.8.0-incubating | ### Schema operations @@ -166,6 +178,13 @@ Refer to [Schema operation](./manage-fileset-metadata-using-gravitino.md#schema- | `authentication.type` | The type of authentication for Hadoop catalog fileset, currently we only support `kerberos`, `simple`. | The parent(schema) value | No | 0.6.0-incubating | | `authentication.kerberos.principal` | The principal of the Kerberos authentication for the fileset. | The parent(schema) value | No | 0.6.0-incubating | | `authentication.kerberos.keytab-uri` | The URI of The keytab for the Kerberos authentication for the fileset. | The parent(schema) value | No | 0.6.0-incubating | +| `credential-providers` | The credential provider types, separated by comma. | (none) | No | 0.8.0-incubating | + +Credential providers can be specified in several places, as listed below. Gravitino checks the `credential-provider` setting in the following order of precedence: + +1. Fileset properties +2. Schema properties +3. Catalog properties ### Fileset operations diff --git a/docs/iceberg-rest-service.md b/docs/iceberg-rest-service.md index 5adc75ad835..d42fc98b4dd 100644 --- a/docs/iceberg-rest-service.md +++ b/docs/iceberg-rest-service.md @@ -27,9 +27,9 @@ The Apache Gravitino Iceberg REST Server follows the [Apache Iceberg REST API sp ## Server management There are three deployment scenarios for Gravitino Iceberg REST server: -- A standalone server in a standalone Gravitino Iceberg REST server package. -- A standalone server in the Gravitino server package. -- An auxiliary service embedded in the Gravitino server. +- A standalone server in a standalone Gravitino Iceberg REST server package, the classpath is `libs`. +- A standalone server in the Gravitino server package, the classpath is `iceberg-rest-server/libs`. +- An auxiliary service embedded in the Gravitino server, the classpath is `iceberg-rest-server/libs`. For detailed instructions on how to build and install the Gravitino server package, please refer to [How to build](./how-to-build.md) and [How to install](./how-to-install.md). To build the Gravitino Iceberg REST server package, use the command `./gradlew compileIcebergRESTServer -x test`. Alternatively, to create the corresponding compressed package in the distribution directory, use `./gradlew assembleIcebergRESTServer -x test`. The Gravitino Iceberg REST server package includes the following files: @@ -100,29 +100,23 @@ The detailed configuration items are as follows: | `gravitino.iceberg-rest.authentication.kerberos.keytab-fetch-timeout-sec` | The fetch timeout of retrieving Kerberos keytab from `authentication.kerberos.keytab-uri`. | 60 | No | 0.7.0-incubating | +### Credential vending + +Please refer to [Credential vending](./security/credential-vending.md) for more details. + ### Storage #### S3 configuration -Gravitino Iceberg REST service supports using static S3 secret key or generating temporary token to access S3 data. - | Configuration item | Description | Default value | Required | Since Version | |----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|------------------------------------------------|------------------| | `gravitino.iceberg-rest.io-impl` | The IO implementation for `FileIO` in Iceberg, use `org.apache.iceberg.aws.s3.S3FileIO` for S3. | (none) | No | 0.6.0-incubating | -| `gravitino.iceberg-rest.credential-provider-type` | Deprecated, please use `gravitino.iceberg-rest.credential-providers` instead. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.credential-providers` | Supports `s3-token` and `s3-secret-key` for S3. `s3-token` generates a temporary token according to the query data path while `s3-secret-key` using the s3 secret access key to access S3 data. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.s3-access-key-id` | The static access key ID used to access S3 data. | (none) | No | 0.6.0-incubating | -| `gravitino.iceberg-rest.s3-secret-access-key` | The static secret access key used to access S3 data. | (none) | No | 0.6.0-incubating | | `gravitino.iceberg-rest.s3-endpoint` | An alternative endpoint of the S3 service, This could be used for S3FileIO with any s3-compatible object storage service that has a different endpoint, or access a private S3 endpoint in a virtual private cloud. | (none) | No | 0.6.0-incubating | | `gravitino.iceberg-rest.s3-region` | The region of the S3 service, like `us-west-2`. | (none) | No | 0.6.0-incubating | -| `gravitino.iceberg-rest.s3-role-arn` | The ARN of the role to access the S3 data. | (none) | Yes, when `credential-providers` is `s3-token` | 0.7.0-incubating | -| `gravitino.iceberg-rest.s3-external-id` | The S3 external id to generate token, only used when `credential-providers` is `s3-token`. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.s3-token-expire-in-secs` | The S3 session token expire time in secs, it couldn't exceed the max session time of the assumed role, only used when `credential-providers` is `s3-token`. | 3600 | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.s3-token-service-endpoint` | An alternative endpoint of the S3 token service, This could be used with s3-compatible object storage service like MINIO that has a different STS endpoint. | (none) | No | 0.8.0-incubating | For other Iceberg s3 properties not managed by Gravitino like `s3.sse.type`, you could config it directly by `gravitino.iceberg-rest.s3.sse.type`. -If you set `credential-providers` explicitly, please downloading [Gravitino AWS bundle jar](https://mvnrepository.com/artifact/org.apache.gravitino/aws-bundle), and place it to the classpath of Iceberg REST server. +Please refer to [S3 credentials](./security/credential-vending.md#s3-credentials) for credential related configurations. :::info To configure the JDBC catalog backend, set the `gravitino.iceberg-rest.warehouse` parameter to `s3://{bucket_name}/${prefix_name}`. For the Hive catalog backend, set `gravitino.iceberg-rest.warehouse` to `s3a://{bucket_name}/${prefix_name}`. Additionally, download the [Iceberg AWS bundle](https://mvnrepository.com/artifact/org.apache.iceberg/iceberg-aws-bundle) and place it in the classpath of Iceberg REST server. @@ -130,24 +124,15 @@ To configure the JDBC catalog backend, set the `gravitino.iceberg-rest.warehouse #### OSS configuration -Gravitino Iceberg REST service supports using static access-key-id and secret-access-key or generating temporary token to access OSS data. - | Configuration item | Description | Default value | Required | Since Version | |---------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------|------------------------------------------------------|------------------| | `gravitino.iceberg-rest.io-impl` | The IO implementation for `FileIO` in Iceberg, use `org.apache.iceberg.aliyun.oss.OSSFileIO` for OSS. | (none) | No | 0.6.0-incubating | -| `gravitino.iceberg-rest.credential-provider-type` | Deprecated, please use `gravitino.iceberg-rest.credential-providers` instead. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.credential-providers` | Supports `oss-token` and `oss-secret-key` for OSS. `oss-token` generates a temporary token according to the query data path while `oss-secret-key` using the oss secret access key to access S3 data. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.oss-access-key-id` | The static access key ID used to access OSS data. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.oss-secret-access-key` | The static secret access key used to access OSS data. | (none) | No | 0.7.0-incubating | | `gravitino.iceberg-rest.oss-endpoint` | The endpoint of Aliyun OSS service. | (none) | No | 0.7.0-incubating | | `gravitino.iceberg-rest.oss-region` | The region of the OSS service, like `oss-cn-hangzhou`, only used when `credential-providers` is `oss-token`. | (none) | No | 0.8.0-incubating | -| `gravitino.iceberg-rest.oss-role-arn` | The ARN of the role to access the OSS data, only used when `credential-providers` is `oss-token`. | (none) | Yes, when `credential-provider-type` is `oss-token`. | 0.8.0-incubating | -| `gravitino.iceberg-rest.oss-external-id` | The OSS external id to generate token, only used when `credential-providers` is `oss-token`. | (none) | No | 0.8.0-incubating | -| `gravitino.iceberg-rest.oss-token-expire-in-secs` | The OSS security token expire time in secs, only used when `credential-providers` is `oss-token`. | 3600 | No | 0.8.0-incubating | For other Iceberg OSS properties not managed by Gravitino like `client.security-token`, you could config it directly by `gravitino.iceberg-rest.client.security-token`. -If you set `credential-providers` explicitly, please downloading [Gravitino Aliyun bundle jar](https://mvnrepository.com/artifact/org.apache.gravitino/aliyun-bundle), and place it to the classpath of Iceberg REST server. +Please refer to [OSS credentials](./security/credential-vending.md#oss-credentials) for credential related configurations. :::info Please set the `gravitino.iceberg-rest.warehouse` parameter to `oss://{bucket_name}/${prefix_name}`. Additionally, download the [Aliyun OSS SDK](https://gosspublic.alicdn.com/sdks/java/aliyun_java_sdk_3.10.2.zip) and copy `aliyun-sdk-oss-3.10.2.jar`, `hamcrest-core-1.1.jar`, `jdom2-2.0.6.jar` in the classpath of Iceberg REST server, `iceberg-rest-server/libs` for the auxiliary server, `libs` for the standalone server. @@ -160,16 +145,14 @@ Supports using static GCS credential file or generating GCS token to access GCS | Configuration item | Description | Default value | Required | Since Version | |---------------------------------------------------|----------------------------------------------------------------------------------------------------|---------------|----------|------------------| | `gravitino.iceberg-rest.io-impl` | The io implementation for `FileIO` in Iceberg, use `org.apache.iceberg.gcp.gcs.GCSFileIO` for GCS. | (none) | No | 0.6.0-incubating | -| `gravitino.iceberg-rest.credential-provider-type` | Deprecated, please use `gravitino.iceberg-rest.credential-providers` instead. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.credential-providers` | Supports `gcs-token`, generates a temporary token according to the query data path. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.gcs-credential-file-path` | Deprecated, please use `gravitino.iceberg-rest.gcs-service-account-file` instead. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.gcs-service-account-file` | The location of GCS credential file, only used when `credential-provider-type` is `gcs-token`. | (none) | No | 0.8.0-incubating | For other Iceberg GCS properties not managed by Gravitino like `gcs.project-id`, you could config it directly by `gravitino.iceberg-rest.gcs.project-id`. -If you set `credential-providers` explicitly, please downloading [Gravitino GCP bundle jar](https://mvnrepository.com/artifact/org.apache.gravitino/gcp-bundle), and place it to the classpath of Iceberg REST server. +Please refer to [GCS credentials](./security/credential-vending.md#gcs-credentials) for credential related configurations. -Please make sure the credential file is accessible by Gravitino, like using `export GOOGLE_APPLICATION_CREDENTIALS=/xx/application_default_credentials.json` before Gravitino Iceberg REST server is started. +:::note +Please ensure that the credential file can be accessed by the Gravitino server. For example, if the server is running on a GCE machine, or you can set the environment variable as `export GOOGLE_APPLICATION_CREDENTIALS=/xx/application_default_credentials.json`, even when the `gcs-service-account-file` has already been configured. +::: :::info Please set `gravitino.iceberg-rest.warehouse` to `gs://{bucket_name}/${prefix_name}`, and download [Iceberg gcp bundle](https://mvnrepository.com/artifact/org.apache.iceberg/iceberg-gcp-bundle) and place it to the classpath of Gravitino Iceberg REST server, `iceberg-rest-server/libs` for the auxiliary server, `libs` for the standalone server. @@ -177,23 +160,13 @@ Please set `gravitino.iceberg-rest.warehouse` to `gs://{bucket_name}/${prefix_na #### ADLS -Gravitino Iceberg REST service supports generating SAS token to access ADLS data. - | Configuration item | Description | Default value | Required | Since Version | |-----------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|----------|------------------| | `gravitino.iceberg-rest.io-impl` | The IO implementation for `FileIO` in Iceberg, use `org.apache.iceberg.azure.adlsv2.ADLSFileIO` for ADLS. | (none) | Yes | 0.8.0-incubating | -| `gravitino.iceberg-rest.credential-provider-type` | Deprecated, please use `gravitino.iceberg-rest.credential-providers` instead. | (none) | No | 0.7.0-incubating | -| `gravitino.iceberg-rest.credential-providers` | Supports `adls-token` and `azure-account-key`. `adls-token` generates a temporary token according to the query data path while `azure-account-key` uses a storage account key to access ADLS data. | (none) | Yes | 0.8.0-incubating | -| `gravitino.iceberg-rest.azure-storage-account-name` | The static storage account name used to access ADLS data. | (none) | Yes | 0.8.0-incubating | -| `gravitino.iceberg-rest.azure-storage-account-key` | The static storage account key used to access ADLS data. | (none) | Yes | 0.8.0-incubating | -| `gravitino.iceberg-rest.azure-tenant-id` | Azure Active Directory (AAD) tenant ID, only used when `credential-providers` is `adls-token`. | (none) | Yes | 0.8.0-incubating | -| `gravitino.iceberg-rest.azure-client-id` | Azure Active Directory (AAD) client ID used for authentication, only used when `credential-providers` is `adls-token`. | (none) | Yes | 0.8.0-incubating | -| `gravitino.iceberg-rest.azure-client-secret` | Azure Active Directory (AAD) client secret used for authentication, only used when `credential-providers` is `adls-token`. | (none) | Yes | 0.8.0-incubating | -| `gravitino.iceberg-rest.adls-token-expire-in-secs` | The ADLS SAS token expire time in secs, only used when `credential-providers` is `adls-token`. | 3600 | No | 0.8.0-incubating | For other Iceberg ADLS properties not managed by Gravitino like `adls.read.block-size-bytes`, you could config it directly by `gravitino.iceberg-rest.adls.read.block-size-bytes`. -If you set `credential-providers` explicitly, please downloading [Gravitino Azure bundle jar](https://mvnrepository.com/artifact/org.apache.gravitino/azure-bundle), and place it to the classpath of Iceberg REST server. +Please refer to [ADLS credentials](./security/credential-vending.md#adls-credentials) for credential related configurations. :::info Please set `gravitino.iceberg-rest.warehouse` to `abfs[s]://{container-name}@{storage-account-name}.dfs.core.windows.net/{path}`, and download the [Iceberg Azure bundle](https://mvnrepository.com/artifact/org.apache.iceberg/iceberg-azure-bundle) and place it in the classpath of Iceberg REST server. diff --git a/docs/security/credential-vending.md b/docs/security/credential-vending.md new file mode 100644 index 00000000000..92370f4315d --- /dev/null +++ b/docs/security/credential-vending.md @@ -0,0 +1,178 @@ +--- +title: "Gravitino credential vending" +slug: /security/credential-vending +keyword: security credential vending +license: "This software is licensed under the Apache License version 2." +--- + +## Background + +Gravitino credential vending is used to generate temporary or static credentials for accessing data. With credential vending, Gravitino provides an unified way to control the access to diverse data sources in different platforms. + +### Capabilities + +- Supports Gravitino Iceberg REST server. +- Supports Gravitino server, only support Hadoop catalog. +- Supports pluggable credentials with build-in credentials: + - S3: `S3TokenCredential`, `S3SecretKeyCredential` + - GCS: `GCSTokenCredential` + - ADLS: `ADLSTokenCredential`, `AzureAccountKeyCredential` + - OSS: `OSSTokenCredential`, `OSSSecretKeyCredential` +- No support for Spark/Trino/Flink connector yet. + +## General configurations + +| Gravitino server catalog properties | Gravitino Iceberg REST server configurations | Description | Default value | Required | Since Version | +|-------------------------------------|--------------------------------------------------------|--------------------------------------------------------------------------------------------|---------------|----------|------------------| +| `credential-provider-type` | `gravitino.iceberg-rest.credential-provider-type` | Deprecated, please use `credential-providers` instead. | (none) | Yes | 0.7.0-incubating | +| `credential-providers` | `gravitino.iceberg-rest.credential-providers` | The credential provider types, separated by comma. | (none) | Yes | 0.8.0-incubating | +| `credential-cache-expire-ratio` | `gravitino.iceberg-rest.credential-cache-expire-ratio` | Ratio of the credential's expiration time when Gravitino remove credential from the cache. | 0.15 | No | 0.8.0-incubating | +| `credential-cache-max-size` | `gravitino.iceberg-rest.cache-max-size` | Max size for the credential cache. | 10000 | No | 0.8.0-incubating | + +## Build-in credentials configurations + +### S3 credentials + +#### S3 secret key credential + +A credential with static S3 access key id and secret access key. + +| Gravitino server catalog properties | Gravitino Iceberg REST server configurations | Description | Default value | Required | Since Version | +|-------------------------------------|---------------------------------------------------|--------------------------------------------------------|---------------|----------|------------------| +| `credential-providers` | `gravitino.iceberg-rest.credential-providers` | `s3-secret-key` for S3 secret key credential provider. | (none) | Yes | 0.8.0-incubating | +| `s3-access-key-id` | `gravitino.iceberg-rest.s3-access-key-id` | The static access key ID used to access S3 data. | (none) | Yes | 0.6.0-incubating | +| `s3-secret-access-key` | `gravitino.iceberg-rest.s3-secret-access-key` | The static secret access key used to access S3 data. | (none) | Yes | 0.6.0-incubating | + +#### S3 token credential + +An S3 token is a token credential with scoped privileges, by leveraging STS [Assume Role](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). To use an S3 token credential, you should create a role and grant it proper privileges. + +| Gravitino server catalog properties | Gravitino Iceberg REST server configurations | Description | Default value | Required | Since Version | +|-------------------------------------|----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|----------|------------------| +| `credential-providers` | `gravitino.iceberg-rest.credential-providers` | `s3-token` for S3 token credential provider. | (none) | Yes | 0.8.0-incubating | +| `s3-access-key-id` | `gravitino.iceberg-rest.s3-access-key-id` | The static access key ID used to access S3 data. | (none) | Yes | 0.6.0-incubating | +| `s3-secret-access-key` | `gravitino.iceberg-rest.s3-secret-access-key` | The static secret access key used to access S3 data. | (none) | Yes | 0.6.0-incubating | +| `s3-role-arn` | `gravitino.iceberg-rest.s3-role-arn` | The ARN of the role to access the S3 data. | (none) | Yes | 0.7.0-incubating | +| `s3-external-id` | `gravitino.iceberg-rest.s3-external-id` | The S3 external id to generate token. | (none) | No | 0.7.0-incubating | +| `s3-token-expire-in-secs` | `gravitino.iceberg-rest.s3-token-expire-in-secs` | The S3 session token expire time in secs, it couldn't exceed the max session time of the assumed role. | 3600 | No | 0.7.0-incubating | +| `s3-token-service-endpoint` | `gravitino.iceberg-rest.s3-token-service-endpoint` | An alternative endpoint of the S3 token service, This could be used with s3-compatible object storage service like MINIO that has a different STS endpoint. | (none) | No | 0.8.0-incubating | + +### OSS credentials + +#### OSS secret key credential + +A credential with static OSS access key id and secret access key. + +| Gravitino server catalog properties | Gravitino Iceberg REST server configurations | Description | Default value | Required | Since Version | +|-------------------------------------|---------------------------------------------------|-------------------------------------------------------------------------------|---------------|----------|------------------| +| `credential-providers` | `gravitino.iceberg-rest.credential-providers` | `oss-secret-key` for OSS secret credential. | (none) | Yes | 0.8.0-incubating | +| `oss-access-key-id` | `gravitino.iceberg-rest.oss-access-key-id` | The static access key ID used to access OSS data. | (none) | Yes | 0.7.0-incubating | +| `oss-secret-access-key` | `gravitino.iceberg-rest.oss-secret-access-key` | The static secret access key used to access OSS data. | (none) | Yes | 0.7.0-incubating | + +#### OSS token credential + +An OSS token is a token credential with scoped privileges, by leveraging STS [Assume Role](https://www.alibabacloud.com/help/en/oss/developer-reference/use-temporary-access-credentials-provided-by-sts-to-access-oss). To use an OSS token credential, you should create a role and grant it proper privileges. + +| Gravitino server catalog properties | Gravitino Iceberg REST server configurations | Description | Default value | Required | Since Version | +|-------------------------------------|---------------------------------------------------|-------------------------------------------------------------------------------|---------------|----------|------------------| +| `credential-providers` | `gravitino.iceberg-rest.credential-providers` | `oss-token` for s3 token credential. | (none) | Yes | 0.8.0-incubating | +| `oss-access-key-id` | `gravitino.iceberg-rest.oss-access-key-id` | The static access key ID used to access OSS data. | (none) | Yes | 0.7.0-incubating | +| `oss-secret-access-key` | `gravitino.iceberg-rest.oss-secret-access-key` | The static secret access key used to access OSS data. | (none) | Yes | 0.7.0-incubating | +| `oss-role-arn` | `gravitino.iceberg-rest.oss-role-arn` | The ARN of the role to access the OSS data. | (none) | Yes | 0.8.0-incubating | +| `oss-external-id` | `gravitino.iceberg-rest.oss-external-id` | The OSS external id to generate token. | (none) | No | 0.8.0-incubating | +| `oss-token-expire-in-secs` | `gravitino.iceberg-rest.oss-token-expire-in-secs` | The OSS security token expire time in secs. | 3600 | No | 0.8.0-incubating | + +### ADLS credentials + +#### Azure account key credential + +A credential with static Azure storage account name and key. + +| Gravitino server catalog properties | Gravitino Iceberg REST server configurations | Description | Default value | Required | Since Version | +|-------------------------------------|-----------------------------------------------------|-----------------------------------------------------------|---------------|----------|------------------| +| `credential-providers` | `gravitino.iceberg-rest.credential-providers` | `azure-account-key` for Azure account key credential. | (none) | Yes | 0.8.0-incubating | +| `azure-storage-account-name` | `gravitino.iceberg-rest.azure-storage-account-name` | The static storage account name used to access ADLS data. | (none) | Yes | 0.8.0-incubating | +| `azure-storage-account-key` | `gravitino.iceberg-rest.azure-storage-account-key` | The static storage account key used to access ADLS data. | (none) | Yes | 0.8.0-incubating | + +#### ADLS token credential + +An ADLS token is a token credential with scoped privileges, by leveraging Azure [User Delegation Sas](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas). To use an ADLS token credential, you should create a Microsoft Entra ID service principal and grant it proper privileges. + +| Gravitino server catalog properties | Gravitino Iceberg REST server configurations | Description | Default value | Required | Since Version | +|-------------------------------------|-----------------------------------------------------|---------------------------------------------------------------------|---------------|----------|------------------| +| `credential-providers` | `gravitino.iceberg-rest.credential-providers` | `adls-token` for ADLS token credential. | (none) | Yes | 0.8.0-incubating | +| `azure-storage-account-name` | `gravitino.iceberg-rest.azure-storage-account-name` | The static storage account name used to access ADLS data. | (none) | Yes | 0.8.0-incubating | +| `azure-storage-account-key` | `gravitino.iceberg-rest.azure-storage-account-key` | The static storage account key used to access ADLS data. | (none) | Yes | 0.8.0-incubating | +| `azure-tenant-id` | `gravitino.iceberg-rest.azure-tenant-id` | Azure Active Directory (AAD) tenant ID. | (none) | Yes | 0.8.0-incubating | +| `azure-client-id` | `gravitino.iceberg-rest.azure-client-id` | Azure Active Directory (AAD) client ID used for authentication. | (none) | Yes | 0.8.0-incubating | +| `azure-client-secret` | `gravitino.iceberg-rest.azure-client-secret` | Azure Active Directory (AAD) client secret used for authentication. | (none) | Yes | 0.8.0-incubating | +| `adls-token-expire-in-secs` | `gravitino.iceberg-rest.adls-token-expire-in-secs` | The ADLS SAS token expire time in secs. | 3600 | No | 0.8.0-incubating | + +### GCS credentials + +#### GCS token credential + +An GCS token is a token credential with scoped privileges, by leveraging GCS [Credential Access Boundaries](https://cloud.google.com/iam/docs/downscoping-short-lived-credentials). To use an GCS token credential, you should create an GCS service account and grant it proper privileges. + +| Gravitino server catalog properties | Gravitino Iceberg REST server configurations | Description | Default value | Required | Since Version | +|-------------------------------------|---------------------------------------------------|------------------------------------------------------------|-------------------------------------|----------|------------------| +| `credential-providers` | `gravitino.iceberg-rest.credential-providers` | `gcs-token` for GCS token credential. | (none) | Yes | 0.8.0-incubating | +| `gcs-credential-file-path` | `gravitino.iceberg-rest.gcs-credential-file-path` | Deprecated, please use `gcs-service-account-file` instead. | GCS Application default credential. | No | 0.7.0-incubating | +| `gcs-service-account-file` | `gravitino.iceberg-rest.gcs-service-account-file` | The location of GCS credential file. | GCS Application default credential. | No | 0.8.0-incubating | + +:::note +For Gravitino Iceberg REST server, please ensure that the credential file can be accessed by the server. For example, if the server is running on a GCE machine, or you can set the environment variable as `export GOOGLE_APPLICATION_CREDENTIALS=/xx/application_default_credentials.json`, even when the `gcs-service-account-file` has already been configured. +::: + +## Custom credentials + +Gravitino supports custom credentials, you can implement the `org.apache.gravitino.credential.CredentialProvider` interface to support custom credentials, and place the corresponding jar to the classpath of Iceberg catalog server or Hadoop catalog. + +## Deployment + +Besides setting credentials related configuration, please download Gravitino cloud bundle jar and place it in the classpath of Iceberg REST server or Hadoop catalog. + +Gravitino cloud bundle jar: + +- [Gravitino AWS bundle jar](https://mvnrepository.com/artifact/org.apache.gravitino/gravitino-aws-bundle) +- [Gravitino Aliyun bundle jar](https://mvnrepository.com/artifact/org.apache.gravitino/gravitino-aliyun-bundle) +- [Gravitino GCP bundle jar](https://mvnrepository.com/artifact/org.apache.gravitino/gravitino-gcp-bundle) +- [Gravitino Azure bundle jar](https://mvnrepository.com/artifact/org.apache.gravitino/gravitino-azure-bundle) + +The classpath of the server: + +- Iceberg REST server: the classpath differs in different deploy mode, please refer to [Server management](../iceberg-rest-service.md#server-management) part. +- Hadoop catalog: `catalogs/hadoop/libs/` + +## Usage example + +### Credential vending for Iceberg REST server + +Suppose the Iceberg table data is stored in S3, follow the steps below: + +1. Download the [Gravitino AWS bundle jar](https://mvnrepository.com/artifact/org.apache.gravitino/gravitino-aws-bundle), and place it to the classpath of Iceberg REST server. + +2. Add s3 token credential configurations. + +``` +gravitino.iceberg-rest.warehouse = s3://{bucket_name}/{warehouse_path} +gravitino.iceberg-rest.io-impl= org.apache.iceberg.aws.s3.S3FileIO +gravitino.iceberg-rest.credential-providers = s3-token +gravitino.iceberg-rest.s3-access-key-id = xxx +gravitino.iceberg-rest.s3-secret-access-key = xxx +gravitino.iceberg-rest.s3-region = {region_name} +gravitino.iceberg-rest.s3-role-arn = {role_arn} +``` + +3. Exploring the Iceberg table with Spark client with credential vending enabled. + +```shell +./bin/spark-sql -v \ +--packages org.apache.iceberg:iceberg-spark-runtime-3.4_2.12:1.3.1 \ +--conf spark.jars={path}/iceberg-aws-bundle-1.5.2.jar \ +--conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \ +--conf spark.sql.catalog.rest=org.apache.iceberg.spark.SparkCatalog \ +--conf spark.sql.catalog.rest.type=rest \ +--conf spark.sql.catalog.rest.uri=http://127.0.0.1:9001/iceberg/ \ +--conf spark.sql.catalog.rest.header.X-Iceberg-Access-Delegation=vended-credentials +``` From 6ad3d3bf9c6db1c67ad7ccfd24d5ebfd2d06454a Mon Sep 17 00:00:00 2001 From: Lord of Abyss <103809695+Abyss-lord@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:37:40 +0800 Subject: [PATCH 36/36] [#6123] fix(CLI): Refactor the validation logic of tag and role (#6127) ### What changes were proposed in this pull request? Refactor the validation logic of the Tag and Role, meantime fix the test case. ### Why are the changes needed? Fix: #6123 ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? ut + local test **Role test** ```bash gcli role grant -m demo_metalake --role admin # Missing --privilege option. gcli role revoke -m demo_metalake --role admin # Missing --privilege option. ``` **Tag test** ```bash gcli tag set -m demo_metalake # Missing --name option. gcli tag set -m demo_metalake --name catalog.schema.table --property property --tag tagA # Missing --value option. gcli tag set -m demo_metalake --name catalog.schema.table --value value --tag tagA # Missing --property option. gcli tag remove -m demo_metalake --tag tagA # Missing --name option. gcli tag remove -m demo_metalake # Missing --name option. gcli tag delete -m demo_metalake # Missing --tag option. gcli tag create -m demo_metalake # Missing --tag option. ``` --- .../apache/gravitino/cli/ErrorMessages.java | 3 + .../gravitino/cli/GravitinoCommandLine.java | 81 +++--- .../gravitino/cli/commands/CreateTag.java | 6 + .../gravitino/cli/commands/DeleteTag.java | 6 + .../cli/commands/GrantPrivilegesToRole.java | 6 + .../gravitino/cli/commands/RemoveAllTags.java | 6 + .../commands/RevokePrivilegesFromRole.java | 6 + .../cli/commands/SetTagProperty.java | 6 + .../gravitino/cli/commands/TagEntity.java | 6 + .../gravitino/cli/commands/UntagEntity.java | 6 + .../gravitino/cli/TestRoleCommands.java | 39 ++- .../apache/gravitino/cli/TestTagCommands.java | 242 ++++++++++-------- 12 files changed, 256 insertions(+), 157 deletions(-) diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java index abc6421d955..ecf1dbff4c7 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/ErrorMessages.java @@ -55,6 +55,7 @@ public class ErrorMessages { public static final String MISSING_GROUP = "Missing --group option."; public static final String MISSING_METALAKE = "Missing --metalake option."; public static final String MISSING_NAME = "Missing --name option."; + public static final String MISSING_PRIVILEGES = "Missing --privilege option."; public static final String MISSING_PROPERTY = "Missing --property option."; public static final String MISSING_PROPERTY_AND_VALUE = "Missing --property and --value options."; public static final String MISSING_ROLE = "Missing --role option."; @@ -63,6 +64,8 @@ public class ErrorMessages { public static final String MISSING_USER = "Missing --user option."; public static final String MISSING_VALUE = "Missing --value option."; + public static final String MULTIPLE_ROLE_COMMAND_ERROR = + "This command only supports one --role option."; public static final String MULTIPLE_TAG_COMMAND_ERROR = "This command only supports one --tag option."; public static final String MISSING_PROVIDER = "Missing --provider option."; diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java index 07a1ecd5b7f..442ec2d1c33 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/GravitinoCommandLine.java @@ -20,7 +20,6 @@ package org.apache.gravitino.cli; import com.google.common.base.Joiner; -import com.google.common.base.Preconditions; import com.google.common.collect.Lists; import java.io.BufferedReader; import java.io.IOException; @@ -643,12 +642,6 @@ protected void handleTagCommand() { Command.setAuthenticationMode(auth, userName); String[] tags = line.getOptionValues(GravitinoOptions.TAG); - if (tags == null - && !((CommandActions.REMOVE.equals(command) && line.hasOption(GravitinoOptions.FORCE)) - || CommandActions.LIST.equals(command))) { - System.err.println(ErrorMessages.MISSING_TAG); - Main.exit(-1); - } if (tags != null) { tags = Arrays.stream(tags).distinct().toArray(String[]::new); @@ -656,41 +649,36 @@ protected void handleTagCommand() { switch (command) { case CommandActions.DETAILS: - newTagDetails(url, ignore, metalake, getOneTag(tags)).handle(); + newTagDetails(url, ignore, metalake, getOneTag(tags)).validate().handle(); break; case CommandActions.LIST: if (!name.hasCatalogName()) { - newListTags(url, ignore, metalake).handle(); + newListTags(url, ignore, metalake).validate().handle(); } else { - newListEntityTags(url, ignore, metalake, name).handle(); + newListEntityTags(url, ignore, metalake, name).validate().handle(); } break; case CommandActions.CREATE: String comment = line.getOptionValue(GravitinoOptions.COMMENT); - newCreateTags(url, ignore, metalake, tags, comment).handle(); + newCreateTags(url, ignore, metalake, tags, comment).validate().handle(); break; case CommandActions.DELETE: boolean forceDelete = line.hasOption(GravitinoOptions.FORCE); - newDeleteTag(url, ignore, forceDelete, metalake, tags).handle(); + newDeleteTag(url, ignore, forceDelete, metalake, tags).validate().handle(); break; case CommandActions.SET: String propertySet = line.getOptionValue(GravitinoOptions.PROPERTY); String valueSet = line.getOptionValue(GravitinoOptions.VALUE); - if (propertySet != null && valueSet != null) { - newSetTagProperty(url, ignore, metalake, getOneTag(tags), propertySet, valueSet).handle(); - } else if (propertySet == null && valueSet == null) { - if (!name.hasName()) { - System.err.println(ErrorMessages.MISSING_NAME); - Main.exit(-1); - } - newTagEntity(url, ignore, metalake, name, tags).handle(); + if (propertySet == null && valueSet == null) { + newTagEntity(url, ignore, metalake, name, tags).validate().handle(); } else { - System.err.println(ErrorMessages.INVALID_SET_COMMAND); - Main.exit(-1); + newSetTagProperty(url, ignore, metalake, getOneTag(tags), propertySet, valueSet) + .validate() + .handle(); } break; @@ -698,33 +686,33 @@ protected void handleTagCommand() { boolean isTag = line.hasOption(GravitinoOptions.TAG); if (!isTag) { boolean forceRemove = line.hasOption(GravitinoOptions.FORCE); - newRemoveAllTags(url, ignore, metalake, name, forceRemove).handle(); + newRemoveAllTags(url, ignore, metalake, name, forceRemove).validate().handle(); } else { String propertyRemove = line.getOptionValue(GravitinoOptions.PROPERTY); if (propertyRemove != null) { - newRemoveTagProperty(url, ignore, metalake, getOneTag(tags), propertyRemove).handle(); + newRemoveTagProperty(url, ignore, metalake, getOneTag(tags), propertyRemove) + .validate() + .handle(); } else { - if (!name.hasName()) { - System.err.println(ErrorMessages.MISSING_NAME); - Main.exit(-1); - } - newUntagEntity(url, ignore, metalake, name, tags).handle(); + newUntagEntity(url, ignore, metalake, name, tags).validate().handle(); } } break; case CommandActions.PROPERTIES: - newListTagProperties(url, ignore, metalake, getOneTag(tags)).handle(); + newListTagProperties(url, ignore, metalake, getOneTag(tags)).validate().handle(); break; case CommandActions.UPDATE: if (line.hasOption(GravitinoOptions.COMMENT)) { String updateComment = line.getOptionValue(GravitinoOptions.COMMENT); - newUpdateTagComment(url, ignore, metalake, getOneTag(tags), updateComment).handle(); + newUpdateTagComment(url, ignore, metalake, getOneTag(tags), updateComment) + .validate() + .handle(); } if (line.hasOption(GravitinoOptions.RENAME)) { String newName = line.getOptionValue(GravitinoOptions.RENAME); - newUpdateTagName(url, ignore, metalake, getOneTag(tags), newName).handle(); + newUpdateTagName(url, ignore, metalake, getOneTag(tags), newName).validate().handle(); } break; @@ -736,7 +724,7 @@ protected void handleTagCommand() { } private String getOneTag(String[] tags) { - if (tags.length > 1) { + if (tags == null || tags.length > 1) { System.err.println(ErrorMessages.MULTIPLE_TAG_COMMAND_ERROR); Main.exit(-1); } @@ -767,34 +755,34 @@ protected void handleRoleCommand() { switch (command) { case CommandActions.DETAILS: if (line.hasOption(GravitinoOptions.AUDIT)) { - newRoleAudit(url, ignore, metalake, getOneRole(roles, CommandActions.DETAILS)).handle(); + newRoleAudit(url, ignore, metalake, getOneRole(roles)).validate().handle(); } else { - newRoleDetails(url, ignore, metalake, getOneRole(roles, CommandActions.DETAILS)).handle(); + newRoleDetails(url, ignore, metalake, getOneRole(roles)).validate().handle(); } break; case CommandActions.LIST: - newListRoles(url, ignore, metalake).handle(); + newListRoles(url, ignore, metalake).validate().handle(); break; case CommandActions.CREATE: - newCreateRole(url, ignore, metalake, roles).handle(); + newCreateRole(url, ignore, metalake, roles).validate().handle(); break; case CommandActions.DELETE: boolean forceDelete = line.hasOption(GravitinoOptions.FORCE); - newDeleteRole(url, ignore, forceDelete, metalake, roles).handle(); + newDeleteRole(url, ignore, forceDelete, metalake, roles).validate().handle(); break; case CommandActions.GRANT: - newGrantPrivilegesToRole( - url, ignore, metalake, getOneRole(roles, CommandActions.GRANT), name, privileges) + newGrantPrivilegesToRole(url, ignore, metalake, getOneRole(roles), name, privileges) + .validate() .handle(); break; case CommandActions.REVOKE: - newRevokePrivilegesFromRole( - url, ignore, metalake, getOneRole(roles, CommandActions.REMOVE), name, privileges) + newRevokePrivilegesFromRole(url, ignore, metalake, getOneRole(roles), name, privileges) + .validate() .handle(); break; @@ -805,9 +793,12 @@ url, ignore, metalake, getOneRole(roles, CommandActions.REMOVE), name, privilege } } - private String getOneRole(String[] roles, String command) { - Preconditions.checkArgument( - roles.length == 1, command + " requires only one role, but multiple are currently passed."); + private String getOneRole(String[] roles) { + if (roles == null || roles.length != 1) { + System.err.println(ErrorMessages.MULTIPLE_ROLE_COMMAND_ERROR); + Main.exit(-1); + } + return roles[0]; } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTag.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTag.java index 87ab0da779d..dabf34c8b1b 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTag.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/CreateTag.java @@ -103,4 +103,10 @@ private void handleMultipleTags() { System.out.println("Tags " + String.join(",", remaining) + " not created"); } } + + @Override + public Command validate() { + if (tags == null) exitWithError(ErrorMessages.MISSING_TAG); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteTag.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteTag.java index 1e05292c82a..26919e06acf 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteTag.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/DeleteTag.java @@ -116,4 +116,10 @@ private void handleOnlyOneTag() { System.out.println("Tag " + tags[0] + " not deleted."); } } + + @Override + public Command validate() { + if (tags == null) exitWithError(ErrorMessages.MISSING_TAG); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/GrantPrivilegesToRole.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/GrantPrivilegesToRole.java index 584e073beac..8630282ea60 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/GrantPrivilegesToRole.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/GrantPrivilegesToRole.java @@ -103,4 +103,10 @@ public void handle() { String all = String.join(",", privileges); System.out.println(role + " granted " + all + " on " + entity.getName()); } + + @Override + public Command validate() { + if (privileges == null) exitWithError(ErrorMessages.MISSING_PRIVILEGES); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveAllTags.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveAllTags.java index a7aa3748a15..5221100a8e9 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveAllTags.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RemoveAllTags.java @@ -118,4 +118,10 @@ public void handle() { System.out.println(entity + " has no tags"); } } + + @Override + public Command validate() { + if (name == null || !name.hasName()) exitWithError(ErrorMessages.MISSING_NAME); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RevokePrivilegesFromRole.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RevokePrivilegesFromRole.java index a62e977a2fb..3bfa7cd4526 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RevokePrivilegesFromRole.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/RevokePrivilegesFromRole.java @@ -103,4 +103,10 @@ public void handle() { String all = String.join(",", privileges); System.out.println(role + " revoked " + all + " on " + entity.getName()); } + + @Override + public Command validate() { + if (privileges == null) exitWithError(ErrorMessages.MISSING_PRIVILEGES); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTagProperty.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTagProperty.java index b5b46b59a71..da7a267b8d4 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTagProperty.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/SetTagProperty.java @@ -74,4 +74,10 @@ public void handle() { System.out.println(tag + " property set."); } + + @Override + public Command validate() { + validatePropertyAndValue(property, value); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/TagEntity.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/TagEntity.java index 7bc8ec37649..4a06918850d 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/TagEntity.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/TagEntity.java @@ -105,4 +105,10 @@ public void handle() { System.out.println(entity + " now tagged with " + all); } + + @Override + public Command validate() { + if (name == null || !name.hasName()) exitWithError(ErrorMessages.MISSING_NAME); + return super.validate(); + } } diff --git a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/UntagEntity.java b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/UntagEntity.java index 8f4a4a9cf02..3503d5eb7bf 100644 --- a/clients/cli/src/main/java/org/apache/gravitino/cli/commands/UntagEntity.java +++ b/clients/cli/src/main/java/org/apache/gravitino/cli/commands/UntagEntity.java @@ -113,4 +113,10 @@ public void handle() { System.out.println(entity + " removed tag " + tags[0].toString() + " now tagged with " + all); } } + + @Override + public Command validate() { + if (name == null || !name.hasName()) exitWithError(ErrorMessages.MISSING_NAME); + return super.validate(); + } } diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestRoleCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestRoleCommands.java index 0e671067e3f..529979582ff 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestRoleCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestRoleCommands.java @@ -80,6 +80,7 @@ void testListRolesCommand() { doReturn(mockList) .when(commandLine) .newListRoles(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -98,12 +99,14 @@ void testRoleDetailsCommand() { doReturn(mockDetails) .when(commandLine) .newRoleDetails(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "admin"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @Test void testRoleDetailsCommandWithMultipleRoles() { + Main.useExit = false; when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); when(mockCommandLine.hasOption(GravitinoOptions.ROLE)).thenReturn(true); @@ -114,7 +117,7 @@ void testRoleDetailsCommandWithMultipleRoles() { new GravitinoCommandLine( mockCommandLine, mockOptions, CommandEntities.ROLE, CommandActions.DETAILS)); - assertThrows(IllegalArgumentException.class, commandLine::handleCommandLine); + assertThrows(RuntimeException.class, commandLine::handleCommandLine); verify(commandLine, never()) .newRoleDetails( eq(GravitinoCommandLine.DEFAULT_URL), eq(false), eq("metalake_demo"), any()); @@ -135,6 +138,7 @@ void testRoleAuditCommand() { doReturn(mockAudit) .when(commandLine) .newRoleAudit(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "group"); + doReturn(mockAudit).when(mockAudit).validate(); commandLine.handleCommandLine(); verify(mockAudit).handle(); } @@ -154,6 +158,7 @@ void testCreateRoleCommand() { .when(commandLine) .newCreateRole( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", new String[] {"admin"}); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -178,6 +183,7 @@ void testCreateRolesCommand() { eq(false), eq("metalake_demo"), eq(new String[] {"admin", "engineer", "scientist"})); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -201,6 +207,7 @@ void testDeleteRoleCommand() { false, "metalake_demo", new String[] {"admin"}); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -227,6 +234,7 @@ void testDeleteRolesCommand() { eq(false), eq("metalake_demo"), eq(new String[] {"admin", "engineer", "scientist"})); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -247,6 +255,7 @@ void testDeleteRoleForceCommand() { .when(commandLine) .newDeleteRole( GravitinoCommandLine.DEFAULT_URL, false, true, "metalake_demo", new String[] {"admin"}); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -276,10 +285,24 @@ void testGrantPrivilegesToRole() { eq("admin"), any(), eq(privileges)); + doReturn(mockGrant).when(mockGrant).validate(); commandLine.handleCommandLine(); verify(mockGrant).handle(); } + @Test + void testGrantPrivilegesToRoleWithoutPrivileges() { + Main.useExit = false; + GrantPrivilegesToRole spyGrantRole = + spy( + new GrantPrivilegesToRole( + GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "admin", null, null)); + assertThrows(RuntimeException.class, spyGrantRole::validate); + verify(spyGrantRole, never()).handle(); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PRIVILEGES, errOutput); + } + @Test void testRevokePrivilegesFromRole() { RevokePrivilegesFromRole mockRevoke = mock(RevokePrivilegesFromRole.class); @@ -305,10 +328,24 @@ void testRevokePrivilegesFromRole() { eq("admin"), any(), eq(privileges)); + doReturn(mockRevoke).when(mockRevoke).validate(); commandLine.handleCommandLine(); verify(mockRevoke).handle(); } + @Test + void testRevokePrivilegesFromRoleWithoutPrivileges() { + Main.useExit = false; + RevokePrivilegesFromRole spyGrantRole = + spy( + new RevokePrivilegesFromRole( + GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "admin", null, null)); + assertThrows(RuntimeException.class, spyGrantRole::validate); + verify(spyGrantRole, never()).handle(); + String errOutput = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_PRIVILEGES, errOutput); + } + @Test void testDeleteRoleCommandWithoutRole() { Main.useExit = false; diff --git a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java index d3b0c8bfe18..a94ccee7daa 100644 --- a/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java +++ b/clients/cli/src/test/java/org/apache/gravitino/cli/TestTagCommands.java @@ -22,7 +22,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.eq; @@ -95,6 +94,7 @@ void testListTagsCommand() { doReturn(mockList) .when(commandLine) .newListTags(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo"); + doReturn(mockList).when(mockList).validate(); commandLine.handleCommandLine(); verify(mockList).handle(); } @@ -113,6 +113,7 @@ void testTagDetailsCommand() { doReturn(mockDetails) .when(commandLine) .newTagDetails(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "tagA"); + doReturn(mockDetails).when(mockDetails).validate(); commandLine.handleCommandLine(); verify(mockDetails).handle(); } @@ -157,6 +158,7 @@ void testCreateTagCommand() { "metalake_demo", new String[] {"tagA"}, "comment"); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -164,25 +166,15 @@ void testCreateTagCommand() { @Test void testCreateCommandWithoutTagOption() { Main.useExit = false; - when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); - when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); - when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(false); - - GravitinoCommandLine commandLine = + CreateTag spyCreate = spy( - new GravitinoCommandLine( - mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.CREATE)); + new CreateTag( + GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", null, "comment")); - assertThrows(RuntimeException.class, commandLine::handleCommandLine); - verify(commandLine, never()) - .newCreateTags( - eq(GravitinoCommandLine.DEFAULT_URL), - eq(false), - eq("metalake_demo"), - isNull(), - isNull()); + assertThrows(RuntimeException.class, spyCreate::validate); + verify(spyCreate, never()).handle(); String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); - assertEquals(output, ErrorMessages.MISSING_TAG); + assertEquals(ErrorMessages.MISSING_TAG, output); } @Test @@ -202,11 +194,16 @@ void testCreateTagsCommand() { doReturn(mockCreate) .when(commandLine) .newCreateTags( - GravitinoCommandLine.DEFAULT_URL, - false, - "metalake_demo", - new String[] {"tagA", "tagB"}, - "comment"); + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + argThat( + argument -> + argument.length == 2 + && argument[0].equals("tagA") + && argument[1].equals("tagB")), + eq("comment")); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -226,6 +223,7 @@ void testCreateTagCommandNoComment() { .when(commandLine) .newCreateTags( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", new String[] {"tagA"}, null); + doReturn(mockCreate).when(mockCreate).validate(); commandLine.handleCommandLine(); verify(mockCreate).handle(); } @@ -245,6 +243,7 @@ void testDeleteTagCommand() { .when(commandLine) .newDeleteTag( GravitinoCommandLine.DEFAULT_URL, false, false, "metalake_demo", new String[] {"tagA"}); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -269,6 +268,7 @@ void testDeleteTagsCommand() { false, "metalake_demo", new String[] {"tagA", "tagB"}); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -289,6 +289,7 @@ void testDeleteTagForceCommand() { .when(commandLine) .newDeleteTag( GravitinoCommandLine.DEFAULT_URL, false, true, "metalake_demo", new String[] {"tagA"}); + doReturn(mockDelete).when(mockDelete).validate(); commandLine.handleCommandLine(); verify(mockDelete).handle(); } @@ -312,64 +313,53 @@ void testSetTagPropertyCommand() { .when(commandLine) .newSetTagProperty( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "tagA", "property", "value"); + doReturn(mockSetProperty).when(mockSetProperty).validate(); commandLine.handleCommandLine(); verify(mockSetProperty).handle(); } @Test - void testSetTagPropertyCommandWithoutPropertyOption() { + void testSetTagPropertyCommandWithoutPropertyAndValue() { Main.useExit = false; - when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); - when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); - when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(true); - when(mockCommandLine.getOptionValues(GravitinoOptions.TAG)).thenReturn(new String[] {"tagA"}); - when(mockCommandLine.hasOption(GravitinoOptions.PROPERTY)).thenReturn(false); - when(mockCommandLine.hasOption(GravitinoOptions.VALUE)).thenReturn(true); - when(mockCommandLine.getOptionValue(GravitinoOptions.VALUE)).thenReturn("value"); - GravitinoCommandLine commandLine = + SetTagProperty spySetProperty = spy( - new GravitinoCommandLine( - mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.SET)); + new SetTagProperty( + GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "tagA", null, null)); + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(output, ErrorMessages.MISSING_PROPERTY_AND_VALUE); + } - assertThrows(RuntimeException.class, commandLine::handleCommandLine); - verify(commandLine, never()) - .newSetTagProperty( - eq(GravitinoCommandLine.DEFAULT_URL), - eq(false), - eq("metalake_demo"), - eq("tagA"), - isNull(), - eq("value")); + @Test + void testSetTagPropertyCommandWithoutPropertyOption() { + Main.useExit = false; + SetTagProperty spySetProperty = + spy( + new SetTagProperty( + GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "tagA", null, "value")); + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); - assertEquals(output, ErrorMessages.INVALID_SET_COMMAND); + assertEquals(output, ErrorMessages.MISSING_PROPERTY); } @Test void testSetTagPropertyCommandWithoutValueOption() { Main.useExit = false; - when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); - when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); - when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(true); - when(mockCommandLine.getOptionValues(GravitinoOptions.TAG)).thenReturn(new String[] {"tagA"}); - when(mockCommandLine.hasOption(GravitinoOptions.PROPERTY)).thenReturn(true); - when(mockCommandLine.getOptionValue(GravitinoOptions.PROPERTY)).thenReturn("property"); - when(mockCommandLine.hasOption(GravitinoOptions.VALUE)).thenReturn(false); - GravitinoCommandLine commandLine = + SetTagProperty spySetProperty = spy( - new GravitinoCommandLine( - mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.SET)); - - assertThrows(RuntimeException.class, commandLine::handleCommandLine); - verify(commandLine, never()) - .newSetTagProperty( - eq(GravitinoCommandLine.DEFAULT_URL), - eq(false), - eq("metalake_demo"), - eq("tagA"), - eq("property"), - isNull()); + new SetTagProperty( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + "tagA", + "property", + null)); + assertThrows(RuntimeException.class, spySetProperty::validate); + verify(spySetProperty, never()).handle(); String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); - assertEquals(output, ErrorMessages.INVALID_SET_COMMAND); + assertEquals(output, ErrorMessages.MISSING_VALUE); } @Test @@ -418,6 +408,7 @@ void testRemoveTagPropertyCommand() { .when(commandLine) .newRemoveTagProperty( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "tagA", "property"); + doReturn(mockRemoveProperty).when(mockRemoveProperty).validate(); commandLine.handleCommandLine(); verify(mockRemoveProperty).handle(); } @@ -463,6 +454,7 @@ void testListTagPropertiesCommand() { doReturn(mockListProperties) .when(commandLine) .newListTagProperties(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "tagA"); + doReturn(mockListProperties).when(mockListProperties).validate(); commandLine.handleCommandLine(); verify(mockListProperties).handle(); } @@ -488,6 +480,7 @@ void testDeleteAllTagCommand() { eq("metalake_demo"), any(FullName.class), eq(true)); + doReturn(mockRemoveAllTags).when(mockRemoveAllTags).validate(); commandLine.handleCommandLine(); verify(mockRemoveAllTags).handle(); } @@ -509,6 +502,7 @@ void testUpdateTagCommentCommand() { .when(commandLine) .newUpdateTagComment( GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "tagA", "new comment"); + doReturn(mockUpdateComment).when(mockUpdateComment).validate(); commandLine.handleCommandLine(); verify(mockUpdateComment).handle(); } @@ -556,6 +550,7 @@ void testUpdateTagNameCommand() { doReturn(mockUpdateName) .when(commandLine) .newUpdateTagName(GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", "tagA", "tagB"); + doReturn(mockUpdateName).when(mockUpdateName).validate(); commandLine.handleCommandLine(); verify(mockUpdateName).handle(); } @@ -602,6 +597,7 @@ void testListEntityTagsCommand() { .when(commandLine) .newListEntityTags( eq(GravitinoCommandLine.DEFAULT_URL), eq(false), eq("metalake_demo"), any()); + doReturn(mockListTags).when(mockListTags).validate(); commandLine.handleCommandLine(); verify(mockListTags).handle(); } @@ -633,6 +629,7 @@ public boolean matches(String[] argument) { return argument != null && argument.length > 0 && "tagA".equals(argument[0]); } })); + doReturn(mockTagEntity).when(mockTagEntity).validate(); commandLine.handleCommandLine(); verify(mockTagEntity).handle(); } @@ -640,27 +637,19 @@ public boolean matches(String[] argument) { @Test void testTagEntityCommandWithoutName() { Main.useExit = false; - when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); - when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); - when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(false); - when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(true); - when(mockCommandLine.getOptionValues(GravitinoOptions.TAG)).thenReturn(new String[] {"tagA"}); - GravitinoCommandLine commandLine = + TagEntity spyTagEntity = spy( - new GravitinoCommandLine( - mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.SET)); - - assertThrows(RuntimeException.class, commandLine::handleCommandLine); - verify(commandLine, never()) - .newTagEntity( - eq(GravitinoCommandLine.DEFAULT_URL), - eq(false), - eq("metalake_demo"), - isNull(), - argThat( - argument -> argument != null && argument.length > 0 && "tagA".equals(argument[0]))); + new TagEntity( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + null, + new String[] {"tagA"})); + + assertThrows(RuntimeException.class, spyTagEntity::validate); + verify(spyTagEntity, never()).handle(); String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); - assertEquals(output, ErrorMessages.MISSING_NAME); + assertEquals(ErrorMessages.MISSING_NAME, output); } @Test @@ -694,6 +683,7 @@ public boolean matches(String[] argument) { && "tagB".equals(argument[1]); } })); + doReturn(mockTagEntity).when(mockTagEntity).validate(); commandLine.handleCommandLine(); verify(mockTagEntity).handle(); } @@ -728,6 +718,7 @@ public boolean matches(String[] argument) { return argument != null && argument.length > 0 && "tagA".equals(argument[0]); } })); + doReturn(mockUntagEntity).when(mockUntagEntity).validate(); commandLine.handleCommandLine(); verify(mockUntagEntity).handle(); } @@ -735,32 +726,19 @@ public boolean matches(String[] argument) { @Test void testUntagEntityCommandWithoutName() { Main.useExit = false; - when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); - when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); - when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(false); - when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(true); - when(mockCommandLine.getOptionValues(GravitinoOptions.TAG)) - .thenReturn(new String[] {"tagA", "tagB"}); - GravitinoCommandLine commandLine = + UntagEntity spyUntagEntity = spy( - new GravitinoCommandLine( - mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.REMOVE)); - - assertThrows(RuntimeException.class, commandLine::handleCommandLine); - verify(commandLine, never()) - .newUntagEntity( - eq(GravitinoCommandLine.DEFAULT_URL), - eq(false), - eq("metalake_demo"), - isNull(), - argThat( - argument -> - argument != null - && argument.length > 0 - && "tagA".equals(argument[0]) - && "tagB".equals(argument[1]))); + new UntagEntity( + GravitinoCommandLine.DEFAULT_URL, + false, + "metalake_demo", + null, + new String[] {"tagA"})); + + assertThrows(RuntimeException.class, spyUntagEntity::validate); + verify(spyUntagEntity, never()).handle(); String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); - assertEquals(output, ErrorMessages.MISSING_NAME); + assertEquals(ErrorMessages.MISSING_NAME, output); } @Test @@ -796,6 +774,7 @@ public boolean matches(String[] argument) { && "tagB".equals(argument[1]); } })); + doReturn(mockUntagEntity).when(mockUntagEntity).validate(); commandLine.handleCommandLine(); verify(mockUntagEntity).handle(); } @@ -803,18 +782,59 @@ public boolean matches(String[] argument) { @Test void testDeleteTagCommandWithoutTagOption() { Main.useExit = false; + DeleteTag spyDeleteTag = + spy(new DeleteTag(GravitinoCommandLine.DEFAULT_URL, false, false, "metalake", null)); + + assertThrows(RuntimeException.class, spyDeleteTag::validate); + verify(spyDeleteTag, never()).handle(); + String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); + assertEquals(ErrorMessages.MISSING_TAG, output); + } + + @Test + void testRemoveAllTagsCommand() { + Main.useExit = false; + RemoveAllTags mockRemoveAllTags = mock(RemoveAllTags.class); when(mockCommandLine.hasOption(GravitinoOptions.METALAKE)).thenReturn(true); when(mockCommandLine.getOptionValue(GravitinoOptions.METALAKE)).thenReturn("metalake_demo"); when(mockCommandLine.hasOption(GravitinoOptions.TAG)).thenReturn(false); + when(mockCommandLine.hasOption(GravitinoOptions.NAME)).thenReturn(true); + when(mockCommandLine.getOptionValue(GravitinoOptions.NAME)).thenReturn("catalog.schema.table"); + when(mockCommandLine.hasOption(GravitinoOptions.FORCE)).thenReturn(true); GravitinoCommandLine commandLine = spy( new GravitinoCommandLine( mockCommandLine, mockOptions, CommandEntities.TAG, CommandActions.REMOVE)); - assertThrows(RuntimeException.class, commandLine::handleCommandLine); - verify(commandLine, never()) - .newDeleteTag(GravitinoCommandLine.DEFAULT_URL, false, false, "metalake", null); + doReturn(mockRemoveAllTags) + .when(commandLine) + .newRemoveAllTags( + eq(GravitinoCommandLine.DEFAULT_URL), + eq(false), + eq("metalake_demo"), + argThat( + argument -> + argument != null + && "catalog".equals(argument.getCatalogName()) + && "schema".equals(argument.getSchemaName()) + && "table".equals(argument.getTableName())), + eq(true)); + doReturn(mockRemoveAllTags).when(mockRemoveAllTags).validate(); + commandLine.handleCommandLine(); + verify(mockRemoveAllTags).handle(); + } + + @Test + void testRemoveAllTagsCommandWithoutName() { + Main.useExit = false; + RemoveAllTags spyRemoveAllTags = + spy( + new RemoveAllTags( + GravitinoCommandLine.DEFAULT_URL, false, "metalake_demo", null, false)); + + assertThrows(RuntimeException.class, spyRemoveAllTags::validate); + verify(spyRemoveAllTags, never()).handle(); String output = new String(errContent.toByteArray(), StandardCharsets.UTF_8).trim(); - assertEquals(output, ErrorMessages.MISSING_TAG); + assertEquals(ErrorMessages.MISSING_NAME, output); } }