Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ without putting the raw address in the log.
| storage.bucket | - | No | Blob storage bucket. |
| storage.overrides.* | - | No | Key-value pairs to override storage settings. `*` might be any specific blob storage setting to be overridden. Refer to [examples](#temporary-credentials-1) in the sections below. |
| storage.createBucket | false | No | Indicates whether bucket should be created on start-up. |
| storageLayout.tenantRooted | false | No | Places every bucket under a tenant root instead of storing it at the top level. Changing it on a populated deployment re-addresses existing data and requires migration; leave disabled otherwise. |
Comment thread
astsiapanay marked this conversation as resolved.
| storageLayout.defaultTenant | default | No | Tenant that existing buckets are placed under when `storageLayout.tenantRooted` is enabled. |
| storage.prefix | - | No | Base prefix for all stored resources. The purpose to use the same bucket for different environments, e.g. dev, prod, pre-prod. Must not contain path separators or any invalid chars. |
| storage.maxUploadedFileSize | 536870912 | No | Maximum size in bytes of uploaded file. If a size of uploaded file exceeds the limit the server returns HTTP code 413 |

Expand Down
14 changes: 14 additions & 0 deletions server/src/main/java/com/epam/aidial/core/server/AiDial.java
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,12 @@
import com.epam.aidial.core.storage.blobstore.BlobStorage;
import com.epam.aidial.core.storage.blobstore.Storage;
import com.epam.aidial.core.storage.cache.CacheClientFactory;
import com.epam.aidial.core.storage.resource.LegacyStorageLayout;
import com.epam.aidial.core.storage.resource.ResourceDescriptor;
import com.epam.aidial.core.storage.resource.ResourceTypes;
import com.epam.aidial.core.storage.resource.StorageLayout;
import com.epam.aidial.core.storage.resource.StorageLayouts;
import com.epam.aidial.core.storage.resource.TenantRootedStorageLayout;
import com.epam.aidial.core.storage.service.LockService;
import com.epam.aidial.core.storage.service.ResourceService;
import com.epam.aidial.core.storage.service.TimerService;
Expand Down Expand Up @@ -206,6 +210,8 @@ void start() throws Exception {
accessTokenValidator = new AccessTokenValidator(settings("identityProviders"), vertx, taskExecutor, client, clientOptions, claimsLogLevel);
}

StorageLayouts.useLayout(createStorageLayout(settings("storageLayout")));

if (storage == null) {
Storage storageConfig = Json.decodeValue(settings("storage").toBuffer(), Storage.class);
storage = new BlobStorage(storageConfig);
Expand Down Expand Up @@ -502,6 +508,14 @@ private JsonObject settings(String key) {
return settings.getJsonObject(key, new JsonObject());
}

private static StorageLayout createStorageLayout(JsonObject settings) {
if (!settings.getBoolean("tenantRooted", false)) {
return LegacyStorageLayout.INSTANCE;
}

return new TenantRootedStorageLayout(settings.getString("defaultTenant", "default"));
}

private List<String> getAllowedRedirectUris() {
return settings("toolsets")
.getJsonObject("security", new JsonObject())
Expand Down
4 changes: 4 additions & 0 deletions server/src/main/resources/aidial.settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
"jclouds.filesystem.basedir": "data"
}
},
"storageLayout": {
"tenantRooted": false,
"defaultTenant": "default"
},
"resources": {
"maxSize" : 67108864,
"maxSizeToCache": 1048576,
Expand Down
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
Expand Up @@ -23,6 +23,7 @@ public class ResourceDescriptor {
public static final String PLATFORM_BUCKET = "platform";
public static final String PLATFORM_LOCATION = PLATFORM_BUCKET + PATH_SEPARATOR;


ResourceType type;
/**
* Resource's name or empty if the resource is a folder
Expand Down Expand Up @@ -106,9 +107,7 @@ public String getDecodedUrl() {
*/
public String getAbsoluteFilePath() {
StringBuilder builder = new StringBuilder();
builder.append(bucketLocation)
.append(type.group())
.append(PATH_SEPARATOR);
builder.append(getStoragePrefix());

if (!parentFolders.isEmpty()) {
builder.append(getParentPath())
Expand All @@ -126,6 +125,15 @@ public String getAbsoluteFilePath() {
return builder.toString();
}

/**
* Returns the layout-dependent prefix every physical path of this resource starts with: the bucket
* location followed by the resource-type folder.
*/
private String getStoragePrefix() {
StorageLayout layout = StorageLayouts.resolveActive();
return layout.resolveLocationPrefix(bucketLocation) + layout.resolveTypeFolder(type.group()) + PATH_SEPARATOR;
}

/**
* Returns the parent resource if any.
*/
Expand Down Expand Up @@ -210,7 +218,7 @@ public ResourceDescriptor resolveByUrl(String url) {
* @param path - to the resource with decrypted bucket
*/
public ResourceDescriptor resolveByPath(String path) {
String prefix = bucketLocation + type.group() + PATH_SEPARATOR;
String prefix = getStoragePrefix();
if (!isFolder) {
throw new IllegalStateException("Resource must be a folder");
}
Expand Down
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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we switch between layouts in runtime?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 {
Comment thread
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;
}
}
Loading
Loading