mirror of
https://github.com/enso-org/enso.git
synced 2024-09-11 13:15:52 +03:00
Integrate Cloud path resolver (#9662)
- Closes #9363 - Cleans up the Cloud mock as it got a bit messy. It still implements the bare minimum to be able to test basic secret and auth handling logic 'offline' (added very simple path resolution, only handling the minimum set of cases for the tests to work). - Adds first implementation of caching Cloud replies. - Currently only caching the `Enso_User.current`. This is a simple one to cache because we do not expect it to ever change, so it can be safely cached for a long period of time (I chose 2h to make it still refresh from time to time while not being noticeable). - We may try using this for caching other values in future PRs.
This commit is contained in:
parent
4dc7992ab5
commit
bdda1830b7
@ -96,13 +96,17 @@ type Enso_Secret
|
||||
case is_path of
|
||||
True ->
|
||||
if parent != Nothing then Error.throw (Illegal_Argument.Error "Parent argument must be Nothing when resolving by `enso://` path.") else
|
||||
parsed_path = Enso_Path.parse name
|
||||
if parsed_path.path_segments.is_empty then Error.throw (Illegal_Argument.Error "The secret path must consist of at least the organization name and the secret name.") else
|
||||
parsed_name = parsed_path.path_segments.last
|
||||
parent = Enso_File.Value parsed_path.parent
|
||||
Enso_Secret.get parsed_name parent
|
||||
Enso_Secret.resolve_path name
|
||||
False ->
|
||||
Enso_Secret.list parent . find s-> s.name == name
|
||||
effective_parent = parent.if_nothing Enso_File.current_working_directory
|
||||
secret_path = effective_parent.enso_path.resolve name . to_text
|
||||
Enso_Secret.resolve_path secret_path
|
||||
|
||||
## PRIVATE
|
||||
resolve_path (path:Text) -> Enso_Secret ! Not_Found =
|
||||
asset = Existing_Enso_Asset.resolve_path path if_not_found=(Error.throw Not_Found)
|
||||
if asset.asset_type != Enso_Asset_Type.Secret then Error.throw (Illegal_Argument.Error "The provided path points to "+asset.asset_type.to_text+", not a Secret.") else
|
||||
Enso_Secret.Value asset.title asset.id
|
||||
|
||||
## GROUP Metadata
|
||||
ICON metadata
|
||||
|
@ -1,5 +1,6 @@
|
||||
import project.Data.Json.JS_Object
|
||||
import project.Data.Text.Text
|
||||
import project.Data.Time.Duration.Duration
|
||||
import project.Data.Vector.Vector
|
||||
import project.Enso_Cloud.Enso_File.Enso_Asset_Type
|
||||
import project.Enso_Cloud.Enso_File.Enso_File
|
||||
@ -30,9 +31,9 @@ type Enso_User
|
||||
Fetch the current user.
|
||||
current : Enso_User
|
||||
current =
|
||||
# TODO this should be cached for a longer period of time
|
||||
json = Utils.http_request_as_json HTTP_Method.Get (Utils.cloud_root_uri + "users/me")
|
||||
Enso_User.from json
|
||||
Utils.get_cached "users/me" cache_duration=(Duration.new minutes=120) <|
|
||||
json = Utils.http_request_as_json HTTP_Method.Get (Utils.cloud_root_uri + "users/me")
|
||||
Enso_User.from json
|
||||
|
||||
## ICON people
|
||||
Lists all known users.
|
||||
|
@ -1,6 +1,7 @@
|
||||
private
|
||||
|
||||
import project.Data.Json.JS_Object
|
||||
import project.Data.Map.Map
|
||||
import project.Data.Text.Text
|
||||
import project.Data.Text.Text_Sub_Range.Text_Sub_Range
|
||||
import project.Data.Time.Date_Time.Date_Time
|
||||
@ -16,6 +17,7 @@ import project.Errors.File_Error.File_Error
|
||||
import project.Errors.Illegal_Argument.Illegal_Argument
|
||||
import project.Errors.Unimplemented.Unimplemented
|
||||
import project.Network.HTTP.HTTP_Method.HTTP_Method
|
||||
import project.Runtime.Context
|
||||
from project.Data.Boolean import Boolean, False, True
|
||||
from project.Data.Text.Extensions import all
|
||||
from project.Enso_Cloud.Public_Utils import get_required_field
|
||||
@ -71,15 +73,23 @@ type Existing_Enso_Asset
|
||||
Fetches the basic information about an existing file from the Cloud.
|
||||
It will fail if the file does not exist.
|
||||
get_asset_reference_for (file : Enso_File) -> Existing_Enso_Asset ! File_Error =
|
||||
# TODO we will get this from the `resolve_path` endpoint, for now though we use manual resolution
|
||||
current_user = Enso_User.current
|
||||
if file.enso_path.organization_name != current_user.organization_name then Unimplemented.throw "Currently only resolving paths for the current user is supported." else
|
||||
root = Existing_Enso_Asset.Value "" current_user.root_directory_id Enso_Asset_Type.Directory
|
||||
file.enso_path.path_segments.fold root current_dir-> segment->
|
||||
children = current_dir.list_directory . catch Illegal_Argument _->
|
||||
Error.throw (Illegal_Argument.Error current_dir.title+" is not a directory.")
|
||||
(children.find c-> c.title == segment) . catch Not_Found _->
|
||||
Error.throw (File_Error.Not_Found file)
|
||||
# TODO remove workaround for bug https://github.com/enso-org/cloud-v2/issues/1173
|
||||
path = if file.enso_path.is_root then file.enso_path.to_text + "/" else file.enso_path.to_text
|
||||
Existing_Enso_Asset.resolve_path path if_not_found=(Error.throw (File_Error.Not_Found file))
|
||||
|
||||
## PRIVATE
|
||||
Resolves a path to an existing asset in the cloud.
|
||||
resolve_path (path : Text) ~if_not_found =
|
||||
handle_not_found _ = if_not_found
|
||||
error_handlers = Map.from_vector [["resource_missing", handle_not_found]]
|
||||
|
||||
uri = Utils.cloud_root_uri+"path/resolve"
|
||||
payload = JS_Object.from_pairs [["path", path]]
|
||||
|
||||
# TODO remove workaround - this should be a Get endpoint, not Post
|
||||
response = Context.Output.with_enabled <|
|
||||
Utils.http_request_as_json HTTP_Method.Post uri payload error_handlers=error_handlers
|
||||
Existing_Enso_Asset.from_json response
|
||||
|
||||
## PRIVATE
|
||||
is_directory self = self.asset_type == Enso_Asset_Type.Directory
|
||||
|
@ -5,6 +5,7 @@ import project.Data.Json.Invalid_JSON
|
||||
import project.Data.Map.Map
|
||||
import project.Data.Numbers.Integer
|
||||
import project.Data.Text.Text
|
||||
import project.Data.Time.Duration.Duration
|
||||
import project.Data.Vector.Vector
|
||||
import project.Enso_Cloud.Errors.Enso_Cloud_Error
|
||||
import project.Enso_Cloud.Errors.Not_Logged_In
|
||||
@ -26,6 +27,7 @@ from project.Data.Boolean import Boolean, False, True
|
||||
from project.Data.Text.Extensions import all
|
||||
|
||||
polyglot java import org.enso.base.enso_cloud.CloudAPI
|
||||
polyglot java import org.enso.base.enso_cloud.CloudRequestCache
|
||||
|
||||
## PRIVATE
|
||||
cloud_root_uri = "" + CloudAPI.getAPIRootURI
|
||||
@ -109,3 +111,9 @@ http_request (method : HTTP_Method) (url : URI) (body : Request_Body = Request_B
|
||||
case handler of
|
||||
Nothing -> Error.throw (Enso_Cloud_Error.Unexpected_Service_Error response.code payload)
|
||||
_ : Function -> handler json_payload
|
||||
|
||||
## PRIVATE
|
||||
Returns the cached value for the given key, or computes it using the given
|
||||
action and caches it for future use.
|
||||
get_cached (key : Text) ~action (cache_duration : Duration = Duration.new seconds=60) =
|
||||
CloudRequestCache.getOrCompute key (_->action) cache_duration
|
||||
|
@ -2,7 +2,7 @@ package org.enso.base.enso_cloud;
|
||||
|
||||
import org.enso.base.Environment_Utils;
|
||||
|
||||
public class CloudAPI {
|
||||
public final class CloudAPI {
|
||||
public static String getAPIRootURI() {
|
||||
var envUri = Environment_Utils.get_environment_variable("ENSO_CLOUD_API_URI");
|
||||
var effectiveUri =
|
||||
@ -12,6 +12,7 @@ public class CloudAPI {
|
||||
}
|
||||
|
||||
public static void flushCloudCaches() {
|
||||
CloudRequestCache.clear();
|
||||
AuthenticationProvider.reset();
|
||||
EnsoSecretReader.flushCache();
|
||||
}
|
||||
|
@ -0,0 +1,34 @@
|
||||
package org.enso.base.enso_cloud;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* A cache that can be used to save results of cloud requests to avoid re-fetching them every time.
|
||||
*
|
||||
* <p>The cache is supposed to store the already processed (parsed etc.) result. If the result is
|
||||
* not cached or the cache entry is expired, the cache will recompute the value using the provided
|
||||
* callback.
|
||||
*/
|
||||
public final class CloudRequestCache {
|
||||
private static final HashMap<String, CacheEntry> cache = new HashMap<>();
|
||||
|
||||
public static void clear() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
public static Object getOrCompute(String key, Function<String, Object> compute, Duration ttl) {
|
||||
var entry = cache.get(key);
|
||||
if (entry != null && entry.expiresAt.isAfter(LocalDateTime.now())) {
|
||||
return entry.value;
|
||||
} else {
|
||||
var value = compute.apply(key);
|
||||
cache.put(key, new CacheEntry(value, LocalDateTime.now().plus(ttl)));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private record CacheEntry(Object value, LocalDateTime expiresAt) {}
|
||||
}
|
@ -20,15 +20,15 @@ import project.Network.Enso_Cloud.Cloud_Tests_Setup.Mock_Credentials
|
||||
polyglot java import org.enso.base.enso_cloud.EnsoSecretAccessDenied
|
||||
polyglot java import org.enso.base.enso_cloud.ExternalLibrarySecretHelper
|
||||
|
||||
add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environment <|
|
||||
add_specs suite_builder setup:Cloud_Tests_Setup =
|
||||
suite_builder.group "Enso Cloud Secrets" pending=setup.pending group_builder->
|
||||
group_builder.specify "should be able to list existing secrets" <|
|
||||
group_builder.specify "should be able to list existing secrets" <| setup.with_prepared_environment <|
|
||||
# This should work regardless of Output context setting:
|
||||
run_with_and_without_output <|
|
||||
# We cannot test much more because we do not know what secrets are already there, further tests will check more by creating and deleting secrets
|
||||
Enso_Secret.list . should_be_a Vector
|
||||
|
||||
group_builder.specify "should allow to create, list and delete secrets" <|
|
||||
group_builder.specify "should allow to create, list and delete secrets" <| setup.with_prepared_environment <|
|
||||
my_secret = Enso_Secret.create "my_test_secret" "my_secret_value"
|
||||
my_secret.should_succeed
|
||||
my_secret.name . should_equal "my_test_secret"
|
||||
@ -42,7 +42,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
Test.with_retries <|
|
||||
Enso_Secret.list . should_not_contain my_secret
|
||||
|
||||
group_builder.specify "should allow to get a secret by name or path" <|
|
||||
group_builder.specify "should allow to get a secret by name or path" <| setup.with_prepared_environment <|
|
||||
created_secret = Enso_Secret.create "my_test_secret-2" "my_secret_value"
|
||||
created_secret.should_succeed
|
||||
Panic.with_finalizer created_secret.delete <|
|
||||
@ -53,10 +53,10 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
path_secret = Enso_Secret.get "enso://"+Enso_User.current.organization_name+"/my_test_secret-2"
|
||||
path_secret . should_equal created_secret
|
||||
|
||||
group_builder.specify "does not allow both parent and path in Enso_Secret.get" <|
|
||||
group_builder.specify "does not allow both parent and path in Enso_Secret.get" <| setup.with_prepared_environment <|
|
||||
Enso_Secret.get "enso://"+Enso_User.current.organization_name+"/SOME-SECRET" parent=Enso_File.root . should_fail_with Illegal_Argument
|
||||
|
||||
group_builder.specify "should fail to create a secret if it already exists" <|
|
||||
group_builder.specify "should fail to create a secret if it already exists" <| setup.with_prepared_environment <|
|
||||
created_secret = Enso_Secret.create "my_test_secret-3" "my_secret_value"
|
||||
created_secret.should_succeed
|
||||
wait_until_secret_is_propagated created_secret
|
||||
@ -71,7 +71,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
r1.should_fail_with Illegal_Argument
|
||||
r1.catch.to_display_text . should_contain "already exists"
|
||||
|
||||
group_builder.specify "should allow to use secrets in HTTPS request headers" pending=setup.httpbin_pending <|
|
||||
group_builder.specify "should allow to use secrets in HTTPS request headers" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
|
||||
secret1 = Enso_Secret.create "my_test_secret-6" "Yet another Mystery"
|
||||
secret1.should_succeed
|
||||
|
||||
@ -80,7 +80,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
response = https.request (Request.get (setup.httpbin_secure_uri / "get") headers=[Header.new "X-My-Secret" secret1])
|
||||
response.decode_as_json.at "headers" . at "X-My-Secret" . should_equal "Yet another Mystery"
|
||||
|
||||
group_builder.specify "should allow to derive values from secrets in Header.authorization_bearer" pending=setup.httpbin_pending <|
|
||||
group_builder.specify "should allow to derive values from secrets in Header.authorization_bearer" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
|
||||
secret_token = Enso_Secret.create "my_test_secret-7" "MySecretToken"
|
||||
secret_token.should_succeed
|
||||
|
||||
@ -90,7 +90,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
response_json = response.decode_as_json
|
||||
response_json.at "headers" . at "Authorization" . should_equal "Bearer MySecretToken"
|
||||
|
||||
group_builder.specify "should allow to derive values from secrets in Header.authorization_basic" pending=setup.httpbin_pending <|
|
||||
group_builder.specify "should allow to derive values from secrets in Header.authorization_basic" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
|
||||
secret_username = Enso_Secret.create "my_test_secret-8" "MyUsername"
|
||||
secret_username.should_succeed
|
||||
Panic.with_finalizer secret_username.delete <|
|
||||
@ -104,7 +104,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
response_json = response.decode_as_json
|
||||
response_json.at "headers" . at "Authorization" . should_equal expected
|
||||
|
||||
group_builder.specify "should allow to derive values from secrets" <|
|
||||
group_builder.specify "should allow to derive values from secrets" <| setup.with_prepared_environment <|
|
||||
secret1 = Enso_Secret.create "my_test_secret-10" "Something"
|
||||
secret1.should_succeed
|
||||
Panic.with_finalizer secret1.delete <| Test.with_retries <|
|
||||
@ -130,7 +130,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
b1.to_text . should_equal "WFk="
|
||||
b2.to_text . should_equal "base64(X__SECRET__)"
|
||||
|
||||
group_builder.specify "does not allow secrets in HTTP headers" pending=setup.httpbin_pending <|
|
||||
group_builder.specify "does not allow secrets in HTTP headers" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
|
||||
secret1 = Enso_Secret.create "my_test_secret-11" "Something"
|
||||
secret1.should_succeed
|
||||
Panic.with_finalizer secret1.delete <| Test.with_retries <|
|
||||
@ -139,7 +139,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
r1.should_fail_with Illegal_Argument
|
||||
r1.catch.to_display_text . should_contain "Secrets are not allowed in HTTP connections, use HTTPS instead."
|
||||
|
||||
group_builder.specify "API exposing secrets to external libraries should not be accessible from unauthorized code" <|
|
||||
group_builder.specify "API exposing secrets to external libraries should not be accessible from unauthorized code" <| setup.with_prepared_environment <|
|
||||
secret1 = Enso_Secret.create "my_test_secret-12" "Something"
|
||||
secret1.should_succeed
|
||||
Panic.with_finalizer secret1.delete <| Test.with_retries <|
|
||||
@ -147,7 +147,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
Test.expect_panic EnsoSecretAccessDenied <|
|
||||
ExternalLibrarySecretHelper.resolveValue java_repr
|
||||
|
||||
group_builder.specify "should allow to create and delete secrets in a sub-directory" pending=setup.real_cloud_pending <|
|
||||
group_builder.specify "should allow to create and delete secrets in a sub-directory" pending=setup.real_cloud_pending <| setup.with_prepared_environment <|
|
||||
subdirectory = Enso_File.root.create_directory "my_test_subdirectory-1"
|
||||
subdirectory.should_succeed
|
||||
Panic.with_finalizer subdirectory.delete <|
|
||||
@ -173,7 +173,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
Enso_Secret.exists "my-nested-secret-1" parent=subdirectory . should_be_false
|
||||
Enso_Secret.get "my-nested-secret-1" parent=subdirectory . should_fail_with Not_Found
|
||||
|
||||
group_builder.specify "should allow to use secrets from a sub-directory" pending=(setup.real_cloud_pending.if_nothing setup.httpbin_pending) <|
|
||||
group_builder.specify "should allow to use secrets from a sub-directory" pending=(setup.real_cloud_pending.if_nothing setup.httpbin_pending) <| setup.with_prepared_environment <|
|
||||
subdirectory = Enso_File.root.create_directory "my_test_subdirectory-2"
|
||||
subdirectory.should_succeed
|
||||
Panic.with_finalizer subdirectory.delete <|
|
||||
@ -185,7 +185,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
response = https.request (Request.get (setup.httpbin_secure_uri / "get") headers=[Header.new "X-My-Nested-Secret" nested_secret])
|
||||
response.decode_as_json.at "headers" . at "X-My-Nested-Secret" . should_equal "NESTED_secret_value"
|
||||
|
||||
group_builder.specify "should allow to update secrets within a sub-directory" pending=(setup.real_cloud_pending.if_nothing setup.httpbin_pending) <|
|
||||
group_builder.specify "should allow to update secrets within a sub-directory" pending=(setup.real_cloud_pending.if_nothing setup.httpbin_pending) <| setup.with_prepared_environment <|
|
||||
subdirectory = Enso_File.root.create_directory "my_test_subdirectory-3"
|
||||
subdirectory.should_succeed
|
||||
Panic.with_finalizer subdirectory.delete <|
|
||||
@ -207,7 +207,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
response = https.request (Request.get (setup.httpbin_secure_uri / "get") headers=[Header.new "X-My-Nested-Secret" nested_secret])
|
||||
response.decode_as_json.at "headers" . at "X-My-Nested-Secret" . should_equal "Value-New-B"
|
||||
|
||||
group_builder.specify "should NOT be able to create/update/delete secrets with disabled Output Context" <|
|
||||
group_builder.specify "should NOT be able to create/update/delete secrets with disabled Output Context" <| setup.with_prepared_environment <|
|
||||
Context.Output.with_disabled <|
|
||||
Enso_Secret.create "foo" "bar" . should_fail_with Forbidden_Operation
|
||||
|
||||
@ -222,7 +222,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
|
||||
# Get should still work
|
||||
Test.with_retries <| Enso_Secret.get "my_test_secret-13" . should_equal secret1
|
||||
|
||||
group_builder.specify "should be able to retry fetching a secret if the token is expired" pending=setup.httpbin_pending <|
|
||||
group_builder.specify "should be able to retry fetching a secret if the token is expired" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
|
||||
mock_setup = Cloud_Tests_Setup.prepare_mock_setup
|
||||
mock_setup.with_prepared_environment <|
|
||||
secret1 = Enso_Secret.create "my_test_secret-"+Random.uuid "Something123"
|
||||
|
@ -0,0 +1,68 @@
|
||||
package org.enso.shttp.cloud_mock;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class AssetStore {
|
||||
static final String ROOT_DIRECTORY_ID = "directory-27xJM00p8jWoL2qByTo6tQfciWC";
|
||||
private final List<Secret> secrets = new LinkedList<>();
|
||||
|
||||
String createSecret(String parentDirectoryId, String title, String value) {
|
||||
if (!parentDirectoryId.equals(ROOT_DIRECTORY_ID)) {
|
||||
throw new IllegalArgumentException(
|
||||
"In Cloud Mock secrets can only be created in the root directory");
|
||||
}
|
||||
|
||||
if (exists(parentDirectoryId, title)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Secret with title " + title + " already exists in the directory");
|
||||
}
|
||||
|
||||
String id = "secret-" + java.util.UUID.randomUUID().toString();
|
||||
secrets.add(new Secret(id, title, value, parentDirectoryId));
|
||||
return id;
|
||||
}
|
||||
|
||||
boolean exists(String parentDirectoryId, String title) {
|
||||
return secrets.stream()
|
||||
.anyMatch(
|
||||
secret ->
|
||||
secret.title.equals(title) && secret.parentDirectoryId.equals(parentDirectoryId));
|
||||
}
|
||||
|
||||
boolean deleteSecret(String id) {
|
||||
return secrets.removeIf(secret -> secret.id.equals(id));
|
||||
}
|
||||
|
||||
Secret findSecretById(String id) {
|
||||
return secrets.stream().filter(secret -> secret.id.equals(id)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
List<Secret> listAssets(String parentDirectoryId) {
|
||||
if (!parentDirectoryId.equals(ROOT_DIRECTORY_ID)) {
|
||||
throw new IllegalArgumentException(
|
||||
"In Cloud Mock secrets can only be listed in the root directory");
|
||||
}
|
||||
|
||||
return List.copyOf(secrets);
|
||||
}
|
||||
|
||||
Secret findAssetInRootByTitle(String subPath) {
|
||||
return secrets.stream()
|
||||
.filter(
|
||||
secret ->
|
||||
secret.title.equals(subPath) && secret.parentDirectoryId.equals(ROOT_DIRECTORY_ID))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
record Secret(String id, String title, String value, String parentDirectoryId) {
|
||||
Asset asAsset() {
|
||||
return new Asset(id, title, parentDirectoryId);
|
||||
}
|
||||
}
|
||||
|
||||
public record Asset(String id, String title, String parentId) {}
|
||||
|
||||
final Asset rootDirectory = new Asset(ROOT_DIRECTORY_ID, "", null);
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package org.enso.shttp.cloud_mock;
|
||||
|
||||
import java.io.IOException;
|
||||
import org.enso.shttp.HttpMethod;
|
||||
|
||||
public class AssetsHandler implements CloudHandler {
|
||||
private static final String ASSETS = "assets";
|
||||
private final AssetStore assetStore;
|
||||
|
||||
public AssetsHandler(AssetStore assetStore) {
|
||||
this.assetStore = assetStore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canHandle(String subPath) {
|
||||
return subPath.startsWith(ASSETS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCloudAPI(CloudExchange exchange) throws IOException {
|
||||
;
|
||||
if (exchange.getMethod() == HttpMethod.DELETE) {
|
||||
String id = exchange.subPath().substring(ASSETS.length() + 1);
|
||||
boolean existed = assetStore.deleteSecret(id);
|
||||
if (existed) {
|
||||
exchange.sendResponse(200, "");
|
||||
} else {
|
||||
exchange.sendResponse(404, "Secret not found: " + id);
|
||||
}
|
||||
} else {
|
||||
exchange.sendResponse(
|
||||
405, "Method not allowed: " + exchange.getMethod() + " - mock only allows DELETE");
|
||||
}
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ package org.enso.shttp.cloud_mock;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import java.io.IOException;
|
||||
import org.enso.shttp.HttpMethod;
|
||||
|
||||
public interface CloudHandler {
|
||||
boolean canHandle(String subPath);
|
||||
@ -16,5 +17,7 @@ public interface CloudHandler {
|
||||
void sendResponse(int code, String response) throws IOException;
|
||||
|
||||
String decodeBodyAsText() throws IOException;
|
||||
|
||||
HttpMethod getMethod();
|
||||
}
|
||||
}
|
||||
|
@ -3,14 +3,23 @@ package org.enso.shttp.cloud_mock;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import org.enso.shttp.HttpMethod;
|
||||
import org.enso.shttp.auth.HandlerWithTokenAuth;
|
||||
|
||||
public class CloudRoot extends HandlerWithTokenAuth {
|
||||
public final String prefix = "/enso-cloud-mock/";
|
||||
|
||||
private final ExpiredTokensCounter expiredTokensCounter;
|
||||
private final AssetStore assetStore = new AssetStore();
|
||||
private final CloudHandler[] handlers =
|
||||
new CloudHandler[] {new UsersHandler(), new SecretsHandler()};
|
||||
new CloudHandler[] {
|
||||
new UsersHandler(),
|
||||
new SecretsHandler(assetStore),
|
||||
new HiddenSecretsHandler(assetStore),
|
||||
new AssetsHandler(assetStore),
|
||||
new PathResolver(assetStore),
|
||||
new DirectoriesHandler(assetStore)
|
||||
};
|
||||
|
||||
public CloudRoot(ExpiredTokensCounter expiredTokensCounter) {
|
||||
this.expiredTokensCounter = expiredTokensCounter;
|
||||
@ -74,6 +83,11 @@ public class CloudRoot extends HandlerWithTokenAuth {
|
||||
public String decodeBodyAsText() throws IOException {
|
||||
return CloudRoot.this.decodeBodyAsText(exchange);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpMethod getMethod() throws IllegalArgumentException {
|
||||
return HttpMethod.valueOf(exchange.getRequestMethod());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,51 @@
|
||||
package org.enso.shttp.cloud_mock;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import org.enso.shttp.HttpMethod;
|
||||
|
||||
/**
|
||||
* Lists assets in a directory.
|
||||
*
|
||||
* <p>In the mock, only root directory can be listed, and it may only contain secrets.
|
||||
*/
|
||||
public class DirectoriesHandler implements CloudHandler {
|
||||
|
||||
private static final String DIRECTORIES = "directories";
|
||||
|
||||
private final AssetStore assetStore;
|
||||
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||
|
||||
public DirectoriesHandler(AssetStore assetStore) {
|
||||
this.assetStore = assetStore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canHandle(String subPath) {
|
||||
return subPath.startsWith(DIRECTORIES);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCloudAPI(CloudExchange exchange) throws IOException {
|
||||
if (exchange.getMethod() == HttpMethod.GET) {
|
||||
listDirectory(exchange.subPath().substring(DIRECTORIES.length() + 1), exchange);
|
||||
} else {
|
||||
exchange.sendResponse(
|
||||
405, "Method not allowed: " + exchange.getMethod() + " - mock only allows GET");
|
||||
}
|
||||
}
|
||||
|
||||
private void listDirectory(String parentId, CloudExchange exchange) throws IOException {
|
||||
final String effectiveParentId = parentId.isEmpty() ? AssetStore.ROOT_DIRECTORY_ID : parentId;
|
||||
ListDirectoryResponse response =
|
||||
new ListDirectoryResponse(
|
||||
assetStore.listAssets(effectiveParentId).stream()
|
||||
.map(AssetStore.Secret::asAsset)
|
||||
.toList());
|
||||
String asJson = jsonMapper.writeValueAsString(response);
|
||||
exchange.sendResponse(200, asJson);
|
||||
}
|
||||
|
||||
private record ListDirectoryResponse(List<AssetStore.Asset> assets) {}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
package org.enso.shttp.cloud_mock;
|
||||
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.enso.shttp.HttpMethod;
|
||||
|
||||
public class HiddenSecretsHandler implements CloudHandler {
|
||||
private static final String HIDDEN_SECRETS = "s3cr3tz";
|
||||
|
||||
private final AssetStore assetStore;
|
||||
private final JsonMapper jsonMapper = new JsonMapper();
|
||||
|
||||
public HiddenSecretsHandler(AssetStore assetStore) {
|
||||
this.assetStore = assetStore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canHandle(String subPath) {
|
||||
return subPath.startsWith(HIDDEN_SECRETS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCloudAPI(CloudExchange exchange) throws IOException {
|
||||
if (exchange.getMethod() == HttpMethod.GET) {
|
||||
getSecret(exchange.subPath().substring(HIDDEN_SECRETS.length() + 1), exchange);
|
||||
} else {
|
||||
exchange.sendResponse(404, "Not found: " + exchange.subPath());
|
||||
}
|
||||
}
|
||||
|
||||
private void getSecret(String id, CloudExchange exchange) throws IOException {
|
||||
var secret = assetStore.findSecretById(id);
|
||||
if (secret == null) {
|
||||
exchange.sendResponse(404, "Secret not found: " + id);
|
||||
} else {
|
||||
String encoded =
|
||||
java.util.Base64.getEncoder()
|
||||
.encodeToString(secret.value().getBytes(StandardCharsets.UTF_8));
|
||||
String asJson = jsonMapper.writeValueAsString(encoded);
|
||||
exchange.sendResponse(200, asJson);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
package org.enso.shttp.cloud_mock;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class PathResolver implements CloudHandler {
|
||||
|
||||
private static final String PATH_RESOLVE = "path/resolve";
|
||||
|
||||
private final String ORGANIZATION_NAME = "Test.ORG";
|
||||
|
||||
private final AssetStore assetStore;
|
||||
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||
|
||||
public PathResolver(AssetStore assetStore) {
|
||||
this.assetStore = assetStore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canHandle(String subPath) {
|
||||
return subPath.startsWith(PATH_RESOLVE);
|
||||
}
|
||||
|
||||
private final Pattern pathPattern = Pattern.compile("enso://(.+?)/(.+)");
|
||||
|
||||
@Override
|
||||
public void handleCloudAPI(CloudExchange exchange) throws IOException {
|
||||
JsonNode root = jsonMapper.readTree(exchange.decodeBodyAsText());
|
||||
String path = root.get("path").asText();
|
||||
var matcher = pathPattern.matcher(path);
|
||||
if (!matcher.matches()) {
|
||||
exchange.sendResponse(400, "Invalid path: " + path);
|
||||
return;
|
||||
}
|
||||
|
||||
String organization = matcher.group(1);
|
||||
String subPath = matcher.group(2);
|
||||
|
||||
if (!organization.equals(ORGANIZATION_NAME)) {
|
||||
exchange.sendResponse(404, "Organization not found: " + organization);
|
||||
return;
|
||||
}
|
||||
|
||||
// The latter condition is a workaround for https://github.com/enso-org/cloud-v2/issues/1173 and
|
||||
// it may be removed once that is fixed
|
||||
boolean isRoot = subPath.isEmpty() || subPath.equals("/");
|
||||
if (isRoot) {
|
||||
String asJson = jsonMapper.writeValueAsString(assetStore.rootDirectory);
|
||||
exchange.sendResponse(200, asJson);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subPath.contains("/")) {
|
||||
exchange.sendResponse(
|
||||
400, "Invalid subpath: " + subPath + " - mock does not support subdirectories");
|
||||
return;
|
||||
}
|
||||
|
||||
AssetStore.Secret asset = assetStore.findAssetInRootByTitle(subPath);
|
||||
if (asset == null) {
|
||||
exchange.sendResponse(404, "Asset not found: " + subPath);
|
||||
return;
|
||||
}
|
||||
|
||||
String asJson = jsonMapper.writeValueAsString(asset.asAsset());
|
||||
exchange.sendResponse(200, asJson);
|
||||
}
|
||||
}
|
@ -3,104 +3,30 @@ package org.enso.shttp.cloud_mock;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.enso.shttp.HttpMethod;
|
||||
|
||||
public class SecretsHandler implements CloudHandler {
|
||||
private static final String SECRETS = "secrets";
|
||||
|
||||
private static final String ROOT = "directory-27xJM00p8jWoL2qByTo6tQfciWC";
|
||||
private final String SECRETS = "secrets";
|
||||
private final String HIDDEN_SECRETS = "s3cr3tz";
|
||||
|
||||
// Temporary mock until we are back to `list_secrets` for `Enso_Secret.list`.
|
||||
private final String DIRECTORIES = "directories";
|
||||
|
||||
// Secrets are now being deleted through assets endpoint
|
||||
private final String ASSETS = "assets/secret-";
|
||||
|
||||
private final AssetStore assetStore;
|
||||
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||
|
||||
// <root> -> <secret_id> -> <secret_value>
|
||||
private HashMap<String, HashMap<String, Secret>> mapping = new HashMap<>();
|
||||
public SecretsHandler(AssetStore assetStore) {
|
||||
this.assetStore = assetStore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canHandle(String subPath) {
|
||||
return subPath.startsWith(SECRETS)
|
||||
|| subPath.startsWith(HIDDEN_SECRETS)
|
||||
|| subPath.startsWith(DIRECTORIES)
|
||||
|| subPath.startsWith(ASSETS);
|
||||
return subPath.startsWith(SECRETS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCloudAPI(CloudExchange exchange) throws IOException {
|
||||
HttpMethod method;
|
||||
try {
|
||||
method = HttpMethod.valueOf(exchange.getHttpExchange().getRequestMethod());
|
||||
} catch (IllegalArgumentException e) {
|
||||
exchange.sendResponse(
|
||||
400, "Invalid method: " + exchange.getHttpExchange().getRequestMethod());
|
||||
return;
|
||||
}
|
||||
|
||||
if (exchange.subPath().startsWith(DIRECTORIES)) {
|
||||
if (method == HttpMethod.GET) {
|
||||
listDirectory(exchange.subPath().substring(DIRECTORIES.length() + 1), exchange);
|
||||
} else {
|
||||
exchange.sendResponse(405, "Method not allowed: " + method);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (exchange.subPath().startsWith(ASSETS)) {
|
||||
if (method == HttpMethod.DELETE) {
|
||||
String idOnly = exchange.subPath().substring(ASSETS.length());
|
||||
String fullId = "secret-" + idOnly;
|
||||
deleteSecret(fullId, exchange);
|
||||
} else {
|
||||
exchange.sendResponse(405, "Method not allowed: " + method);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (exchange.subPath().equals(SECRETS)) {
|
||||
handleTopLevel(method, exchange);
|
||||
} else if (exchange.subPath().startsWith(SECRETS)) {
|
||||
handleSecretSpecific(method, exchange.subPath().substring(SECRETS.length() + 1), exchange);
|
||||
} else if (exchange.subPath().startsWith(HIDDEN_SECRETS)) {
|
||||
if (method == HttpMethod.GET) {
|
||||
getSecret(exchange.subPath().substring(HIDDEN_SECRETS.length() + 1), exchange);
|
||||
} else {
|
||||
exchange.sendResponse(404, "Not found: " + exchange.subPath());
|
||||
}
|
||||
if (exchange.getMethod() == HttpMethod.POST) {
|
||||
createSecret(exchange);
|
||||
} else {
|
||||
exchange.sendResponse(404, "Not found: " + exchange.subPath());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSecretSpecific(HttpMethod method, String name, CloudExchange exchange)
|
||||
throws IOException {
|
||||
switch (method) {
|
||||
case DELETE:
|
||||
deleteSecret(name, exchange);
|
||||
break;
|
||||
default:
|
||||
exchange.sendResponse(405, "Method not allowed: " + method);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleTopLevel(HttpMethod method, CloudExchange exchange) throws IOException {
|
||||
switch (method) {
|
||||
case GET:
|
||||
listSecrets(exchange);
|
||||
break;
|
||||
case POST:
|
||||
createSecret(exchange);
|
||||
break;
|
||||
default:
|
||||
exchange.sendResponse(405, "Method not allowed: " + method);
|
||||
exchange.sendResponse(
|
||||
405, "Method not allowed: " + exchange.getMethod() + " - mock only allows POST");
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,80 +34,18 @@ public class SecretsHandler implements CloudHandler {
|
||||
JsonNode root = jsonMapper.readTree(exchange.decodeBodyAsText());
|
||||
String name = root.get("name").asText();
|
||||
String value = root.get("value").asText();
|
||||
String parentId = root.has("parentDirectoryId") ? root.get("parentDirectoryId").asText() : ROOT;
|
||||
String secretId = "secret-" + UUID.randomUUID();
|
||||
accessRoot(parentId).put(secretId, new Secret(name, value));
|
||||
String asJson = jsonMapper.writeValueAsString(secretId);
|
||||
exchange.sendResponse(200, asJson);
|
||||
}
|
||||
String parentId =
|
||||
root.has("parentDirectoryId")
|
||||
? root.get("parentDirectoryId").asText()
|
||||
: AssetStore.ROOT_DIRECTORY_ID;
|
||||
|
||||
private void listSecrets(CloudExchange exchange) throws IOException {
|
||||
// TODO currently the cloud API does not seem to handle a parent_id parameter, so we always rely
|
||||
// on ROOT
|
||||
String parentId = ROOT;
|
||||
ListSecretsResponse response =
|
||||
new ListSecretsResponse(
|
||||
accessRoot(parentId).entrySet().stream()
|
||||
.map(
|
||||
entry -> new ListSecretsResponse.Element(entry.getKey(), entry.getValue().name))
|
||||
.toList());
|
||||
String asJson = jsonMapper.writeValueAsString(response);
|
||||
exchange.sendResponse(200, asJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a workaround because Enso_Secret.list currently relies on `list_directory` instead of
|
||||
* `list_secrets`, as `list_secrets` was unable to handle sub-directories. Once `list_secrets` is
|
||||
* fixed, this temporary workaround may be removed from the mock.
|
||||
*/
|
||||
private void listDirectory(String parentId, CloudExchange exchange) throws IOException {
|
||||
final String effectiveParentId = parentId.isEmpty() ? ROOT : parentId;
|
||||
ListDirectoryResponse response =
|
||||
new ListDirectoryResponse(
|
||||
accessRoot(parentId).entrySet().stream()
|
||||
.map(
|
||||
entry ->
|
||||
new ListDirectoryResponse.Element(
|
||||
entry.getKey(), entry.getValue().name, effectiveParentId))
|
||||
.toList());
|
||||
String asJson = jsonMapper.writeValueAsString(response);
|
||||
exchange.sendResponse(200, asJson);
|
||||
}
|
||||
|
||||
private void getSecret(String id, CloudExchange exchange) throws IOException {
|
||||
String parentId = ROOT;
|
||||
Secret secret = accessRoot(parentId).get(id);
|
||||
if (secret == null) {
|
||||
exchange.sendResponse(404, "Secret not found: " + id);
|
||||
} else {
|
||||
String encoded =
|
||||
java.util.Base64.getEncoder()
|
||||
.encodeToString(secret.value.getBytes(StandardCharsets.UTF_8));
|
||||
exchange.sendResponse(200, '"' + encoded + '"');
|
||||
if (assetStore.exists(parentId, name)) {
|
||||
exchange.sendResponse(400, "{\"code\": \"resource_already_exists\"}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteSecret(String name, CloudExchange exchange) throws IOException {
|
||||
String parentId = ROOT;
|
||||
boolean existed = accessRoot(parentId).remove(name) != null;
|
||||
if (existed) {
|
||||
exchange.sendResponse(200, "");
|
||||
} else {
|
||||
exchange.sendResponse(404, "Secret not found: " + name);
|
||||
}
|
||||
String createdSecretId = assetStore.createSecret(parentId, name, value);
|
||||
String asJson = jsonMapper.writeValueAsString(createdSecretId);
|
||||
exchange.sendResponse(200, asJson);
|
||||
}
|
||||
|
||||
private HashMap<String, Secret> accessRoot(String rootId) {
|
||||
return mapping.computeIfAbsent(rootId, k -> new HashMap<>());
|
||||
}
|
||||
|
||||
private record ListSecretsResponse(List<Element> secrets) {
|
||||
public record Element(String id, String name) {}
|
||||
}
|
||||
|
||||
private record ListDirectoryResponse(List<Element> assets) {
|
||||
public record Element(String id, String title, String parentId) {}
|
||||
}
|
||||
|
||||
private record Secret(String name, String value) {}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user