-
Notifications
You must be signed in to change notification settings - Fork 41
feat: add tenant-rooted storage layout behind a setting #1863 #1866
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
c2560fd
81ed8ff
b12b8aa
3565bbc
e6d863a
ac416f4
8eed9e5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| package com.epam.aidial.core.server; | ||
|
|
||
| import com.epam.aidial.core.storage.resource.LegacyStorageLayout; | ||
| import com.epam.aidial.core.storage.resource.StorageLayouts; | ||
| import io.vertx.core.http.HttpMethod; | ||
| import io.vertx.core.json.JsonObject; | ||
| import org.junit.jupiter.api.AfterEach; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.io.IOException; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.util.List; | ||
| import java.util.stream.Stream; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| /** | ||
| * Drives the resource API with {@code storageLayout.tenantRooted} enabled: the whole stack — descriptor, | ||
| * cache and blob store — has to agree on the tenant-rooted paths, which unit tests cannot show. | ||
| */ | ||
| public class TenantRootedLayoutApiTest extends ResourceBaseTest { | ||
|
|
||
| private static final String TENANT = "test-tenant"; | ||
|
|
||
| @Override | ||
| protected JsonObject additionalSettingsOverrides() { | ||
| return new JsonObject().put("storageLayout", new JsonObject() | ||
| .put("tenantRooted", true) | ||
| .put("defaultTenant", TENANT)); | ||
| } | ||
|
|
||
| @AfterEach | ||
| public void restoreLegacyLayout() { | ||
| StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); | ||
| } | ||
|
|
||
| @Test | ||
| public void testResourceRoundTrip() { | ||
| Response created = resourceRequest(HttpMethod.PUT, "/folder/conversation", CONVERSATION_BODY_1); | ||
| assertEquals(200, created.status()); | ||
|
|
||
| Response read = resourceRequest(HttpMethod.GET, "/folder/conversation"); | ||
| assertEquals(200, read.status()); | ||
| assertEquals(CONVERSATION_BODY_1, read.body()); | ||
| } | ||
|
|
||
| @Test | ||
| public void testResourceListingAndDeletion() { | ||
| assertEquals(200, resourceRequest(HttpMethod.PUT, "/folder/conversation", CONVERSATION_BODY_1).status()); | ||
|
|
||
| Response listing = metadata("/folder/"); | ||
| assertEquals(200, listing.status()); | ||
| assertTrue(listing.body().contains("conversations/" + bucket + "/folder/conversation"), | ||
| () -> "Unexpected listing: " + listing.body()); | ||
|
|
||
| assertEquals(200, resourceRequest(HttpMethod.DELETE, "/folder/conversation").status()); | ||
| assertEquals(404, resourceRequest(HttpMethod.GET, "/folder/conversation").status()); | ||
| } | ||
|
|
||
| @Test | ||
| public void testBlobIsStoredUnderTenantRoot() throws IOException { | ||
| assertEquals(200, resourceRequest(HttpMethod.PUT, "/folder/conversation", CONVERSATION_BODY_1).status()); | ||
| // the resource is written back to the blob store asynchronously | ||
| Response flushed = resourceRequest(HttpMethod.GET, "/folder/conversation"); | ||
| assertEquals(200, flushed.status()); | ||
|
|
||
| List<Path> storedPaths = findStoredPaths(""); | ||
| List<Path> tenantRootedPaths = storedPaths.stream() | ||
| .filter(path -> path.toString().contains(".org/" + TENANT)) | ||
| .toList(); | ||
| assertTrue(!tenantRootedPaths.isEmpty(), | ||
| () -> "No blob stored under the tenant root, found: " + storedPaths); | ||
| assertTrue(tenantRootedPaths.stream().anyMatch(path -> path.toString().contains(".conversations")), | ||
| () -> "Conversations are not stored in a reserved type folder: " + tenantRootedPaths); | ||
| } | ||
|
|
||
| private List<Path> findStoredPaths(String marker) throws IOException { | ||
| try (Stream<Path> paths = Files.walk(testDir)) { | ||
| return paths.filter(Files::isRegularFile) | ||
| .filter(path -> path.toString().contains(marker)) | ||
| .toList(); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.epam.aidial.core.storage.resource; | ||
|
|
||
| /** | ||
| * The bucket-rooted layout: the location prefix and the resource-type folder are stored verbatim. | ||
| */ | ||
| public final class LegacyStorageLayout implements StorageLayout { | ||
|
|
||
| public static final LegacyStorageLayout INSTANCE = new LegacyStorageLayout(); | ||
|
|
||
| private LegacyStorageLayout() { | ||
| } | ||
|
|
||
| @Override | ||
| public String resolveLocationPrefix(String bucketLocation) { | ||
| return bucketLocation; | ||
| } | ||
|
|
||
| @Override | ||
| public String resolveTypeFolder(String group) { | ||
| return group; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.epam.aidial.core.storage.resource; | ||
|
|
||
| /** | ||
| * Supplies the two parts of a physical storage path that differ between storage layouts: the bucket | ||
| * location prefix and the resource-type folder. {@link ResourceDescriptor} composes them with the | ||
| * resource path, which is layout-independent. | ||
| */ | ||
| public interface StorageLayout { | ||
|
|
||
| String resolveLocationPrefix(String bucketLocation); | ||
|
|
||
| String resolveTypeFolder(String group); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.epam.aidial.core.storage.resource; | ||
|
|
||
| /** | ||
| * Holds the layout every physical path is composed with. Set once during start-up, before any resource | ||
| * is read or written; process-wide because {@link ResourceDescriptor} is constructed everywhere and | ||
| * carries no configuration of its own. | ||
| */ | ||
| public final class StorageLayouts { | ||
|
|
||
| private static volatile StorageLayout active = LegacyStorageLayout.INSTANCE; | ||
|
|
||
| private StorageLayouts() { | ||
| } | ||
|
|
||
| public static StorageLayout resolveActive() { | ||
| return active; | ||
| } | ||
|
|
||
| public static void useLayout(StorageLayout layout) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we switch between layouts in runtime?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, by design: the layout is fixed at startup before any resource I/O, because a runtime flip re-addresses live data out from under the Redis cache, the dirty write-behind queue, and per-resource locks (all keyed by physical path). The setter exists so the comparison tests can boot two instances in one JVM. Per-bucket migration in P2 will come as a composite layout consulted per resolution, not a runtime switch.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a dangerous method by nature. I'm thinking how we can make us safe. Can we come up with more robust mechanism so anyone couldn't change the layout in runtime? |
||
| active = layout; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| package com.epam.aidial.core.storage.resource; | ||
|
|
||
| import lombok.experimental.UtilityClass; | ||
|
|
||
| import javax.annotation.Nullable; | ||
|
|
||
| /** | ||
| * Converts the two halves of a physical storage path between the legacy bucket-rooted layout and the | ||
| * tenant-rooted one: the bucket location prefix and the resource-type folder. Path composition itself | ||
| * stays in {@link ResourceDescriptor#getAbsoluteFilePath()}. | ||
| * | ||
| * <p>The conversion is total and reversible in both directions, so a migrated path can always be mapped | ||
| * back to its origin. | ||
| * | ||
| * <p>Legacy locations are produced by the server-side bucket builder; the prefixes are repeated here | ||
| * because this module cannot depend on it. | ||
| */ | ||
| @UtilityClass | ||
| public class TenantLayoutTransform { | ||
|
astsiapanay marked this conversation as resolved.
|
||
|
|
||
| private static final String LEGACY_USERS_PREFIX = "Users/"; | ||
| private static final String LEGACY_KEYS_PREFIX = "Keys/"; | ||
|
|
||
| private static final String ORG_PREFIX = ".org/"; | ||
| private static final String USERS_SEGMENT = ".users/"; | ||
| private static final String KEYS_SEGMENT = ".keys/"; | ||
|
|
||
| /** | ||
| * The platform scope is the root of the tenant-rooted tree, above any tenant, so it has no prefix. | ||
| */ | ||
| private static final String PLATFORM_LOCATION = ""; | ||
|
|
||
| private static final char TYPE_FOLDER_MARKER = '.'; | ||
|
|
||
| public String toTenantLocation(String legacyLocation, String tenantId) { | ||
| if (ResourceDescriptor.PLATFORM_LOCATION.equals(legacyLocation)) { | ||
| return PLATFORM_LOCATION; | ||
| } | ||
|
|
||
| String tenantRoot = tenantRoot(tenantId); | ||
| if (ResourceDescriptor.PUBLIC_LOCATION.equals(legacyLocation)) { | ||
| return tenantRoot; | ||
| } | ||
|
|
||
| String userId = principalId(legacyLocation, LEGACY_USERS_PREFIX); | ||
| if (userId != null) { | ||
| return tenantRoot + USERS_SEGMENT + userId; | ||
| } | ||
|
|
||
| String project = principalId(legacyLocation, LEGACY_KEYS_PREFIX); | ||
| if (project != null) { | ||
| return tenantRoot + KEYS_SEGMENT + project; | ||
| } | ||
|
|
||
| throw new IllegalArgumentException("Unsupported legacy bucket location: " + legacyLocation); | ||
| } | ||
|
|
||
| public String toLegacyLocation(String tenantLocation, String tenantId) { | ||
| if (PLATFORM_LOCATION.equals(tenantLocation)) { | ||
| return ResourceDescriptor.PLATFORM_LOCATION; | ||
| } | ||
|
|
||
| String tenantRoot = tenantRoot(tenantId); | ||
| if (!tenantLocation.startsWith(tenantRoot)) { | ||
| throw new IllegalArgumentException("Location does not belong to tenant " + tenantId + ": " + tenantLocation); | ||
| } | ||
|
|
||
| String scope = tenantLocation.substring(tenantRoot.length()); | ||
| if (scope.isEmpty()) { | ||
| return ResourceDescriptor.PUBLIC_LOCATION; | ||
| } | ||
|
|
||
| String userId = principalId(scope, USERS_SEGMENT); | ||
| if (userId != null) { | ||
| return LEGACY_USERS_PREFIX + userId; | ||
| } | ||
|
|
||
| String project = principalId(scope, KEYS_SEGMENT); | ||
| if (project != null) { | ||
| return LEGACY_KEYS_PREFIX + project; | ||
| } | ||
|
|
||
| throw new IllegalArgumentException("Unsupported tenant bucket location: " + tenantLocation); | ||
| } | ||
|
|
||
| public String toTenantTypeFolder(String legacyTypeFolder) { | ||
| if (legacyTypeFolder.isEmpty() || legacyTypeFolder.charAt(0) == TYPE_FOLDER_MARKER) { | ||
| throw new IllegalArgumentException("Unsupported legacy resource type folder: " + legacyTypeFolder); | ||
| } | ||
|
|
||
| return TYPE_FOLDER_MARKER + legacyTypeFolder; | ||
| } | ||
|
|
||
| public String toLegacyTypeFolder(String tenantTypeFolder) { | ||
| if (tenantTypeFolder.length() < 2 || tenantTypeFolder.charAt(0) != TYPE_FOLDER_MARKER) { | ||
| throw new IllegalArgumentException("Unsupported tenant resource type folder: " + tenantTypeFolder); | ||
| } | ||
|
|
||
| return tenantTypeFolder.substring(1); | ||
| } | ||
|
|
||
| private String tenantRoot(String tenantId) { | ||
| if (tenantId.isEmpty()) { | ||
| throw new IllegalArgumentException("Tenant id must not be empty"); | ||
| } | ||
|
|
||
| return ORG_PREFIX + tenantId + ResourceDescriptor.PATH_SEPARATOR; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the principal id following the given prefix, trailing separator included, or null when the | ||
| * location does not carry that prefix. The id may span several segments: an application's own bucket | ||
| * is keyed by the application url. | ||
| */ | ||
| @Nullable | ||
| private String principalId(String location, String prefix) { | ||
| if (!location.startsWith(prefix)) { | ||
| return null; | ||
| } | ||
|
|
||
| String id = location.substring(prefix.length()); | ||
| return id.length() > 1 && id.endsWith(ResourceDescriptor.PATH_SEPARATOR) ? id : null; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.